From ec32f8ea75f14a8f382d1abaf5c4c3b169af7ccb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 15:01:15 +0800 Subject: [PATCH 001/312] Add architecture.md, progress.md and docs/updates; commit per stage Work sat uncommitted for weeks in several repositories of this workspace and progress files grew into change logs. The project guidelines now ask for a commit at every stage, keep progress.md to open items only, record finished work in monthly batches under docs/updates/ with an index and query commands, and add a short architecture.md overview. --- CLAUDE.md | 14 +++- architecture.md | 171 ++++++++++++++++++++++++++++++++++++++++ docs/updates/2026-09.md | 11 +++ docs/updates/README.md | 63 +++++++++++++++ progress.md | 17 ++++ 5 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 architecture.md create mode 100644 docs/updates/2026-09.md create mode 100644 docs/updates/README.md create mode 100644 progress.md diff --git a/CLAUDE.md b/CLAUDE.md index 4823b034..2462f7ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Automation-first Python IDE built on PySide6 + JEditor, integrating Web/API/GUI/Load testing into a single environment. -**This file is the only home for project rules.** Anything that constrains how work is done here — conventions, security requirements, quality gates, commit policy — belongs in this file. Do not start a `progress.md`, a scratch notes file, or any other side document to hold rules: a rule kept somewhere else is a rule nobody reads. Reference material that is not a rule (the architecture map, the plugin API) lives in its own file and is linked from here. +**This file is the only home for project rules.** Anything that constrains how work is done here — conventions, security requirements, quality gates, commit policy — belongs in this file. Do not put rules in `progress.md` (it holds outstanding work only), a scratch notes file, or any other side document: a rule kept somewhere else is a rule nobody reads. Reference material that is not a rule (the architecture map, the plugin API) lives in its own file and is linked from here. ## Architecture @@ -128,6 +128,18 @@ Per function: cyclomatic and cognitive complexity ≤ 15 (hard cap 20) · ≤ 75 - No `TODO` / `FIXME` without an issue reference (`# TODO(#123): ...`) - Justify each `# noqa: RULE` with a short reason — never blanket-disable +## Stage commits, `progress.md`, `docs/updates/` and `architecture.md` + +Workspace rule shared by every repository under `D:\Codes` (full text: `D:\Codes\CLAUDE.md`). + +- **Commit at every stage.** A stage is the smallest piece of work that leaves the repository consistent and passes this project's checks (definition of done, tests, lint): one finished `progress.md` item, or one self-contained step of a larger one. Commit it before starting the next stage, before switching to another repository, and before the session ends. Do not leave work uncommitted across sessions; if a stage cannot be finished, commit the consistent part and record the rest in `progress.md`. + - Stage only the files that stage touched (`git add `, never `git add -A`), follow this file's commit-message rules, and never add AI attribution. + - Committing is not pushing: push or open a PR only as this project's branch flow says or when asked. +- **`progress.md`** (repository root, tracked) holds outstanding work only: no finished items, no history, no rules. +- **`docs/updates/`** records finished work: one batch file per month (`YYYY-MM.md`), one entry per piece of work headed `## U-YYYYMMDD-NN · date · title · #tags`, and an index with query commands in `docs/updates/README.md`. When a `progress.md` item is done, delete it and add a `#done` entry plus its index row in the same commit. +- **`architecture.md`** (repository root) is the short architecture overview: layers, entry points, main flows, extension points, cross-project boundaries. Update it in the same commit whenever a change alters any of those. `architecture_explore.md` stays the detailed per-module map under its own rule in this file. +- **Cross-project contracts** are listed in `architecture.md` §6: what other repositories rely on here (CLI flags, import paths, constructor arguments, file layouts) and what this repository relies on elsewhere. No test here protects them, so never rename or remove one without changing its consumers in the same round, and update §6 whenever a contract is added or changes. + ## Commit & PR rules - Commit messages: short imperative sentence ("Update stable version", "Fix github actions") diff --git a/architecture.md b/architecture.md new file mode 100644 index 00000000..c777115e --- /dev/null +++ b/architecture.md @@ -0,0 +1,171 @@ +# PyBreeze Architecture + +> Short overview for people and agents. Per-module detail lives in [`architecture_explore.md`](architecture_explore.md). +> Last verified: 2026-09-22 against `d0068d2` on `dev`. + +## 1. Purpose + +PyBreeze is an automation-first Python IDE built on JEditor. It is published as `pybreeze` +(stable, `pyproject.toml`) and `pybreeze_dev` (dev, `dev.toml`). It does not have its own editor. +Instead it subclasses JEditor's main window and adds menus, tool tabs and docks around it: + +- menus that drive the automation packages (AutoControl, WebRunner, APITestka, LoadDensity, + FileAutomation, MailThunder, TestPioneer); +- prthinker and chain-of-thought LLM code review; +- SSH/SFTP, embedded JupyterLab and a diagram editor; +- a set of HTTP developer tools. + +The central rule: the editor process never runs user scripts itself. They run in subprocesses, and +their output reaches the UI through Queue + QTimer. + +## 2. Layers and directories + +| Path | Responsibility | +| --- | --- | +| `pybreeze/__init__.py` | Facade: `start_editor`, `PyBreezeMainWindow`, `EDITOR_EXTEND_TAB`, re-exported JEditor plugin functions | +| `pybreeze/__main__.py`, `exe/start_pybreeze.py` | Launch scripts (module run, executable build) | +| `pybreeze/pybreeze_ui/editor_main/` | `PyBreezeMainWindow(EditorMain)`, `start_editor()`, file-tree context menu | +| `pybreeze/pybreeze_ui/menu/` | Menu builders; `build_menubar.py` is the single entry. Holds `automation_menu/` (factory, per-package menus, TestPioneer, prthinker), `install_menu/`, `tools/tools_menu.py`, `plugin_menu/`, `extend_jeditor_tab_menu/` | +| `pybreeze/pybreeze_ui/tools_gui/` | Thin tool tabs (cURL/HAR import, JWT, regex, diff, headers, …) backed by `pybreeze/utils/` | +| `pybreeze/pybreeze_ui/diagram_editor/` | Diagram editor (QGraphicsScene, Mermaid import, PNG/SVG export) | +| `pybreeze/pybreeze_ui/extend_ai_gui/`, `dialog/` | LLM code-review chain and prompt editors; prthinker settings dialog | +| `pybreeze/pybreeze_ui/connect_gui/` | `ssh/` terminal + SFTP tree; `url/` HTTP code-review client | +| `pybreeze/pybreeze_ui/jupyter_lab_gui/`, `show_code_window/`, `syntax/` | JupyterLab tab; `CodeWindow` subprocess output window; automation keyword highlighting | +| `pybreeze/extend/process_executor/` | Subprocess isolation layer: `TaskProcessManager`, `process_executor_utils.py`, `FileRunnerProcess`, `queue_pump.py`, one sub-package per automation package, plus `test_pioneer/` and `prthinker/` | +| `pybreeze/extend/mail_thunder_extend/`, `prthinker_extend/` | Post-test email hook; prthinker settings and argument assembly (pure logic) | +| `pybreeze/extend_multi_language/` | PyBreeze's English and Traditional Chinese strings, merged into JEditor's dictionaries | +| `pybreeze/utils/` | Pure logic (only `file_process/get_dir_file_list.py` imports Qt): request parsing and codegen, HTTP tools, `network/` SSRF validation and capped reads, exceptions, logging, `app_dirs.py`, `subprocess_util.py` | +| `test/test_utils/` | Unit tests (pure logic and headless widgets). `test/unit_test/start_automation/` holds the launch tests | +| `pyproject.toml`, `dev.toml` | Stable and dev packaging (keep both in sync) | +| `.github/workflows/` | `dev.yml`, `stable.yml` (unit tests on a Windows matrix, then SonarCloud) | +| `docs/`, `linux_package_source/`, `architecture_diagram/` | Sphinx docs, Debian package source, architecture image | + +The layers are presentation (`pybreeze_ui/`), then execution (`extend/`), then foundation (`utils/`, +`extend_multi_language/`), then external subprocesses. + +## 3. Entry points and public interfaces + +- **CLI**: `python -m pybreeze` (`pybreeze/__main__.py`). No console script is declared. +- **Programmatic**: `pybreeze.start_editor(debug_mode=False, theme="dark_amber.xml", **kwargs)`. + `debug_mode=True` adds an auto-close timer, which CI uses. +- **Main window**: `PyBreezeMainWindow` exposes `tab_widget`, `current_run_code_window` and + `python_compiler`. +- **Custom tabs**: `EDITOR_EXTEND_TAB: dict[str, type[QWidget]]` in + `pybreeze/pybreeze_ui/editor_main/main_ui.py`. This is PyBreeze's own registry, separate from + JEditor's dict of the same name. +- **Plugin API (re-exported)**: `load_external_plugins`, `register_programming_language`, + `register_natural_language`. +- **Persisted state**: `~/.pybreeze/` via `utils/app_dirs.pybreeze_data_dir()` (SSH known hosts, + prthinker settings, edited prompts, review history). The editor settings inherited from JEditor + stay in `.jeditor/` under the working directory. + +## 4. Main flows + +**Startup** + +``` +python -m pybreeze → start_editor() → QApplication → PyBreezeMainWindow() + → EditorMain.__init__(extend=True) [JEditor builds the editor, loads jeditor_plugins/] + → drop JEditor Help menu → update_language_dict() → add_menu_to_menubar() + → syntax_extend_package() → EDITOR_EXTEND_TAB tabs → setup_file_tree_context_menu() + → apply_stylesheet(theme) → showMaximized() → startup_setting() → exec() → os._exit() +``` + +Before PySide6 is imported, `main_ui.py` sets `LOCUST_SKIP_MONKEY_PATCH=1` to keep LoadDensity's +gevent patching away from Qt. + +**Run an automation script** + +``` +Automation menu (automation_menu_factory.build_automation_menu) → call_() in +extend/process_executor// → build_process() (process_executor_utils.py) + → CodeWindow + TaskProcessManager → python -m --execute_str | --execute_file + → stdout/stderr reader threads → Queue → QTimer → pump_message_queue() → CodeWindow + → optional send_after_test() (mail_thunder_extend) +``` + +**Run a non-Python file through a plugin** + +``` +Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() + → FileRunnerProcess.run_file() → interpret, or compile then run → CodeWindow +``` + +## 5. Extension points + +- **Custom tabs**: add entries to `EDITOR_EXTEND_TAB` (`pybreeze_ui/editor_main/main_ui.py`) before + `start_editor()`. +- **File plugins**: `jeditor_plugins/` in the working directory, loaded by JEditor + (`je_editor/plugins/plugin_loader.py`). `PLUGIN_RUN_CONFIG` entries appear in the Run with… and + Plugins menus and execute via `FileRunnerProcess`. The plugin browser tab reuses JEditor's + `PluginBrowserWidget`. See `PLUGIN_GUIDE.md`. +- **New automation package**: + - add a sub-package under `extend/process_executor/` with a `_PACKAGE` constant that calls + `build_process()`; + - add a menu via `pybreeze_ui/menu/automation_menu/automation_menu_factory.py`, wired in + `menu/build_menubar.py`; + - add an installer in `menu/install_menu/automation_menu/`; + - add keywords in `pybreeze_ui/syntax/syntax_keyword.py`. +- **New tool tab or dock**: a widget in `pybreeze_ui/tools_gui/`, its logic in `pybreeze/utils/`, and + rows in `_WIDGET_FACTORIES` / `_TAB_ACTIONS` / `_DOCK_ACTIONS` / `_DOCK_TITLES` + (`pybreeze_ui/menu/tools/tools_menu.py`). +- **UI strings**: add keys to both `extend_multi_language/extend_english.py` and + `extend_traditional_chinese.py`. `test/test_utils/test_language_parity.py` enforces parity. + +## 6. Cross-project boundaries + +- **JEditor (upstream)**: `PyBreezeMainWindow` subclasses `je_editor.EditorMain` in extend mode. + `pybreeze/__init__.py` re-exports JEditor's plugin API. PyBreeze also imports JEditor internals + (e.g. `PluginBrowserWidget`, `DestroyDock`, `check_and_choose_venv`, `actually_color_dict`). It + merges its strings by mutating JEditor's `english_word_dict` and `traditional_chinese_word_dict` + in place (`extend_multi_language/update_language_dict.py`). JEditor translation changes must keep + `test_language_parity.py` green. +- **Automation packages**: `je_auto_control`, `je_web_runner`, `je_api_testka`, `je_load_density`, + `automation_file` and `je_mail_thunder` run as `python -m --execute_str/--execute_file` + (`extend/process_executor/python_task_process_manager.py`). TestPioneer runs as + `python -m test_pioneer -e ` through its own `TestPioneerProcess` + (`extend/process_executor/test_pioneer/`). +- **prthinker**: runs as `python -m prthinker` via `TaskProcessManager.start_module_process` + (`extend/process_executor/prthinker/`). Secrets are passed as environment variables, never argv. + It is not on PyPI: the Install menu asks for a local source folder and installs `[runner]`. + README states it needs Python 3.12+. +- **IDE_Plugins**: the plugin browser's default repo (set in JEditor). Its run configs execute here via + `FileRunnerProcess`. +- **PySide6 pin**: it must match JEditor and FrontEngine. PyBreeze pins it in `pyproject.toml`, + `dev.toml` and `requirements.txt`. At last verification PyBreeze pinned 6.11.0, while FrontEngine + and JEditor's stable `pyproject.toml` pinned 6.11.1. + +## 7. Design constraints + +- Keep `architecture_explore.md` and the CLAUDE.md tree current in the same change + (CLAUDE.md § Architecture). +- Never update UI from a worker thread: use Queue + QTimer or Signal/Slot. Store every menu `QAction` + on the main window. Custom exceptions derive from `ITEException`. Log via `pybreeze_logger` + (§ Conventions). +- Every outbound request to a user URL passes SSRF validation (`utils/network/url_validation.py`) + with timeouts and size caps (§ Security › Network). +- SSH uses the interactive host-key policy, never auto-add (§ Security › SSH). +- Subprocesses use argument lists, `shell=False` and a `timeout`, and pass secrets through `env` + (§ Security › Subprocess). +- The JupyterLab server stays localhost-only (§ Security › JupyterLab). +- Persist data only under `~/.pybreeze/` (§ Security › File I/O). +- Complexity, length and nesting caps; no silent `except`; no `assert` in runtime code + (§ Code quality gates). +- `main` is stable and `dev` is development, and the version must be bumped in both toml files. + SonarCloud automatic analysis stays off (§ Branching & CI). +- Commit and PR text must follow the attribution rules (§ Commit & PR rules). + +## 8. When to update this file + +Update it in the same change when any of these changes: + +- a top-level package or directory in §2; +- an entry point or an export in `pybreeze/__init__.py`; +- the startup, execution or plugin-run flow in §4; +- an extension point in §5; +- a cross-repo contract in §6 (JEditor base class or imports, the subprocess command-line protocol, + the prthinker install path, the PySide6 pin); +- a CLAUDE.md section that §7 points to is renamed. + +Per-module changes go to `architecture_explore.md`. Refresh the "Last verified" line when you +re-check this file. diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md new file mode 100644 index 00000000..96191940 --- /dev/null +++ b/docs/updates/2026-09.md @@ -0,0 +1,11 @@ +# 2026-09 update log + +Index and query commands: [README.md](README.md). New entries go at the end. + +--- + +## U-20260922-01 · 2026-09-22 · Adopt progress/architecture/docs-updates rules · #docs #migration + +- **What**: `CLAUDE.md` gained the workspace rule section (commit at every stage; `progress.md` holds open items only; finished work is recorded in `docs/updates/`; `architecture.md` is the short overview). Added `architecture.md` and this `docs/updates/` index. Moved PyBreeze's open items here from JEditor's `PROGRESS.md` and seeded the rest from the workspace inventory of 2026-09-22. +- **Files**: `CLAUDE.md`, `progress.md`, `architecture.md`, `docs/updates/`. +- **Open items**: see `progress.md`. diff --git a/docs/updates/README.md b/docs/updates/README.md new file mode 100644 index 00000000..27a841e9 --- /dev/null +++ b/docs/updates/README.md @@ -0,0 +1,63 @@ +# docs/updates: update log index + +`progress.md` holds only work that is **not done yet**. Everything that *was* done (what changed, measured numbers, decisions, snapshots) is recorded here: **one batch file per month**, one entry per piece of work, each entry with a fixed-format ID and tags, and one row per entry in the index below. + +> No TODOs here. If an entry mentions something still open, it only points to it (e.g. "open item: `progress.md` #3"); the item itself lives in `progress.md`. + +## How to query + +Run from the repository root: + +| To find | Command | +|---|---| +| every entry, one line each | `rg -n "^## U-2" docs/updates` | +| entries of one type | `rg -n "^## U-2.*#done" docs/updates` | +| entries with a topic tag | `rg -n "^## U-2.*#" docs/updates` | +| one day or one month | `rg -n "^## U-202609" docs/updates` | +| the full text of one entry | `rg -n -A 60 "^## U-20260922-01" docs/updates` | +| any keyword | `rg -n "keyword" docs/updates` | + +Without `rg`: `git grep -n "^## U-2" -- docs/updates`, or in PowerShell `Select-String -Path docs/updates/*.md -Pattern '^## U-2'`. + +## Entry format + +```markdown +## U-YYYYMMDD-NN · YYYY-MM-DD · one-line title · #type #topic + +- **What**: ... +- **Result / numbers**: ... +- **Files**: `path` ... +- **Evidence**: commit, file:line, link ... +- **Open items**: none / see `progress.md` ... +``` + +- **ID**: `U-` + date + two-digit sequence for that day. IDs are never renumbered or reused, so code comments and other documents can cite them. +- **Type tag** (exactly one): `#done` finished `progress.md` item, `#snapshot` measurement or inventory, `#decision`, `#incident`, `#migration`, `#docs`, `#release`. +- Topic tags are free-form (`#mcp`, `#wayland`, ...). +- Keep conclusions, numbers, files and evidence; drop the reasoning trail and dead ends. + +## Batch rules + +1. One file per month: `docs/updates/YYYY-MM.md`. Append new entries at the end. +2. Over about 800 lines, continue in `YYYY-MM-b.md` (then `-c`) and list it in the batch table below. +3. **Claim the ID first**: write the heading line and the index row, then fill in the body. Check the day's last number with `rg -n "^## U-YYYYMMDD" docs/updates`. +4. **One line per index row**: title only (about 60 characters), no summary. +5. Never rewrite a recorded entry. Correct it with a new `#decision` or `#incident` entry and add "→ corrected in U-..." to the old one. + +## When a `progress.md` item is done + +In the same commit: delete the item from `progress.md`, add a `#done` entry here that names it, and add its index row. + +--- + +## Index (newest first) + +| ID | Date | Title | Tags | Batch | +|---|---|---|---|---| +| U-20260922-01 | 2026-09-22 | Adopt progress/architecture/docs-updates rules | #docs #migration | [2026-09](2026-09.md) | + +## Batches + +| File | Period | Entries | +|---|---|---:| +| [2026-09.md](2026-09.md) | 2026-09 | 1 | diff --git a/progress.md b/progress.md new file mode 100644 index 00000000..bf7eca18 --- /dev/null +++ b/progress.md @@ -0,0 +1,17 @@ +# progress.md: PyBreeze + +Outstanding work only. When an item is done, delete it in the same commit and add a `#done` entry to `docs/updates/` (format and query commands: `docs/updates/README.md`). No finished items, no history, no rules (rules live in `CLAUDE.md`). +Item numbers (`#n`) are never reused. Tags: [DECIDE] needs the owner's decision, [BLOCKED] waits on something else, [UNVERIFIED] observed but not confirmed. +Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X-1, X-8, X-11, X-13, X-17). + +## Open + +- **#1** [BLOCKED] The prthinker integration (menu, settings, install; commit `99ef96f`) has never run a real review: this machine's PyBreeze venv is Python 3.11 and prthinker needs 3.12 or newer. Install prthinker with a 3.12+ interpreter and run one review (moved from JEditor's `PROGRESS.md`; workspace X-8). +- **#2** The run window strips indentation from program output: `.strip()` in `pybreeze/extend/process_executor/queue_pump.py:36` and `file_runner_process.py:150` (`architecture_explore.md` ≈:428). +- **#3** `_append_text` is duplicated verbatim in three places, and the TestPioneer runner raises out when no interpreter is found (`architecture_explore.md` ≈:426, ≈:438). +- **#4** `requirements.txt` lists `pybreeze` itself and `dev_requirements.txt` lists `pybreeze_dev`, so CI installs the PyPI build instead of the checkout. FrontEngine fixed the same problem (see the header comment of its `requirements.txt`). +- **#5** `CLAUDE.md` "Branching & CI" says `dev` publishes `pybreeze_dev`, but `dev.yml` has no publish job and `dev.toml` is not kept in step (workspace X-13). +- **#6** [UNVERIFIED] With Traditional Chinese selected at startup, PyBreeze's labels may be missing until the language is switched once: JEditor builds the merged dictionary before PyBreeze adds its strings to JEditor's word dicts (found by reading the code, not by running it). +- **#7** PySide6 is pinned `==6.11.0` while je_editor and frontengine pin `==6.11.1`, so the latest releases cannot be installed together (workspace X-1). +- **#8** PyBreeze imports JEditor modules that je_editor does not export (`PluginBrowserWidget`, `DestroyDock`, `check_and_choose_venv`, `choose_file_get_save_file_path`, `write_file`, `actually_color_dict`) and edits JEditor's `english_word_dict` / `traditional_chinese_word_dict` in place — an undocumented contract (workspace X-17). +- **#9** Package metadata and README clone instructions still point to `Intergration-Automation-Testing/AutomationEditor` (workspace X-11). From c97154bd7695a5490a4d007a35bcb65dbaf01d21 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 16:22:26 +0800 Subject: [PATCH 002/312] Run with the interpreter chosen in the IDE --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 8 ++ docs/updates/README.md | 3 +- .../process_executor_utils.py | 26 ++++--- .../prthinker/prthinker_process.py | 12 +-- test/test_utils/test_build_task_process.py | 76 +++++++++++++++++++ test/test_utils/test_prthinker_process.py | 12 ++- 7 files changed, 115 insertions(+), 28 deletions(-) create mode 100644 test/test_utils/test_build_task_process.py diff --git a/architecture_explore.md b/architecture_explore.md index 313f66e9..03b45f9f 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -91,7 +91,7 @@ Template Method 定義的子行程生命週期: | 階段 | 做的事 | |---|---| -| 解譯器解析 | `renew_path()` → 用 `find_venv_path()` 找 `venv/`、`.venv/`,交給 `check_and_choose_venv()`;找不到時**不拋例外**,直接把錯誤寫進執行視窗並回傳 `False` | +| 解譯器解析 | `renew_path()` → 執行視窗帶著 IDE 選定的直譯器(`python_compiler`,由 `build_task_process()` 從主視窗抄過來)就用它;沒選才用 `find_venv_path()` 找 `venv/`、`.venv/`,交給 `check_and_choose_venv()`;找不到時**不拋例外**,直接把錯誤寫進執行視窗並回傳 `False` | | 啟動 | `subprocess.Popen(args, shell=False, creationflags=CREATE_NO_WINDOW, env=PYTHONIOENCODING=...)` | | 讀取 | 兩條 daemon Thread 各自 `readline()` stdout / stderr,塞進 `Queue`;**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | | 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則 | @@ -111,7 +111,7 @@ Template Method 定義的子行程生命週期: | `start_process()` | 建 `CodeWindow` + `TaskProcessManager` → `start_test_process()` | | `build_process_from_file()` | 以檔案路徑執行單一檔案 | | `run_dir_files_with_package()` | 問使用者選資料夾,對每個 `.json` 開一個執行視窗批次跑 | -| `_build_task_process()` | 共用建構:建 `CodeWindow`、掛進 `main_window.current_run_code_window`、決定要不要接 `send_after_test` | +| `build_task_process()` | 共用建構:建 `CodeWindow` 並帶上主視窗選定的直譯器、掛進 `main_window.current_run_code_window`、決定要不要接 `send_after_test`。prthinker 審查也走這裡 | ### 4.3 各自動化模組(Strategy) @@ -410,7 +410,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 65 個 `test_*.py`、985 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 66 個 `test_*.py`、989 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 96191940..964b5aef 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -9,3 +9,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **What**: `CLAUDE.md` gained the workspace rule section (commit at every stage; `progress.md` holds open items only; finished work is recorded in `docs/updates/`; `architecture.md` is the short overview). Added `architecture.md` and this `docs/updates/` index. Moved PyBreeze's open items here from JEditor's `PROGRESS.md` and seeded the rest from the workspace inventory of 2026-09-22. - **Files**: `CLAUDE.md`, `progress.md`, `architecture.md`, `docs/updates/`. - **Open items**: see `progress.md`. + +## U-20260922-02 · 2026-09-22 · Run windows ignored the interpreter chosen in the IDE · #incident #process-executor + +- **What**: every automation run (the six `call_*` menus, the directory batch runs) and every prthinker review started its child with the working directory's `venv/` / `.venv/`, or whatever `python` was on `PATH`, even after the user picked an interpreter in the Python environment menu. `TaskProcessManager.renew_path()` reads `python_compiler` from the run window it is given, and the run window was always a fresh `CodeWindow` whose `python_compiler` is `None`; the main window's choice (set by JEditor's menu or restored from the saved setting in `startup_setting()`) never reached it. Only the TestPioneer runner, which read the main window directly, used it. +- **Fix**: `process_executor_utils._build_task_process` became the public `build_task_process()` and copies `main_window.python_compiler` onto the run window. prthinker's `_run` now opens its run window through it instead of repeating the same four steps. +- **Result**: a run uses the chosen interpreter; the working-directory search is only the fallback. For prthinker this means a 3.12+ interpreter chosen in the IDE is enough, even while PyBreeze itself runs on 3.11. +- **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `pybreeze/extend/process_executor/prthinker/prthinker_process.py`, `test/test_utils/test_build_task_process.py` (new), `test/test_utils/test_prthinker_process.py`, `architecture_explore.md` §4.1–4.2. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 27a841e9..b44addd5 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -54,10 +54,11 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-02 | 2026-09-22 | Run windows ignored the interpreter chosen in the IDE | #incident #process-executor | [2026-09](2026-09.md) | | U-20260922-01 | 2026-09-22 | Adopt progress/architecture/docs-updates rules | #docs #migration | [2026-09](2026-09.md) | ## Batches | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 1 | +| [2026-09.md](2026-09.md) | 2026-09 | 2 | diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index 2acaa68a..3093bf7c 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -44,7 +44,7 @@ def start_process( send_mail: bool = False, program_buffer: int = 1024000 ): - process = _build_task_process(main_window, send_mail, program_buffer) + process = build_task_process(main_window, send_mail, program_buffer) process.start_test_process( package, exec_str=test_format_code, @@ -65,7 +65,7 @@ def build_process_from_file( the file is already on disk. """ try: - process = _build_task_process(main_window, send_mail, program_buffer) + process = build_task_process(main_window, send_mail, program_buffer) process.start_test_process_file(package, file_path) except ITETestExecutorException as error: pybreeze_logger.error(repr(error)) @@ -95,23 +95,25 @@ def run_dir_files_with_package( pybreeze_logger.error("%s multi file error: %r", package, error) -def _build_task_process( +def build_task_process( main_window: PyBreezeMainWindow, - send_mail: bool, - program_buffer: int, + send_mail: bool = False, + program_buffer: int = 1024000, ) -> TaskProcessManager: + """Open a fresh run window and the task process manager that writes to it. + + The run window carries the interpreter chosen in the IDE (the Python + environment menu, or the saved setting), so the child runs with that + interpreter; only when none was chosen does the manager fall back to a + ``venv`` / ``.venv`` in the working directory, then to ``PATH``. + """ code_window = CodeWindow() + code_window.python_compiler = main_window.python_compiler main_window.current_run_code_window.append(code_window) main_window.clear_code_result() - if send_mail: - return TaskProcessManager( - main_window=code_window, - task_done_trigger_function=send_after_test, - program_buffer_size=program_buffer, - program_encoding=main_window.encoding, - ) return TaskProcessManager( code_window, + task_done_trigger_function=send_after_test if send_mail else None, program_buffer_size=program_buffer, program_encoding=main_window.encoding, ) diff --git a/pybreeze/extend/process_executor/prthinker/prthinker_process.py b/pybreeze/extend/process_executor/prthinker/prthinker_process.py index caee55d4..c03d881d 100644 --- a/pybreeze/extend/process_executor/prthinker/prthinker_process.py +++ b/pybreeze/extend/process_executor/prthinker/prthinker_process.py @@ -17,8 +17,7 @@ PRTHINKER_PACKAGE, environment_for, load_setting, review_file_arguments, review_pr_arguments ) -from pybreeze.extend.process_executor.python_task_process_manager import TaskProcessManager -from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow +from pybreeze.extend.process_executor.process_executor_utils import build_task_process from pybreeze.utils.logging.logger import pybreeze_logger if TYPE_CHECKING: @@ -74,14 +73,7 @@ def review_pull_request( def _run(main_window: PyBreezeMainWindow, arguments: List[str], setting: dict, program_buffer: int) -> bool: """開一個執行視窗把 prthinker 跑起來 / Open a run window and start prthinker in it.""" - code_window = CodeWindow() - main_window.current_run_code_window.append(code_window) - main_window.clear_code_result() - process = TaskProcessManager( - code_window, - program_buffer_size=program_buffer, - program_encoding=main_window.encoding, - ) + process = build_task_process(main_window, program_buffer=program_buffer) process.start_module_process( PRTHINKER_PACKAGE, arguments, environment_for(setting)) return True diff --git a/test/test_utils/test_build_task_process.py b/test/test_utils/test_build_task_process.py new file mode 100644 index 00000000..24047079 --- /dev/null +++ b/test/test_utils/test_build_task_process.py @@ -0,0 +1,76 @@ +"""The run window an automation run opens: which interpreter it runs with, and where it is kept.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + + +@pytest.fixture(scope="module") +def qt_app(): + from PySide6.QtWidgets import QApplication + app = QApplication.instance() + if app is None: + try: + app = QApplication([]) + except Exception as exc: # pragma: no cover - no usable Qt platform + pytest.skip(f"Cannot start QApplication: {exc}") + return app + + +class MainWindow: + """The little of the IDE's main window that opening a run window touches.""" + + def __init__(self, python_compiler=None) -> None: + self.python_compiler = python_compiler + self.current_run_code_window: list = [] + self.encoding = "utf-8" + self.cleared = False + + def clear_code_result(self) -> None: + self.cleared = True + + +class TestBuildTaskProcess: + def test_the_interpreter_chosen_in_the_ide_is_used(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + process = build_task_process(MainWindow("C:/envs/py312/python.exe")) + + assert process.renew_path() is True + assert process.compiler_path == "C:/envs/py312/python.exe" + + def test_without_a_choice_the_working_directory_is_searched(self, qt_app, monkeypatch): + from pybreeze.extend.process_executor import python_task_process_manager as manager_module + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + searched: list = [] + monkeypatch.setattr(manager_module, "find_venv_path", lambda: "venv-dir") + monkeypatch.setattr( + manager_module, "check_and_choose_venv", + lambda path: searched.append(path) or "found/python") + + process = build_task_process(MainWindow()) + + assert process.renew_path() is True + assert searched == ["venv-dir"] + assert process.compiler_path == "found/python" + + def test_the_run_window_is_kept_and_the_old_result_cleared(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + main_window = MainWindow() + process = build_task_process(main_window) + + assert main_window.current_run_code_window == [process.main_window] + assert main_window.cleared + + def test_the_report_mail_is_sent_only_when_asked(self, qt_app): + from pybreeze.extend.mail_thunder_extend.mail_thunder_setting import send_after_test + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + assert build_task_process(MainWindow()).task_done_trigger_function is None + assert build_task_process( + MainWindow(), send_mail=True).task_done_trigger_function is send_after_test diff --git a/test/test_utils/test_prthinker_process.py b/test/test_utils/test_prthinker_process.py index 7e5d877c..f74e04c4 100644 --- a/test/test_utils/test_prthinker_process.py +++ b/test/test_utils/test_prthinker_process.py @@ -3,6 +3,7 @@ import pytest +from pybreeze.extend.process_executor import process_executor_utils from pybreeze.extend.process_executor.prthinker import prthinker_process from pybreeze.extend.prthinker_extend.prthinker_setting import DEFAULT_SETTING @@ -22,6 +23,7 @@ def __init__(self, current_widget=None) -> None: self._current_widget = current_widget self.current_run_code_window: list = [] self.encoding = "utf-8" + self.python_compiler = None self.cleared = False def currentWidget(self): # noqa: N802 — the Qt name this stands in for @@ -31,6 +33,12 @@ def clear_code_result(self) -> None: self.cleared = True +class RunWindow: + """Stands in for the run window the review's output goes to.""" + + python_compiler = None + + class RecordingProcess: """Stands in for the task process manager and remembers the call.""" @@ -47,8 +55,8 @@ def start_module_process(self, package, arguments, environment=None): def recorded(monkeypatch): """Catch the review before it reaches a real process or a real window.""" RecordingProcess.calls = [] - monkeypatch.setattr(prthinker_process, "TaskProcessManager", RecordingProcess) - monkeypatch.setattr(prthinker_process, "CodeWindow", lambda: object()) + monkeypatch.setattr(process_executor_utils, "TaskProcessManager", RecordingProcess) + monkeypatch.setattr(process_executor_utils, "CodeWindow", RunWindow) # The editor tab is recognised by its type, so the stand-in becomes that type. monkeypatch.setattr(prthinker_process, "EditorWidget", Editor) return RecordingProcess.calls From 26226454f728cd9a48327401a44d01ad7432b86d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 16:28:06 +0800 Subject: [PATCH 003/312] Point project and sister-project URLs at their current repositories --- README.md | 20 +++++++++---------- README/README_zh-CN.md | 18 ++++++++--------- README/README_zh-TW.md | 18 ++++++++--------- dev.toml | 4 ++-- docs/source/Eng/menu_plugins.rst | 2 +- docs/source/Zh/menu_plugins.rst | 2 +- docs/updates/2026-09.md | 6 ++++++ docs/updates/README.md | 3 ++- progress.md | 3 +-- .../api_testka_menu/build_api_testka_menu.py | 2 +- .../build_autocontrol_menu.py | 2 +- .../build_load_density_menu.py | 2 +- .../web_runner_menu/build_webrunner_menu.py | 2 +- pyproject.toml | 4 ++-- 14 files changed, 47 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 230ff4ef..e64bfb3e 100644 --- a/README.md +++ b/README.md @@ -57,23 +57,23 @@ PyBreeze covers the full spectrum of automation testing out of the box: | Dimension | Module | What it does | |---|---|---| -| **API** | [APITestka](https://github.com/Intergration-Automation-Testing/APITestka) | RESTful testing with request builders, response analyzers, mock servers and assertions | -| **Web** | [WebRunner](https://github.com/Intergration-Automation-Testing/WebRunner) | Browser-driven interaction and testing with driver and locator integration | -| **GUI** | [AutoControl](https://github.com/Intergration-Automation-Testing/AutoControl) | Desktop automation via image recognition, coordinates, keyboard/mouse control and recording | -| **Load** | [LoadDensity](https://github.com/Intergration-Automation-Testing/LoadDensity) | High-concurrency performance testing for stability under pressure | +| **API** | [APITestka](https://github.com/Integration-Automation/APITestka) | RESTful testing with request builders, response analyzers, mock servers and assertions | +| **Web** | [WebRunner](https://github.com/Integration-Automation/WebRunner) | Browser-driven interaction and testing with driver and locator integration | +| **GUI** | [AutoControl](https://github.com/Integration-Automation/AutoControlGUI) | Desktop automation via image recognition, coordinates, keyboard/mouse control and recording | +| **Load** | [LoadDensity](https://github.com/Integration-Automation/LoadDensity) | High-concurrency performance testing for stability under pressure | Plus: -- **File Automation** — file and directory operations via [automation-file](https://github.com/Intergration-Automation-Testing/AutomationFile) -- **Mail Automation** — report delivery via [MailThunder](https://github.com/Intergration-Automation-Testing/MailThunder) -- **Test Framework** — YAML-driven execution via [TestPioneer](https://github.com/Intergration-Automation-Testing/TestPioneer) +- **File Automation** — file and directory operations via [automation-file](https://github.com/Integration-Automation/FileAutomation) +- **Mail Automation** — report delivery via [MailThunder](https://github.com/Integration-Automation/MailThunder) +- **Test Framework** — YAML-driven execution via [TestPioneer](https://github.com/Integration-Automation/TestPioneer) Each module gets the same menu shape: **Run** (single script, batch directory, with or without an emailed report), **Help** (docs and GitHub open as in-IDE browser tabs), **Project** (scaffold a template directory), and where available a native GUI tab. ### IDE core - **Automation-aware syntax highlighting** — the `AT_*` / GUI / Web / Load keyword sets are registered for `.json`, and TestPioneer's schema for `.yml`, on top of JEditor's language support -- **Code editor** — built on [JEditor](https://github.com/Intergration-Automation-Testing/JEditor): tabs, project tree, format checker, debugger, terminal, variable inspector and a git client pane +- **Code editor** — built on [JEditor](https://github.com/Integration-Automation/JEDITOR): tabs, project tree, format checker, debugger, terminal, variable inspector and a git client pane - **Script execution** — single or batch; large action files are passed by path, never through the command line, so Windows' ~32 KB argv limit is never a factor - **Report generation** — HTML / JSON / XML after a run, with optional email delivery - **Integrated JupyterLab** — launches as a tab, installing JupyterLab into the project venv if it is missing @@ -296,7 +296,7 @@ pip install pybreeze ### From source ```bash -git clone https://github.com/Intergration-Automation-Testing/AutomationEditor.git +git clone https://github.com/Integration-Automation/PyBreeze.git cd AutomationEditor pip install -r requirements.txt ``` @@ -360,7 +360,7 @@ PyBreeze/ │ │ │ ├── python_task_process_manager.py # TaskProcessManager (core) │ │ │ ├── process_executor_utils.py # build_process / start_process │ │ │ ├── file_runner_process.py # Plugin run configs (any language) -│ │ │ ├── queue_pump.py # Shared QTimer drain +│ │ │ ├── queue_pump.py # Shared pipe reader + QTimer drain │ │ │ ├── api_testka/ auto_control/ web_runner/ │ │ │ ├── load_density/ file_automation/ mail_thunder/ │ │ │ ├── test_pioneer/ prthinker/ diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index c7069321..ed73aa75 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -40,23 +40,23 @@ 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) | 高并发性能测试引擎,用于监控系统在极端压力下的稳定性 | +| **Web 自动化** | [WebRunner](https://github.com/Integration-Automation/WebRunner) | 浏览器交互模拟与测试,深度集成浏览器驱动与元素定位器 | +| **API 自动化** | [APITestka](https://github.com/Integration-Automation/APITestka) | RESTful API 开发与测试,内置请求构建器、响应分析器、Mock 服务器及断言验证 | +| **GUI 自动化** | [AutoControl](https://github.com/Integration-Automation/AutoControlGUI) | 桌面应用程序自动化,支持图像识别、坐标定位、键盘鼠标控制及动作录制 | +| **负载与压力测试** | [LoadDensity](https://github.com/Integration-Automation/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 驱动的测试执行 +- **文件自动化** — 通过 [automation-file](https://github.com/Integration-Automation/FileAutomation) 模块实现自动化文件与目录操作 +- **邮件自动化** — 通过 [MailThunder](https://github.com/Integration-Automation/MailThunder) 实现自动化邮件发送(例如测试报告投递) +- **测试框架** — 通过 [TestPioneer](https://github.com/Integration-Automation/TestPioneer) 实现结构化 YAML 驱动的测试执行 ### IDE 核心功能 PyBreeze 不仅仅是一个代码编辑器——它是自动化生命周期的指挥中心: - **语法高亮** — 内置 Python 语法高亮,针对自动化库(APITestka、AutoControl、WebRunner、LoadDensity 等)提供深度关键字识别。可通过插件添加自定义语法规则。 -- **代码编辑器** — 基于 [JEditor](https://github.com/Intergration-Automation-Testing/JEditor) 构建,提供完整的编辑器功能,包含标签页管理、文件树导航与项目工作区支持。 +- **代码编辑器** — 基于 [JEditor](https://github.com/Integration-Automation/JEDITOR) 构建,提供完整的编辑器功能,包含标签页管理、文件树导航与项目工作区支持。 - **脚本执行** — 直接在 IDE 中执行自动化脚本,并实时显示输出。支持单脚本与多脚本批量执行。 - **报告生成** — 自动化模块可在测试执行后生成 HTML、JSON 和 XML 报告,并支持可选的电子邮件投递。 - **集成 JupyterLab** — 在 PyBreeze 中直接以标签页方式启动 JupyterLab,进行交互式笔记本开发。若未安装 JupyterLab 将自动安装。 @@ -227,7 +227,7 @@ pip install pybreeze ### 从源码安装 ```bash -git clone https://github.com/Intergration-Automation-Testing/AutomationEditor.git +git clone https://github.com/Integration-Automation/PyBreeze.git cd AutomationEditor pip install -r requirements.txt ``` diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index bd87015a..b69d3f75 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -40,23 +40,23 @@ 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) | 高併發效能測試引擎,用於監控系統在極端壓力下的穩定性 | +| **Web 自動化** | [WebRunner](https://github.com/Integration-Automation/WebRunner) | 瀏覽器互動模擬與測試,深度整合瀏覽器驅動與元素定位器 | +| **API 自動化** | [APITestka](https://github.com/Integration-Automation/APITestka) | RESTful API 開發與測試,內建請求建構器、回應分析器、Mock 伺服器及斷言驗證 | +| **GUI 自動化** | [AutoControl](https://github.com/Integration-Automation/AutoControlGUI) | 桌面應用程式自動化,支援圖像辨識、座標定位、鍵盤滑鼠控制及動作錄製 | +| **負載與壓力測試** | [LoadDensity](https://github.com/Integration-Automation/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 驅動的測試執行 +- **檔案自動化** — 透過 [automation-file](https://github.com/Integration-Automation/FileAutomation) 模組實現自動化檔案與目錄操作 +- **郵件自動化** — 透過 [MailThunder](https://github.com/Integration-Automation/MailThunder) 實現自動化郵件寄送(例如測試報告傳遞) +- **測試框架** — 透過 [TestPioneer](https://github.com/Integration-Automation/TestPioneer) 實現結構化 YAML 驅動的測試執行 ### IDE 核心功能 PyBreeze 不僅僅是一個程式碼編輯器——它是自動化生命週期的指揮中心: - **語法高亮** — 內建 Python 語法高亮,針對自動化函式庫(APITestka、AutoControl、WebRunner、LoadDensity 等)提供深度關鍵字識別。可透過插件新增自訂語法規則。 -- **程式碼編輯器** — 基於 [JEditor](https://github.com/Intergration-Automation-Testing/JEditor) 構建,提供完整的編輯器功能,包含分頁管理、檔案樹瀏覽與專案工作區支援。 +- **程式碼編輯器** — 基於 [JEditor](https://github.com/Integration-Automation/JEDITOR) 構建,提供完整的編輯器功能,包含分頁管理、檔案樹瀏覽與專案工作區支援。 - **腳本執行** — 直接在 IDE 中執行自動化腳本,並即時顯示輸出。支援單一腳本與多腳本批次執行。 - **報告生成** — 自動化模組可在測試執行後生成 HTML、JSON 和 XML 報告,並支援可選的電子郵件傳遞。 - **整合 JupyterLab** — 在 PyBreeze 中直接以分頁方式啟動 JupyterLab,進行互動式筆記本開發。若未安裝 JupyterLab 將自動安裝。 @@ -227,7 +227,7 @@ pip install pybreeze ### 從原始碼安裝 ```bash -git clone https://github.com/Intergration-Automation-Testing/AutomationEditor.git +git clone https://github.com/Integration-Automation/PyBreeze.git cd AutomationEditor pip install -r requirements.txt ``` diff --git a/dev.toml b/dev.toml index e6618761..0f1f6dd6 100644 --- a/dev.toml +++ b/dev.toml @@ -33,8 +33,8 @@ classifiers = [ ] [project.urls] -Homepage = "https://github.com/Intergration-Automation-Testing/AutomationEditor" -Code = "https://github.com/Intergration-Automation-Testing/AutomationEditor" +Homepage = "https://github.com/Integration-Automation/PyBreeze" +Code = "https://github.com/Integration-Automation/PyBreeze" [project.readme] file = "README.md" diff --git a/docs/source/Eng/menu_plugins.rst b/docs/source/Eng/menu_plugins.rst index 52ec4532..c6372911 100644 --- a/docs/source/Eng/menu_plugins.rst +++ b/docs/source/Eng/menu_plugins.rst @@ -44,7 +44,7 @@ Creating Plugins For detailed information on creating custom plugins, including syntax highlighting plugins and UI translation plugins, see the -`Plugin Guide `_. +`Plugin Guide `_. Syntax Highlighting Plugin Example ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/source/Zh/menu_plugins.rst b/docs/source/Zh/menu_plugins.rst index 5f214d6a..2e98ec75 100644 --- a/docs/source/Zh/menu_plugins.rst +++ b/docs/source/Zh/menu_plugins.rst @@ -42,7 +42,7 @@ Run With 選單 -------- 關於建立自訂外掛的詳細資訊,包括語法高亮外掛和 UI 翻譯外掛, -請參閱 `外掛指南 `_。 +請參閱 `外掛指南 `_。 語法高亮外掛範例 ^^^^^^^^^^^^^^^^ diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 964b5aef..d3a8e523 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -17,3 +17,9 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result**: a run uses the chosen interpreter; the working-directory search is only the fallback. For prthinker this means a 3.12+ interpreter chosen in the IDE is enough, even while PyBreeze itself runs on 3.11. - **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `pybreeze/extend/process_executor/prthinker/prthinker_process.py`, `test/test_utils/test_build_task_process.py` (new), `test/test_utils/test_prthinker_process.py`, `architecture_explore.md` §4.1–4.2. - **Open items**: none. + +## U-20260922-03 · 2026-09-22 · Point project URLs at the current repository · #done #metadata + +- **What**: replaced the misspelled or renamed GitHub owner in the project URLs (`Intergration-Automation-Testing/…`, and where present `JE-Chen/je_editor`, `JE-Chen/Integration-testing-environment`, the `AutoControl`/`AutomationEditor`/`AutomationFile`/`JEditor` repo names) with the current `origin`. Package metadata and clone commands now name `Integration-Automation/PyBreeze`; the sister-project links in the READMEs and the `github_url` of the APITestka, AutoControl, LoadDensity and WebRunner menus now use `Integration-Automation/…` (AutoControl → `AutoControlGUI`, AutomationFile → `FileAutomation`, JEditor → `JEDITOR`). The plugin-guide link in `docs/source/{Eng,Zh}/menu_plugins.rst` now targets `PyBreeze/blob/main/PLUGIN_GUIDE.md` (where it should point in the long run is workspace X-10). Workspace item X-11. Closes `progress.md` #9. +- **Files**: `README.md`, `README/README_zh-CN.md`, `README/README_zh-TW.md`, `dev.toml`, `docs/source/Eng/menu_plugins.rst`, `docs/source/Zh/menu_plugins.rst`, `pybreeze/pybreeze_ui/menu/automation_menu/api_testka_menu/build_api_testka_menu.py`, `pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py`, `pybreeze/pybreeze_ui/menu/automation_menu/load_density_menu/build_load_density_menu.py`, `pybreeze/pybreeze_ui/menu/automation_menu/web_runner_menu/build_webrunner_menu.py`, `pyproject.toml`. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index b44addd5..99c522aa 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -54,6 +54,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-03 | 2026-09-22 | Point project URLs at the current repository | #done #metadata | [2026-09](2026-09.md) | | U-20260922-02 | 2026-09-22 | Run windows ignored the interpreter chosen in the IDE | #incident #process-executor | [2026-09](2026-09.md) | | U-20260922-01 | 2026-09-22 | Adopt progress/architecture/docs-updates rules | #docs #migration | [2026-09](2026-09.md) | @@ -61,4 +62,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 2 | +| [2026-09.md](2026-09.md) | 2026-09 | 3 | diff --git a/progress.md b/progress.md index bf7eca18..17ec1b23 100644 --- a/progress.md +++ b/progress.md @@ -2,7 +2,7 @@ Outstanding work only. When an item is done, delete it in the same commit and add a `#done` entry to `docs/updates/` (format and query commands: `docs/updates/README.md`). No finished items, no history, no rules (rules live in `CLAUDE.md`). Item numbers (`#n`) are never reused. Tags: [DECIDE] needs the owner's decision, [BLOCKED] waits on something else, [UNVERIFIED] observed but not confirmed. -Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X-1, X-8, X-11, X-13, X-17). +Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X-1, X-8, X-13, X-17). ## Open @@ -14,4 +14,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#6** [UNVERIFIED] With Traditional Chinese selected at startup, PyBreeze's labels may be missing until the language is switched once: JEditor builds the merged dictionary before PyBreeze adds its strings to JEditor's word dicts (found by reading the code, not by running it). - **#7** PySide6 is pinned `==6.11.0` while je_editor and frontengine pin `==6.11.1`, so the latest releases cannot be installed together (workspace X-1). - **#8** PyBreeze imports JEditor modules that je_editor does not export (`PluginBrowserWidget`, `DestroyDock`, `check_and_choose_venv`, `choose_file_get_save_file_path`, `write_file`, `actually_color_dict`) and edits JEditor's `english_word_dict` / `traditional_chinese_word_dict` in place — an undocumented contract (workspace X-17). -- **#9** Package metadata and README clone instructions still point to `Intergration-Automation-Testing/AutomationEditor` (workspace X-11). 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 5c26d31e..6e51d354 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 @@ -34,7 +34,7 @@ def set_apitestka_menu(ui_we_want_to_set: PyBreezeMainWindow): 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_url="https://github.com/Integration-Automation/APITestka", github_label_key="apitestka_github_label", github_tab_label_key="apitestka_github_tab_label", create_project_func=safe_create_project("je_api_testka"), 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 5e52f685..7f331bcf 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 @@ -38,7 +38,7 @@ def set_autocontrol_menu(ui_we_want_to_set: PyBreezeMainWindow): 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_url="https://github.com/Integration-Automation/AutoControlGUI", github_label_key="autocontrol_github_label", github_tab_label_key="autocontrol_github_tab_label", create_project_func=safe_create_project("je_auto_control"), 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 f2409d8a..ace3070c 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 @@ -34,7 +34,7 @@ def set_load_density_menu(ui_we_want_to_set: PyBreezeMainWindow): 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_url="https://github.com/Integration-Automation/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"), 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 939ed422..721682a7 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 @@ -32,7 +32,7 @@ def set_web_runner_menu(ui_we_want_to_set: PyBreezeMainWindow): 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_url="https://github.com/Integration-Automation/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"), diff --git a/pyproject.toml b/pyproject.toml index ecb133f1..f76b2386 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,8 +33,8 @@ classifiers = [ ] [project.urls] -Homepage = "https://github.com/Intergration-Automation-Testing/AutomationEditor" -Code = "https://github.com/Intergration-Automation-Testing/AutomationEditor" +Homepage = "https://github.com/Integration-Automation/PyBreeze" +Code = "https://github.com/Integration-Automation/PyBreeze" [project.readme] file = "README.md" From d279973ad3aa04e8b1709269f9354bc5da2d6c1b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 16:29:36 +0800 Subject: [PATCH 004/312] Keep the output's indentation and share one run-window pipeline --- CLAUDE.md | 4 +- architecture.md | 4 +- architecture_explore.md | 42 ++-- docs/updates/2026-09.md | 16 ++ docs/updates/README.md | 4 +- progress.md | 2 - .../process_executor/file_runner_process.py | 95 +++------ .../python_task_process_manager.py | 76 +++----- .../extend/process_executor/queue_pump.py | 58 ++++-- .../test_pioneer_process_manager.py | 181 ++---------------- .../show_code_window/code_window.py | 31 ++- test/test_utils/test_code_window.py | 73 +++++++ test/test_utils/test_queue_pump.py | 60 +++++- test/test_utils/test_run_output.py | 126 ++++++++++++ 14 files changed, 435 insertions(+), 337 deletions(-) create mode 100644 test/test_utils/test_run_output.py diff --git a/CLAUDE.md b/CLAUDE.md index 2462f7ad..8817cd6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,10 +25,10 @@ pybreeze/ │ │ ├── python_task_process_manager.py # TaskProcessManager (subprocess + threads + QTimer) │ │ ├── process_executor_utils.py # build_process / start_process / run_dir_files_* │ │ ├── file_runner_process.py # FileRunnerProcess — plugin run configs (any language) -│ │ ├── queue_pump.py # Shared per-tick queue drain +│ │ ├── queue_pump.py # Shared pipe reader + per-tick queue drain │ │ ├── api_testka/ auto_control/ web_runner/ load_density/ │ │ ├── file_automation/ mail_thunder/ # Each delegates to build_process with its package name -│ │ ├── test_pioneer/ # TestPioneerProcess (custom variant) +│ │ ├── test_pioneer/ # python -m test_pioneer -e via start_module_process │ │ └── prthinker/ # Code review via start_module_process (secrets via env) │ ├── mail_thunder_extend/ # Post-test email report hook │ └── prthinker_extend/ # prthinker settings + argument assembly (pure logic, no Qt) diff --git a/architecture.md b/architecture.md index c777115e..a25ee030 100644 --- a/architecture.md +++ b/architecture.md @@ -80,7 +80,7 @@ gevent patching away from Qt. Automation menu (automation_menu_factory.build_automation_menu) → call_() in extend/process_executor// → build_process() (process_executor_utils.py) → CodeWindow + TaskProcessManager → python -m --execute_str | --execute_file - → stdout/stderr reader threads → Queue → QTimer → pump_message_queue() → CodeWindow + → stdout/stderr reader threads → Queue → QTimer → pump_message_queue() → CodeWindow.append_output() → optional send_after_test() (mail_thunder_extend) ``` @@ -123,7 +123,7 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() - **Automation packages**: `je_auto_control`, `je_web_runner`, `je_api_testka`, `je_load_density`, `automation_file` and `je_mail_thunder` run as `python -m --execute_str/--execute_file` (`extend/process_executor/python_task_process_manager.py`). TestPioneer runs as - `python -m test_pioneer -e ` through its own `TestPioneerProcess` + `python -m test_pioneer -e ` through the same manager's `start_module_process` (`extend/process_executor/test_pioneer/`). - **prthinker**: runs as `python -m prthinker` via `TaskProcessManager.start_module_process` (`extend/process_executor/prthinker/`). Secrets are passed as environment variables, never argv. diff --git a/architecture_explore.md b/architecture_explore.md index 03b45f9f..d578060d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -1,6 +1,6 @@ # PyBreeze 架構探勘 / Architecture Exploration -> 掃描範圍:`pybreeze/`(149 個 `.py`、約 16,400 行)+ `test/`、`exe/`、`docs/`、CI 設定 +> 掃描範圍:`pybreeze/`(189 個 `.py`、約 16,300 行)+ `test/`、`exe/`、`docs/`、CI 設定 > 對應版本:`pyproject.toml` 1.0.21(stable)/`dev.toml` 1.0.14(dev),分支 `dev` --- @@ -93,9 +93,9 @@ Template Method 定義的子行程生命週期: |---|---| | 解譯器解析 | `renew_path()` → 執行視窗帶著 IDE 選定的直譯器(`python_compiler`,由 `build_task_process()` 從主視窗抄過來)就用它;沒選才用 `find_venv_path()` 找 `venv/`、`.venv/`,交給 `check_and_choose_venv()`;找不到時**不拋例外**,直接把錯誤寫進執行視窗並回傳 `False` | | 啟動 | `subprocess.Popen(args, shell=False, creationflags=CREATE_NO_WINDOW, env=PYTHONIOENCODING=...)` | -| 讀取 | 兩條 daemon Thread 各自 `readline()` stdout / stderr,塞進 `Queue`;**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | -| 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則 | -| 收尾 | `exit_program()`:join 執行緒(timeout 2s)→ drain queue → `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | +| 讀取 | 兩條 daemon Thread 各自經 `queue_pump.read_stream_into_queue()` 對 stdout / stderr `readline()`,原樣塞進 `Queue`(保留縮排、行尾與空行);**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | +| 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則,交給 `CodeWindow.append_output()` | +| 收尾 | `exit_program()`:join 執行緒(timeout 2s)→ drain queue(`max_messages=None` 一次抽乾)→ `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | 三種啟動介面: @@ -133,17 +133,23 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `file_automation` | `automation_file` | | `mail_thunder` | `je_mail_thunder`(只有單一 `call_mail_thunder()`) | -### 4.4 兩個特化執行器 +`test_pioneer/test_pioneer_process_manager.py` 只剩 `init_and_start_test_pioneer_process()`:經 `build_task_process()` 開執行視窗,再用 `start_module_process("test_pioneer", ["-e", ])` 跑,跟其他套件走同一個 `TaskProcessManager`(找不到直譯器時一樣寫進執行視窗,不會從選單 callback 拋出)。 + +### 4.4 特化執行器 -- **`test_pioneer/test_pioneer_process_manager.py`** — `TestPioneerProcess`。跑 `python -m test_pioneer -e `。自帶一份幾乎與 `TaskProcessManager` 相同的 pump / drain / exit 邏輯(只有 `queue_pump` 被抽出共用)。 - **`file_runner_process.py`** — `FileRunnerProcess`。**唯一不跑 Python 的執行器**,服務插件註冊的 run config: - 直譯式:`compiler [args...] file`(如 `go run main.go`) - 編譯式:`compiler file -o out` → 執行 `out` → 執行完 `os.remove()` 清掉產物 - 編譯有 60 秒 timeout,QTimer 間隔 50 ms(比 Python 執行器更快) + - 讀取、pump、drain 與寫進視窗都用 §4.5 的共用函式 + +### 4.5 `queue_pump.py` 與 `CodeWindow.append_output()` -### 4.5 `queue_pump.py` +子行程輸出到執行視窗的整條管線,兩個執行器(`TaskProcessManager`、`FileRunnerProcess`)共用: -抽出來的共用 pump:`MAX_MESSAGES_PER_PUMP = 256`。註解說明得很清楚 — 每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。目前只有 `TaskProcessManager` 和 `TestPioneerProcess` 用它,`FileRunnerProcess` 仍是自己的迴圈。 +- `read_stream_into_queue(stream, queue, buffer_size, encoding, keep_reading)` — reader 執行緒用。行**原樣**進 queue(縮排、行尾、空行都留著);空讀 = EOF 即停,管線被關掉的 `OSError` / `ValueError` 記 debug 後停 +- `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 +- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。`\r\n` 與單獨的 `\r` 轉成換行;換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行 --- @@ -410,7 +416,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 66 個 `test_*.py`、989 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 67 個 `test_*.py`、1006 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) @@ -423,23 +429,17 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 以下是客觀觀察,不是缺陷判定,但值得留意: -1. **三份幾乎相同的 pump/drain 邏輯** — `TaskProcessManager`、`TestPioneerProcess`、`FileRunnerProcess` 各自實作 `_read_stream_into_queue` / `drain_*` / `_append_text`。`queue_pump.py` 只抽走了其中兩者的 pump 部分;`_append_text` 三處逐字重複(CLAUDE.md 的「6 行以上重複區塊應抽 helper」規則)。 - -2. **執行視窗會吃掉輸出的縮排** — `FileRunnerProcess._pull_text()` / `_drain_queues()` 與 `queue_pump.pump_message_queue()` 都對每一行做 `.strip()`。對日誌型輸出無感,但子行程若輸出程式碼或任何有縮排結構的文字,執行視窗裡會全部靠左(`images/run_output_window.png` 這張實拍截圖就看得到)。修法是只去掉行尾換行。 - -3. **`file_tree_context_menu.setup_file_tree_context_menu()` 用 monkey patch** — 直接覆寫 `main_window.tab_widget.addTab` 來攔截新分頁。可行但脆弱,若他處也包裝 `addTab` 會疊加。 - -4. **`PyBreezeMainWindow.__init__` 的 `extend` 參數語意分歧** — 對 `super()` 恆傳 `extend=True`,而參數本身只用來決定要不要設定 Windows AppUserModelID 與視窗圖示。 +1. **`file_tree_context_menu.setup_file_tree_context_menu()` 用 monkey patch** — 直接覆寫 `main_window.tab_widget.addTab` 來攔截新分頁。可行但脆弱,若他處也包裝 `addTab` 會疊加。 -5. **`start_editor()` 以 `os._exit(ret)` 收場** — 繞過 atexit 與 Qt 拆解。對 GUI 主程式常見(避免殘留執行緒卡住),但 `closeEvent` 之後的清理路徑等於不存在。 +2. **`PyBreezeMainWindow.__init__` 的 `extend` 參數語意分歧** — 對 `super()` 恆傳 `extend=True`,而參數本身只用來決定要不要設定 Windows AppUserModelID 與視窗圖示。 -6. **`mail_thunder_setting.send_after_test()` 捕捉裸 `Exception`** — 最後一個 `except Exception` 只記 log,符合「不讓寄信失敗炸掉測試」的意圖,但與 CLAUDE.md「避免捕捉 Exception 除非立即重拋」有張力。 +3. **`start_editor()` 以 `os._exit(ret)` 收場** — 繞過 atexit 與 Qt 拆解。對 GUI 主程式常見(避免殘留執行緒卡住),但 `closeEvent` 之後的清理路徑等於不存在。 -7. **`test_pioneer_process_manager` 建構子直接呼叫 `check_and_choose_venv()`** — 不像 `TaskProcessManager.renew_path()` 那樣把找不到解譯器的情況轉成視窗訊息,這裡會直接讓 `JEditorExecException` 從選單 callback 逃出去。 +4. **`mail_thunder_setting.send_after_test()` 捕捉裸 `Exception`** — 最後一個 `except Exception` 只記 log,符合「不讓寄信失敗炸掉測試」的意圖,但與 CLAUDE.md「避免捕捉 Exception 除非立即重拋」有張力。 -8. **`PackageManager` 名實不符** — 類別名暗示 pip 管理,實際只承載 `syntax_check_list` 六個項目;pip 安裝落在 `install_utils.install_package()`。 +5. **`PackageManager` 名實不符** — 類別名暗示 pip 管理,實際只承載 `syntax_check_list` 六個項目;pip 安裝落在 `install_utils.install_package()`。 -9. **`PLUGIN_GUIDE.md` 指向不存在的目錄** — 文件末尾說內建插件都在 `exe/jeditor_plugins/`,但該目錄不在 repo 裡,`je_editor` 套件內也只有 `plugin_loader.py`。README 已改為以「範例」描述這些插件。 +6. **`PLUGIN_GUIDE.md` 指向不存在的目錄** — 文件末尾說內建插件都在 `exe/jeditor_plugins/`,但該目錄不在 repo 裡,`je_editor` 套件內也只有 `plugin_loader.py`。README 已改為以「範例」描述這些插件。 --- diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index d3a8e523..8a1b3ee9 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -23,3 +23,19 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **What**: replaced the misspelled or renamed GitHub owner in the project URLs (`Intergration-Automation-Testing/…`, and where present `JE-Chen/je_editor`, `JE-Chen/Integration-testing-environment`, the `AutoControl`/`AutomationEditor`/`AutomationFile`/`JEditor` repo names) with the current `origin`. Package metadata and clone commands now name `Integration-Automation/PyBreeze`; the sister-project links in the READMEs and the `github_url` of the APITestka, AutoControl, LoadDensity and WebRunner menus now use `Integration-Automation/…` (AutoControl → `AutoControlGUI`, AutomationFile → `FileAutomation`, JEditor → `JEDITOR`). The plugin-guide link in `docs/source/{Eng,Zh}/menu_plugins.rst` now targets `PyBreeze/blob/main/PLUGIN_GUIDE.md` (where it should point in the long run is workspace X-10). Workspace item X-11. Closes `progress.md` #9. - **Files**: `README.md`, `README/README_zh-CN.md`, `README/README_zh-TW.md`, `dev.toml`, `docs/source/Eng/menu_plugins.rst`, `docs/source/Zh/menu_plugins.rst`, `pybreeze/pybreeze_ui/menu/automation_menu/api_testka_menu/build_api_testka_menu.py`, `pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py`, `pybreeze/pybreeze_ui/menu/automation_menu/load_density_menu/build_load_density_menu.py`, `pybreeze/pybreeze_ui/menu/automation_menu/web_runner_menu/build_webrunner_menu.py`, `pyproject.toml`. - **Open items**: none. + +## U-20260922-04 · 2026-09-22 · Run window keeps the output's indentation · #done #process-executor + +- **What**: `progress.md` #2. The reader threads dropped whitespace-only lines, and `pump_message_queue()`, `FileRunnerProcess._pull_text()` / `_drain_queues()` and both drain methods called `.strip()` on every line, so indented output (code, JSON, tracebacks) arrived flush left and blank lines vanished. +- **Fix**: lines now travel exactly as read. `CodeWindow.append_output()` turns `\r\n` and a lone `\r` into line breaks and inserts nothing else, so a line longer than the read buffer, which arrives in pieces, joins up again instead of being broken at each piece. Status messages the window writes itself (`Task exit with code …`, the missing-interpreter error) pass `own_line=True`, which starts them on a fresh line when the program left one unfinished. +- **Result / numbers**: `python -m json.tool` output shows its 4- and 8-space indentation in the run window; before the change the same test showed `def f():\nreturn 1\nwarned`. Unit tests 985 → 1006 across this entry and U-20260922-02/-05. +- **Files**: `pybreeze/extend/process_executor/queue_pump.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `test/test_utils/test_queue_pump.py`, `test/test_utils/test_code_window.py`, `test/test_utils/test_run_output.py` (new, runs real child processes through the whole pipeline). +- **Open items**: none. + +## U-20260922-05 · 2026-09-22 · One output pipeline for every run window · #done #process-executor + +- **What**: `progress.md` #3. `TaskProcessManager`, `TestPioneerProcess` and `FileRunnerProcess` each carried their own reader loop, drain loops and a verbatim copy of `_append_text`; the TestPioneer runner also resolved its interpreter in its constructor with `check_and_choose_venv()` and let `JEditorExecException` escape the menu callback. +- **Fix**: one pipeline, used by both remaining executors. `queue_pump.read_stream_into_queue()` is the reader thread body; `pump_message_queue(..., max_messages=None)` is the final drain; `CodeWindow.append_output()` replaces the three `_append_text` copies. `TestPioneerProcess` (≈180 lines) is gone: `init_and_start_test_pioneer_process()` opens a run window with `build_task_process()` and calls `start_module_process("test_pioneer", ["-e", ])`, so a missing interpreter is written to the run window like everywhere else. `FileRunnerProcess._pull_text()` now drains at most 256 messages per tick like the other executor instead of emptying the queue in one tick. +- **Also fixed**: `_append_text` wrote at the widget's own cursor, which follows the user's clicks and selections. Selecting text in a run window while the program was still printing replaced the selection with the new output (reproduced offscreen: `first\nsecond\n` + select `first` + append `third\n` gave `third\n\nsecond\n`). `append_output()` always writes at the end of the document. +- **Files**: `pybreeze/extend/process_executor/{queue_pump,python_task_process_manager,file_runner_process}.py`, `pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, tests as in U-20260922-04, `architecture_explore.md` §4.1, §4.3–4.5, §18–19, `architecture.md` §4 and §6, `CLAUDE.md` tree. The matching `README.md` tree line went in early, with 2622645. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 99c522aa..51bc95af 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -54,6 +54,8 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-05 | 2026-09-22 | One output pipeline for every run window | #done #process-executor | [2026-09](2026-09.md) | +| U-20260922-04 | 2026-09-22 | Run window keeps the output's indentation | #done #process-executor | [2026-09](2026-09.md) | | U-20260922-03 | 2026-09-22 | Point project URLs at the current repository | #done #metadata | [2026-09](2026-09.md) | | U-20260922-02 | 2026-09-22 | Run windows ignored the interpreter chosen in the IDE | #incident #process-executor | [2026-09](2026-09.md) | | U-20260922-01 | 2026-09-22 | Adopt progress/architecture/docs-updates rules | #docs #migration | [2026-09](2026-09.md) | @@ -62,4 +64,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 3 | +| [2026-09.md](2026-09.md) | 2026-09 | 5 | diff --git a/progress.md b/progress.md index 17ec1b23..48d249f2 100644 --- a/progress.md +++ b/progress.md @@ -7,8 +7,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open - **#1** [BLOCKED] The prthinker integration (menu, settings, install; commit `99ef96f`) has never run a real review: this machine's PyBreeze venv is Python 3.11 and prthinker needs 3.12 or newer. Install prthinker with a 3.12+ interpreter and run one review (moved from JEditor's `PROGRESS.md`; workspace X-8). -- **#2** The run window strips indentation from program output: `.strip()` in `pybreeze/extend/process_executor/queue_pump.py:36` and `file_runner_process.py:150` (`architecture_explore.md` ≈:428). -- **#3** `_append_text` is duplicated verbatim in three places, and the TestPioneer runner raises out when no interpreter is found (`architecture_explore.md` ≈:426, ≈:438). - **#4** `requirements.txt` lists `pybreeze` itself and `dev_requirements.txt` lists `pybreeze_dev`, so CI installs the PyPI build instead of the checkout. FrontEngine fixed the same problem (see the header comment of its `requirements.txt`). - **#5** `CLAUDE.md` "Branching & CI" says `dev` publishes `pybreeze_dev`, but `dev.yml` has no publish job and `dev.toml` is not kept in step (workspace X-13). - **#6** [UNVERIFIED] With Traditional Chinese selected at startup, PyBreeze's labels may be missing until the language is switched once: JEditor builds the merged dictionary before PyBreeze adds its strings to JEditor's word dicts (found by reading the code, not by running it). diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index 3bfc2ac1..de4820a3 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -8,7 +8,6 @@ from __future__ import annotations import os -import queue import subprocess import sys from pathlib import Path @@ -16,10 +15,8 @@ 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 pybreeze.extend.process_executor.queue_pump import pump_message_queue, read_stream_into_queue from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.subprocess_util import no_window_creationflags, utf8_subprocess_env @@ -74,7 +71,7 @@ def _compile_and_run(self, compiler: str, args: list, output_flag: str, file_pat output_name += ".exe" compile_cmd = [compiler] + args + [file_path, output_flag, output_name] - self._append_text(f"[Compile] {' '.join(compile_cmd)}\n", is_error=False) + self.main_window.append_output(f"[Compile] {' '.join(compile_cmd)}\n", is_error=False) try: # Runs the plugin-configured compiler against a file the user opened. @@ -87,22 +84,22 @@ def _compile_and_run(self, compiler: str, args: list, output_flag: str, file_pat creationflags=no_window_creationflags(), ) except FileNotFoundError: - self._append_text(f"[Error] Compiler not found: {compiler}\n", is_error=True) + self.main_window.append_output(f"[Error] Compiler not found: {compiler}\n", is_error=True) return except subprocess.TimeoutExpired: - self._append_text("[Error] Compilation timed out (60s)\n", is_error=True) + self.main_window.append_output("[Error] Compilation timed out (60s)\n", is_error=True) return if result.stdout: - self._append_text(result.stdout.decode(self.program_encoding, "replace"), is_error=False) + self.main_window.append_output(result.stdout.decode(self.program_encoding, "replace"), is_error=False) if result.stderr: - self._append_text(result.stderr.decode(self.program_encoding, "replace"), is_error=True) + self.main_window.append_output(result.stderr.decode(self.program_encoding, "replace"), is_error=True) if result.returncode != 0: - self._append_text(f"[Compile failed] exit code {result.returncode}\n", is_error=True) + self.main_window.append_output(f"[Compile failed] exit code {result.returncode}\n", is_error=True) return - self._append_text(f"[Run] {output_name}\n", is_error=False) + self.main_window.append_output(f"[Run] {output_name}\n", is_error=False) self._start_process([output_name], cleanup_binary=output_name) def _start_process(self, command: list[str], cleanup_binary: str | None = None) -> None: @@ -110,7 +107,7 @@ def _start_process(self, command: list[str], cleanup_binary: str | None = None) self._cleanup_binary = cleanup_binary cmd_display = " ".join(command) - self._append_text(f"> {cmd_display}\n", is_error=False) + self.main_window.append_output(f"> {cmd_display}\n", is_error=False) try: # Run the user's plugin-configured command. shell=False is explicit; @@ -125,7 +122,7 @@ def _start_process(self, command: list[str], cleanup_binary: str | None = None) env=utf8_subprocess_env(self.program_encoding), ) except FileNotFoundError: - self._append_text(f"[Error] Command not found: {command[0]}\n", is_error=True) + self.main_window.append_output(f"[Error] Command not found: {command[0]}\n", is_error=True) return self.still_running = True @@ -144,23 +141,8 @@ def _start_process(self, command: list[str], cleanup_binary: str | None = None) def _pull_text(self) -> None: """Timer callback: pump queues to UI.""" - try: - while not self.output_queue.empty(): - msg = self.output_queue.get_nowait() - msg = str(msg).strip() - if msg: - self._append_text(msg + "\n", is_error=False) - except queue.Empty: - pass - - try: - while not self.error_queue.empty(): - msg = self.error_queue.get_nowait() - msg = str(msg).strip() - if msg: - self._append_text(msg + "\n", is_error=True) - except queue.Empty: - pass + pump_message_queue(self.output_queue, self.main_window.append_output, is_error=False) + pump_message_queue(self.error_queue, self.main_window.append_output, is_error=True) if self.process is not None: self.process.poll() @@ -185,7 +167,7 @@ def _finish(self) -> None: self._drain_queues() if self.process is not None: - self._append_text( + self.main_window.append_output( f"\n[Process exited with code {self.process.returncode}]\n", is_error=self.process.returncode != 0, ) @@ -200,51 +182,22 @@ def _finish(self) -> None: def _drain_queues(self) -> None: """Drain all remaining messages from output/error queues to UI.""" - while not self.output_queue.empty(): - try: - msg = self.output_queue.get_nowait() - msg = str(msg).strip() - if msg: - self._append_text(msg + "\n", is_error=False) - except queue.Empty: - break - while not self.error_queue.empty(): - try: - msg = self.error_queue.get_nowait() - msg = str(msg).strip() - if msg: - self._append_text(msg + "\n", is_error=True) - except queue.Empty: - break + pump_message_queue(self.output_queue, self.main_window.append_output, is_error=False, max_messages=None) + pump_message_queue(self.error_queue, self.main_window.append_output, is_error=True, max_messages=None) def _read_stream(self, stream_name: str, target_queue: Queue) -> None: - # Empty read from readline means the pipe reached EOF (the child closed - # the stream), so stop immediately rather than spinning on a closed pipe - # until the process is reaped. - try: - while self.still_running: - proc = self.process - if proc is None: - break - data = getattr(proc, stream_name).readline(self.program_buffer_size) - if not data: - break - if isinstance(data, bytes): - data = data.decode(self.program_encoding, "replace") - target_queue.put(data) - except (OSError, ValueError) as error: - pybreeze_logger.debug("Reader for %s stopped: %s", stream_name, error) + stream = getattr(self.process, stream_name, None) + if stream is None: + return + read_stream_into_queue( + stream, target_queue, + buffer_size=self.program_buffer_size, + encoding=self.program_encoding, + keep_reading=lambda: self.still_running, + ) def _read_stdout(self) -> None: self._read_stream("stdout", self.output_queue) def _read_stderr(self) -> None: self._read_stream("stderr", self.error_queue) - - def _append_text(self, text: str, is_error: bool) -> 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) diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 79af2aff..7a57c8d0 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import queue import subprocess import sys import threading @@ -11,12 +10,10 @@ 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.exception.exceptions import JEditorExecException from je_editor.utils.venv_check.check_venv import check_and_choose_venv -from pybreeze.extend.process_executor.queue_pump import pump_message_queue +from pybreeze.extend.process_executor.queue_pump import pump_message_queue, read_stream_into_queue from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.subprocess_util import no_window_creationflags, utf8_subprocess_env @@ -77,7 +74,8 @@ def renew_path(self) -> bool: self.compiler_path = check_and_choose_venv(venv_path) except JEditorExecException as error: pybreeze_logger.error("No Python interpreter found for run: %r", error) - self._append_text(f"[Error] No Python interpreter found: {error}", is_error=True) + self.main_window.append_output( + f"[Error] No Python interpreter found: {error}\n", is_error=True, own_line=True) self.main_window.show() return False else: @@ -159,19 +157,10 @@ def _spawn_and_pump( 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): - pump_message_queue(self.run_output_queue, self._append_text, is_error=False) - pump_message_queue(self.run_error_queue, self._append_text, is_error=True) + pump_message_queue(self.run_output_queue, self.main_window.append_output, is_error=False) + pump_message_queue(self.run_error_queue, self.main_window.append_output, is_error=True) if self.process is None: if self.timer.isActive(): self.timer.stop() @@ -196,52 +185,31 @@ def exit_program(self): self.drain_and_display_queue() if self.process is not None: self.process.terminate() - self._append_text(f"Task exit with code {self.process.returncode}") + self.main_window.append_output( + f"Task exit with code {self.process.returncode}\n", own_line=True) self.process = None if self.task_done_trigger_function is not None: try: self.task_done_trigger_function() - except Exception as e: - pybreeze_logger.error(f"Task done trigger failed: {e}") + except Exception as error: # noqa: BLE001 — a failing hook (e.g. the report mail) must not break the run window + pybreeze_logger.error("Task done trigger failed: %r", error) def drain_and_display_queue(self): - while not self.run_output_queue.empty(): - try: - output_message = str(self.run_output_queue.get_nowait()).strip() - if output_message: - self._append_text(output_message) - except queue.Empty: - break - while not self.run_error_queue.empty(): - try: - error_message = str(self.run_error_queue.get_nowait()).strip() - if error_message: - self._append_text(error_message, is_error=True) - except queue.Empty: - break + pump_message_queue( + self.run_output_queue, self.main_window.append_output, is_error=False, max_messages=None) + pump_message_queue( + self.run_error_queue, self.main_window.append_output, is_error=True, max_messages=None) def _read_stream_into_queue(self, stream_name: str, target_queue: Queue) -> None: - # Block on readline until a line arrives or the pipe hits EOF. Stopping on - # EOF (empty read) is essential: without it the loop spins at 100% CPU - # re-reading a closed pipe until the QTimer notices the process exited. - while self.still_run_program: - proc = self.process - if proc is None: - break - stream = getattr(proc, stream_name) - if stream is None: - break - try: - line = stream.readline(self.program_buffer_size) - except (ValueError, OSError): - # Pipe closed underneath us during shutdown. - break - if not line: - break - if isinstance(line, bytes): - line = line.decode(self.program_encoding, "replace") - if line.strip(): - target_queue.put(line) + stream = getattr(self.process, stream_name, None) + if stream is None: + return + read_stream_into_queue( + stream, target_queue, + buffer_size=self.program_buffer_size, + encoding=self.program_encoding, + keep_reading=lambda: self.still_run_program, + ) def read_program_output_from_process(self): self._read_stream_into_queue("stdout", self.run_output_queue) diff --git a/pybreeze/extend/process_executor/queue_pump.py b/pybreeze/extend/process_executor/queue_pump.py index bcd4eaae..220c6bd4 100644 --- a/pybreeze/extend/process_executor/queue_pump.py +++ b/pybreeze/extend/process_executor/queue_pump.py @@ -1,15 +1,19 @@ -"""Shared helper for the process-executor QTimer pump loop. +"""Shared plumbing between a child process's pipes and its run window. -The Python task runner and the Test Pioneer runner both drain the same -``(output_queue, error_queue) → append_text`` pattern on a ~100 ms timer. -This module factors the per-tick drain into a single function so changes -(e.g., multi-message batching) can be made in one place. +Every executor moves output the same way: a reader thread per pipe copies lines +into a ``Queue``, and a ~100 ms QTimer on the UI thread drains the queues into +the run window. This module holds both halves so a change (batching, EOF +handling, what counts as a line) is made in one place. """ from __future__ import annotations +import itertools import queue from collections.abc import Callable from queue import Queue +from typing import IO + +from pybreeze.utils.logging.logger import pybreeze_logger # Drain up to this many messages per timer tick. Draining one line per ~100 ms # tick caps throughput at ~10 lines/s and makes chatty scripts crawl; batching @@ -18,22 +22,54 @@ MAX_MESSAGES_PER_PUMP = 256 +def read_stream_into_queue( + stream: IO, + target_queue: Queue, + *, + buffer_size: int, + encoding: str, + keep_reading: Callable[[], bool], +) -> None: + """Copy *stream* into *target_queue* line by line, exactly as read. + + Runs on a reader thread. Stops when *keep_reading* turns false or the pipe + reaches EOF. Stopping on EOF (an empty read) is essential: without it the + loop spins at 100% CPU re-reading a closed pipe until the QTimer notices the + process exited. Lines keep their leading whitespace and line ending, and + blank lines are kept, so the run window shows the output as it was written. + """ + while keep_reading(): + try: + line = stream.readline(buffer_size) + except (OSError, ValueError) as error: + # The pipe was closed underneath us during shutdown. + pybreeze_logger.debug("Output reader stopped: %s", error) + break + if not line: + break + if isinstance(line, bytes): + line = line.decode(encoding, "replace") + target_queue.put(line) + + def pump_message_queue( q: Queue, append_fn: Callable[[str, bool], None], *, is_error: bool, - max_messages: int = MAX_MESSAGES_PER_PUMP, + max_messages: int | None = MAX_MESSAGES_PER_PUMP, ) -> None: """Drain up to *max_messages* pending messages from *q* into *append_fn*. - Stops early when the queue empties. Empty strings (after stripping) are - skipped. ``queue.Empty`` from the racy non-blocking get is treated as a - clean stop, not an error. + ``None`` drains until the queue is empty, which is what an executor wants + once its process has exited. Messages are passed on unchanged; only empty + strings are skipped. ``queue.Empty`` from the racy non-blocking get is + treated as a clean stop, not an error. """ - for _ in range(max_messages): + budget = itertools.count() if max_messages is None else range(max_messages) + for _ in budget: try: - message = str(q.get_nowait()).strip() + message = str(q.get_nowait()) except queue.Empty: return if message: 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 47735abc..b1922de2 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 @@ -1,181 +1,22 @@ from __future__ import annotations -import queue -import subprocess -import threading -from queue import Queue from typing import TYPE_CHECKING -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.extend.process_executor.python_task_process_manager import find_venv_path -from pybreeze.extend.process_executor.queue_pump import pump_message_queue -from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow -from pybreeze.utils.subprocess_util import no_window_creationflags, utf8_subprocess_env +from pybreeze.extend.process_executor.process_executor_utils import build_task_process if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow +_PACKAGE = "test_pioneer" -class TestPioneerProcess: - - def __init__( - self, - main_window: PyBreezeMainWindow, - executable_path: str, - program_buffer: int = 1024000, - encoding: str = "utf-8", - ): - self._main_window: PyBreezeMainWindow = main_window - # Code window init - self._code_window = CodeWindow() - self._main_window.current_run_code_window.append(self._code_window) - self._main_window.clear_code_result() - self._still_run_program: bool = False - self._program_buffer_size = program_buffer - self._program_encoding = encoding - self._run_output_queue: Queue = Queue() - self._run_error_queue: Queue = Queue() - self._read_program_error_output_from_thread: threading.Thread | None = None - 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: - venv_path = find_venv_path() - self._compiler_path = check_and_choose_venv(venv_path) - else: - self._compiler_path = main_window.python_compiler - args = [ - str(self._compiler_path), - "-m", - "test_pioneer", - "-e", - executable_path - ] - # Launch the test_pioneer CLI in the user's configured Python interpreter. - # Argument list is assembled from a curated template + an executable path the - # user selected via file dialog. shell=False. nosec B603. - self._process: subprocess.Popen | None = subprocess.Popen( # nosec B603 # nosemgrep # noqa: S603 - args, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - creationflags=no_window_creationflags(), - env=utf8_subprocess_env(self._program_encoding), - ) - - 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): - pump_message_queue(self._run_output_queue, self._append_text, is_error=False) - pump_message_queue(self._run_error_queue, self._append_text, is_error=True) - if self._process is None: - if self._timer.isActive(): - self._timer.stop() - return - if self._process.returncode is not None: - if self._timer.isActive(): - self._timer.stop() - self.exit_program() - elif self._still_run_program: - self._process.poll() - - # 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() - 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 = str(self._run_output_queue.get_nowait()).strip() - if output_message: - self._append_text(output_message) - except queue.Empty: - break - while not self._run_error_queue.empty(): - try: - error_message = str(self._run_error_queue.get_nowait()).strip() - if error_message: - self._append_text(error_message, is_error=True) - except queue.Empty: - break - - def _read_stream_into_queue(self, stream_name: str, target_queue: Queue) -> None: - # Block on readline until a line arrives or the pipe hits EOF. Stopping on - # EOF (empty read) is essential: without it the loop spins at 100% CPU - # re-reading a closed pipe until the QTimer notices the process exited. - while self._still_run_program: - proc = self._process - if proc is None: - break - stream = getattr(proc, stream_name) - if stream is None: - break - try: - line = stream.readline(self._program_buffer_size) - except (ValueError, OSError): - # Pipe closed underneath us during shutdown. - break - if not line: - break - if isinstance(line, bytes): - line = line.decode(self._program_encoding, "replace") - if line.strip(): - target_queue.put(line) - - def read_program_output_from_process(self): - self._read_stream_into_queue("stdout", self._run_output_queue) - - def read_program_error_output_from_process(self): - self._read_stream_into_queue("stderr", self._run_error_queue) - - def start_test_pioneer_process(self): - self._still_run_program = True - # program output message queue thread - self._read_program_output_from_thread = threading.Thread( - target=self.read_program_output_from_process, - daemon=True - ) - self._read_program_output_from_thread.start() - # program error message queue thread - self._read_program_error_output_from_thread = threading.Thread( - target=self.read_program_error_output_from_process, - daemon=True - ) - self._read_program_error_output_from_thread.start() - # start Pyside update - # start timer - self._code_window.setWindowTitle("Test Pioneer") - self._code_window.show() - self._timer = QTimer(self._code_window) - self._timer.setInterval(100) - self._timer.timeout.connect(self.pull_text) - self._timer.start() +def init_and_start_test_pioneer_process( + ui_we_want_to_set: PyBreezeMainWindow, file_path: str, program_buffer: int = 1024000) -> None: + """Run ``python -m test_pioneer -e `` in a new run window. -def init_and_start_test_pioneer_process(ui_we_want_to_set: PyBreezeMainWindow, file_path: str): - test_pioneer_process_manager = TestPioneerProcess( - main_window=ui_we_want_to_set, executable_path=file_path) - test_pioneer_process_manager.start_test_pioneer_process() + It goes through the same task process manager as the other automation + packages, so a missing interpreter is reported in the run window rather + than raised out of the menu callback. + """ + process = build_task_process(ui_we_want_to_set, program_buffer=program_buffer) + process.start_module_process(_PACKAGE, ["-e", file_path]) diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index e1a56576..0e090f6d 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -1,6 +1,7 @@ from __future__ import annotations -from PySide6.QtGui import QGuiApplication +from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict +from PySide6.QtGui import QGuiApplication, QTextCharFormat, QTextCursor from PySide6.QtWidgets import QWidget, QGridLayout, QTextEdit, QScrollArea # Cap the output scrollback so a runaway script (e.g. an infinite print loop) @@ -9,6 +10,15 @@ MAX_OUTPUT_BLOCKS = 10000 +def normalize_line_endings(text: str) -> str: + """Turn ``\r\n`` and a lone ``\r`` into ``\n``. + + A child on Windows writes ``\r\n``, and a progress bar rewinds its line + with a bare ``\r``; the text widget only breaks lines on ``\n``. + """ + return text.replace("\r\n", "\n").replace("\r", "\n") + + class CodeWindow(QWidget): def __init__(self): @@ -37,3 +47,22 @@ def __init__(self): self.resize(500, 500) self.setLayout(self.grid_layout) self.setFocus() + + def append_output(self, text: str, is_error: bool = False, *, own_line: bool = False) -> None: + """Append *text* to the end of the output, in the normal or the error colour. + + The text goes in as written: a line break is wherever *text* has one. + *own_line* is for the window's own status messages, which should not + continue a line the program left unfinished. The text always lands at + the end, because the widget's own cursor follows the user's clicks and + selections, and writing there would splice output into the middle or + overwrite whatever the user had selected. + """ + cursor = QTextCursor(self.code_result.document()) + cursor.movePosition(QTextCursor.MoveOperation.End) + if own_line and cursor.positionInBlock() > 0: + text = "\n" + text + text_format = QTextCharFormat() + color_key = "error_output_color" if is_error else "normal_output_color" + text_format.setForeground(actually_color_dict.get(color_key)) + cursor.insertText(normalize_line_endings(text), text_format) diff --git a/test/test_utils/test_code_window.py b/test/test_utils/test_code_window.py index fca16396..3f56ee28 100644 --- a/test/test_utils/test_code_window.py +++ b/test/test_utils/test_code_window.py @@ -43,3 +43,76 @@ def test_old_lines_dropped_once_capped(self, qt_app): text = window.code_result.toPlainText() assert "line 499" in text # newest kept assert "line 0\n" not in text # oldest dropped + + +class TestAppendOutput: + def test_indentation_and_blank_lines_are_kept(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + for line in ["def f():\n", " return 1\n", "\n", "\tdone\n"]: + window.append_output(line) + + assert window.code_result.toPlainText() == "def f():\n return 1\n\n\tdone\n" + + def test_windows_and_carriage_return_endings_become_line_breaks(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("one\r\ntwo\r 50%\r100%\n") + + assert window.code_result.toPlainText() == "one\ntwo\n 50%\n100%\n" + + def test_pieces_of_one_line_join_up(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + for piece in ["abcd", "efgh", "ij\n"]: + window.append_output(piece) + + assert window.code_result.toPlainText() == "abcdefghij\n" + + def test_a_selection_is_not_overwritten(self, qt_app): + from PySide6.QtGui import QTextCursor + + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("first\nsecond\n") + selection = window.code_result.textCursor() + selection.setPosition(0) + selection.setPosition(5, QTextCursor.MoveMode.KeepAnchor) + window.code_result.setTextCursor(selection) + + window.append_output("third\n") + + assert window.code_result.toPlainText() == "first\nsecond\nthird\n" + + def test_own_line_starts_after_an_unfinished_line(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("progress 90%") + window.append_output("Task exit with code 0\n", own_line=True) + window.append_output("Task exit with code 0\n", own_line=True) + + assert window.code_result.toPlainText() == ( + "progress 90%\nTask exit with code 0\nTask exit with code 0\n") + + def test_errors_use_the_error_colour(self, qt_app): + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + actually_color_dict, + ) + from PySide6.QtGui import QTextCursor + + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("ok\n") + window.append_output("boom\n", is_error=True) + + cursor = QTextCursor(window.code_result.document()) + cursor.setPosition(1) + assert cursor.charFormat().foreground().color() == actually_color_dict["normal_output_color"] + cursor.setPosition(len("ok\n") + 1) + assert cursor.charFormat().foreground().color() == actually_color_dict["error_output_color"] diff --git a/test/test_utils/test_queue_pump.py b/test/test_utils/test_queue_pump.py index 36b933db..dc2a6b88 100644 --- a/test/test_utils/test_queue_pump.py +++ b/test/test_utils/test_queue_pump.py @@ -1,10 +1,12 @@ from __future__ import annotations +import io from queue import Queue from pybreeze.extend.process_executor.queue_pump import ( MAX_MESSAGES_PER_PUMP, pump_message_queue, + read_stream_into_queue, ) @@ -22,6 +24,16 @@ def _collect_pump(messages, *, max_messages=MAX_MESSAGES_PER_PUMP): return q, received +def _read_all(data: bytes, *, keep_reading=lambda: True) -> list[str]: + q: Queue = Queue() + read_stream_into_queue( + io.BytesIO(data), q, buffer_size=1024, encoding="utf-8", keep_reading=keep_reading) + items = [] + while not q.empty(): + items.append(q.get()) + return items + + class TestPumpBatching: def test_drains_many_messages_in_one_call(self): q, received = _collect_pump([f"line {i}" for i in range(50)]) @@ -33,12 +45,22 @@ def test_respects_max_messages_bound(self): assert len(received) == 4 assert q.qsize() == 6 # remaining drained on later ticks + def test_none_drains_everything(self): + q, received = _collect_pump( + [f"line {i}" for i in range(MAX_MESSAGES_PER_PUMP * 3)], max_messages=None) + assert len(received) == MAX_MESSAGES_PER_PUMP * 3 + assert q.empty() + def test_empty_queue_is_noop(self): _, received = _collect_pump([]) assert received == [] - def test_blank_messages_are_skipped(self): - _, received = _collect_pump([" ", "\n", "real"]) + def test_messages_pass_through_unchanged(self): + _, received = _collect_pump([" indented\n", "\n", " \n", "last"]) + assert [text for text, _ in received] == [" indented\n", "\n", " \n", "last"] + + def test_only_empty_messages_are_skipped(self): + _, received = _collect_pump(["", "real"]) assert received == [("real", False)] def test_is_error_flag_forwarded(self): @@ -49,3 +71,37 @@ def test_is_error_flag_forwarded(self): q, lambda text, is_error: received.append((text, is_error)), is_error=True ) assert received == [("boom", True)] + + +class TestReadStreamIntoQueue: + def test_lines_keep_indentation_blank_lines_and_endings(self): + data = b"def f():\n return 1\n\n\tdone\r\n" + assert _read_all(data) == ["def f():\n", " return 1\n", "\n", "\tdone\r\n"] + + def test_bytes_are_decoded_with_replacement(self): + assert _read_all("café\n".encode() + b"\xff\n") == ["café\n", "�\n"] + + def test_stops_when_told_to(self): + assert _read_all(b"one\ntwo\n", keep_reading=lambda: False) == [] + + def test_a_closed_pipe_ends_the_reader(self): + class Closed: + def readline(self, _size): + raise ValueError("I/O operation on closed file") + + q: Queue = Queue() + read_stream_into_queue( + Closed(), q, buffer_size=16, encoding="utf-8", keep_reading=lambda: True) + assert q.empty() + + def test_a_line_longer_than_the_buffer_arrives_in_pieces(self): + # The pieces carry no line break of their own, so displayed back to back + # they form the original line again. + pieces = [] + q: Queue = Queue() + read_stream_into_queue( + io.BytesIO(b"abcdefghij\n"), q, buffer_size=4, encoding="utf-8", + keep_reading=lambda: True) + while not q.empty(): + pieces.append(q.get()) + assert pieces == ["abcd", "efgh", "ij\n"] diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py new file mode 100644 index 00000000..42fbfb25 --- /dev/null +++ b/test/test_utils/test_run_output.py @@ -0,0 +1,126 @@ +"""A child's output, end to end: pipe, reader thread, queue, timer pump, run window.""" +from __future__ import annotations + +import json +import os +import sys +import time + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +# A child process on a busy CI runner can take a while to start. +_RUN_TIMEOUT_SECONDS = 30 + + +@pytest.fixture(scope="module") +def qt_app(): + from PySide6.QtWidgets import QApplication + app = QApplication.instance() + if app is None: + try: + app = QApplication([]) + except Exception as exc: # pragma: no cover - no usable Qt platform + pytest.skip(f"Cannot start QApplication: {exc}") + return app + + +def _run_events_until(qt_app, finished) -> None: + deadline = time.monotonic() + _RUN_TIMEOUT_SECONDS + while not finished(): + if time.monotonic() > deadline: + pytest.fail("the child process did not finish in time") + qt_app.processEvents() + time.sleep(0.01) + + +class MainWindow: + """The little of the IDE's main window that opening a run window touches.""" + + def __init__(self, python_compiler=None) -> None: + self.python_compiler = python_compiler + self.current_run_code_window: list = [] + self.encoding = "utf-8" + + def clear_code_result(self) -> None: + """Nothing to clear in a stand-in.""" + + +class TestTaskProcessOutput: + def test_indented_output_keeps_its_indentation(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + data = tmp_path / "data.json" + data.write_text(json.dumps({"outer": {"inner": 1}}), encoding="utf-8") + process = build_task_process(MainWindow(sys.executable)) + + process.start_module_process("json.tool", [str(data)]) + _run_events_until(qt_app, lambda: process.process is None) + + text = process.main_window.code_result.toPlainText() + assert '{\n "outer": {\n "inner": 1\n }\n}\n' in text + assert text.endswith("Task exit with code 0\n") + + +class TestFileRunnerOutput: + def test_indentation_and_blank_lines_survive(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + script = tmp_path / "show.py" + script.write_text( + "import sys\n" + "print('def f():')\n" + "print(' return 1')\n" + "print()\n" + "print(' warned', file=sys.stderr)\n", + encoding="utf-8", + ) + window = CodeWindow() + runner = FileRunnerProcess(window) + + runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + _run_events_until(qt_app, lambda: runner.process is None) + + text = window.code_result.toPlainText() + assert "def f():\n return 1\n\n" in text + assert " warned\n" in text + assert text.endswith("[Process exited with code 0]\n") + + +class TestTestPioneerRun: + def test_runs_the_chosen_yaml_through_the_task_manager(self, monkeypatch): + from pybreeze.extend.process_executor.test_pioneer import test_pioneer_process_manager + + calls: list = [] + + class Recorder: + def start_module_process(self, package, arguments, environment=None): + calls.append((package, list(arguments), environment)) + + monkeypatch.setattr( + test_pioneer_process_manager, "build_task_process", + lambda main_window, program_buffer: Recorder()) + + test_pioneer_process_manager.init_and_start_test_pioneer_process( + MainWindow(), "C:/tests/run.yml") + + assert calls == [("test_pioneer", ["-e", "C:/tests/run.yml"], None)] + + def test_no_interpreter_is_reported_not_raised(self, qt_app, monkeypatch): + from je_editor.utils.exception.exceptions import JEditorExecException + + from pybreeze.extend.process_executor import python_task_process_manager as manager_module + from pybreeze.extend.process_executor.test_pioneer import test_pioneer_process_manager + + def no_python(_path): + raise JEditorExecException("no python interpreter found") + + monkeypatch.setattr(manager_module, "check_and_choose_venv", no_python) + main_window = MainWindow() + + test_pioneer_process_manager.init_and_start_test_pioneer_process(main_window, "run.yml") + + run_window = main_window.current_run_code_window[0] + assert "No Python interpreter found" in run_window.code_result.toPlainText() From 93e69b7c9b6a398cae9c09e7253575fb8fbb25a2 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 16:31:45 +0800 Subject: [PATCH 005/312] Let the run window follow its output --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 8 +++++ docs/updates/README.md | 3 +- .../show_code_window/code_window.py | 7 +++++ test/test_utils/test_code_window.py | 30 +++++++++++++++++++ 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index d578060d..e111d2a7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -149,7 +149,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `read_stream_into_queue(stream, queue, buffer_size, encoding, keep_reading)` — reader 執行緒用。行**原樣**進 queue(縮排、行尾、空行都留著);空讀 = EOF 即停,管線被關掉的 `OSError` / `ValueError` 記 debug 後停 - `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 -- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。`\r\n` 與單獨的 `\r` 轉成換行;換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行 +- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。`\r\n` 與單獨的 `\r` 轉成換行;換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 --- @@ -416,7 +416,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 67 個 `test_*.py`、1006 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 67 個 `test_*.py`、1008 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 8a1b3ee9..d87c1f70 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -39,3 +39,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Also fixed**: `_append_text` wrote at the widget's own cursor, which follows the user's clicks and selections. Selecting text in a run window while the program was still printing replaced the selection with the new output (reproduced offscreen: `first\nsecond\n` + select `first` + append `third\n` gave `third\n\nsecond\n`). `append_output()` always writes at the end of the document. - **Files**: `pybreeze/extend/process_executor/{queue_pump,python_task_process_manager,file_runner_process}.py`, `pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, tests as in U-20260922-04, `architecture_explore.md` §4.1, §4.3–4.5, §18–19, `architecture.md` §4 and §6, `CLAUDE.md` tree. The matching `README.md` tree line went in early, with 2622645. - **Open items**: none. + +## U-20260922-06 · 2026-09-22 · Run window follows its output · #incident #process-executor + +- **What**: a run window never scrolled on its own. Output is inserted through a text cursor, and inserting text never moves the view, so a long run (a test suite, a load test) kept showing its first screen and the user had to drag the scroll bar to see the latest lines, including the final `Task exit with code …`. +- **Fix**: `CodeWindow.append_output()` checks whether the view is at the bottom before it writes, and if so moves it to the new bottom afterwards. A user who has scrolled up to read is left where they are. +- **Result / numbers**: offscreen, 300 appended lines ended at scroll value 3848 of 3848 (before: 0 of 3848); with the view moved to 100, 50 more lines left it at 100. +- **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `test/test_utils/test_code_window.py`, `architecture_explore.md` §4.5. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 51bc95af..3d35cbf0 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -54,6 +54,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-06 | 2026-09-22 | Run window follows its output | #incident #process-executor | [2026-09](2026-09.md) | | U-20260922-05 | 2026-09-22 | One output pipeline for every run window | #done #process-executor | [2026-09](2026-09.md) | | U-20260922-04 | 2026-09-22 | Run window keeps the output's indentation | #done #process-executor | [2026-09](2026-09.md) | | U-20260922-03 | 2026-09-22 | Point project URLs at the current repository | #done #metadata | [2026-09](2026-09.md) | @@ -64,4 +65,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 5 | +| [2026-09.md](2026-09.md) | 2026-09 | 6 | diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index 0e090f6d..646a7a0f 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -57,7 +57,12 @@ def append_output(self, text: str, is_error: bool = False, *, own_line: bool = F the end, because the widget's own cursor follows the user's clicks and selections, and writing there would splice output into the middle or overwrite whatever the user had selected. + + The view follows the output while it is scrolled to the bottom, like a + terminal; once the user scrolls up to read, it stays where they left it. """ + scroll_bar = self.code_result.verticalScrollBar() + follow_output = scroll_bar.value() >= scroll_bar.maximum() cursor = QTextCursor(self.code_result.document()) cursor.movePosition(QTextCursor.MoveOperation.End) if own_line and cursor.positionInBlock() > 0: @@ -66,3 +71,5 @@ def append_output(self, text: str, is_error: bool = False, *, own_line: bool = F color_key = "error_output_color" if is_error else "normal_output_color" text_format.setForeground(actually_color_dict.get(color_key)) cursor.insertText(normalize_line_endings(text), text_format) + if follow_output: + scroll_bar.setValue(scroll_bar.maximum()) diff --git a/test/test_utils/test_code_window.py b/test/test_utils/test_code_window.py index 3f56ee28..2f768a5c 100644 --- a/test/test_utils/test_code_window.py +++ b/test/test_utils/test_code_window.py @@ -116,3 +116,33 @@ def test_errors_use_the_error_colour(self, qt_app): assert cursor.charFormat().foreground().color() == actually_color_dict["normal_output_color"] cursor.setPosition(len("ok\n") + 1) assert cursor.charFormat().foreground().color() == actually_color_dict["error_output_color"] + + +class TestFollowOutput: + def test_the_view_follows_output_at_the_bottom(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.show() + for i in range(300): + window.append_output(f"line {i}\n") + + scroll_bar = window.code_result.verticalScrollBar() + assert scroll_bar.maximum() > 0 + assert scroll_bar.value() == scroll_bar.maximum() + + def test_a_reader_who_scrolled_up_is_left_alone(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.show() + for i in range(300): + window.append_output(f"line {i}\n") + scroll_bar = window.code_result.verticalScrollBar() + scroll_bar.setValue(10) + + for i in range(50): + window.append_output(f"more {i}\n") + + assert scroll_bar.value() == 10 + assert scroll_bar.maximum() > 10 From 2dc52488028083e54d40ea743bc213090fdba949 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 16:36:34 +0800 Subject: [PATCH 006/312] Install the checkout's dependencies instead of the published build --- README.md | 4 ++-- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- dev_requirements.txt | 32 ++++++++++++++++++-------------- docs/updates/2026-09.md | 8 ++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - requirements.txt | 8 +++++++- 8 files changed, 39 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e64bfb3e..5c618feb 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ pip install pybreeze ```bash git clone https://github.com/Integration-Automation/PyBreeze.git -cd AutomationEditor +cd PyBreeze pip install -r requirements.txt ``` @@ -424,7 +424,7 @@ python -m pip install -r dev_requirements.txt python -m pytest test/test_utils/ -v --tb=short ``` -- **Unit tests** — `test/test_utils/`, 60 modules covering the pure-logic layer (curl and HAR parsing, header analysis, SSRF validation, JWT, hashing, timestamps, diffing) plus headless Qt widget tests via `QT_QPA_PLATFORM=offscreen`, with Hypothesis property tests over the parsers +- **Unit tests** — `test/test_utils/`, 67 modules covering the pure-logic layer (curl and HAR parsing, header analysis, SSRF validation, JWT, hashing, timestamps, diffing) plus headless Qt widget tests via `QT_QPA_PLATFORM=offscreen`, with Hypothesis property tests over the parsers - **Startup tests** — `test/unit_test/start_automation/` launches the IDE in debug mode and verifies it comes up and exits cleanly - **CI** — GitHub Actions on Windows across Python 3.10 – 3.14, on every push and PR plus a nightly run - **Static analysis** — SonarCloud, Codacy and Bandit diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index ed73aa75..f5a8599b 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -228,7 +228,7 @@ pip install pybreeze ```bash git clone https://github.com/Integration-Automation/PyBreeze.git -cd AutomationEditor +cd PyBreeze pip install -r requirements.txt ``` diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index b69d3f75..ce18808f 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -228,7 +228,7 @@ pip install pybreeze ```bash git clone https://github.com/Integration-Automation/PyBreeze.git -cd AutomationEditor +cd PyBreeze pip install -r requirements.txt ``` diff --git a/dev_requirements.txt b/dev_requirements.txt index 2c38764e..57fd9b1d 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,14 +1,18 @@ -build -twine -PySide6 -je-api-testka -je-load-density -je-auto-control -je-web-runner -je-mail-thunder -pybreeze_dev -sphinx -sphinx-rtd-theme -PySide6 -auto-py-to-exe -test_pioneer \ No newline at end of file +# -*- coding: utf-8 -*- +# 開發用:執行所需的套件(requirements.txt)加上測試、lint、文件與打包工具。 +# 這裡不列 pybreeze_dev:那會把 PyPI 上發佈的版本裝進來,CI 測到的就不是工作目錄的程式碼。 +# +# For development: everything PyBreeze needs to run (requirements.txt) plus the +# test, lint, docs and packaging tools. It does not list pybreeze_dev: that would +# install the published build, so CI would be testing PyPI's code instead of the +# working tree. +-r requirements.txt +pytest +pytest-cov +hypothesis +ruff +sphinx +sphinx-rtd-theme +build +twine +auto-py-to-exe diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index d87c1f70..1074efb3 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -47,3 +47,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: offscreen, 300 appended lines ended at scroll value 3848 of 3848 (before: 0 of 3848); with the view moved to 100, 50 more lines left it at 100. - **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `test/test_utils/test_code_window.py`, `architecture_explore.md` §4.5. - **Open items**: none. + +## U-20260922-07 · 2026-09-22 · Install the checkout, not the published build · #done #dependencies + +- **What**: `progress.md` #4. `requirements.txt` listed `pybreeze` and `dev_requirements.txt` listed `pybreeze_dev`, so both CI workflows installed the published PyPI build next to the checkout. `dev_requirements.txt` also got `je-editor`, `automation-file`, `paramiko` and `jupyterlab` only through that published package, so it named only part of what the IDE needs. It listed `PySide6` twice, unpinned, and left out the pytest tooling that `CLAUDE.md` § Development tells a developer to run after installing it. +- **Fix**: `requirements.txt` drops `pybreeze` and gets a header saying why (the same wording FrontEngine uses). `dev_requirements.txt` is now `-r requirements.txt` plus pytest, pytest-cov, hypothesis, ruff, sphinx, sphinx-rtd-theme, build, twine and auto-py-to-exe, so runtime and dev can no longer drift apart. Both files start with a PEP 263 `# -*- coding: utf-8 -*-` line: their comments are bilingual, and pip before 25.0 (pypa/pip#12771) tried the locale encoding (cp1252 on a Windows runner) before UTF-8 unless the file declares one. The README "From source" steps (English, zh-TW, zh-CN) cloned `PyBreeze.git` and then ran `cd AutomationEditor`; they now `cd PyBreeze`. +- **Result / numbers**: `pip install --dry-run --ignore-installed -r dev_requirements.txt` resolves 262 packages from PyPI with no `pybreeze*` among them. The same resolution showed why #7 matters: with `PySide6==6.11.0`, pip backtracks je_editor from 1.0.25 (which pins 6.11.1) to 1.0.24. +- **Files**: `requirements.txt`, `dev_requirements.txt`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`. +- **Open items**: `progress.md` #7 (PySide6 pin). diff --git a/docs/updates/README.md b/docs/updates/README.md index 3d35cbf0..bbbaf575 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -54,6 +54,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-07 | 2026-09-22 | Install the checkout, not the published build | #done #dependencies | [2026-09](2026-09.md) | | U-20260922-06 | 2026-09-22 | Run window follows its output | #incident #process-executor | [2026-09](2026-09.md) | | U-20260922-05 | 2026-09-22 | One output pipeline for every run window | #done #process-executor | [2026-09](2026-09.md) | | U-20260922-04 | 2026-09-22 | Run window keeps the output's indentation | #done #process-executor | [2026-09](2026-09.md) | @@ -65,4 +66,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 6 | +| [2026-09.md](2026-09.md) | 2026-09 | 7 | diff --git a/progress.md b/progress.md index 48d249f2..9fa11a9a 100644 --- a/progress.md +++ b/progress.md @@ -7,7 +7,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open - **#1** [BLOCKED] The prthinker integration (menu, settings, install; commit `99ef96f`) has never run a real review: this machine's PyBreeze venv is Python 3.11 and prthinker needs 3.12 or newer. Install prthinker with a 3.12+ interpreter and run one review (moved from JEditor's `PROGRESS.md`; workspace X-8). -- **#4** `requirements.txt` lists `pybreeze` itself and `dev_requirements.txt` lists `pybreeze_dev`, so CI installs the PyPI build instead of the checkout. FrontEngine fixed the same problem (see the header comment of its `requirements.txt`). - **#5** `CLAUDE.md` "Branching & CI" says `dev` publishes `pybreeze_dev`, but `dev.yml` has no publish job and `dev.toml` is not kept in step (workspace X-13). - **#6** [UNVERIFIED] With Traditional Chinese selected at startup, PyBreeze's labels may be missing until the language is switched once: JEditor builds the merged dictionary before PyBreeze adds its strings to JEditor's word dicts (found by reading the code, not by running it). - **#7** PySide6 is pinned `==6.11.0` while je_editor and frontengine pin `==6.11.1`, so the latest releases cannot be installed together (workspace X-1). diff --git a/requirements.txt b/requirements.txt index 127144ed..9c89fbd6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,10 @@ -pybreeze +# -*- coding: utf-8 -*- +# 執行 PyBreeze 所需的套件,與 pyproject.toml / dev.toml 的 dependencies 保持一致。 +# 這裡不列 pybreeze 自己:那會把 PyPI 上發佈的版本裝進來,CI 測到的就不是工作目錄的程式碼。 +# +# What PyBreeze needs to run, kept in step with the dependencies in pyproject.toml +# and dev.toml. It does not list pybreeze itself: that would install the published +# build, so CI would be testing PyPI's code instead of the working tree. PySide6==6.11.0 je-editor je_auto_control From 4b8cec789ccfc9de97c6333496d10dab97d8c676 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 16:37:18 +0800 Subject: [PATCH 007/312] Point PLUGIN_GUIDE.md at JEditor's guide instead of keeping a copy --- PLUGIN_GUIDE.md | 318 ++----------------------------- architecture_explore.md | 2 - docs/source/Eng/menu_plugins.rst | 3 +- docs/source/Zh/menu_plugins.rst | 3 +- docs/updates/2026-09.md | 6 + docs/updates/README.md | 3 +- 6 files changed, 25 insertions(+), 310 deletions(-) diff --git a/PLUGIN_GUIDE.md b/PLUGIN_GUIDE.md index 23ec0751..02926d5d 100644 --- a/PLUGIN_GUIDE.md +++ b/PLUGIN_GUIDE.md @@ -1,314 +1,22 @@ # PyBreeze Plugin Guide / 插件開發指南 -PyBreeze (jeditor) supports external plugins for adding **syntax highlighting** and **UI translations**. +PyBreeze is built on JEditor and uses its plugin system unchanged: the `je_editor.plugins` API +(`register_programming_language`, `register_natural_language`, `PLUGIN_RUN_CONFIG`), the +`jeditor_plugins/` directory and the *Plugins → Plugin Browser* menu. There is therefore one guide +for both editors, kept in the JEditor repository: -PyBreeze (jeditor) 支援外部插件,可用於新增**語法高亮**和 **UI 翻譯**。 +**→ [JEditor `PLUGIN_GUIDE.md`](https://github.com/Integration-Automation/JEDITOR/blob/main/PLUGIN_GUIDE.md)** ---- - -## Quick Start / 快速開始 - -1. Create a `.py` file in the `jeditor_plugins/` directory (under your working directory). -2. Define a `register()` function. -3. Optionally define `PLUGIN_NAME`, `PLUGIN_AUTHOR`, `PLUGIN_VERSION` for the Plugins menu. - - - -1. 在工作目錄下的 `jeditor_plugins/` 建立一個 `.py` 檔案。 -2. 定義一個 `register()` 函式。 -3. 可選:定義 `PLUGIN_NAME`、`PLUGIN_AUTHOR`、`PLUGIN_VERSION`,會顯示在插件選單中。 - ---- - -## Plugin Metadata / 插件元資料 - -```python -PLUGIN_NAME = "My Plugin" # Display name in the Plugins menu / 插件選單顯示名稱 -PLUGIN_AUTHOR = "Your Name" # Author / 作者 -PLUGIN_VERSION = "1.0.0" # Version / 版本號 -``` - -All three are optional. If omitted, the filename is used as the plugin name. - -三者皆為可選。若未定義,則以檔名作為插件名稱。 - ---- - -## Syntax Highlighting Plugin / 語法高亮插件 - -Use `register_programming_language()` to add syntax highlighting for file types. - -使用 `register_programming_language()` 為檔案類型新增語法高亮。 - -### API - -```python -from je_editor.plugins import register_programming_language - -register_programming_language( - suffix=".ext", # File extension / 副檔名 - syntax_words={...}, # Keyword groups / 關鍵字群組 - syntax_rules={...}, # Regex rules (optional) / 正則規則(可選) -) -``` - -### syntax_words format / syntax_words 格式 - -```python -from PySide6.QtGui import QColor - -syntax_words = { - "group_name": { - "words": ("keyword1", "keyword2", ...), # Tuple or set of keywords / 關鍵字元組或集合 - "color": QColor(r, g, b), # Highlight color / 高亮顏色 - }, - # More groups... -} -``` - -### syntax_rules format / syntax_rules 格式 - -```python -syntax_rules = { - "rule_name": { - "rules": (r"regex_pattern", ...), # Tuple of regex patterns / 正則表達式元組 - "color": QColor(r, g, b), # Highlight color / 高亮顏色 - }, -} -``` - -### Full Example / 完整範例 - -```python -"""Go syntax highlighting plugin.""" -from PySide6.QtGui import QColor -from je_editor.plugins import register_programming_language - -PLUGIN_NAME = "Go Syntax Highlighting" -PLUGIN_AUTHOR = "Your Name" -PLUGIN_VERSION = "1.0.0" - -go_syntax_words = { - "keywords": { - "words": ( - "break", "case", "chan", "const", "continue", - "default", "defer", "else", "fallthrough", "for", - "func", "go", "goto", "if", "import", - "interface", "map", "package", "range", "return", - "select", "struct", "switch", "type", "var", - ), - "color": QColor(86, 156, 214), - }, - "types": { - "words": ( - "bool", "byte", "complex64", "complex128", - "float32", "float64", "int", "int8", "int16", - "int32", "int64", "rune", "string", "uint", - "uint8", "uint16", "uint32", "uint64", "uintptr", - "error", "nil", "true", "false", "iota", - ), - "color": QColor(78, 201, 176), - }, -} - -go_syntax_rules = { - "single_line_comment": { - "rules": (r"//[^\n]*",), - "color": QColor(106, 153, 85), - }, -} - - -def register() -> None: - register_programming_language( - suffix=".go", - syntax_words=go_syntax_words, - syntax_rules=go_syntax_rules, - ) -``` - -### Multiple Suffixes / 多個副檔名 - -If a language uses multiple file extensions, register each suffix with the same `syntax_words`: - -若一個語言使用多個副檔名,用相同的 `syntax_words` 分別註冊每個副檔名: - -```python -def register() -> None: - for suffix in (".cpp", ".cxx", ".cc", ".h", ".hpp", ".hxx"): - register_programming_language( - suffix=suffix, - syntax_words=cpp_syntax_words, - syntax_rules=cpp_syntax_rules, - ) -``` - -They will be grouped under one submenu in the Plugins menu. - -它們會在插件選單中合併顯示在同一個子選單下。 - ---- - -## Translation Plugin / 翻譯插件 - -Use `register_natural_language()` to add a new UI language. - -使用 `register_natural_language()` 新增 UI 語言。 - -### API - -```python -from je_editor.plugins import register_natural_language - -register_natural_language( - language_key="French", # Internal key / 內部鍵值 - display_name="Francais", # Shown in Language menu / 語言選單顯示名稱 - word_dict={...}, # Translation dictionary / 翻譯字典 -) -``` - -### word_dict keys / word_dict 鍵值 - -The `word_dict` should contain the same keys as jeditor's built-in `english_word_dict`. -Common keys include: - -`word_dict` 應包含與 jeditor 內建 `english_word_dict` 相同的鍵值。 -常用鍵值包括: - -| Key | Description / 說明 | -|---|---| -| `application_name` | Window title / 視窗標題 | -| `file_menu_label` | File menu / 檔案選單 | -| `run_menu_label` | Run menu / 執行選單 | -| `tab_name_editor` | Editor tab / 編輯器分頁 | -| `language_menu_label` | Language menu / 語言選單 | -| `help_menu_label` | Help menu / 幫助選單 | - -For a complete list, refer to `je_editor.utils.multi_language.english.english_word_dict` -or see the example plugin `exe/jeditor_plugins/french.py`. - -完整鍵值列表請參考 `je_editor.utils.multi_language.english.english_word_dict`, -或參考範例插件 `exe/jeditor_plugins/french.py`。 - -### Full Example / 完整範例 - -```python -"""Japanese translation plugin.""" -from je_editor.plugins import register_natural_language - -PLUGIN_NAME = "Japanese Translation" -PLUGIN_AUTHOR = "Your Name" -PLUGIN_VERSION = "1.0.0" - -japanese_word_dict = { - "application_name": "JEditor", - "file_menu_label": "ファイル", - "run_menu_label": "実行", - "tab_name_editor": "エディタ", - "language_menu_label": "言語", - "language_menu_bar_english": "英語", - "language_menu_bar_traditional_chinese": "繁体字中国語", - "language_menu_bar_please_restart_messagebox": "アプリケーションを再起動してください", - # ... more keys -} - - -def register() -> None: - register_natural_language( - language_key="Japanese", - display_name="日本語", - word_dict=japanese_word_dict, - ) -``` - ---- - -## Run Config (Execute Files) / 執行設定 - -Plugins can register a `PLUGIN_RUN_CONFIG` to enable running files from the **Run with...** menu. - -插件可定義 `PLUGIN_RUN_CONFIG`,讓使用者可以從 **以...執行** 選單執行檔案。 - -### Interpreted Languages / 直譯式語言 - -For languages that run directly (Go, Java 11+, Python): - -直接執行的語言(Go、Java 11+、Python): - -```python -PLUGIN_RUN_CONFIG = { - "name": "Go", # Display name in menu / 選單顯示名稱 - "suffixes": (".go",), # Supported file types / 支援的副檔名 - "compiler": "go", # Executable / 執行檔 - "args": ("run",), # Args before file path / 檔案路徑前的參數 -} -# Runs: go run file.go -``` - -### Compiled Languages / 編譯式語言 - -For languages that need compile-then-run (C, C++, Rust): - -需要先編譯再執行的語言(C、C++、Rust): - -```python -PLUGIN_RUN_CONFIG = { - "name": "C (GCC)", - "suffixes": (".c",), - "compiler": "gcc", - "args": (), - "compile_then_run": True, # Compile first, then run output / 先編譯再執行 - "output_flag": "-o", # Flag for output binary / 輸出檔案的旗標 -} -# Compiles: gcc file.c -o file -# Then runs: ./file (Linux/Mac) or file.exe (Windows) -``` - -### Config Keys / 設定鍵值 - -| Key | Required | Description | -|---|---|---| -| `name` | Yes | Display name / 顯示名稱 | -| `suffixes` | Yes | Tuple of file extensions / 副檔名元組 | -| `compiler` | Yes | Compiler/interpreter executable / 編譯器或直譯器 | -| `args` | No | Extra args before file path / 檔案路徑前的額外參數 | -| `compile_then_run` | No | If `True`, compile first / 若為 `True` 則先編譯 | -| `output_flag` | No | Output file flag (default `"-o"`) / 輸出旗標 | - ---- - -## Directory Structure / 目錄結構 - -``` -working_directory/ - jeditor_plugins/ - my_syntax.py # Single-file plugin / 單檔插件 - my_language.py - my_package/ # Package plugin / 套件插件 - __init__.py -``` - -- Plugins are auto-discovered from `jeditor_plugins/` under the current working directory. -- Files starting with `_` or `.` are ignored. -- Each plugin must have a `register()` function. - - - -- 插件會從工作目錄下的 `jeditor_plugins/` 自動載入。 -- 以 `_` 或 `.` 開頭的檔案會被忽略。 -- 每個插件必須有 `register()` 函式。 +Ready-made plugins (C, C++, Go, Java and Rust highlighting with run support, a French UI +translation) live in [IDE_Plugins](https://github.com/Jeffrey-Plugin-Repos/IDE_Plugins). --- -## Existing Plugins / 現有插件 +PyBreeze 建立在 JEditor 之上,原封不動地使用它的插件系統:`je_editor.plugins` API +(`register_programming_language`、`register_natural_language`、`PLUGIN_RUN_CONFIG`)、 +`jeditor_plugins/` 目錄,以及 *插件 → Plugin Browser* 選單。所以兩個編輯器共用一份指南,放在 JEditor repo: -| Plugin | File | Type | Run Support | -|---|---|---|---| -| C Syntax Highlighting | `c_syntax.py` | Syntax (`.c`) | GCC compile & run | -| C++ Syntax Highlighting | `cpp_syntax.py` | Syntax (`.cpp`, `.cxx`, `.cc`, `.h`, `.hpp`, `.hxx`) | G++ compile & run | -| Go Syntax Highlighting | `go_syntax.py` | Syntax (`.go`) | `go run` | -| Java Syntax Highlighting | `java_syntax.py` | Syntax (`.java`) | `java` | -| Rust Syntax Highlighting | `rust_syntax.py` | Syntax (`.rs`) | rustc compile & run | -| French Translation | `french.py` | Language | - | +**→ [JEditor `PLUGIN_GUIDE.md`](https://github.com/Integration-Automation/JEDITOR/blob/main/PLUGIN_GUIDE.md)** -All plugins are located in `exe/jeditor_plugins/`. +現成的插件(C、C++、Go、Java、Rust 語法高亮與執行設定,以及法文介面翻譯)放在 +[IDE_Plugins](https://github.com/Jeffrey-Plugin-Repos/IDE_Plugins)。 diff --git a/architecture_explore.md b/architecture_explore.md index e111d2a7..e9a1c774 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -439,8 +439,6 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 5. **`PackageManager` 名實不符** — 類別名暗示 pip 管理,實際只承載 `syntax_check_list` 六個項目;pip 安裝落在 `install_utils.install_package()`。 -6. **`PLUGIN_GUIDE.md` 指向不存在的目錄** — 文件末尾說內建插件都在 `exe/jeditor_plugins/`,但該目錄不在 repo 裡,`je_editor` 套件內也只有 `plugin_loader.py`。README 已改為以「範例」描述這些插件。 - --- ## 20. 一句話總結 diff --git a/docs/source/Eng/menu_plugins.rst b/docs/source/Eng/menu_plugins.rst index c6372911..f08a7445 100644 --- a/docs/source/Eng/menu_plugins.rst +++ b/docs/source/Eng/menu_plugins.rst @@ -44,7 +44,8 @@ Creating Plugins For detailed information on creating custom plugins, including syntax highlighting plugins and UI translation plugins, see the -`Plugin Guide `_. +`Plugin Guide `_ +(the guide lives in the JEditor repository, since PyBreeze uses JEditor's plugin system). Syntax Highlighting Plugin Example ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/source/Zh/menu_plugins.rst b/docs/source/Zh/menu_plugins.rst index 2e98ec75..8b6d4531 100644 --- a/docs/source/Zh/menu_plugins.rst +++ b/docs/source/Zh/menu_plugins.rst @@ -42,7 +42,8 @@ Run With 選單 -------- 關於建立自訂外掛的詳細資訊,包括語法高亮外掛和 UI 翻譯外掛, -請參閱 `外掛指南 `_。 +請參閱 `外掛指南 `_ +(指南放在 JEditor repo,因為 PyBreeze 用的是 JEditor 的插件系統)。 語法高亮外掛範例 ^^^^^^^^^^^^^^^^ diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 1074efb3..8c1ff29e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -55,3 +55,9 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: `pip install --dry-run --ignore-installed -r dev_requirements.txt` resolves 262 packages from PyPI with no `pybreeze*` among them. The same resolution showed why #7 matters: with `PySide6==6.11.0`, pip backtracks je_editor from 1.0.25 (which pins 6.11.1) to 1.0.24. - **Files**: `requirements.txt`, `dev_requirements.txt`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`. - **Open items**: `progress.md` #7 (PySide6 pin). + +## U-20260922-08 · 2026-09-22 · PLUGIN_GUIDE.md points to JEditor's guide · #done #docs + +- **What**: `PLUGIN_GUIDE.md` was a word-for-word copy of JEditor's, and both copies pointed at a nonexistent `exe/jeditor_plugins/`. PyBreeze uses JEditor's plugin system unchanged (`EditorMain.__init__` calls `load_external_plugins()`), so the guide now lives only in JEditor (fixed there, JEDITOR U-20260922-04) and this file is a short bilingual pointer to it and to IDE_Plugins. The *Creating Plugins* link in `docs/source/{Eng,Zh}/menu_plugins.rst` goes straight to JEditor's guide, and known issue 6 in `architecture_explore.md` §19 is gone. Workspace item X-10. +- **Files**: `PLUGIN_GUIDE.md`, `docs/source/Eng/menu_plugins.rst`, `docs/source/Zh/menu_plugins.rst`, `architecture_explore.md`. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index bbbaf575..1a5cda03 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -54,6 +54,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-08 | 2026-09-22 | PLUGIN_GUIDE.md points to JEditor's guide | #done #docs | [2026-09](2026-09.md) | | U-20260922-07 | 2026-09-22 | Install the checkout, not the published build | #done #dependencies | [2026-09](2026-09.md) | | U-20260922-06 | 2026-09-22 | Run window follows its output | #incident #process-executor | [2026-09](2026-09.md) | | U-20260922-05 | 2026-09-22 | One output pipeline for every run window | #done #process-executor | [2026-09](2026-09.md) | @@ -66,4 +67,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 7 | +| [2026-09.md](2026-09.md) | 2026-09 | 8 | From fb6cc20663a6c0cf836dc1ad5661a29a2afbf812 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 16:39:04 +0800 Subject: [PATCH 008/312] Describe the release flow CI actually runs --- CLAUDE.md | 5 +++-- architecture.md | 9 +++++---- docs/updates/2026-09.md | 8 ++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8817cd6f..88e2d84a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,8 +58,9 @@ pybreeze/ ## Branching & CI -- `main`: stable, publishes `pybreeze` · `dev`: development, publishes `pybreeze_dev` -- Version config: `pyproject.toml` (stable), `dev.toml` (dev) — keep both in sync when bumping +- `main`: stable. On every push to `main`, the `publish` job in `stable.yml` bumps the patch version in `pyproject.toml`, uploads `pybreeze` to PyPI, then commits and tags the bump. `dev`: development. `dev.yml` runs the tests and SonarCloud and publishes nothing +- Never edit a version by hand: CI owns `pyproject.toml`'s, and `dev` is always behind `origin/main` +- `dev.toml` describes a `pybreeze_dev` package that no workflow builds; PyPI's `pybreeze_dev` stopped at the 1.0.14 it names. Whether CI should publish it or `dev.toml` should be deleted is undecided (workspace X-13). Until then, keep its `dependencies` identical to `pyproject.toml`'s - `unit-tests` job: GitHub Actions on Windows, Python 3.10–3.14 — install deps → pytest `test/test_utils/` → `start_automation_test` → `extend_automation_test` - `sonarcloud` job: CI-based SonarQube Cloud analysis (`sonar-project.properties`), `needs: unit-tests` so it can consume the `coverage-xml` artifact that leg uploads. Automatic Analysis is off and must stay off — the two modes are mutually exclusive and the scanner refuses to run alongside it - SonarCloud's plan for this organization exposes results for `main` and for pull requests only. An analysis pushed for another branch succeeds but its results read back 403, so `dev.yml` scans on pull requests only; `stable.yml` also scans pushes to `main`. Do not "fix" this by scanning every `dev` push — the numbers are not readable diff --git a/architecture.md b/architecture.md index a25ee030..475fb0a0 100644 --- a/architecture.md +++ b/architecture.md @@ -5,8 +5,9 @@ ## 1. Purpose -PyBreeze is an automation-first Python IDE built on JEditor. It is published as `pybreeze` -(stable, `pyproject.toml`) and `pybreeze_dev` (dev, `dev.toml`). It does not have its own editor. +PyBreeze is an automation-first Python IDE built on JEditor. CI publishes it as `pybreeze` +(`pyproject.toml`) from `main`; `dev.toml` describes a `pybreeze_dev` package that nothing +publishes any more (workspace X-13). It does not have its own editor. Instead it subclasses JEditor's main window and adds menus, tool tabs and docks around it: - menus that drive the automation packages (AutoControl, WebRunner, APITestka, LoadDensity, @@ -36,7 +37,7 @@ their output reaches the UI through Queue + QTimer. | `pybreeze/extend_multi_language/` | PyBreeze's English and Traditional Chinese strings, merged into JEditor's dictionaries | | `pybreeze/utils/` | Pure logic (only `file_process/get_dir_file_list.py` imports Qt): request parsing and codegen, HTTP tools, `network/` SSRF validation and capped reads, exceptions, logging, `app_dirs.py`, `subprocess_util.py` | | `test/test_utils/` | Unit tests (pure logic and headless widgets). `test/unit_test/start_automation/` holds the launch tests | -| `pyproject.toml`, `dev.toml` | Stable and dev packaging (keep both in sync) | +| `pyproject.toml`, `dev.toml` | Stable packaging (CI bumps and publishes it) and the unpublished dev packaging (keep its dependencies identical) | | `.github/workflows/` | `dev.yml`, `stable.yml` (unit tests on a Windows matrix, then SonarCloud) | | `docs/`, `linux_package_source/`, `architecture_diagram/` | Sphinx docs, Debian package source, architecture image | @@ -151,7 +152,7 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() - Persist data only under `~/.pybreeze/` (§ Security › File I/O). - Complexity, length and nesting caps; no silent `except`; no `assert` in runtime code (§ Code quality gates). -- `main` is stable and `dev` is development, and the version must be bumped in both toml files. +- `main` is stable and `dev` is development. CI owns the version: never edit it by hand. SonarCloud automatic analysis stays off (§ Branching & CI). - Commit and PR text must follow the attribution rules (§ Commit & PR rules). diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 8c1ff29e..0d61ba81 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -61,3 +61,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **What**: `PLUGIN_GUIDE.md` was a word-for-word copy of JEditor's, and both copies pointed at a nonexistent `exe/jeditor_plugins/`. PyBreeze uses JEditor's plugin system unchanged (`EditorMain.__init__` calls `load_external_plugins()`), so the guide now lives only in JEditor (fixed there, JEDITOR U-20260922-04) and this file is a short bilingual pointer to it and to IDE_Plugins. The *Creating Plugins* link in `docs/source/{Eng,Zh}/menu_plugins.rst` goes straight to JEditor's guide, and known issue 6 in `architecture_explore.md` §19 is gone. Workspace item X-10. - **Files**: `PLUGIN_GUIDE.md`, `docs/source/Eng/menu_plugins.rst`, `docs/source/Zh/menu_plugins.rst`, `architecture_explore.md`. - **Open items**: none. + +## U-20260922-09 · 2026-09-22 · Describe the release flow CI actually runs · #done #ci + +- **What**: `progress.md` #5. `CLAUDE.md` § Branching & CI said `dev` publishes `pybreeze_dev` and that both toml versions are bumped together; `architecture.md` §1, §2 and §7 repeated it. In fact `dev.yml` has only the `unit-tests` and `sonarcloud` jobs, the only publisher is `stable.yml`'s `publish` job (on a push to `main` it bumps the patch version in `pyproject.toml` alone, uploads `pybreeze`, then commits and tags the bump), and PyPI's `pybreeze_dev` is still at the 1.0.14 in `dev.toml`, while `pybreeze` is at 1.0.30. The workspace rule is also that nobody edits a version by hand. +- **Fix**: those four places now describe what CI does. `dev.toml` is described as unpublished packaging whose `dependencies` stay identical to `pyproject.toml`'s. Whether to publish it or delete it stays with the owner (workspace X-13). +- **Files**: `CLAUDE.md`, `architecture.md`. +- **Evidence**: `.github/workflows/stable.yml` `publish` job; `pip index versions pybreeze_dev` → 1.0.14; `pip index versions pybreeze` → 1.0.30. +- **Open items**: workspace X-13 (decision). diff --git a/docs/updates/README.md b/docs/updates/README.md index 1a5cda03..0aa52807 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -54,6 +54,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-09 | 2026-09-22 | Describe the release flow CI actually runs | #done #ci | [2026-09](2026-09.md) | | U-20260922-08 | 2026-09-22 | PLUGIN_GUIDE.md points to JEditor's guide | #done #docs | [2026-09](2026-09.md) | | U-20260922-07 | 2026-09-22 | Install the checkout, not the published build | #done #dependencies | [2026-09](2026-09.md) | | U-20260922-06 | 2026-09-22 | Run window follows its output | #incident #process-executor | [2026-09](2026-09.md) | @@ -67,4 +68,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 8 | +| [2026-09.md](2026-09.md) | 2026-09 | 9 | diff --git a/progress.md b/progress.md index 9fa11a9a..1baeb489 100644 --- a/progress.md +++ b/progress.md @@ -7,7 +7,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open - **#1** [BLOCKED] The prthinker integration (menu, settings, install; commit `99ef96f`) has never run a real review: this machine's PyBreeze venv is Python 3.11 and prthinker needs 3.12 or newer. Install prthinker with a 3.12+ interpreter and run one review (moved from JEditor's `PROGRESS.md`; workspace X-8). -- **#5** `CLAUDE.md` "Branching & CI" says `dev` publishes `pybreeze_dev`, but `dev.yml` has no publish job and `dev.toml` is not kept in step (workspace X-13). - **#6** [UNVERIFIED] With Traditional Chinese selected at startup, PyBreeze's labels may be missing until the language is switched once: JEditor builds the merged dictionary before PyBreeze adds its strings to JEditor's word dicts (found by reading the code, not by running it). - **#7** PySide6 is pinned `==6.11.0` while je_editor and frontengine pin `==6.11.1`, so the latest releases cannot be installed together (workspace X-1). - **#8** PyBreeze imports JEditor modules that je_editor does not export (`PluginBrowserWidget`, `DestroyDock`, `check_and_choose_venv`, `choose_file_get_save_file_path`, `write_file`, `actually_color_dict`) and edits JEditor's `english_word_dict` / `traditional_chinese_word_dict` in place — an undocumented contract (workspace X-17). From 3a1e87c006a22719e47db7447f162033bcb284c8 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 16:47:47 +0800 Subject: [PATCH 009/312] Pin PySide6 6.11.1 to match the published je_editor --- README.md | 4 ++-- README/README_zh-CN.md | 4 ++-- README/README_zh-TW.md | 4 ++-- architecture.md | 5 +++-- dev.toml | 2 +- docs/updates/2026-09.md | 8 ++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - pyproject.toml | 2 +- requirements.txt | 2 +- 10 files changed, 22 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5c618feb..70ec7802 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ pip install -r requirements.txt - **Python**: 3.10 – 3.14 - **OS**: Windows, macOS, Linux -- **GUI**: PySide6 6.11.0 (installed automatically) +- **GUI**: PySide6 6.11.1 (installed automatically) --- @@ -399,7 +399,7 @@ PyBreeze/ | Package | Purpose | |---|---| -| `PySide6` (6.11.0) | GUI framework (Qt for Python) | +| `PySide6` (6.11.1) | GUI framework (Qt for Python) | | `je-editor` | Base code editor engine | | `je_api_testka` | API testing automation | | `je_auto_control` | GUI/desktop automation | diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index f5a8599b..3ff92521 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -236,7 +236,7 @@ pip install -r requirements.txt - **Python**:3.10 或更高版本 - **操作系统**:Windows、macOS、Linux -- **GUI 框架**:PySide6 6.11.0(自动安装) +- **GUI 框架**:PySide6 6.11.1(自动安装) --- @@ -382,7 +382,7 @@ PyBreeze/ | 包 | 用途 | |---|---| -| `PySide6` (6.11.0) | GUI 框架(Qt for Python)| +| `PySide6` (6.11.1) | GUI 框架(Qt for Python)| | `je-editor` | 基础代码编辑器引擎 | | `je_api_testka` | API 测试自动化 | | `je_auto_control` | GUI/桌面自动化 | diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index ce18808f..6905c447 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -236,7 +236,7 @@ pip install -r requirements.txt - **Python**:3.10 或更高版本 - **作業系統**:Windows、macOS、Linux -- **GUI 框架**:PySide6 6.11.0(自動安裝) +- **GUI 框架**:PySide6 6.11.1(自動安裝) --- @@ -382,7 +382,7 @@ PyBreeze/ | 套件 | 用途 | |---|---| -| `PySide6` (6.11.0) | GUI 框架(Qt for Python)| +| `PySide6` (6.11.1) | GUI 框架(Qt for Python)| | `je-editor` | 基礎程式碼編輯器引擎 | | `je_api_testka` | API 測試自動化 | | `je_auto_control` | GUI/桌面自動化 | diff --git a/architecture.md b/architecture.md index 475fb0a0..69426251 100644 --- a/architecture.md +++ b/architecture.md @@ -133,8 +133,9 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() - **IDE_Plugins**: the plugin browser's default repo (set in JEditor). Its run configs execute here via `FileRunnerProcess`. - **PySide6 pin**: it must match JEditor and FrontEngine. PyBreeze pins it in `pyproject.toml`, - `dev.toml` and `requirements.txt`. At last verification PyBreeze pinned 6.11.0, while FrontEngine - and JEditor's stable `pyproject.toml` pinned 6.11.1. + `dev.toml` and `requirements.txt`. At last verification all three pinned 6.11.1, the pin of the + published je_editor 1.0.25 and frontengine; PySide6 6.11.2 is out and dependabot has proposed it + to JEditor and FrontEngine (workspace X-1). PyBreeze moves when the published je_editor does. ## 7. Design constraints diff --git a/dev.toml b/dev.toml index 0f1f6dd6..fceb3fbc 100644 --- a/dev.toml +++ b/dev.toml @@ -16,7 +16,7 @@ license-files = ["LICENSE"] dependencies = [ "je-editor", "je_auto_control", "je_web_runner", "je_load_density", "je_api_testka", "je-mail-thunder", - "automation-file", "PySide6==6.11.0", "test_pioneer", "paramiko", + "automation-file", "PySide6==6.11.1", "test_pioneer", "paramiko", "jupyterlab", ] classifiers = [ diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 0d61ba81..d897d15c 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -69,3 +69,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Files**: `CLAUDE.md`, `architecture.md`. - **Evidence**: `.github/workflows/stable.yml` `publish` job; `pip index versions pybreeze_dev` → 1.0.14; `pip index versions pybreeze` → 1.0.30. - **Open items**: workspace X-13 (decision). + +## U-20260922-10 · 2026-09-22 · Pin PySide6 6.11.1 like the published je_editor · #done #dependencies + +- **What**: `progress.md` #7. PyBreeze pinned `PySide6==6.11.0` while the published je_editor 1.0.25 and frontengine 1.0.77 pin 6.11.1, so the latest releases could not be installed together. pip got around the conflict by backtracking je_editor to 1.0.24 (the last one pinning 6.11.0), which meant CI never ran against the current JEditor. +- **Fix**: the pin is `PySide6==6.11.1` in `pyproject.toml`, `dev.toml` and `requirements.txt`, and the version the three READMEs quote says the same. +- **Result / numbers**: a fresh Python 3.13 venv built from `dev_requirements.txt` alone resolves je_editor 1.0.25, PySide6 6.11.1 and frontengine 1.0.77, with no `pybreeze*`. In that venv, the unit tests at the preceding commit `fb6cc20` (this change touches no code) pass, 1008 of 1008, and both startup tests (`start_automation_test.py`, `extend_automation_test.py`) exit 0. +- **Files**: `pyproject.toml`, `dev.toml`, `requirements.txt`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture.md` §6. +- **Open items**: PySide6 6.11.2 is on PyPI and dependabot has proposed it to JEditor and FrontEngine. PyBreeze should move when the published je_editor does (workspace X-1). diff --git a/docs/updates/README.md b/docs/updates/README.md index 0aa52807..258f5588 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -54,6 +54,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-10 | 2026-09-22 | Pin PySide6 6.11.1 like the published je_editor | #done #dependencies | [2026-09](2026-09.md) | | U-20260922-09 | 2026-09-22 | Describe the release flow CI actually runs | #done #ci | [2026-09](2026-09.md) | | U-20260922-08 | 2026-09-22 | PLUGIN_GUIDE.md points to JEditor's guide | #done #docs | [2026-09](2026-09.md) | | U-20260922-07 | 2026-09-22 | Install the checkout, not the published build | #done #dependencies | [2026-09](2026-09.md) | @@ -68,4 +69,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 9 | +| [2026-09.md](2026-09.md) | 2026-09 | 10 | diff --git a/progress.md b/progress.md index 1baeb489..a8aa3795 100644 --- a/progress.md +++ b/progress.md @@ -8,5 +8,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#1** [BLOCKED] The prthinker integration (menu, settings, install; commit `99ef96f`) has never run a real review: this machine's PyBreeze venv is Python 3.11 and prthinker needs 3.12 or newer. Install prthinker with a 3.12+ interpreter and run one review (moved from JEditor's `PROGRESS.md`; workspace X-8). - **#6** [UNVERIFIED] With Traditional Chinese selected at startup, PyBreeze's labels may be missing until the language is switched once: JEditor builds the merged dictionary before PyBreeze adds its strings to JEditor's word dicts (found by reading the code, not by running it). -- **#7** PySide6 is pinned `==6.11.0` while je_editor and frontengine pin `==6.11.1`, so the latest releases cannot be installed together (workspace X-1). - **#8** PyBreeze imports JEditor modules that je_editor does not export (`PluginBrowserWidget`, `DestroyDock`, `check_and_choose_venv`, `choose_file_get_save_file_path`, `write_file`, `actually_color_dict`) and edits JEditor's `english_word_dict` / `traditional_chinese_word_dict` in place — an undocumented contract (workspace X-17). diff --git a/pyproject.toml b/pyproject.toml index f76b2386..7629637c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ license-files = ["LICENSE"] dependencies = [ "je-editor", "je_auto_control", "je_web_runner", "je_load_density", "je_api_testka", "je-mail-thunder", - "automation-file", "PySide6==6.11.0", "test_pioneer", "paramiko", + "automation-file", "PySide6==6.11.1", "test_pioneer", "paramiko", "jupyterlab", ] classifiers = [ diff --git a/requirements.txt b/requirements.txt index 9c89fbd6..ff35362d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # What PyBreeze needs to run, kept in step with the dependencies in pyproject.toml # and dev.toml. It does not list pybreeze itself: that would install the published # build, so CI would be testing PyPI's code instead of the working tree. -PySide6==6.11.0 +PySide6==6.11.1 je-editor je_auto_control je_web_runner From 94d11a5778ff4667e0d1eee470a76ca76be427ce Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 16:51:45 +0800 Subject: [PATCH 010/312] Start with a non-English language saved instead of crashing --- architecture.md | 9 ++- architecture_explore.md | 8 +-- docs/updates/2026-09.md | 8 +++ docs/updates/README.md | 3 +- progress.md | 1 - .../update_language_dict.py | 21 +++++- pybreeze/pybreeze_ui/editor_main/main_ui.py | 8 ++- test/test_utils/test_language_parity.py | 41 +++++++++++ test/test_utils/test_startup_language.py | 71 +++++++++++++++++++ 9 files changed, 158 insertions(+), 12 deletions(-) create mode 100644 test/test_utils/test_startup_language.py diff --git a/architecture.md b/architecture.md index 69426251..a0f893e7 100644 --- a/architecture.md +++ b/architecture.md @@ -66,8 +66,9 @@ The layers are presentation (`pybreeze_ui/`), then execution (`extend/`), then f ``` python -m pybreeze → start_editor() → QApplication → PyBreezeMainWindow() + → update_language_dict() [before JEditor picks the startup language] → EditorMain.__init__(extend=True) [JEditor builds the editor, loads jeditor_plugins/] - → drop JEditor Help menu → update_language_dict() → add_menu_to_menubar() + → drop JEditor Help menu → add_menu_to_menubar() → syntax_extend_package() → EDITOR_EXTEND_TAB tabs → setup_file_tree_context_menu() → apply_stylesheet(theme) → showMaximized() → startup_setting() → exec() → os._exit() ``` @@ -119,7 +120,11 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() `pybreeze/__init__.py` re-exports JEditor's plugin API. PyBreeze also imports JEditor internals (e.g. `PluginBrowserWidget`, `DestroyDock`, `check_and_choose_venv`, `actually_color_dict`). It merges its strings by mutating JEditor's `english_word_dict` and `traditional_chinese_word_dict` - in place (`extend_multi_language/update_language_dict.py`). JEditor translation changes must keep + in place, and writes its `application_name` into every dict in + `language_wrapper.choose_language_dict` (`extend_multi_language/update_language_dict.py`). That has + to happen before `EditorMain.__init__`: JEditor serves every language but English from a merged + copy built when it picks the startup language, so strings added later are missing and a `None` + menu title crashes Qt (`test_startup_language.py`). JEditor translation changes must keep `test_language_parity.py` green. - **Automation packages**: `je_auto_control`, `je_web_runner`, `je_api_testka`, `je_load_density`, `automation_file` and `je_mail_thunder` run as `python -m --execute_str/--execute_file` diff --git a/architecture_explore.md b/architecture_explore.md index e9a1c774..bef8ca1b 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -62,13 +62,13 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ## 3. 啟動流程 -`pybreeze/pybreeze_ui/editor_main/main_ui.py:98` 的 `start_editor()`: +`pybreeze/pybreeze_ui/editor_main/main_ui.py:100` 的 `start_editor()`: 1. 取得(或建立)`QApplication` 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: + - `update_language_dict()` 併入 PyBreeze 的 571 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - - `update_language_dict()` 併入 PyBreeze 的 571 條翻譯 - 設定標題、Windows AppUserModelID、圖示 - `add_menu_to_menubar()` — 建構全部選單(見 §5) - `syntax_extend_package()` — 註冊 `.json` / `.yml` 自動化關鍵字高亮 @@ -342,7 +342,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 571 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典。`test_language_parity.py` 守住兩邊鍵值必須對齊。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 571 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -416,7 +416,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 67 個 `test_*.py`、1008 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 68 個 `test_*.py`、1012 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index d897d15c..3ad5613c 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -77,3 +77,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: a fresh Python 3.13 venv built from `dev_requirements.txt` alone resolves je_editor 1.0.25, PySide6 6.11.1 and frontengine 1.0.77, with no `pybreeze*`. In that venv, the unit tests at the preceding commit `fb6cc20` (this change touches no code) pass, 1008 of 1008, and both startup tests (`start_automation_test.py`, `extend_automation_test.py`) exit 0. - **Files**: `pyproject.toml`, `dev.toml`, `requirements.txt`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture.md` §6. - **Open items**: PySide6 6.11.2 is on PyPI and dependabot has proposed it to JEditor and FrontEngine. PyBreeze should move when the published je_editor does (workspace X-1). + +## U-20260922-11 · 2026-09-22 · IDE crashed on start with a non-English language saved · #done #i18n + +- **What**: `progress.md` #6, which only suspected that labels "may be missing". Running it showed something worse: with Traditional Chinese (or Japanese, or Simplified Chinese) saved as the language, PyBreeze did not start. `EditorMain.__init__` calls `language_wrapper.reset_language()` with the saved language, and for anything but English JEditor serves a merged copy `{**english, **chosen}` built at that moment. PyBreeze added its 571 strings only after `super().__init__()` returned, so the copy lacked all of them. `add_menu_to_menubar()` then passed `None` to `QMenuBar.addMenu()`, and the process died with an access violation (exit 3221225477 / 0xC0000005) inside `build_menubar.py:38`. A first launch was unaffected, because JEditor's default settings already say English. But anyone who chose 繁體中文 in the language menu could not start the IDE again until they edited `.jeditor/user_setting.json` by hand. A second, smaller defect: Japanese and Simplified Chinese carry JEditor's own `application_name`, so their window was titled "JEditor". +- **Fix**: `PyBreezeMainWindow.__init__` calls `update_language_dict()` before `super().__init__()`. `update_language_dict()` also writes PyBreeze's `application_name` into every dict in `language_wrapper.choose_language_dict`. It is the only JEditor key PyBreeze replaces rather than adds. +- **Result / numbers**: started for real (child process, offscreen and on the Windows platform), all four languages construct the window with every menu titled; before, Traditional Chinese and Japanese exited 3221225477. Unit tests 1008 → 1012. +- **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `pybreeze/extend_multi_language/update_language_dict.py`, `test/test_utils/test_startup_language.py` (new: starts the IDE in a child with a saved language, since a crash cannot be caught in-process), `test/test_utils/test_language_parity.py` (every registered language resolves every key the code uses; the window is "PyBreeze" in each), `architecture.md` §4 and §6, `architecture_explore.md` §3, §13, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 258f5588..d30e026a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -54,6 +54,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-11 | 2026-09-22 | IDE crashed on start with a non-English language saved | #done #i18n | [2026-09](2026-09.md) | | U-20260922-10 | 2026-09-22 | Pin PySide6 6.11.1 like the published je_editor | #done #dependencies | [2026-09](2026-09.md) | | U-20260922-09 | 2026-09-22 | Describe the release flow CI actually runs | #done #ci | [2026-09](2026-09.md) | | U-20260922-08 | 2026-09-22 | PLUGIN_GUIDE.md points to JEditor's guide | #done #docs | [2026-09](2026-09.md) | @@ -69,4 +70,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 10 | +| [2026-09.md](2026-09.md) | 2026-09 | 11 | diff --git a/progress.md b/progress.md index a8aa3795..2d1d9981 100644 --- a/progress.md +++ b/progress.md @@ -7,5 +7,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open - **#1** [BLOCKED] The prthinker integration (menu, settings, install; commit `99ef96f`) has never run a real review: this machine's PyBreeze venv is Python 3.11 and prthinker needs 3.12 or newer. Install prthinker with a 3.12+ interpreter and run one review (moved from JEditor's `PROGRESS.md`; workspace X-8). -- **#6** [UNVERIFIED] With Traditional Chinese selected at startup, PyBreeze's labels may be missing until the language is switched once: JEditor builds the merged dictionary before PyBreeze adds its strings to JEditor's word dicts (found by reading the code, not by running it). - **#8** PyBreeze imports JEditor modules that je_editor does not export (`PluginBrowserWidget`, `DestroyDock`, `check_and_choose_venv`, `choose_file_get_save_file_path`, `write_file`, `actually_color_dict`) and edits JEditor's `english_word_dict` / `traditional_chinese_word_dict` in place — an undocumented contract (workspace X-17). diff --git a/pybreeze/extend_multi_language/update_language_dict.py b/pybreeze/extend_multi_language/update_language_dict.py index 84d0fa27..5f7afe22 100644 --- a/pybreeze/extend_multi_language/update_language_dict.py +++ b/pybreeze/extend_multi_language/update_language_dict.py @@ -1,10 +1,29 @@ from __future__ import annotations -from pybreeze.extend_multi_language.extend_english import update_english_word_dict +from je_editor import language_wrapper + +from pybreeze.extend_multi_language.extend_english import ( + pybreeze_english_word_dict, update_english_word_dict +) from pybreeze.extend_multi_language.extend_traditional_chinese import \ update_traditional_chinese_word_dict +# The one JEditor string PyBreeze replaces rather than adds. The product's name is +# the same in every language, but JEditor's other languages (Japanese, Simplified +# Chinese, any a plugin registers) carry their own "JEditor", which would win over +# the English fallback PyBreeze's other strings rely on. +_APPLICATION_NAME_KEY = "application_name" + def update_language_dict(): + """Add PyBreeze's strings to JEditor's word dicts. + + Call it before JEditor picks the startup language (``PyBreezeMainWindow`` + does, ahead of ``EditorMain.__init__``): JEditor serves every language but + English from a merged copy built at that moment. + """ update_traditional_chinese_word_dict() update_english_word_dict() + application_name = pybreeze_english_word_dict[_APPLICATION_NAME_KEY] + for word_dict in language_wrapper.choose_language_dict.values(): + word_dict[_APPLICATION_NAME_KEY] = application_name diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index 5ff5291e..d101b5d4 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -28,6 +28,11 @@ class PyBreezeMainWindow(EditorMain): def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, extend: bool = False) -> None: + # PyBreeze's strings must be in JEditor's word dicts before EditorMain.__init__ + # picks the startup language: for any language but English it builds the + # dictionary it reads from as a merged copy, so strings added afterwards are + # missing from it, and a menu given a None title crashes Qt. + update_language_dict() super().__init__(debug_mode, show_system_tray_ray, extend=True) # Note: EditorMain.__init__ already calls load_external_plugins() # which auto-discovers jeditor_plugins/ in the current working directory. @@ -40,9 +45,6 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, if self.help_menu: self.help_menu.deleteLater() - # Update language_dict - update_language_dict() - # Title self.setWindowTitle(language_wrapper.language_word_dict.get("application_name")) self.setToolTip(language_wrapper.language_word_dict.get("application_name")) diff --git a/test/test_utils/test_language_parity.py b/test/test_utils/test_language_parity.py index 0cbaf134..012109ad 100644 --- a/test/test_utils/test_language_parity.py +++ b/test/test_utils/test_language_parity.py @@ -82,3 +82,44 @@ def test_every_prompt_editor_label_key_exists(self): if key not in EN ] assert not missing, f"Prompt editor label keys missing from the dict: {missing}" + + +class TestEveryLanguageServesPyBreezeStrings: + def test_each_registered_language_resolves_every_key(self): + # JEditor serves each language but English from a merged copy of its dict + # and English's. Once PyBreeze's strings are in, every language, even one + # PyBreeze does not translate, must resolve every key the code asks for. + from je_editor import language_wrapper + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + update_language_dict() + used = _code_used_keys() + original = language_wrapper.language + missing = {} + try: + for language in language_wrapper.available_languages(): + language_wrapper.reset_language(language) + blank = sorted( + key for key in used if not language_wrapper.language_word_dict.get(key)) + if blank: + missing[language] = blank + finally: + language_wrapper.reset_language(original) + assert not missing, f"Keys a language cannot resolve: {missing}" + + def test_the_window_is_called_pybreeze_in_every_language(self): + from je_editor import language_wrapper + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + update_language_dict() + original = language_wrapper.language + names = {} + try: + for language in language_wrapper.available_languages(): + language_wrapper.reset_language(language) + names[language] = language_wrapper.language_word_dict["application_name"] + finally: + language_wrapper.reset_language(original) + assert set(names.values()) == {"PyBreeze"}, names diff --git a/test/test_utils/test_startup_language.py b/test/test_utils/test_startup_language.py new file mode 100644 index 00000000..4dac5cc9 --- /dev/null +++ b/test/test_utils/test_startup_language.py @@ -0,0 +1,71 @@ +"""Starting the real main window with a language other than English saved. + +JEditor picks the saved language inside ``EditorMain.__init__`` and serves it +from a merged copy of its dictionaries. PyBreeze's strings have to be in by +then; when they were added afterwards, every PyBreeze menu got a ``None`` title +and Qt crashed the process (access violation) before the window appeared. A +crash like that cannot be caught inside pytest, so each start runs in a child. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +_START_TIMEOUT_SECONDS = 120 + +# The child writes what it saw to a file: JEditor redirects sys.stdout into +# its own console widget once the window exists. +_CHILD = """ +import json, sys +from PySide6.QtWidgets import QApplication +app = QApplication([]) +from je_editor import language_wrapper +from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow +window = PyBreezeMainWindow(debug_mode=True) +with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump({ + "language": language_wrapper.language, + "menu_titles": [action.text() for action in window.menuBar().actions()], + "automation_menu": window.automation_menu.title(), + "window_title": window.windowTitle(), + }, handle, ensure_ascii=False) +""" + + +def _start_with_saved_language(tmp_path: Path, language: str) -> dict: + settings_dir = tmp_path / ".jeditor" + settings_dir.mkdir() + (settings_dir / "user_setting.json").write_text( + json.dumps({"language": language}), encoding="utf-8") + result_file = tmp_path / "result.json" + environment = { + **os.environ, + "QT_QPA_PLATFORM": "offscreen", + "PYTHONPATH": os.pathsep.join( + filter(None, [str(_REPOSITORY_ROOT), os.environ.get("PYTHONPATH")])), + } + completed = subprocess.run( # noqa: S603 — fixed argv: this interpreter and a literal script + [sys.executable, "-c", _CHILD, str(result_file)], + cwd=tmp_path, env=environment, capture_output=True, timeout=_START_TIMEOUT_SECONDS, + check=False, shell=False, + ) + assert completed.returncode == 0, ( + f"the IDE did not start with {language} saved (exit {completed.returncode})\n" + f"{completed.stderr.decode('utf-8', 'replace')[-2000:]}") + return json.loads(result_file.read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("language", ["Traditional_Chinese", "Japanese"]) +def test_the_ide_starts_with_the_saved_language(tmp_path, language): + seen = _start_with_saved_language(tmp_path, language) + + assert seen["language"] == language + assert all(seen["menu_titles"]), seen["menu_titles"] + assert seen["automation_menu"] + assert seen["window_title"] == "PyBreeze" From 7c972ffce4839261d88312059da789e76bcc7d1b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 16:58:04 +0800 Subject: [PATCH 011/312] Save before a plugin run in the file's own encoding and line ending --- architecture.md | 1 + architecture_explore.md | 6 +- docs/updates/2026-09.md | 8 ++ docs/updates/README.md | 3 +- .../menu/plugin_menu/build_plugin_menu.py | 58 +--------- .../menu/plugin_menu/build_run_with_menu.py | 54 ++++++---- test/test_utils/test_plugin_menu.py | 102 +++++++++++++++--- 7 files changed, 140 insertions(+), 92 deletions(-) diff --git a/architecture.md b/architecture.md index a0f893e7..7830a4ed 100644 --- a/architecture.md +++ b/architecture.md @@ -90,6 +90,7 @@ extend/process_executor// → build_process() (process_executor_utils.py) ``` Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() + → run_current_file_with() → save_current_file_for_run() [tab's own encoding and line ending] → FileRunnerProcess.run_file() → interpret, or compile then run → CodeWindow ``` diff --git a/architecture_explore.md b/architecture_explore.md index bef8ca1b..66df79a6 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -189,8 +189,8 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.4 插件選單 -- **`build_plugin_menu.py`** — 讀 `je_editor.plugins.get_all_plugin_metadata()`,每個插件一個子選單(About + 每個副檔名一個 Run 動作);另有「Plugin Browser」分頁入口 -- **`build_run_with_menu.py`** — 讀 `get_all_plugin_run_configs()`,在 Run 選單下加「Run with…」。執行前會強制存檔、驗副檔名,再交給 `FileRunnerProcess` +- **`build_plugin_menu.py`** — 讀 `je_editor.plugins.get_all_plugin_metadata()`,每個插件一個子選單(About + 每個副檔名一個 Run 動作,動作直接呼叫 `run_current_file_with()`);另有「Plugin Browser」分頁入口 +- **`build_run_with_menu.py`** — 讀 `get_all_plugin_run_configs()`,在 Run 選單下加「Run with…」。`run_current_file_with()` 先經 `save_current_file_for_run()` 存檔(已有檔名的分頁照 JEditor 自己存檔的方式寫:`write_file_with_encoding()` 用分頁的編碼與行尾,寫之前 `mark_ignore_next_file_change()`、寫完 `mark_saved()`;沒檔名的走 JEditor 的另存新檔),再驗副檔名、交給 `FileRunnerProcess`。Plugins 選單的 Run 動作也走這一條 ### 5.5 安裝選單 @@ -416,7 +416,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 68 個 `test_*.py`、1012 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 68 個 `test_*.py`、1017 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 3ad5613c..ddfa5f8e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -85,3 +85,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: started for real (child process, offscreen and on the Windows platform), all four languages construct the window with every menu titled; before, Traditional Chinese and Japanese exited 3221225477. Unit tests 1008 → 1012. - **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `pybreeze/extend_multi_language/update_language_dict.py`, `test/test_utils/test_startup_language.py` (new: starts the IDE in a child with a saved language, since a crash cannot be caught in-process), `test/test_utils/test_language_parity.py` (every registered language resolves every key the code uses; the window is "PyBreeze" in each), `architecture.md` §4 and §6, `architecture_explore.md` §3, §13, §18. - **Open items**: none. + +## U-20260922-12 · 2026-09-22 · Run with rewrote the file in UTF-8 and CRLF · #incident #plugins + +- **What**: before a "Run with…" or Plugins-menu run, PyBreeze saved a tab that already had a file with JEditor's plain `write_file()`. That function always opens the file as UTF-8 in text mode, so every such run rewrote the file on disk. On Windows an LF file came back CRLF; a Big5, UTF-16 or any non-UTF-8 file JEditor had opened in its own encoding was re-encoded as UTF-8 (reproduced: Big5 `中文\n第二行\n` became the UTF-8 bytes `e4 b8 ad e6 96 87 0d 0a …`). The write also skipped `mark_ignore_next_file_change()`, so the tab's file watcher took PyBreeze's own save for an outside edit and asked whether to reload, while the program was starting. It left the tab's unsaved `*` marker in place, too. The same save-and-run block was written out twice, once in each menu. +- **Fix**: one helper, `build_run_with_menu.save_current_file_for_run()`, writes the way JEditor's own saves do: `write_file_with_encoding()` with the tab's `file_encoding` and `line_ending`, `mark_ignore_next_file_change()` first and `mark_saved()` after. A tab without a file still goes through JEditor's Save As. The Plugins menu's run callback now calls `run_current_file_with()` instead of repeating the save, the suffix check and the run. +- **Result / numbers**: the Big5/CRLF test file keeps its exact bytes. `build_plugin_menu.py` drops three JEditor imports (`EditorWidget`, `choose_file_get_save_file_path`, `write_file`) and 48 net lines. +- **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py`, `test/test_utils/test_plugin_menu.py`, `architecture.md` §4, `architecture_explore.md` §5.4, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index d30e026a..5ddd4f14 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -54,6 +54,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-12 | 2026-09-22 | Run with rewrote the file in UTF-8 and CRLF | #incident #plugins | [2026-09](2026-09.md) | | U-20260922-11 | 2026-09-22 | IDE crashed on start with a non-English language saved | #done #i18n | [2026-09](2026-09.md) | | U-20260922-10 | 2026-09-22 | Pin PySide6 6.11.1 like the published je_editor | #done #dependencies | [2026-09](2026-09.md) | | U-20260922-09 | 2026-09-22 | Describe the release flow CI actually runs | #done #ci | [2026-09](2026-09.md) | @@ -70,4 +71,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 11 | +| [2026-09.md](2026-09.md) | 2026-09 | 12 | diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py index 2ef65818..ab0772eb 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py @@ -1,6 +1,5 @@ from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING from PySide6.QtGui import QAction @@ -8,13 +7,9 @@ from je_editor import language_wrapper from je_editor.plugins import get_all_plugin_metadata -from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget import PluginBrowserWidget -from je_editor.pyside_ui.dialog.file_dialog.save_file_dialog import choose_file_get_save_file_path -from je_editor.utils.file.save.save_file import write_file -from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess -from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow +from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import run_current_file_with if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -136,53 +131,10 @@ def callback(): def _make_run_callback(ui_we_want_to_set: PyBreezeMainWindow, run_config: dict): """ - 建立使用插件執行設定來執行程式的回呼函式。 - Create a callback to run a program using plugin run config. - 使用 PyBreeze 的 FileRunnerProcess 與 CodeWindow。 - Uses PyBreeze's FileRunnerProcess and CodeWindow. + 建立使用插件執行設定來執行程式的回呼函式,與「Run with...」選單同一套流程。 + Create a callback that runs the current file with a plugin run config, the + same way the "Run with..." menu does. """ def callback(): - widget = ui_we_want_to_set.tab_widget.currentWidget() - if not isinstance(widget, EditorWidget): - return - - # 取得並儲存檔案 / Get and save file - if widget.current_file: - write_file(widget.current_file, widget.code_edit.toPlainText()) - file_path = widget.current_file - else: - if not choose_file_get_save_file_path(ui_we_want_to_set): - return - file_path = widget.current_file - - # The save dialog can be accepted without a path being set. - if not file_path: - return - - # 檢查副檔名是否匹配 / Check suffix match - file_suffix = Path(file_path).suffix.lower() - supported = run_config.get("suffixes", ()) - if supported and file_suffix not in supported: - msg = QMessageBox(ui_we_want_to_set) - msg.setWindowTitle(language_wrapper.language_word_dict.get("run_with_menu_label", "Run with...")) - msg.setText( - language_wrapper.language_word_dict.get( - "run_with_suffix_mismatch", - "Current file ({suffix}) does not match expected suffixes: {expected}", - ).format(suffix=file_suffix, expected=", ".join(supported)) - ) - msg.exec() - return - - # 建立 CodeWindow 並執行 / Create CodeWindow and run - code_window = CodeWindow() - code_window.setWindowTitle(f"{run_config['name']} - {Path(file_path).name}") - ui_we_want_to_set.current_run_code_window.append(code_window) - - runner = FileRunnerProcess( - main_window=code_window, - program_encoding=ui_we_want_to_set.encoding, - ) - runner.run_file(run_config, file_path) - + run_current_file_with(ui_we_want_to_set, run_config) return callback diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index 8e988601..97da4020 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -1,7 +1,8 @@ """ "Run with..." menu — lets users run the current file with a plugin-registered runner. -Uses je_editor's plugin run config registry instead of scanning sys.modules. +Uses je_editor's plugin run config registry instead of scanning sys.modules. The +Plugins menu's run entries go through ``run_current_file_with`` here as well. """ from __future__ import annotations @@ -11,11 +12,11 @@ from PySide6.QtGui import QAction from PySide6.QtWidgets import QMessageBox -from je_editor import language_wrapper +from je_editor import EditorWidget, language_wrapper from je_editor.plugins import get_all_plugin_run_configs from je_editor.pyside_ui.dialog.file_dialog.save_file_dialog import choose_file_get_save_file_path -from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget -from je_editor.utils.file.save.save_file import write_file +from je_editor.utils.encodings.text_codec import DEFAULT_ENCODING, LINE_ENDING_LF +from je_editor.utils.file.save.save_file import write_file_with_encoding from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow @@ -24,26 +25,35 @@ from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -def _get_current_file(main_window: PyBreezeMainWindow) -> str | None: - """Get and save the current editor file, return its path or None.""" +def save_current_file_for_run(main_window: PyBreezeMainWindow) -> str | None: + """Save the current editor tab and return its path, or None when there is nothing to run. + + A tab that already has a file is written in place the way JEditor's own saves + write it: in the tab's encoding and line ending (a Big5 or CRLF file stays + one), with the tab's file watcher told to expect the write (otherwise it asks + whether to reload the file PyBreeze just wrote), and with the tab's unsaved + marker cleared. A tab without a file goes through JEditor's Save As dialog. + """ widget = main_window.tab_widget.currentWidget() if not isinstance(widget, EditorWidget): return None - - # If file already saved, just write current content - if widget.current_file: - write_file(widget.current_file, widget.code_edit.toPlainText()) - return widget.current_file - - # No file yet — ask user to save first - if choose_file_get_save_file_path(main_window): - return widget.current_file - return None - - -def _run_with(main_window: PyBreezeMainWindow, run_config: dict) -> None: - """Execute the current file using the given run config.""" - file_path = _get_current_file(main_window) + if not widget.current_file: + if not choose_file_get_save_file_path(main_window): + return None + # The save dialog can be accepted without a path being set. + return widget.current_file or None + widget.mark_ignore_next_file_change() + write_file_with_encoding( + str(widget.current_file), widget.code_edit.toPlainText(), + getattr(widget, "file_encoding", DEFAULT_ENCODING), + getattr(widget, "line_ending", LINE_ENDING_LF)) + widget.mark_saved() + return widget.current_file + + +def run_current_file_with(main_window: PyBreezeMainWindow, run_config: dict) -> None: + """Save the current file, then run it with *run_config* in a new run window.""" + file_path = save_current_file_for_run(main_window) if not file_path: return @@ -93,6 +103,6 @@ def set_run_with_menu(ui_we_want_to_set: PyBreezeMainWindow) -> None: action = QAction(label, ui_we_want_to_set.run_with_menu) action.triggered.connect( - lambda checked=False, cfg=config: _run_with(ui_we_want_to_set, cfg) + lambda checked=False, cfg=config: run_current_file_with(ui_we_want_to_set, cfg) ) ui_we_want_to_set.run_with_menu.addAction(action) diff --git a/test/test_utils/test_plugin_menu.py b/test/test_utils/test_plugin_menu.py index 67612cc6..be860cdd 100644 --- a/test/test_utils/test_plugin_menu.py +++ b/test/test_utils/test_plugin_menu.py @@ -12,7 +12,7 @@ import pytest from PySide6.QtWidgets import ( - QApplication, QMainWindow, QMenuBar, QTabWidget, QWidget + QApplication, QMainWindow, QMenuBar, QPlainTextEdit, QTabWidget, QWidget ) from pybreeze.extend_multi_language.update_language_dict import update_language_dict @@ -20,7 +20,7 @@ from pybreeze.pybreeze_ui.menu.plugin_menu import build_run_with_menu as run_with from pybreeze.pybreeze_ui.menu.plugin_menu.build_plugin_menu import set_plugin_menu from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import ( - _get_current_file, _run_with, set_run_with_menu + run_current_file_with, save_current_file_for_run, set_run_with_menu ) GO_CONFIG = {"name": "Go", "compiler": "go", "args": ("run",), "suffixes": (".go",)} @@ -115,10 +115,10 @@ class TestFindingTheFileToRun: def test_a_non_editor_tab_offers_no_file(self, window): window.tab_widget.addTab(QWidget(), "not an editor") window.tab_widget.setCurrentIndex(0) - assert _get_current_file(window) is None + assert save_current_file_for_run(window) is None def test_no_tabs_at_all_offers_no_file(self, window): - assert _get_current_file(window) is None + assert save_current_file_for_run(window) is None class TestRefusingToRunTheWrongFile: @@ -126,7 +126,7 @@ def test_a_suffix_mismatch_warns_and_runs_nothing( self, window, tmp_path, monkeypatch): script = tmp_path / "script.py" script.touch() - monkeypatch.setattr(run_with, "_get_current_file", lambda _w: str(script)) + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) shown: list[str] = [] monkeypatch.setattr( run_with.QMessageBox, "exec", lambda self: shown.append(self.text())) @@ -135,24 +135,24 @@ def test_a_suffix_mismatch_warns_and_runs_nothing( run_with, "FileRunnerProcess", lambda **kwargs: started.append(kwargs)) - _run_with(window, GO_CONFIG) + run_current_file_with(window, GO_CONFIG) assert shown and ".py" in shown[0] assert not started def test_no_file_means_nothing_runs(self, window, monkeypatch): - monkeypatch.setattr(run_with, "_get_current_file", lambda _w: None) + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: None) started: list[object] = [] monkeypatch.setattr( run_with, "FileRunnerProcess", lambda **kwargs: started.append(kwargs)) - _run_with(window, GO_CONFIG) + run_current_file_with(window, GO_CONFIG) assert not started def test_a_matching_suffix_opens_a_run_window_and_starts( self, window, tmp_path, monkeypatch): script = tmp_path / "main.go" script.touch() - monkeypatch.setattr(run_with, "_get_current_file", lambda _w: str(script)) + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) class Runner: def __init__(self, **kwargs) -> None: @@ -165,7 +165,7 @@ def run_file(self, config, path) -> None: started: list[Runner] = [] monkeypatch.setattr(run_with, "FileRunnerProcess", Runner) - _run_with(window, GO_CONFIG) + run_current_file_with(window, GO_CONFIG) assert len(started) == 1 assert started[0].ran == (GO_CONFIG, str(script)) @@ -175,12 +175,12 @@ def test_a_config_without_suffixes_accepts_any_file( self, window, tmp_path, monkeypatch): script = tmp_path / "anything.xyz" script.touch() - monkeypatch.setattr(run_with, "_get_current_file", lambda _w: str(script)) + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) started: list[object] = [] monkeypatch.setattr( run_with, "FileRunnerProcess", lambda **kwargs: type("R", (), {"run_file": lambda *a: started.append(a)})()) - _run_with(window, {"name": "Anything", "compiler": "cat"}) + run_current_file_with(window, {"name": "Anything", "compiler": "cat"}) assert started @@ -240,7 +240,83 @@ def test_a_run_callback_ignores_a_non_editor_tab(self, window, monkeypatch): window.tab_widget.setCurrentIndex(0) started: list[object] = [] monkeypatch.setattr( - plugin_menu, "FileRunnerProcess", + run_with, "FileRunnerProcess", lambda **kwargs: started.append(kwargs)) plugin_menu._make_run_callback(window, GO_CONFIG)() assert not started + + def test_a_run_callback_runs_the_way_run_with_does(self, window, monkeypatch): + calls: list[tuple] = [] + monkeypatch.setattr( + plugin_menu, "run_current_file_with", + lambda main_window, config: calls.append((main_window, config))) + plugin_menu._make_run_callback(window, GO_CONFIG)() + assert calls == [(window, GO_CONFIG)] + + +class EditorTab(QWidget): + """Stands in for a JEditor editor tab: its file, its text and how it saves.""" + + def __init__(self, current_file=None, text="", file_encoding="utf-8", + line_ending="\n") -> None: + super().__init__() + self.current_file = current_file + self.code_edit = QPlainTextEdit(text) + self.file_encoding = file_encoding + self.line_ending = line_ending + self.events: list[str] = [] + + def mark_ignore_next_file_change(self) -> None: + self.events.append("expect write") + + def mark_saved(self) -> None: + self.events.append("saved") + + +@pytest.fixture() +def editor_tab(window, monkeypatch): + """Put an editor tab in the window; the helper recognises it by type.""" + monkeypatch.setattr(run_with, "EditorWidget", EditorTab) + + def make(**kwargs) -> EditorTab: + tab = EditorTab(**kwargs) + window.tab_widget.addTab(tab, "tab") + window.tab_widget.setCurrentWidget(tab) + return tab + return make + + +class TestSavingBeforeARun: + def test_a_saved_file_keeps_its_encoding_and_line_endings(self, window, editor_tab, tmp_path): + target = tmp_path / "legacy.txt" + target.write_bytes("舊\r\n".encode("big5")) + editor_tab(current_file=str(target), text="中文\n第二行\n", + file_encoding="big5", line_ending="\r\n") + + assert save_current_file_for_run(window) == str(target) + assert target.read_bytes() == "中文\r\n第二行\r\n".encode("big5") + + def test_the_tab_expects_the_write_and_is_marked_saved(self, window, editor_tab, tmp_path): + target = tmp_path / "main.go" + tab = editor_tab(current_file=str(target), text="package main\n") + + save_current_file_for_run(window) + + assert tab.events == ["expect write", "saved"] + assert target.read_bytes() == b"package main\n" + + def test_an_unnamed_tab_goes_through_save_as(self, window, editor_tab, tmp_path, monkeypatch): + tab = editor_tab(text="print(1)\n") + chosen = str(tmp_path / "chosen.py") + + def save_as(_main_window): + tab.current_file = chosen + return True + + monkeypatch.setattr(run_with, "choose_file_get_save_file_path", save_as) + assert save_current_file_for_run(window) == chosen + + def test_a_cancelled_save_as_runs_nothing(self, window, editor_tab, monkeypatch): + editor_tab(text="print(1)\n") + monkeypatch.setattr(run_with, "choose_file_get_save_file_path", lambda _w: False) + assert save_current_file_for_run(window) is None From ea979039f533c42345813a7cce3a1022e37b64c3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 16:58:21 +0800 Subject: [PATCH 012/312] Claim update-log IDs under an atomic lock Two parallel sessions picked the same entry number because claiming an ID first does not stop simultaneous writers. Entries are now numbered under an atomic mkdir lock in docs/updates/, with a duplicate check before commit. --- docs/updates/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/updates/README.md b/docs/updates/README.md index 5ddd4f14..4a3d22dc 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -40,7 +40,11 @@ Without `rg`: `git grep -n "^## U-2" -- docs/updates`, or in PowerShell `Select- 1. One file per month: `docs/updates/YYYY-MM.md`. Append new entries at the end. 2. Over about 800 lines, continue in `YYYY-MM-b.md` (then `-c`) and list it in the batch table below. -3. **Claim the ID first**: write the heading line and the index row, then fill in the body. Check the day's last number with `rg -n "^## U-YYYYMMDD" docs/updates`. +3. **Claim the ID under a lock.** Several sessions may write this log at the same time (for example parallel autonomous runs), and without a lock two of them pick the same number: + 1. `mkdir docs/updates/.id-lock`. Creating a directory is atomic, so only one writer succeeds. If it already exists, someone else is claiming: wait a few seconds and retry. A lock older than 10 minutes is stale and may be removed. + 2. Find the day's last number with `rg -n "^## U-YYYYMMDD" docs/updates` and write the heading line and the index row. + 3. `rmdir docs/updates/.id-lock`, then fill in the body. Git never tracks the empty lock directory. + 4. Before committing, `rg -c "^## U-" docs/updates` must report one match in total. If not, renumber your entry under the lock and fix its index row. Whoever merges a branch renumbers entries that reuse an ID. 4. **One line per index row**: title only (about 60 characters), no summary. 5. Never rewrite a recorded entry. Correct it with a new `#decision` or `#incident` entry and add "→ corrected in U-..." to the old one. From b5f6f3db07a913ba67d7b15e895ab5e51b4f0e1d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 17:05:22 +0800 Subject: [PATCH 013/312] List and test the JEditor internals PyBreeze depends on --- architecture.md | 24 ++- architecture_explore.md | 2 +- docs/updates/2026-09.md | 8 + docs/updates/README.md | 3 +- progress.md | 1 - .../python_task_process_manager.py | 2 +- .../editor_main/file_tree_context_menu.py | 3 +- .../menu/plugin_menu/build_plugin_menu.py | 3 +- .../menu/plugin_menu/build_run_with_menu.py | 3 +- .../pybreeze_ui/tools_gui/output_actions.py | 2 +- test/test_utils/test_curl_import_gui.py | 2 +- test/test_utils/test_jeditor_contract.py | 149 ++++++++++++++++++ test/test_utils/test_output_actions.py | 2 +- test/test_utils/test_run_output.py | 2 +- test/test_utils/test_task_manager_venv.py | 2 +- 15 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 test/test_utils/test_jeditor_contract.py diff --git a/architecture.md b/architecture.md index 7830a4ed..4d9acf98 100644 --- a/architecture.md +++ b/architecture.md @@ -117,9 +117,27 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() ## 6. Cross-project boundaries -- **JEditor (upstream)**: `PyBreezeMainWindow` subclasses `je_editor.EditorMain` in extend mode. - `pybreeze/__init__.py` re-exports JEditor's plugin API. PyBreeze also imports JEditor internals - (e.g. `PluginBrowserWidget`, `DestroyDock`, `check_and_choose_venv`, `actually_color_dict`). It +- **JEditor (upstream)**: `PyBreezeMainWindow` subclasses `je_editor.EditorMain` in extend mode + and calls `super().__init__(debug_mode, show_system_tray_ray, extend=True)`. `pybreeze/__init__.py` + re-exports JEditor's plugin API. Everything else PyBreeze takes from the top level is in + je_editor's `__all__`, except for these names, which it imports from module paths JEditor has not + promised to keep. `test/test_utils/test_jeditor_contract.py` pins each path and the shape + PyBreeze calls it with, and fails if the code imports an internal name that is not on this list: + + | Name | JEditor module | Used in | + | --- | --- | --- | + | `PluginBrowserWidget` | `pyside_ui.main_ui.plugin_browser.plugin_browser_widget` | `menu/plugin_menu/build_plugin_menu.py` | + | `DestroyDock` | `pyside_ui.main_ui.dock.destroy_dock` | `menu/tools/tools_menu.py` | + | `check_and_choose_venv` | `utils.venv_check.check_venv` | `extend/process_executor/python_task_process_manager.py` | + | `choose_file_get_save_file_path` | `pyside_ui.dialog.file_dialog.save_file_dialog` | `menu/plugin_menu/build_run_with_menu.py` | + | `write_file_with_encoding` | `utils.file.save.save_file` | `menu/plugin_menu/build_run_with_menu.py` | + | `DEFAULT_ENCODING`, `LINE_ENDING_LF` | `utils.encodings.text_codec` | `menu/plugin_menu/build_run_with_menu.py` | + | `actually_color_dict` | `pyside_ui.main_ui.save_settings.user_color_setting_file` | `show_code_window/code_window.py`, `automation_menu/auto_control_menu/build_autocontrol_menu.py` | + + PyBreeze also relies on `EditorWidget`'s `current_file`, `code_edit`, `file_encoding`, + `line_ending`, `mark_ignore_next_file_change()` and `mark_saved()`, and on `language_wrapper`'s + `choose_language_dict` serving English and Traditional Chinese from the exported dict objects + themselves. Having je_editor export the names in the table is workspace X-17. It merges its strings by mutating JEditor's `english_word_dict` and `traditional_chinese_word_dict` in place, and writes its `application_name` into every dict in `language_wrapper.choose_language_dict` (`extend_multi_language/update_language_dict.py`). That has diff --git a/architecture_explore.md b/architecture_explore.md index 66df79a6..72e70e46 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -416,7 +416,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 68 個 `test_*.py`、1017 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 69 個 `test_*.py`、1047 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ddfa5f8e..8290482b 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -93,3 +93,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: the Big5/CRLF test file keeps its exact bytes. `build_plugin_menu.py` drops three JEditor imports (`EditorWidget`, `choose_file_get_save_file_path`, `write_file`) and 48 net lines. - **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py`, `test/test_utils/test_plugin_menu.py`, `architecture.md` §4, `architecture_explore.md` §5.4, §18. - **Open items**: none. + +## U-20260922-13 · 2026-09-22 · Write down and test what PyBreeze takes from JEditor · #done #jeditor + +- **What**: `progress.md` #8. PyBreeze reached into JEditor through module paths je_editor never promised to keep, and nothing said which ones or checked them. Taking stock showed three kinds: names je_editor already exports but PyBreeze imported by their internal path (`EditorWidget` twice, `JEditorExecException`, `get_all_plugin_run_configs`, `get_all_plugin_metadata`); names U-20260922-12 made unnecessary (`write_file`, and the second copy of `choose_file_get_save_file_path`); and the ones PyBreeze truly needs. +- **Fix**: the exported names now come from `je_editor`'s top level. The remaining eight internal names are listed in `architecture.md` §6 with their modules and users, with the `EditorWidget` members and `language_wrapper` behaviour PyBreeze relies on: `PluginBrowserWidget`, `DestroyDock`, `check_and_choose_venv`, `choose_file_get_save_file_path`, `write_file_with_encoding`, `DEFAULT_ENCODING`, `LINE_ENDING_LF` and `actually_color_dict`. The new `test/test_utils/test_jeditor_contract.py` pins each import path and the shape PyBreeze calls it with (constructor and function parameters, the colour keys, the save members, and that English and Traditional Chinese are served from the exported dict objects PyBreeze edits in place). It also fails when the code imports an internal name that is not on the list, so the list cannot go stale. Two tests that patched `EditorWidget` at its internal path now patch `je_editor.EditorWidget`. +- **Result / numbers**: internal `je_editor` import lines in `pybreeze/` went from 19, naming 10 distinct names (at `ec32f8e`, before this day's work), to 8, naming exactly the 8 listed ones. The contract test passes against je_editor 1.0.22 (local venv), 1.0.25 (current PyPI) and the JEDITOR working tree at `b1f6261`. Unit tests 1017 → 1047. +- **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py`, `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `pybreeze/pybreeze_ui/tools_gui/output_actions.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/{build_plugin_menu,build_run_with_menu}.py`, `test/test_utils/test_jeditor_contract.py` (new), `test/test_utils/{test_output_actions,test_curl_import_gui,test_run_output,test_task_manager_venv}.py`, `architecture.md` §6, `architecture_explore.md` §18. +- **Open items**: having je_editor export the eight names is workspace X-17 (JEditor side). diff --git a/docs/updates/README.md b/docs/updates/README.md index 4a3d22dc..2f865ebd 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-13 | 2026-09-22 | Write down and test what PyBreeze takes from JEditor | #done #jeditor | [2026-09](2026-09.md) | | U-20260922-12 | 2026-09-22 | Run with rewrote the file in UTF-8 and CRLF | #incident #plugins | [2026-09](2026-09.md) | | U-20260922-11 | 2026-09-22 | IDE crashed on start with a non-English language saved | #done #i18n | [2026-09](2026-09.md) | | U-20260922-10 | 2026-09-22 | Pin PySide6 6.11.1 like the published je_editor | #done #dependencies | [2026-09](2026-09.md) | @@ -75,4 +76,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 12 | +| [2026-09.md](2026-09.md) | 2026-09 | 13 | diff --git a/progress.md b/progress.md index 2d1d9981..6cd10a90 100644 --- a/progress.md +++ b/progress.md @@ -7,4 +7,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open - **#1** [BLOCKED] The prthinker integration (menu, settings, install; commit `99ef96f`) has never run a real review: this machine's PyBreeze venv is Python 3.11 and prthinker needs 3.12 or newer. Install prthinker with a 3.12+ interpreter and run one review (moved from JEditor's `PROGRESS.md`; workspace X-8). -- **#8** PyBreeze imports JEditor modules that je_editor does not export (`PluginBrowserWidget`, `DestroyDock`, `check_and_choose_venv`, `choose_file_get_save_file_path`, `write_file`, `actually_color_dict`) and edits JEditor's `english_word_dict` / `traditional_chinese_word_dict` in place — an undocumented contract (workspace X-17). diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 7a57c8d0..4973c29f 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -10,7 +10,7 @@ from threading import Thread from PySide6.QtCore import QTimer -from je_editor.utils.exception.exceptions import JEditorExecException +from je_editor import JEditorExecException from je_editor.utils.venv_check.check_venv import check_and_choose_venv from pybreeze.extend.process_executor.queue_pump import pump_message_queue, read_stream_into_queue diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index a7aa7e54..8f108c5c 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -13,8 +13,7 @@ QTreeView, QMenu, QFileSystemModel, QInputDialog, QMessageBox, QApplication, ) -from je_editor import language_wrapper -from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget +from je_editor import EditorWidget, language_wrapper from pybreeze.utils.logging.logger import pybreeze_logger diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py index ab0772eb..939f6194 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py @@ -5,8 +5,7 @@ from PySide6.QtGui import QAction from PySide6.QtWidgets import QMessageBox -from je_editor import language_wrapper -from je_editor.plugins import get_all_plugin_metadata +from je_editor import get_all_plugin_metadata, language_wrapper from je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget import PluginBrowserWidget from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import run_current_file_with diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index 97da4020..105499a1 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -12,8 +12,7 @@ from PySide6.QtGui import QAction from PySide6.QtWidgets import QMessageBox -from je_editor import EditorWidget, language_wrapper -from je_editor.plugins import get_all_plugin_run_configs +from je_editor import EditorWidget, get_all_plugin_run_configs, language_wrapper from je_editor.pyside_ui.dialog.file_dialog.save_file_dialog import choose_file_get_save_file_path from je_editor.utils.encodings.text_codec import DEFAULT_ENCODING, LINE_ENDING_LF from je_editor.utils.file.save.save_file import write_file_with_encoding diff --git a/pybreeze/pybreeze_ui/tools_gui/output_actions.py b/pybreeze/pybreeze_ui/tools_gui/output_actions.py index 82aa7fe1..dbba4ed6 100644 --- a/pybreeze/pybreeze_ui/tools_gui/output_actions.py +++ b/pybreeze/pybreeze_ui/tools_gui/output_actions.py @@ -90,7 +90,7 @@ def open_in_editor(self) -> QWidget | None: if tab_widget is None: pybreeze_logger.info("output_actions.py no tab_widget to open editor in") return None - from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget + from je_editor import EditorWidget editor = EditorWidget(self._main_window) editor.code_edit.setPlainText(self._output.toPlainText()) tab_widget.addTab( diff --git a/test/test_utils/test_curl_import_gui.py b/test/test_utils/test_curl_import_gui.py index a16a7797..1e9c63ca 100644 --- a/test/test_utils/test_curl_import_gui.py +++ b/test/test_utils/test_curl_import_gui.py @@ -141,7 +141,7 @@ def test_open_in_editor_adds_tab_with_code(self, widget_with_window): gui.input_edit.setPlainText("curl https://example.com/api") gui.convert() with patch( - "je_editor.pyside_ui.main_ui.editor.editor_widget.EditorWidget", _FakeEditor + "je_editor.EditorWidget", _FakeEditor ): editor = gui.actions.open_in_editor() assert len(window.tab_widget.added) == 1 diff --git a/test/test_utils/test_jeditor_contract.py b/test/test_utils/test_jeditor_contract.py new file mode 100644 index 00000000..44d63692 --- /dev/null +++ b/test/test_utils/test_jeditor_contract.py @@ -0,0 +1,149 @@ +"""What PyBreeze takes from JEditor that je_editor does not export. + +je_editor's ``__all__`` is its promise; the names below sit outside it, in module +paths JEditor is free to move. PyBreeze still needs them, so this file pins each +one down: its import path, and the shape PyBreeze calls it with. When JEditor +moves or reshapes one, this fails here instead of in the IDE at the moment +someone opens that menu. ``architecture.md`` §6 lists the same set; keep the two +in step, and move a name to ``EXPORTED`` once je_editor exports it +(workspace X-17). +""" +from __future__ import annotations + +import importlib +import inspect +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +# (module, name, where PyBreeze uses it) +INTERNAL = [ + ("je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget", "PluginBrowserWidget", + "menu/plugin_menu/build_plugin_menu.py"), + ("je_editor.pyside_ui.main_ui.dock.destroy_dock", "DestroyDock", + "menu/tools/tools_menu.py"), + ("je_editor.utils.venv_check.check_venv", "check_and_choose_venv", + "extend/process_executor/python_task_process_manager.py"), + ("je_editor.pyside_ui.dialog.file_dialog.save_file_dialog", "choose_file_get_save_file_path", + "menu/plugin_menu/build_run_with_menu.py"), + ("je_editor.utils.file.save.save_file", "write_file_with_encoding", + "menu/plugin_menu/build_run_with_menu.py"), + ("je_editor.utils.encodings.text_codec", "DEFAULT_ENCODING", + "menu/plugin_menu/build_run_with_menu.py"), + ("je_editor.utils.encodings.text_codec", "LINE_ENDING_LF", + "menu/plugin_menu/build_run_with_menu.py"), + ("je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file", "actually_color_dict", + "show_code_window/code_window.py, auto_control_menu/build_autocontrol_menu.py"), +] + +# Names PyBreeze imports from je_editor's top level, i.e. from its __all__. +EXPORTED = [ + "EditorMain", "EditorWidget", "MainBrowserWidget", "ShellManager", "JEditorExecException", + "language_wrapper", "english_word_dict", "traditional_chinese_word_dict", "jeditor_logger", + "load_external_plugins", "register_programming_language", "register_natural_language", + "get_all_plugin_run_configs", "get_all_plugin_metadata", +] + + +def _internal(module: str, name: str): + return getattr(importlib.import_module(module), name) + + +def _parameters(function) -> list[str]: + return [name for name in inspect.signature(function).parameters if name != "self"] + + +@pytest.mark.parametrize(("module", "name", "_used_in"), INTERNAL) +def test_each_internal_name_is_where_pybreeze_imports_it(module, name, _used_in): + assert hasattr(importlib.import_module(module), name), f"{module}.{name} moved" + + +@pytest.mark.parametrize("name", EXPORTED) +def test_each_top_level_name_is_still_exported(name): + import je_editor + + assert name in je_editor.__all__, f"je_editor stopped exporting {name}" + + +class TestTheShapesPyBreezeCalls: + def test_the_main_window_takes_the_arguments_pybreeze_passes(self): + from je_editor import EditorMain + + # PyBreezeMainWindow calls super().__init__(debug_mode, show_system_tray_ray, extend=True). + assert _parameters(EditorMain.__init__)[:3] == ["debug_mode", "show_system_tray_ray", "extend"] + assert callable(EditorMain.clear_code_result) + assert callable(EditorMain.startup_setting) + + def test_an_editor_tab_can_be_saved_the_way_a_run_saves_it(self): + from je_editor import EditorWidget + + # save_current_file_for_run() reads these and calls these. + source = inspect.getsource(EditorWidget) + for attribute in ("current_file", "code_edit", "file_encoding", "line_ending"): + assert f"self.{attribute}" in source, f"EditorWidget has no {attribute}" + assert callable(EditorWidget.mark_ignore_next_file_change) + assert callable(EditorWidget.mark_saved) + + def test_the_save_helpers_take_the_arguments_pybreeze_passes(self): + write = _internal("je_editor.utils.file.save.save_file", "write_file_with_encoding") + save_as = _internal( + "je_editor.pyside_ui.dialog.file_dialog.save_file_dialog", + "choose_file_get_save_file_path") + + assert _parameters(write)[:4] == ["file_path", "content", "encoding", "line_ending"] + assert len(_parameters(save_as)) == 1 + assert _internal("je_editor.utils.encodings.text_codec", "LINE_ENDING_LF") == "\n" + + def test_the_interpreter_search_takes_one_directory(self): + search = _internal("je_editor.utils.venv_check.check_venv", "check_and_choose_venv") + assert len(_parameters(search)) == 1 + + def test_the_output_colours_are_there(self): + colours = _internal( + "je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file", + "actually_color_dict") + assert {"normal_output_color", "error_output_color"} <= set(colours) + + def test_the_widgets_build_without_arguments(self): + from PySide6.QtWidgets import QDockWidget, QWidget + + dock = _internal("je_editor.pyside_ui.main_ui.dock.destroy_dock", "DestroyDock") + browser = _internal( + "je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget", + "PluginBrowserWidget") + assert issubclass(dock, QDockWidget) and not _parameters(dock.__init__) + assert issubclass(browser, QWidget) + assert all( + parameter.default is not inspect.Parameter.empty + for name, parameter in inspect.signature(browser.__init__).parameters.items() + if name != "self") + + def test_the_language_wrapper_has_what_pybreeze_reads(self): + from je_editor import language_wrapper + + for member in ("language", "language_word_dict", "choose_language_dict"): + assert hasattr(language_wrapper, member), member + assert callable(language_wrapper.reset_language) + assert callable(language_wrapper.available_languages) + # PyBreeze adds its strings to these two dicts in place, so they must be + # the very objects the wrapper serves English and Traditional Chinese from. + from je_editor import english_word_dict, traditional_chinese_word_dict + assert language_wrapper.choose_language_dict["English"] is english_word_dict + assert language_wrapper.choose_language_dict["Traditional_Chinese"] is traditional_chinese_word_dict + + +def test_the_list_matches_the_code(): + """Every internal je_editor import in pybreeze/ is listed above, and nothing else is.""" + import pathlib + import re + + import pybreeze + + pattern = re.compile(r"^\s*from (je_editor\.[\w.]+) import ([\w, ]+)$", re.MULTILINE) + found = set() + for path in pathlib.Path(pybreeze.__file__).parent.rglob("*.py"): + for module, names in pattern.findall(path.read_text(encoding="utf-8")): + found.update((module, name.strip()) for name in names.split(",")) + assert found == {(module, name) for module, name, _ in INTERNAL} diff --git a/test/test_utils/test_output_actions.py b/test/test_utils/test_output_actions.py index 7504d244..7e15c13f 100644 --- a/test/test_utils/test_output_actions.py +++ b/test/test_utils/test_output_actions.py @@ -72,7 +72,7 @@ def test_opens_tab_with_text(self, app): parent, output, actions = _make(app, main_window=window) output.setPlainText("some code") with patch( - "je_editor.pyside_ui.main_ui.editor.editor_widget.EditorWidget", _FakeEditor + "je_editor.EditorWidget", _FakeEditor ): editor = actions.open_in_editor() assert editor.code_edit.text == "some code" diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index 42fbfb25..7e8e55d4 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -109,7 +109,7 @@ def start_module_process(self, package, arguments, environment=None): assert calls == [("test_pioneer", ["-e", "C:/tests/run.yml"], None)] def test_no_interpreter_is_reported_not_raised(self, qt_app, monkeypatch): - from je_editor.utils.exception.exceptions import JEditorExecException + from je_editor import JEditorExecException from pybreeze.extend.process_executor import python_task_process_manager as manager_module from pybreeze.extend.process_executor.test_pioneer import test_pioneer_process_manager diff --git a/test/test_utils/test_task_manager_venv.py b/test/test_utils/test_task_manager_venv.py index f5420a0a..ff343bf3 100644 --- a/test/test_utils/test_task_manager_venv.py +++ b/test/test_utils/test_task_manager_venv.py @@ -20,7 +20,7 @@ def qt_app(): def _raise_no_python(_path): - from je_editor.utils.exception.exceptions import JEditorExecException + from je_editor import JEditorExecException raise JEditorExecException("no python interpreter found") From 745afe5022205ddbfe7f529b5a9c6878d507b49f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 17:23:34 +0800 Subject: [PATCH 014/312] Keep backslashes in extra prthinker arguments on Windows --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 7 ++++++ docs/updates/README.md | 3 ++- .../prthinker_extend/prthinker_setting.py | 25 ++++++++++++++++++- test/test_utils/test_prthinker_setting.py | 17 ++++++++++++- 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 72e70e46..cb3279d7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -353,7 +353,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 - 設定存 `~/.pybreeze/prthinker_setting.json` - `environment_for()` 把設定轉成 `PRTHINKER_*` 環境變數交給子行程,**命令列只留「這次要審什麼」** — API key 不會出現在工作管理員或執行紀錄 - `SECRET_SETTINGS` 四個欄位在 `loggable()` 中一律縮成 `(set)` / 空字串 -- `extra_arguments()` 用 `shlex.split()`,解析失敗當作沒有而不是讓整次審查失敗 +- `extra_arguments()` 經 `split_arguments()` 以命令列規則斷詞(引號內空白不拆);反斜線是路徑分隔字元的平台(Windows)上反斜線不當跳脫字元,`C:\reviews` 才不會變成 `C:reviews`。解析失敗當作沒有而不是讓整次審查失敗 - `install_target()` 回傳 `[runner]`,因為 prthinker 不在 PyPI 上 支援的後端:`remote / local / openai / anthropic / gemini / cohere / mistral / claude-cli / codex-cli`;平台:`github / gitlab / gitea`。 @@ -416,7 +416,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 69 個 `test_*.py`、1047 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 69 個 `test_*.py`、1050 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 8290482b..997450ac 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -101,3 +101,10 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: internal `je_editor` import lines in `pybreeze/` went from 19, naming 10 distinct names (at `ec32f8e`, before this day's work), to 8, naming exactly the 8 listed ones. The contract test passes against je_editor 1.0.22 (local venv), 1.0.25 (current PyPI) and the JEDITOR working tree at `b1f6261`. Unit tests 1017 → 1047. - **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py`, `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `pybreeze/pybreeze_ui/tools_gui/output_actions.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/{build_plugin_menu,build_run_with_menu}.py`, `test/test_utils/test_jeditor_contract.py` (new), `test/test_utils/{test_output_actions,test_curl_import_gui,test_run_output,test_task_manager_venv}.py`, `architecture.md` §6, `architecture_explore.md` §18. - **Open items**: having je_editor export the eight names is workspace X-17 (JEditor side). + +## U-20260922-14 · 2026-09-22 · Extra prthinker arguments lost their Windows backslashes · #incident #prthinker + +- **What**: the prthinker settings' free-form "extra arguments" were split with `shlex.split()`, which follows POSIX rules: a backslash escapes the next character. On Windows, where a backslash separates path parts, `--output-dir C:\reviews\out` reached prthinker as `C:reviewsout`. Found while running a real review for `progress.md` #1. +- **Fix**: `prthinker_setting.split_arguments(text, backslash_escapes=...)` splits with `shlex.shlex` in POSIX mode (quotes still group, and are removed), and turns backslash escaping off where `os.sep` is a backslash. Elsewhere it behaves as before. +- **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §14, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 2f865ebd..e07ebbfb 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-14 | 2026-09-22 | Extra prthinker arguments lost their Windows backslashes | #incident #prthinker | [2026-09](2026-09.md) | | U-20260922-13 | 2026-09-22 | Write down and test what PyBreeze takes from JEditor | #done #jeditor | [2026-09](2026-09.md) | | U-20260922-12 | 2026-09-22 | Run with rewrote the file in UTF-8 and CRLF | #incident #plugins | [2026-09](2026-09.md) | | U-20260922-11 | 2026-09-22 | IDE crashed on start with a non-English language saved | #done #i18n | [2026-09](2026-09.md) | @@ -76,4 +77,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 13 | +| [2026-09.md](2026-09.md) | 2026-09 | 14 | diff --git a/pybreeze/extend/prthinker_extend/prthinker_setting.py b/pybreeze/extend/prthinker_extend/prthinker_setting.py index ecd48e0d..05738155 100644 --- a/pybreeze/extend/prthinker_extend/prthinker_setting.py +++ b/pybreeze/extend/prthinker_extend/prthinker_setting.py @@ -17,6 +17,7 @@ from __future__ import annotations import json +import os import shlex from pathlib import Path from typing import Dict, List @@ -178,12 +179,34 @@ def extra_arguments(setting: Dict[str, str]) -> List[str]: if not text: return [] try: - return shlex.split(text) + return split_arguments(text, backslash_escapes=os.sep != "\\") except ValueError as error: pybreeze_logger.error("prthinker extra arguments could not be read: %r", error) return [] +def split_arguments(text: str, *, backslash_escapes: bool) -> List[str]: + """ + 以命令列的規則斷詞 + Split *text* into arguments the way a command line is split. + + Windows 的路徑用反斜線分隔,那裡的反斜線不能當跳脫字元,否則 ``C:\\reviews`` 會變成 + ``C:reviews``。 + Where a backslash separates path parts (Windows), it must not escape the + next character, or ``C:\\reviews`` would come out as ``C:reviews``. + + :param text: 要斷詞的文字 / the text to split + :param backslash_escapes: 反斜線是否為跳脫字元 / whether a backslash escapes + :return: 參數 / the arguments + :raises ValueError: 引號沒有關上時 / when a quote is left open + """ + lexer = shlex.shlex(text, posix=True) + lexer.whitespace_split = True + if not backslash_escapes: + lexer.escape = "" + return list(lexer) + + def review_file_arguments(file_path: str, setting: Dict[str, str]) -> List[str]: """ 組出審查單一檔案的參數 diff --git a/test/test_utils/test_prthinker_setting.py b/test/test_utils/test_prthinker_setting.py index e807c1a1..671b551c 100644 --- a/test/test_utils/test_prthinker_setting.py +++ b/test/test_utils/test_prthinker_setting.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import os import pytest @@ -9,7 +10,7 @@ from pybreeze.extend.prthinker_extend.prthinker_setting import ( DEFAULT_SETTING, INSTALL_EXTRAS, SECRET_SETTINGS, SETTING_ENVIRONMENT, environment_for, extra_arguments, install_target, load_setting, loggable, - review_file_arguments, review_pr_arguments, save_setting + review_file_arguments, review_pr_arguments, save_setting, split_arguments ) @@ -92,6 +93,20 @@ def test_a_quoted_argument_stays_in_one_piece(self): setting = {**DEFAULT_SETTING, "extra_arguments": '--marker "a b"'} assert extra_arguments(setting) == ["--marker", "a b"] + def test_a_windows_path_keeps_its_backslashes(self): + assert split_arguments( + r'--output-dir C:\reviews\out --marker "C:\with space\x"', + backslash_escapes=False, + ) == ["--output-dir", r"C:\reviews\out", "--marker", r"C:\with space\x"] + + def test_where_backslash_escapes_it_still_does(self): + assert split_arguments(r"--marker a\ b", backslash_escapes=True) == ["--marker", "a b"] + + def test_the_platform_decides_what_a_backslash_means(self): + setting = {**DEFAULT_SETTING, "extra_arguments": r"--output-dir out\here"} + expected = r"out\here" if os.sep == "\\" else "outhere" + assert extra_arguments(setting) == ["--output-dir", expected] + def test_an_unclosed_quote_costs_only_the_extra_arguments(self): setting = {**DEFAULT_SETTING, "extra_arguments": '--marker "unclosed'} assert extra_arguments(setting) == [] From bf5fe2b863959a6615fdb652ad124dea2ad76597 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 17:26:29 +0800 Subject: [PATCH 015/312] Record a dependency audit of the CI install --- docs/updates/2026-09.md | 7 +++++++ docs/updates/README.md | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 997450ac..d8dc8c9d 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -108,3 +108,10 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Fix**: `prthinker_setting.split_arguments(text, backslash_escapes=...)` splits with `shlex.shlex` in POSIX mode (quotes still group, and are removed), and turns backslash escaping off where `os.sep` is a backslash. Elsewhere it behaves as before. - **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §14, §18. - **Open items**: none. + +## U-20260922-15 · 2026-09-22 · Dependency audit of what CI installs · #snapshot #dependencies + +- **What**: a CVE audit of what CI installs now, as opposed to the long-lived local `.venv` (which still had je_editor 1.0.22 and PySide6 6.11.0). A fresh Python 3.13 venv was built from `dev_requirements.txt` alone after U-20260922-07 and -10, and `pip-audit` was run in it. +- **Result / numbers**: 276 packages audited, 0 with a known vulnerability. Resolved versions of the direct dependencies that an audit on 2026-06-27 had flagged: jupyterlab 4.6.4, paramiko 5.0.0. The rest of the stack: je_editor 1.0.25, PySide6 6.11.1, frontengine 1.0.77. +- **Evidence**: `python -m pip_audit --progress-spinner off --format json` in that venv; the same venv passed the unit tests (1008) and both startup tests (U-20260922-10). +- **Open items**: dependencies other than PySide6 are still unpinned, despite `CLAUDE.md` § Security › Dependencies. Pinning the je_* family changes how releases are cut, so it is the owner's call (not added to `progress.md`). diff --git a/docs/updates/README.md b/docs/updates/README.md index e07ebbfb..f1886592 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-15 | 2026-09-22 | Dependency audit of what CI installs | #snapshot #dependencies | [2026-09](2026-09.md) | | U-20260922-14 | 2026-09-22 | Extra prthinker arguments lost their Windows backslashes | #incident #prthinker | [2026-09](2026-09.md) | | U-20260922-13 | 2026-09-22 | Write down and test what PyBreeze takes from JEditor | #done #jeditor | [2026-09](2026-09.md) | | U-20260922-12 | 2026-09-22 | Run with rewrote the file in UTF-8 and CRLF | #incident #plugins | [2026-09](2026-09.md) | @@ -77,4 +78,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 14 | +| [2026-09.md](2026-09.md) | 2026-09 | 15 | From 35a7e65d75d9f499cd50a7e7da2ba01b5d2f6fba Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 21:58:21 +0800 Subject: [PATCH 016/312] Test the prthinker command line PyBreeze builds against the real CLI The new contract test runs python -m prthinker with the arguments and environment PyBreeze assembles, pointed at a closed local port, and fails if prthinker rejects any of them. CI installs prthinker on the Python 3.12 leg so the test runs there; elsewhere it skips unless PYBREEZE_PRTHINKER_PYTHON names an interpreter that has it. --- .github/workflows/dev.yml | 6 ++ .github/workflows/stable.yml | 6 ++ docs/updates/2026-09.md | 8 ++ docs/updates/README.md | 3 +- test/test_utils/test_prthinker_contract.py | 115 +++++++++++++++++++++ 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 test/test_utils/test_prthinker_contract.py diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index edd826fe..a0392514 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -31,6 +31,12 @@ jobs: run: python -m pip install -r dev_requirements.txt - name: Install test tooling run: python -m pip install pytest pytest-cov hypothesis + - name: Install prthinker for the command-line contract test + # prthinker is not on PyPI and needs Python 3.12+. One leg is enough to + # check that the arguments and environment PyBreeze builds are still the + # ones prthinker accepts (test/test_utils/test_prthinker_contract.py). + if: matrix.python-version == '3.12' + run: python -m pip install "prthinker[runner] @ https://github.com/JE-Chen/Code-Review-Framework-Combining-Large-Language-Models-and-Chain-of-Thought-Reasoning/archive/refs/heads/main.zip" - name: Run unit tests (pytest) run: python -m pytest test/test_utils/ -v --tb=short --cov=pybreeze --cov-branch --cov-report=xml - name: Upload coverage for analysis diff --git a/.github/workflows/stable.yml b/.github/workflows/stable.yml index 1c64b312..5c4766f1 100644 --- a/.github/workflows/stable.yml +++ b/.github/workflows/stable.yml @@ -31,6 +31,12 @@ jobs: run: python -m pip install -r requirements.txt - name: Install test tooling run: python -m pip install pytest pytest-cov hypothesis + - name: Install prthinker for the command-line contract test + # prthinker is not on PyPI and needs Python 3.12+. One leg is enough to + # check that the arguments and environment PyBreeze builds are still the + # ones prthinker accepts (test/test_utils/test_prthinker_contract.py). + if: matrix.python-version == '3.12' + run: python -m pip install "prthinker[runner] @ https://github.com/JE-Chen/Code-Review-Framework-Combining-Large-Language-Models-and-Chain-of-Thought-Reasoning/archive/refs/heads/main.zip" - name: Run unit tests (pytest) run: python -m pytest test/test_utils/ -v --tb=short --cov=pybreeze --cov-branch --cov-report=xml - name: Upload coverage for analysis diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index d8dc8c9d..5420bef0 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -115,3 +115,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: 276 packages audited, 0 with a known vulnerability. Resolved versions of the direct dependencies that an audit on 2026-06-27 had flagged: jupyterlab 4.6.4, paramiko 5.0.0. The rest of the stack: je_editor 1.0.25, PySide6 6.11.1, frontengine 1.0.77. - **Evidence**: `python -m pip_audit --progress-spinner off --format json` in that venv; the same venv passed the unit tests (1008) and both startup tests (U-20260922-10). - **Open items**: dependencies other than PySide6 are still unpinned, despite `CLAUDE.md` § Security › Dependencies. Pinning the je_* family changes how releases are cut, so it is the owner's call (not added to `progress.md`). + +## U-20260922-16 · 2026-09-22 · Contract test for the prthinker command line · #done #prthinker #ci + +- **What**: the contract-test half of workspace X-8. `test/test_utils/test_prthinker_contract.py` runs the real `python -m prthinker` with the arguments (`review_file_arguments`, `review_pr_arguments`) and the environment (`utf8_subprocess_env` plus `environment_for`) PyBreeze builds, with the backend and the forge pointed at a closed local port. Reaching `ConnectError` means prthinker accepted every argument and variable; a rename on either side stops it earlier with "unrecognized arguments", "is required", "is not a valid" or "invalid choice". A fourth case checks that every backend PyBreeze offers (`BACKENDS`) is a `prthinker.config.BackendKind` value. The tests skip unless prthinker is importable by the running interpreter or `PYBREEZE_PRTHINKER_PYTHON` names one that has it. +- **CI**: `dev.yml` and `stable.yml` install `prthinker[runner]` from the GitHub `main` archive on the Python 3.12 leg only (prthinker is not on PyPI and needs 3.12+), before the unit tests. +- **Result / numbers**: 33 passed for the contract test plus `test_prthinker_setting.py` with `PYBREEZE_PRTHINKER_PYTHON` set to the prthinker checkout's `.venv` (Python 3.14.4, `dev` at `14a3d42`). Full `test/test_utils/` in the local 3.11 venv: 1050 passed, 4 skipped (the contract tests). `ruff check` on the new file is clean. +- **Files**: `test/test_utils/test_prthinker_contract.py` (new), `.github/workflows/dev.yml`, `.github/workflows/stable.yml`. +- **Open items**: `progress.md` #1 (a real review) and the rest of workspace X-8. diff --git a/docs/updates/README.md b/docs/updates/README.md index f1886592..9544c395 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-16 | 2026-09-22 | Contract test for the prthinker command line | #done #prthinker #ci | [2026-09](2026-09.md) | | U-20260922-15 | 2026-09-22 | Dependency audit of what CI installs | #snapshot #dependencies | [2026-09](2026-09.md) | | U-20260922-14 | 2026-09-22 | Extra prthinker arguments lost their Windows backslashes | #incident #prthinker | [2026-09](2026-09.md) | | U-20260922-13 | 2026-09-22 | Write down and test what PyBreeze takes from JEditor | #done #jeditor | [2026-09](2026-09.md) | @@ -78,4 +79,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 15 | +| [2026-09.md](2026-09.md) | 2026-09 | 16 | diff --git a/test/test_utils/test_prthinker_contract.py b/test/test_utils/test_prthinker_contract.py new file mode 100644 index 00000000..3b1e466f --- /dev/null +++ b/test/test_utils/test_prthinker_contract.py @@ -0,0 +1,115 @@ +"""PyBreeze's side of the prthinker command line, checked against the real prthinker. + +prthinker is not on PyPI and needs Python 3.12 or newer, so these run only where +an interpreter with prthinker installed is at hand: the one running the tests +(CI's 3.12 leg installs it), or one named by ``PYBREEZE_PRTHINKER_PYTHON``. + +Each case runs the real CLI with the arguments and the environment PyBreeze +builds, with the backend and the forge pointed at a closed local port. Reaching +the network (``ConnectError``) means prthinker accepted every argument and every +variable. A rename on either side stops it earlier instead, with +"unrecognized arguments", "is required" or "is not a valid". +""" +from __future__ import annotations + +import importlib.util +import json +import os +import socket +import subprocess +import sys + +import pytest + +from pybreeze.extend.prthinker_extend.prthinker_setting import ( + BACKENDS, DEFAULT_SETTING, PRTHINKER_PACKAGE, environment_for, review_file_arguments, + review_pr_arguments +) +from pybreeze.utils.subprocess_util import utf8_subprocess_env + +_RUN_TIMEOUT_SECONDS = 120 +_REJECTIONS = ("unrecognized arguments", "is required", "is not a valid", "invalid choice") + + +def _prthinker_python() -> str | None: + named = os.environ.get("PYBREEZE_PRTHINKER_PYTHON") + if named: + return named + if importlib.util.find_spec(PRTHINKER_PACKAGE) is not None: + return sys.executable + return None + + +PRTHINKER_PYTHON = _prthinker_python() +pytestmark = pytest.mark.skipif( + PRTHINKER_PYTHON is None, + reason="prthinker is not installed here (set PYBREEZE_PRTHINKER_PYTHON to run these)") + + +@pytest.fixture() +def closed_url() -> str: + """An address on this machine where nothing listens.""" + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + return f"http://127.0.0.1:{port}" + + +def _run(arguments: list[str], setting: dict, cwd) -> subprocess.CompletedProcess: + # The same environment TaskProcessManager gives the child. + environment = {**utf8_subprocess_env("utf-8"), **environment_for(setting)} + return subprocess.run( # noqa: S603 — fixed interpreter and argv built by the code under test + [PRTHINKER_PYTHON, "-m", PRTHINKER_PACKAGE, *arguments], + cwd=cwd, env=environment, stdin=subprocess.DEVNULL, capture_output=True, + timeout=_RUN_TIMEOUT_SECONDS, check=False, shell=False, + ) + + +def _assert_accepted(completed: subprocess.CompletedProcess) -> None: + errors = completed.stderr.decode("utf-8", "replace") + rejected = [phrase for phrase in _REJECTIONS if phrase in errors] + assert not rejected, f"prthinker rejected what PyBreeze sent ({rejected}):\n{errors[-2000:]}" + assert "ConnectError" in errors, f"prthinker stopped before the network:\n{errors[-2000:]}" + + +def test_every_backend_pybreeze_offers_is_one_prthinker_knows(): + completed = subprocess.run( # noqa: S603 — fixed interpreter and a literal script + [PRTHINKER_PYTHON, "-c", + "import json; from prthinker.config import BackendKind; " + "print(json.dumps([kind.value for kind in BackendKind]))"], + capture_output=True, timeout=_RUN_TIMEOUT_SECONDS, check=True, shell=False, + ) + known = set(json.loads(completed.stdout)) + assert set(BACKENDS) <= known, f"offered but unknown to prthinker: {set(BACKENDS) - known}" + + +@pytest.mark.parametrize("backend", ["openai", "remote"]) +def test_reviewing_a_file_is_accepted(tmp_path, closed_url, backend): + source = tmp_path / "main.py" + source.write_text("print(1)\n", encoding="utf-8") + setting = { + **DEFAULT_SETTING, + "backend": backend, + "model_name": "any-model", + "remote_url": closed_url, + "openai_base_url": f"{closed_url}/v1", + "openai_api_key": "not-a-real-key", + # prthinker's local RAG index ships only with its repository, not its + # package, so an installed prthinker needs it off (or remote). + "extra_arguments": "--no-rag --max-new-tokens 16", + } + _assert_accepted(_run(review_file_arguments(str(source), setting), setting, tmp_path)) + + +def test_reviewing_a_pull_request_is_accepted(tmp_path, closed_url): + setting = { + **DEFAULT_SETTING, + "backend": "openai", + "openai_base_url": f"{closed_url}/v1", + "openai_api_key": "not-a-real-key", + "platform": "github", + "platform_base_url": closed_url, + "repository": "owner/name", + "platform_token": "not-a-real-token", + } + _assert_accepted(_run(review_pr_arguments(12, setting), setting, tmp_path)) From c5eafe30279c899e1d0f0e4b7309440ab6a47427 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 22 Sep 2026 23:57:48 +0800 Subject: [PATCH 017/312] Keep the automation menus' entries alive Every Run, HELP and Project submenu the automation menu factory built was empty, and so was AutoControl's Record submenu: each QAction had no parent and lived only in a local variable, so it was deleted when the builder returned. Parent each action to its menu, and guard the whole started menu bar against an empty submenu. --- CLAUDE.md | 2 +- architecture.md | 4 +- architecture_explore.md | 6 +- docs/updates/2026-09.md | 9 ++ docs/updates/README.md | 3 +- .../build_autocontrol_menu.py | 4 +- .../automation_menu_factory.py | 10 +- test/test_utils/started_window.py | 64 ++++++++++ .../test_automation_menu_factory.py | 111 ++++++++++++++++++ test/test_utils/test_started_menus.py | 45 +++++++ test/test_utils/test_startup_language.py | 60 ++-------- 11 files changed, 254 insertions(+), 64 deletions(-) create mode 100644 test/test_utils/started_window.py create mode 100644 test/test_utils/test_automation_menu_factory.py create mode 100644 test/test_utils/test_started_menus.py diff --git a/CLAUDE.md b/CLAUDE.md index 88e2d84a..8d4b5382 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ ruff check pybreeze/ # before committing non-trivia - **Never update UI from a worker thread** — Queue + QTimer (see `TaskProcessManager`) or Qt Signal/Slot - Custom exceptions inherit from `ITEException`; log via `pybreeze_logger` (lazy `%s` formatting, never `print()`) - Plugin API: `register_programming_language()` / `register_natural_language()` from `je_editor.plugins` -- A QAction built for a menu must be stored on the main window — Qt holds no reference and a GC'd action silently stops responding +- A QAction built for a menu must be kept alive: store it on the main window or give it the menu as its parent. A menu does not own the actions added to it, so one held only by a local variable is deleted when the builder returns and its entry disappears - Delete unused code immediately — no dead imports, unreachable branches, commented-out blocks, or `_old_` prefixes - Follow PEP 8 and standard Pythonic practice; `ruff` is the arbiter diff --git a/architecture.md b/architecture.md index 4d9acf98..2e51b25d 100644 --- a/architecture.md +++ b/architecture.md @@ -165,8 +165,8 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() - Keep `architecture_explore.md` and the CLAUDE.md tree current in the same change (CLAUDE.md § Architecture). -- Never update UI from a worker thread: use Queue + QTimer or Signal/Slot. Store every menu `QAction` - on the main window. Custom exceptions derive from `ITEException`. Log via `pybreeze_logger` +- Never update UI from a worker thread: use Queue + QTimer or Signal/Slot. Keep every menu `QAction` + alive: store it on the main window or parent it to its menu. Custom exceptions derive from `ITEException`. Log via `pybreeze_logger` (§ Conventions). - Every outbound request to a user URL passes SSRF validation (`utils/network/url_validation.py`) with timeouts and size caps (§ Security › Network). diff --git a/architecture_explore.md b/architecture_explore.md index cb3279d7..b143dc0a 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -159,7 +159,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.1 `automation_menu_factory.py` — 選單工廠 -`build_automation_menu()` 用宣告式參數組出標準自動化子選單:`Run` 子選單 / `Help`(文件+GitHub,開內嵌瀏覽器分頁)/ `Project`(建立範本目錄)/ GUI 分頁。六個自動化模組全部靠它,`build_*_menu.py` 只剩一份設定表。 +`build_automation_menu()` 用宣告式參數組出標準自動化子選單:`Run` 子選單 / `Help`(文件+GitHub,開內嵌瀏覽器分頁)/ `Project`(建立範本目錄)/ GUI 分頁。六個自動化模組全部靠它,`build_*_menu.py` 只剩一份設定表。每個 QAction 都以它所在的選單為 parent,由 Qt 持有;AutoControl 額外的 `Record` 子選單也一樣。 `safe_create_project(import_name)` 回傳延遲 import 的 closure,模組沒裝時只記 log 不炸選單。 @@ -185,7 +185,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `_TAB_ACTIONS: tuple[...]` — (widget key, 主視窗屬性, 選單屬性, action 語言鍵, 分頁標籤鍵) - `_DOCK_ACTIONS` / `_DOCK_TITLES` — 同一組 widget 也能開成右側 dock -`_register_action()` 有一段關鍵註解:QAction 必須 `setattr` 掛回主視窗,否則 Qt 不持有它、被 GC 後選單項就失效。 +`_register_action()` 有一段關鍵註解:QAction 必須 `setattr` 掛回主視窗,否則 Qt 不持有它、被 GC 後選單項就失效。另一種做法是建構時把選單當 parent(自動化選單工廠、插件選單用這種)。`test_started_menus.py` 在子行程啟動真的 IDE、GC 後走訪整條選單列,任何子選單變空就失敗(JEditor 的兩個字型選單除外:offscreen 平台沒有字型)。 ### 5.4 插件選單 @@ -416,7 +416,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 69 個 `test_*.py`、1050 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 72 個 `test_*.py`、1058 個測試(4 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 5420bef0..6a3afb0e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -123,3 +123,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: 33 passed for the contract test plus `test_prthinker_setting.py` with `PYBREEZE_PRTHINKER_PYTHON` set to the prthinker checkout's `.venv` (Python 3.14.4, `dev` at `14a3d42`). Full `test/test_utils/` in the local 3.11 venv: 1050 passed, 4 skipped (the contract tests). `ruff check` on the new file is clean. - **Files**: `test/test_utils/test_prthinker_contract.py` (new), `.github/workflows/dev.yml`, `.github/workflows/stable.yml`. - **Open items**: `progress.md` #1 (a real review) and the rest of workspace X-8. + +## U-20260922-17 · 2026-09-22 · Automation submenus came up empty · #incident #menus + +- **What**: every `Run`, `HELP` and `Project` submenu of the six factory-built automation menus (APITestka, AutoControl, FileAutomation, LoadDensity, MailThunder, WebRunner) was empty, and so was AutoControl's `Record` submenu; the `GUI` entries were gone as well. `build_automation_menu()` and `set_autocontrol_menu()` built each `QAction` with no parent and kept it only in a local variable. A menu does not own the actions added to it, so each one was deleted when the builder returned and its entry disappeared. Found by walking the started IDE's menu bar while preparing the real prthinker review for `progress.md` #1. +- **Fix**: each of those actions takes the menu it is added to as its parent, the way the plugin menus already did. `CLAUDE.md` § Conventions and `architecture.md` §7 now allow either way of keeping an action alive (stored on the main window, or parented to its menu). +- **Tests**: `test_automation_menu_factory.py` builds each kind of entry, collects garbage, and triggers it. `test_started_menus.py` starts the real IDE in a child, collects garbage, and fails on any empty submenu of the whole menu bar (JEditor's two font menus excepted: the offscreen platform has no fonts). The child start is shared with `test_startup_language.py` through the new `test/test_utils/started_window.py`. All four new tests fail with the fix reverted. +- **Result / numbers**: unit tests 1050 → 1054 passed, 4 skipped (the prthinker contract tests, local Python 3.11 venv). `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py`, `pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py`, `test/test_utils/{test_automation_menu_factory,test_started_menus,started_window,test_startup_language}.py`, `CLAUDE.md`, `architecture.md` §7, `architecture_explore.md` §5.1, §5.3, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9544c395..5e8edbb6 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-17 | 2026-09-22 | Automation submenus came up empty | #incident #menus | [2026-09](2026-09.md) | | U-20260922-16 | 2026-09-22 | Contract test for the prthinker command line | #done #prthinker #ci | [2026-09](2026-09.md) | | U-20260922-15 | 2026-09-22 | Dependency audit of what CI installs | #snapshot #dependencies | [2026-09](2026-09.md) | | U-20260922-14 | 2026-09-22 | Extra prthinker arguments lost their Windows backslashes | #incident #prthinker | [2026-09](2026-09.md) | @@ -79,4 +80,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 16 | +| [2026-09.md](2026-09.md) | 2026-09 | 17 | 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 7f331bcf..79b5752f 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 @@ -51,11 +51,11 @@ def set_autocontrol_menu(ui_we_want_to_set: PyBreezeMainWindow): 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 = QAction(lang.get("autocontrol_record_start_label"), record_menu) record_action.triggered.connect(je_auto_control.record) record_menu.addAction(record_action) - stop_record_action = QAction(lang.get("autocontrol_record_stop_label")) + stop_record_action = QAction(lang.get("autocontrol_record_stop_label"), record_menu) stop_record_action.triggered.connect(lambda: stop_record(ui_we_want_to_set)) record_menu.addAction(stop_record_action) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py index bc77c5a7..f6ee209a 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py @@ -58,7 +58,7 @@ def build_automation_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"])) + action = QAction(lang.get(action_config["label_key"]), run_menu) callback = action_config["callback"] action.triggered.connect(callback) run_menu.addAction(action) @@ -67,14 +67,14 @@ def build_automation_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 = QAction(lang.get(doc_label_key), help_menu) 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 = QAction(lang.get(github_label_key), help_menu) github_action.triggered.connect( lambda checked=False, u=github_url, t=github_tab_label_key: open_web_browser(ui, u, lang.get(t)) @@ -84,13 +84,13 @@ def build_automation_menu( # 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 = QAction(lang.get(create_project_label_key), project_menu) 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 = QAction(gui_label, menu) gui_action.triggered.connect( lambda checked=False: ui.tab_widget.addTab(gui_widget_class(), gui_label) ) diff --git a/test/test_utils/started_window.py b/test/test_utils/started_window.py new file mode 100644 index 00000000..6a0f3732 --- /dev/null +++ b/test/test_utils/started_window.py @@ -0,0 +1,64 @@ +"""Start the real main window in a child interpreter and read back what it saw. + +A child keeps the test process safe: a Qt crash while the window is being built +(an access violation) cannot be caught inside pytest. The child writes its +findings to a file, because JEditor redirects ``sys.stdout`` into its own +console widget once the window exists. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +_START_TIMEOUT_SECONDS = 120 + +_PRELUDE = """ +import gc, json, sys +from PySide6.QtWidgets import QApplication +app = QApplication([]) +from je_editor import language_wrapper +from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow +window = PyBreezeMainWindow(debug_mode=True) +gc.collect() +""" + +_REPORT = """ +with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump(result, handle, ensure_ascii=False) +""" + + +def run_started_window(tmp_path: Path, body: str, *, saved_language: str | None = None) -> object: + """Start the IDE in a child, run *body* there, and return the ``result`` it set. + + *body* runs after the window is built and garbage has been collected, with + ``window`` and ``language_wrapper`` in scope; it must assign a JSON-able + value to ``result``. The child's working directory is *tmp_path*, which is + where JEditor looks for its settings, so *saved_language* is written there + as the saved language before the start. + """ + if saved_language is not None: + settings_dir = tmp_path / ".jeditor" + settings_dir.mkdir() + (settings_dir / "user_setting.json").write_text( + json.dumps({"language": saved_language}), encoding="utf-8") + result_file = tmp_path / "result.json" + environment = { + **os.environ, + "QT_QPA_PLATFORM": "offscreen", + "PYTHONPATH": os.pathsep.join( + filter(None, [str(_REPOSITORY_ROOT), os.environ.get("PYTHONPATH")])), + } + completed = subprocess.run( # noqa: S603 — fixed argv: this interpreter and a script built from literals + [sys.executable, "-c", _PRELUDE + body + _REPORT, str(result_file)], + cwd=tmp_path, env=environment, capture_output=True, timeout=_START_TIMEOUT_SECONDS, + check=False, shell=False, + ) + assert completed.returncode == 0, ( + f"the IDE did not start (exit {completed.returncode})\n" + f"{completed.stderr.decode('utf-8', 'replace')[-2000:]}") + return json.loads(result_file.read_text(encoding="utf-8")) diff --git a/test/test_utils/test_automation_menu_factory.py b/test/test_utils/test_automation_menu_factory.py new file mode 100644 index 00000000..d7241ae0 --- /dev/null +++ b/test/test_utils/test_automation_menu_factory.py @@ -0,0 +1,111 @@ +"""The automation menu factory: every entry it builds is still there, and still answers, later. + +A QAction added to a menu is not owned by it. One kept only in a local variable +is deleted when the builder returns, and its entry disappears from the menu, so +each test collects garbage before looking. +""" +from __future__ import annotations + +import gc +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QApplication, QMainWindow, QMenu, QTabWidget, QWidget + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.automation_menu import automation_menu_factory +from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( + build_automation_menu +) + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeWindow(QMainWindow): + """A main window with the members the factory touches.""" + + def __init__(self): + super().__init__() + self.automation_menu = self.menuBar().addMenu("Automation") + self.tab_widget = QTabWidget() + + +@pytest.fixture +def window(app): + fake = FakeWindow() + yield fake + fake.deleteLater() + + +def _submenu_actions(menu: QMenu, index: int) -> list[QAction]: + # Hold the submenu's own action while asking it for the submenu: PySide + # deletes the submenu along with a temporary wrapper of that action. + submenu_action = menu.actions()[index] + return submenu_action.menu().actions() + + +def test_run_actions_survive_and_answer(window): + calls = [] + menu = build_automation_menu( + window, "run_label", + run_actions=[ + {"label_key": "run_label", "callback": lambda: calls.append("first")}, + {"label_key": "help_label", "callback": lambda: calls.append("second")}, + ], + ) + gc.collect() + + run_actions = _submenu_actions(menu, 0) + assert len(run_actions) == 2 + run_actions[1].trigger() + assert calls == ["second"] + + +def test_help_actions_survive_and_open_their_page(window, monkeypatch): + opened = [] + monkeypatch.setattr( + automation_menu_factory, "open_web_browser", + lambda ui, url, label: opened.append(url)) + menu = build_automation_menu( + window, "run_label", + doc_url="https://docs.example", doc_label_key="help_label", + doc_tab_label_key="help_label", + github_url="https://github.example", github_label_key="run_label", + github_tab_label_key="run_label", + ) + gc.collect() + + help_actions = _submenu_actions(menu, 0) + assert len(help_actions) == 2 + for action in help_actions: + action.trigger() + assert opened == ["https://docs.example", "https://github.example"] + + +def test_project_and_gui_actions_survive_and_answer(window): + created = [] + menu = build_automation_menu( + window, "run_label", + create_project_func=lambda: created.append(True), + create_project_label_key="project_label", + gui_widget_class=QWidget, gui_label="GUI", + ) + gc.collect() + + project_actions = _submenu_actions(menu, 0) + assert len(project_actions) == 1 + project_actions[0].trigger() + assert created == [True] + + gui_action = menu.actions()[1] + assert gui_action.text() == "GUI" + gui_action.trigger() + assert window.tab_widget.count() == 1 diff --git a/test/test_utils/test_started_menus.py b/test/test_utils/test_started_menus.py new file mode 100644 index 00000000..ab4df27c --- /dev/null +++ b/test/test_utils/test_started_menus.py @@ -0,0 +1,45 @@ +"""Every submenu of the started IDE still has entries once garbage is collected. + +A QAction added to a menu is not owned by the menu. One built with no parent and +kept only in a local variable is deleted when its builder returns, and the menu +it was added to is left empty: that is how every Run, HELP and Project submenu +of the automation menus came up empty. + +JEditor's two font menus are left out: they list the platform's font families, +and the offscreen platform the tests run on has none. +""" +from __future__ import annotations + +from test_utils.started_window import run_started_window + +_REPORT_EMPTY_SUBMENUS = """ +font_menus = [window.file_menu.font_menu, window.text_menu.font_menu] + +def empty_submenus(menu, path): + empty = [] + for action in menu.actions(): + submenu = action.menu() + if submenu is None or submenu in font_menus: + continue + where = path + [action.text()] + if not submenu.actions(): + empty.append(" > ".join(where)) + empty.extend(empty_submenus(submenu, where)) + return empty + +result = { + "automation_entries": len(window.automation_menu.actions()), + "empty": [ + entry + for top in window.menuBar().actions() if top.menu() is not None + for entry in empty_submenus(top.menu(), [top.text()]) + ], +} +""" + + +def test_no_submenu_of_the_started_ide_is_empty(tmp_path): + seen = run_started_window(tmp_path, _REPORT_EMPTY_SUBMENUS) + + assert seen["automation_entries"] > 0 + assert seen["empty"] == [] diff --git a/test/test_utils/test_startup_language.py b/test/test_utils/test_startup_language.py index 4dac5cc9..43d6ab32 100644 --- a/test/test_utils/test_startup_language.py +++ b/test/test_utils/test_startup_language.py @@ -3,67 +3,27 @@ JEditor picks the saved language inside ``EditorMain.__init__`` and serves it from a merged copy of its dictionaries. PyBreeze's strings have to be in by then; when they were added afterwards, every PyBreeze menu got a ``None`` title -and Qt crashed the process (access violation) before the window appeared. A -crash like that cannot be caught inside pytest, so each start runs in a child. +and Qt crashed the process (access violation) before the window appeared. """ from __future__ import annotations -import json -import os -import subprocess -import sys -from pathlib import Path - import pytest -_REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -_START_TIMEOUT_SECONDS = 120 +from test_utils.started_window import run_started_window -# The child writes what it saw to a file: JEditor redirects sys.stdout into -# its own console widget once the window exists. -_CHILD = """ -import json, sys -from PySide6.QtWidgets import QApplication -app = QApplication([]) -from je_editor import language_wrapper -from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -window = PyBreezeMainWindow(debug_mode=True) -with open(sys.argv[1], "w", encoding="utf-8") as handle: - json.dump({ - "language": language_wrapper.language, - "menu_titles": [action.text() for action in window.menuBar().actions()], - "automation_menu": window.automation_menu.title(), - "window_title": window.windowTitle(), - }, handle, ensure_ascii=False) +_REPORT_WHAT_STARTED = """ +result = { + "language": language_wrapper.language, + "menu_titles": [action.text() for action in window.menuBar().actions()], + "automation_menu": window.automation_menu.title(), + "window_title": window.windowTitle(), +} """ -def _start_with_saved_language(tmp_path: Path, language: str) -> dict: - settings_dir = tmp_path / ".jeditor" - settings_dir.mkdir() - (settings_dir / "user_setting.json").write_text( - json.dumps({"language": language}), encoding="utf-8") - result_file = tmp_path / "result.json" - environment = { - **os.environ, - "QT_QPA_PLATFORM": "offscreen", - "PYTHONPATH": os.pathsep.join( - filter(None, [str(_REPOSITORY_ROOT), os.environ.get("PYTHONPATH")])), - } - completed = subprocess.run( # noqa: S603 — fixed argv: this interpreter and a literal script - [sys.executable, "-c", _CHILD, str(result_file)], - cwd=tmp_path, env=environment, capture_output=True, timeout=_START_TIMEOUT_SECONDS, - check=False, shell=False, - ) - assert completed.returncode == 0, ( - f"the IDE did not start with {language} saved (exit {completed.returncode})\n" - f"{completed.stderr.decode('utf-8', 'replace')[-2000:]}") - return json.loads(result_file.read_text(encoding="utf-8")) - - @pytest.mark.parametrize("language", ["Traditional_Chinese", "Japanese"]) def test_the_ide_starts_with_the_saved_language(tmp_path, language): - seen = _start_with_saved_language(tmp_path, language) + seen = run_started_window(tmp_path, _REPORT_WHAT_STARTED, saved_language=language) assert seen["language"] == language assert all(seen["menu_titles"]), seen["menu_titles"] From 014a4931ca7cd13100b60526acd5735ac9ac4666 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 00:10:31 +0800 Subject: [PATCH 018/312] Keep each run window's executor alive with the window Callers dropped the TaskProcessManager or FileRunnerProcess they started, and the QTimer connection to its pump does not keep it alive: once the reader threads ended, a garbage collection removed it and the run window lost the rest of the output and the exit line. The run window now holds its executor as CodeWindow.runner. --- architecture.md | 2 + architecture_explore.md | 7 ++- docs/updates/2026-09.md | 9 ++++ docs/updates/README.md | 3 +- .../process_executor_utils.py | 6 ++- .../menu/plugin_menu/build_run_with_menu.py | 4 +- .../show_code_window/code_window.py | 11 +++++ test/test_utils/test_run_output.py | 44 +++++++++++++++++++ 8 files changed, 79 insertions(+), 7 deletions(-) diff --git a/architecture.md b/architecture.md index 2e51b25d..1c9d5288 100644 --- a/architecture.md +++ b/architecture.md @@ -168,6 +168,8 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() - Never update UI from a worker thread: use Queue + QTimer or Signal/Slot. Keep every menu `QAction` alive: store it on the main window or parent it to its menu. Custom exceptions derive from `ITEException`. Log via `pybreeze_logger` (§ Conventions). +- An executor is held by the run window it writes to (`CodeWindow.runner`): its QTimer connection does + not keep it alive, and a collected executor stops pumping output before the exit line. - Every outbound request to a user URL passes SSRF validation (`utils/network/url_validation.py`) with timeouts and size caps (§ Security › Network). - SSH uses the interactive host-key policy, never auto-add (§ Security › SSH). diff --git a/architecture_explore.md b/architecture_explore.md index b143dc0a..43d3e857 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -111,7 +111,7 @@ Template Method 定義的子行程生命週期: | `start_process()` | 建 `CodeWindow` + `TaskProcessManager` → `start_test_process()` | | `build_process_from_file()` | 以檔案路徑執行單一檔案 | | `run_dir_files_with_package()` | 問使用者選資料夾,對每個 `.json` 開一個執行視窗批次跑 | -| `build_task_process()` | 共用建構:建 `CodeWindow` 並帶上主視窗選定的直譯器、掛進 `main_window.current_run_code_window`、決定要不要接 `send_after_test`。prthinker 審查也走這裡 | +| `build_task_process()` | 共用建構:建 `CodeWindow` 並帶上主視窗選定的直譯器、掛進 `main_window.current_run_code_window`、決定要不要接 `send_after_test`。建好的 `TaskProcessManager` 掛在執行視窗的 `runner` 上,所以呼叫端可以不留參考。prthinker 審查也走這裡 | ### 4.3 各自動化模組(Strategy) @@ -142,6 +142,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - 編譯式:`compiler file -o out` → 執行 `out` → 執行完 `os.remove()` 清掉產物 - 編譯有 60 秒 timeout,QTimer 間隔 50 ms(比 Python 執行器更快) - 讀取、pump、drain 與寫進視窗都用 §4.5 的共用函式 + - `run_current_file_with()` 建好後同樣掛在執行視窗的 `runner` 上 ### 4.5 `queue_pump.py` 與 `CodeWindow.append_output()` @@ -396,6 +397,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 鐵律:worker thread 一律不碰 UI。普通執行緒走 Queue + QTimer,`QThread` 走 Signal/Slot。 +執行器的壽命:QTimer 接到執行器的 `pull_text()` / `_pull_text()` 這條連線**不會**讓執行器活著;reader 執行緒一結束,沒人持有的執行器就會被 GC,剩下的輸出和結束那一行都不會出現。所以執行器一律掛在它寫入的 `CodeWindow.runner` 上,跟視窗同壽;視窗又掛在主視窗的 `current_run_code_window`。 + --- ## 17. 持久化資料 @@ -416,7 +419,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 72 個 `test_*.py`、1058 個測試(4 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 72 個 `test_*.py`、1060 個測試(4 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 6a3afb0e..ef6d824e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -132,3 +132,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1050 → 1054 passed, 4 skipped (the prthinker contract tests, local Python 3.11 venv). `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py`, `pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py`, `test/test_utils/{test_automation_menu_factory,test_started_menus,started_window,test_startup_language}.py`, `CLAUDE.md`, `architecture.md` §7, `architecture_explore.md` §5.1, §5.3, §18. - **Open items**: none. + +## U-20260922-18 · 2026-09-22 · Run windows lost their output and exit line · #incident #process-executor + +- **What**: a run window could stop showing a child's output part-way, or show none of it, and never printed `Task exit with code …` / `[Process exited with code …]`. Every caller dropped its executor: `start_process()`, `build_process_from_file()`, prthinker's `_run()` and TestPioneer kept the `TaskProcessManager` from `build_task_process()` in a local variable, and `run_current_file_with()` did the same with its `FileRunnerProcess`. The executor's QTimer connection to its pump does not keep it alive, so once its reader threads ended nothing held it; the next garbage collection removed it and the pump stopped. Probe: `python -m this` through `build_task_process()` came back empty on one run and without the exit line on the next, and whole every time the manager was held. Found when the first PyBreeze-driven prthinker review (`progress.md` #1) showed an empty run window. +- **Fix**: the run window holds its executor. `CodeWindow` has a `runner` attribute; `build_task_process()` and `run_current_file_with()` set it, so the executor lives as long as its window, which `current_run_code_window` keeps. `architecture.md` §7 states the rule. +- **Tests**: two tests in `test_run_output.py` start a real child without holding its executor and collect garbage on every event-loop check, one through `build_task_process()` and one through `run_current_file_with()`. Both failed (no exit line within 30 s) before the fix. The existing tests held their executor, which is why they never saw it. +- **Result / numbers**: unit tests 1054 → 1056 passed, 4 skipped (local Python 3.11 venv). After the fix, the PyBreeze-driven prthinker review showed its whole traceback and `Task exit with code 1` (the review itself failed: see the next prthinker entry). +- **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/extend/process_executor/process_executor_utils.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `test/test_utils/test_run_output.py`, `architecture.md` §7, `architecture_explore.md` §4.2, §4.4, §16, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 5e8edbb6..0e17aaaf 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260922-18 | 2026-09-22 | Run windows lost their output and exit line | #incident #process-executor | [2026-09](2026-09.md) | | U-20260922-17 | 2026-09-22 | Automation submenus came up empty | #incident #menus | [2026-09](2026-09.md) | | U-20260922-16 | 2026-09-22 | Contract test for the prthinker command line | #done #prthinker #ci | [2026-09](2026-09.md) | | U-20260922-15 | 2026-09-22 | Dependency audit of what CI installs | #snapshot #dependencies | [2026-09](2026-09.md) | @@ -80,4 +81,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 17 | +| [2026-09.md](2026-09.md) | 2026-09 | 18 | diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index 3093bf7c..fa0301b3 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -105,15 +105,17 @@ def build_task_process( The run window carries the interpreter chosen in the IDE (the Python environment menu, or the saved setting), so the child runs with that interpreter; only when none was chosen does the manager fall back to a - ``venv`` / ``.venv`` in the working directory, then to ``PATH``. + ``venv`` / ``.venv`` in the working directory, then to ``PATH``. The run + window holds the manager (``CodeWindow.runner``), so a caller may drop it. """ code_window = CodeWindow() code_window.python_compiler = main_window.python_compiler main_window.current_run_code_window.append(code_window) main_window.clear_code_result() - return TaskProcessManager( + code_window.runner = TaskProcessManager( code_window, task_done_trigger_function=send_after_test if send_mail else None, program_buffer_size=program_buffer, program_encoding=main_window.encoding, ) + return code_window.runner diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index 105499a1..3195819a 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -75,11 +75,11 @@ def run_current_file_with(main_window: PyBreezeMainWindow, run_config: dict) -> code_window.setWindowTitle(f"{run_config['name']} - {Path(file_path).name}") main_window.current_run_code_window.append(code_window) - runner = FileRunnerProcess( + code_window.runner = FileRunnerProcess( main_window=code_window, program_encoding=main_window.encoding, ) - runner.run_file(run_config, file_path) + code_window.runner.run_file(run_config, file_path) def set_run_with_menu(ui_we_want_to_set: PyBreezeMainWindow) -> None: diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index 646a7a0f..8a1db9d1 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -1,9 +1,15 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict from PySide6.QtGui import QGuiApplication, QTextCharFormat, QTextCursor from PySide6.QtWidgets import QWidget, QGridLayout, QTextEdit, QScrollArea +if TYPE_CHECKING: + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.extend.process_executor.python_task_process_manager import TaskProcessManager + # Cap the output scrollback so a runaway script (e.g. an infinite print loop) # cannot grow the document without bound and exhaust memory; the oldest lines # are dropped once the limit is reached, like a terminal's scrollback buffer. @@ -25,6 +31,11 @@ def __init__(self): # UI used to show run code or shell command result. super().__init__() self.python_compiler = None + # The executor writing to this window. Holding it here keeps it alive as + # long as the window: its timer's connection to its pump does not, so + # an executor nobody else holds is collected once its reader threads + # end, and the rest of the output and the exit line never arrive. + self.runner: TaskProcessManager | FileRunnerProcess | None = None self.grid_layout = QGridLayout() self.code_result = QTextEdit() self.code_result.setLineWrapMode(self.code_result.LineWrapMode.NoWrap) diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index 7e8e55d4..15ddbe8b 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -1,6 +1,7 @@ """A child's output, end to end: pipe, reader thread, queue, timer pump, run window.""" from __future__ import annotations +import gc import json import os import sys @@ -35,6 +36,18 @@ def _run_events_until(qt_app, finished) -> None: time.sleep(0.01) +def _finished_after_collecting(run_window, last_line: str): + """Return a check that collects garbage, then looks for *last_line* in *run_window*. + + Collecting on every check is what a long-running IDE does sooner or later: + whatever keeps the run going must survive it. + """ + def finished() -> bool: + gc.collect() + return last_line in run_window.code_result.toPlainText() + return finished + + class MainWindow: """The little of the IDE's main window that opening a run window touches.""" @@ -62,6 +75,20 @@ def test_indented_output_keeps_its_indentation(self, qt_app, tmp_path): assert '{\n "outer": {\n "inner": 1\n }\n}\n' in text assert text.endswith("Task exit with code 0\n") + def test_the_run_finishes_with_no_one_else_holding_its_manager(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + data = tmp_path / "data.json" + data.write_text(json.dumps({"key": "value"}), encoding="utf-8") + main_window = MainWindow(sys.executable) + + build_task_process(main_window).start_module_process("json.tool", [str(data)]) + run_window = main_window.current_run_code_window[0] + _run_events_until( + qt_app, _finished_after_collecting(run_window, "Task exit with code 0")) + + assert '"key": "value"' in run_window.code_result.toPlainText() + class TestFileRunnerOutput: def test_indentation_and_blank_lines_survive(self, qt_app, tmp_path): @@ -88,6 +115,23 @@ def test_indentation_and_blank_lines_survive(self, qt_app, tmp_path): assert " warned\n" in text assert text.endswith("[Process exited with code 0]\n") + def test_run_with_finishes_with_no_one_else_holding_its_runner( + self, qt_app, tmp_path, monkeypatch): + from pybreeze.pybreeze_ui.menu.plugin_menu import build_run_with_menu as run_with + + script = tmp_path / "hello.py" + script.write_text("print('hello from the plugin run')\n", encoding="utf-8") + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) + main_window = MainWindow() + + run_with.run_current_file_with( + main_window, {"name": "Python", "compiler": sys.executable, "suffixes": (".py",)}) + run_window = main_window.current_run_code_window[0] + _run_events_until( + qt_app, _finished_after_collecting(run_window, "[Process exited with code 0]")) + + assert "hello from the plugin run" in run_window.code_result.toPlainText() + class TestTestPioneerRun: def test_runs_the_chosen_yaml_through_the_task_manager(self, monkeypatch): From 2147bdb7bc87dd8cd929ad02612afea42a4251d1 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 00:20:49 +0800 Subject: [PATCH 019/312] Turn prthinker's rule retrieval off unless the server is asked prthinker retrieves rules locally by default, from an index that ships only with its repository, so every review from the prthinker PyBreeze installs stopped on ModuleNotFoundError. A rag setting, off or remote, is now always sent. With it the first real review ran end to end from PyBreeze's own menus, which closes progress.md #1. --- README.md | 2 +- architecture_explore.md | 3 +- docs/updates/2026-09.md | 9 ++++++ docs/updates/README.md | 3 +- images/prthinker_setting.png | Bin 31100 -> 23171 bytes progress.md | 3 +- .../prthinker_extend/prthinker_setting.py | 28 ++++++++++++++++-- .../extend_multi_language/extend_english.py | 1 + .../extend_traditional_chinese.py | 1 + .../dialog/prthinker_setting_dialog.py | 12 ++++---- test/test_utils/test_prthinker_contract.py | 19 ++++++++++-- test/test_utils/test_prthinker_setting.py | 25 +++++++++++++++- .../test_prthinker_setting_dialog.py | 7 ++++- 13 files changed, 95 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 70ec7802..cb4b3b6c 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Run the [prthinker](https://github.com/JE-Chen/Code-Review-Framework-Combining-L ![prthinker settings](images/prthinker_setting.png) -One settings form holds the inference backend (`remote`, `local`, OpenAI-compatible, Anthropic, Gemini, Cohere, Mistral, `claude-cli`, `codex-cli`), the code host (GitHub / GitLab / Gitea) and the repository. **Keys and tokens are handed to the review as environment variables, never on a command line** where a process list would show them — and they are masked in logs. +One settings form holds the inference backend (`remote`, `local`, OpenAI-compatible, Anthropic, Gemini, Cohere, Mistral, `claude-cli`, `codex-cli`), the code host (GitHub / GitLab / Gitea) and the repository. **Keys and tokens are handed to the review as environment variables, never on a command line** where a process list would show them — and they are masked in logs. Rule retrieval (RAG) is `off` unless set to `remote`, which asks the prthinker server's `/rag`: prthinker's local rule index ships with its repository, not with the package installed from it. The review runs with the interpreter chosen in `Python Env`, so PyBreeze itself can stay on an older Python than the 3.12 prthinker needs. ### CoT Prompt Editor diff --git a/architecture_explore.md b/architecture_explore.md index 43d3e857..89015cd8 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -356,6 +356,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 - `SECRET_SETTINGS` 四個欄位在 `loggable()` 中一律縮成 `(set)` / 空字串 - `extra_arguments()` 經 `split_arguments()` 以命令列規則斷詞(引號內空白不拆);反斜線是路徑分隔字元的平台(Windows)上反斜線不當跳脫字元,`C:\reviews` 才不會變成 `C:reviews`。解析失敗當作沒有而不是讓整次審查失敗 - `install_target()` 回傳 `[runner]`,因為 prthinker 不在 PyPI 上 +- 規則檢索(`rag`,`RAG_MODES = ("off", "remote")`)**永遠明講**:`off` 給 `PRTHINKER_RAG_ENABLED=false`,`remote` 給 `PRTHINKER_REMOTE_RAG=true`(走伺服器的 `/rag`),認不得的值當 `off`。prthinker 的預設是本機 FAISS 檢索,但那份索引(`codes/`)只在它的原始碼庫、被排除在套件外,從這裡裝的 prthinker 一跑就 `ModuleNotFoundError: codes` 支援的後端:`remote / local / openai / anthropic / gemini / cohere / mistral / claude-cli / codex-cli`;平台:`github / gitlab / gitea`。 @@ -419,7 +420,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 72 個 `test_*.py`、1060 個測試(4 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 72 個 `test_*.py`、1068 個測試(5 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ef6d824e..e34ced40 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -141,3 +141,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1054 → 1056 passed, 4 skipped (local Python 3.11 venv). After the fix, the PyBreeze-driven prthinker review showed its whole traceback and `Task exit with code 1` (the review itself failed: see the next prthinker entry). - **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/extend/process_executor/process_executor_utils.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `test/test_utils/test_run_output.py`, `architecture.md` §7, `architecture_explore.md` §4.2, §4.4, §16, §18. - **Open items**: none. + +## U-20260923-01 · 2026-09-23 · First real prthinker review run from PyBreeze · #done #prthinker + +- **What**: `progress.md` #1 (workspace X-8). The prthinker integration had never run a real review, and the reason given, that PyBreeze's venv is Python 3.11 while prthinker needs 3.12 or newer, did not hold: the review and the install both run with the interpreter chosen in the IDE, not PyBreeze's own. The run was driven through PyBreeze's own actions, headless: PyBreeze from a fresh Python 3.13 venv built from `requirements.txt` (je_editor 1.0.25, PySide6 6.11.1) with this checkout on `PYTHONPATH`; a second, empty 3.13 venv set as the IDE's interpreter; `Install ▸ Automation ▸ Install prthinker` pip-installed `[runner]` into it (prthinker `dev` at `14a3d42`); `Code Review ▸ Review the current file` then reviewed a copy of `prthinker_process.py`. Settings: backend `claude-cli` (the machine's `claude` 2.1.278, so no API key), extra arguments `--claude-cli-model haiku --output-dir C:\...\out`, everything else default. +- **Found on the way**: U-20260922-17 (empty automation submenus) and U-20260922-18 (run windows losing their output). And with the defaults the review could not run at all: prthinker retrieves rules locally unless told otherwise, and that index (`codes/`) lives only in its repository; the package excludes it, so every review from an installed prthinker died on `ModuleNotFoundError: No module named 'codes'`. The contract test (U-20260922-16) had passed `--no-rag` by hand for that reason, which hid it. +- **Fix**: a `rag` setting, `off` (default) or `remote` (`RAG_MODES`), always sent: `off` as `PRTHINKER_RAG_ENABLED=false`, `remote` as `PRTHINKER_REMOTE_RAG=true`, which asks the server's `/rag`. An unknown stored value counts as `off`. The settings dialog picks it from a list; choice fields now come from one table (`CHOICE_FIELDS`) instead of an `if` per field. The contract test no longer passes `--no-rag`, so the default is what it checks, and a new case checks `remote`. Renaming either variable fails three contract cases (checked). +- **Result / numbers**: review finished in 136 s with `Task exit with code 0`: five steps (`first_summary`, `first_code_review`, `linter`, `code_smell`, `total_summary`), their log lines streaming into the run window, the summary printed at the end, and five `*_result.md` files written to the backslashed `--output-dir`. Unit tests 1056 → 1063 passed, 5 skipped (local 3.11 venv, where the contract tests skip); contract and settings tests 40 passed against the prthinker the Install menu put in the 3.13 venv. `ruff check` clean. `images/prthinker_setting.png` re-rendered from the dialog. +- **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/{test_prthinker_setting,test_prthinker_setting_dialog,test_prthinker_contract}.py`, `images/prthinker_setting.png`, `README.md`, `architecture_explore.md` §14, §18, `progress.md`. +- **Open items**: none here. The prthinker side of workspace X-8 is closed in the root `progress.md`. diff --git a/docs/updates/README.md b/docs/updates/README.md index 0e17aaaf..02c2c902 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-01 | 2026-09-23 | First real prthinker review run from PyBreeze | #done #prthinker | [2026-09](2026-09.md) | | U-20260922-18 | 2026-09-22 | Run windows lost their output and exit line | #incident #process-executor | [2026-09](2026-09.md) | | U-20260922-17 | 2026-09-22 | Automation submenus came up empty | #incident #menus | [2026-09](2026-09.md) | | U-20260922-16 | 2026-09-22 | Contract test for the prthinker command line | #done #prthinker #ci | [2026-09](2026-09.md) | @@ -81,4 +82,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 18 | +| [2026-09.md](2026-09.md) | 2026-09 | 19 | diff --git a/images/prthinker_setting.png b/images/prthinker_setting.png index 9fd28017b640f604467f32ec8c09d1650b08e9f0..bd275cf73d9d56265157c93fd08db338a679dfd1 100644 GIT binary patch literal 23171 zcmdqJby$@9+Wviufuex4l1r2>rBhS}X^`%amhLV^2@#Nx4(aY@NGXx-25IT;nD;yA zTJF7{z20X(@9&TIIey2nJQi!b7jw_deP7pko#*Eo-xrcX=xBs!5Cox%2*YF`2*m{a z3A&B~9??J1E&>0#VIiz+4M7-<$Um1p(P0ol&>xTp?5UhX%<6=?xTjzmW=!a zLq}VOp-mlQdg;i5><9pa2q6Nkl%QfAAhq}tBxs} zB>&!#Td3dmwu&*LOm>^ec+C{PFmW(R06`Wui<#=+oDJ~qp+O#!Z>b@O8!Hile!zTC zpwBpe|KL=H-6ewC^^eqDBVxi%TIna)qa3xcA<_cd9v~7j!}4+>;3gKW3Y7Jj2ENG(M4IcLVy| zYF2o9f(H)LCLpL&4iYVhy$RL%-h*9&x_xh7g`TA8-0W$tsW623b{?>k4t+R|a3&zH zDK$c$YMBmfujpuDA%MQ)_F^Ye9#qZPhplR_pCyY?!cD^hwm!|$+=4v%)*}=W(XV-? z8yCz*g8ycj_PJ-YuI_U9${DPh&wYwTF+tTgvn#mhdhSRO zHA2tVpYXl?#2Md&z8q#o!pGV;4u7E~T;Iwv`h17Sm~nVXtPCa;9k@S=0(BCS&`GNn zInXOar#L^atTHPS)~cJ4hlSqD%HjWmoburGvt=dKM9Ys>4T5Zz2iHvTSL(D2FuV;q zH(rs5*hn14HZ68#y&xUl@0#6|Qpd0z1HY*$YnX584<=5>=Q>m4`7alomRPve@+<7? zbt1&ce!LzIJfBIFaXaj*BZzl`n?sPRth5*C)P|CD;JX^l4a&N0bKNx^QJ zX<2|S6gs_nxn%Oe$~<>AUQ}wm*6<6(9YO_d%U9H_YBMR{e4A3t8As)#U8|IBtgN$q zOb?q=X*1*ASgt-k)LRueQd{L`ev8U@*74@FDNQrrR2T+(j2Ph|JL8_1-kNA}w=Lr8 zbJ*yr*<|zc5jN~T9YTB(g_F)-*gn@+o$}Mw0;;Pw$&CT9kI&y|aS__u~GoPNtgR z@$^8(oO9OAsg;f7ox<*7P5GnbOT^)M3NxjMQw=vU+D}fnC&x;Z{I8i*ufP3b8yl}{ zUi^xP&!(f$#5-nK#<0PTrLS*VYN}S&tvrX#d84Z%wWIs&%&-5NXF}tgGF=&8h*!P1 z-@+Tkb$+>MwMc{54mBJ;(&a@&R6sGNlR4p+`16VTSV4Jj#4;1kQI{|fy9Y}pvw0it zjLW^XeArk)1GWP%5SJ@<7x|gJ+&m})om$R13dCuD~$Gmxh`323|K$-1bs4c0QCdXq#ckdjDMLz1ua z6v+@v@u#i!HP#w1H^fQo;c|ic$1KN#>VrUI#?RR~=Cxd-9++--Pd2yQEstw?H>a-h z{QS@cd3vJ@_{GhK&(Fqcj~0(L^%w)*7b6-7$Z`;e%TukBc4qajE>)>bGWVZ%pI*jt z#8q|8HZvsNo7ixzwLz`nAuwnG@2A;e7Gl3FvSWDP{jfudDU57)zScF)khIk$YF-;5 z)KPIZNf3o!H}u^Zl2gOzrjZ501JZ$3q_>(EP0&xgzAY$Nz118&z<3D?Ku1>mV(ctX z-7QSYDgU)TE`v@I zJ3)etTelP%+TXd}*+-?Cp?g$D1G>nb%Zc*)!z&icDG!i8j+nirrlqZbtFB=~PeLYi zj}GX-K~0Q|9!2Dzw0Q_14;+Et9q-c(X>We!MfMeEe8@vqRLloi{JE#b%49} zPamJR)urc8Dg&983w(X(>87hQh|j-%K6}!2xxMI-#uDlV336~vYggd)JHa3*KypQtb?bY==-fB;3KQdkjOiETXLaN7 zhqhe(rbSAoVe5C33_4BmL6LH}6|&Tfs@?U0rD74)zmM<4318nBU#c?N#&JR)%kt1# zXp7b^FppuPAh+-EkD>H&@m-~Cc0bz(F&PRcSl}`s^uMQK9J&Q>E9p=aCnsN+jF$K| zF{Yi3gY~M)Y9ybR3m*q7NGWx!ruhkV5w`7q`En?HNl(v&rdZn8gxaoAt*ph@#?$dE zU{6K=3!6NifB-iJhyMB%E1{8F6M=p{U!BPLj<5o*Lv=mG(~4^NWYi5DDn#Zj^T>uW zmnfX&<5OjtEswKOVV319;aQ-8-IV%QBT3%dEGXaY5IkUPG&5p6-Nc%WYC(Zs^h>*; zF+6=D%Z#XgR%5Ux=W#`ibg4*x`KN!Y3V=(?VtDa(BD59+*Tji{{qN zWMl?%uR<8yRBVf#@$PG1R@fGeSZ%J+QhyTXb1f_>f(yJs;fxIq9vzu*Jl3WAM2}z> z^hr#NGv^n>iiaetLzBUebYuRYn1G6qrH%p_7Dm$}+>i$iH(j+*NR}~KM1d@{f4|zL zPiWF1&TgHixpsKq-t)=JQE51^JiKphR#faC_a^)*)cJ@1z2!}zP{V~T%WBW?IUwwu zi4I7k$9LXOIex~bAQa)yA1y+tD~%PAOuQWx~<2J$Jo zIVW@qMv;1quHjQ_&&(GM)zk=Z^9hZ?Yx_7%B90F2P8{iF;$Gi>~J@e;O?W2F^W9aj-T$XAX(#W6k(zr9s)vVehMe_WW>SH+?D2dOZ0h|tveIHTs5#1Mm?}OeU5^#&g zPMcx{IqAd};&wlT5y7e8th;w8;d!5G+h4i0Q7^0Hs;*lIu0&Sge2sfE*UN#!;;+a^ z6h_5@>i<*x7P|W@nT+y%lhHz002yq&4;w^^V0|oWV{0?g8~duJ_JjKhAw}xTFIo;o zBkwN|l2O56`vhdy-yMj)&&$xx<}V+<3Wa_?QdC2^9Ofacf3v`my2z*)sfbMokc#+7 z=Z#pKXO5(DsQ!snI)9&%lMm&~JTM`Ese`X@Kam5Cq#~}&zr;v8ug^%kPb)jzvjIbb zj)JMBd8U_xU@f!b@gy$RtA}^k`vqKa{Hm`X2K4EQbnWjYPDT81QTmRUSUMYi3#O%W$goN1vS8*EsHmF>*S= zI9#k7;^LDL&(_}Y8{fJ9F8Z2xbRj%k z#cS@``?ZBhR}l?Gb-Q4`t%5FG0Y#~!u^{b0oF(+$;I!1O*^BWWx4gth#1j0+{l0H*)Rq~}mm2gwjCj@KLXJGVg2*SP!*K{EfqqRSyXY3U z*1HR1iB`*U;fqv>>X9uk^GxZ(%m~~cjMea3S9cGm%s+*{#Xm@;E_r+#vQVozson@*-~8sXrfG7T8&yW*?@H_zcq`WB4N9HZ z0?;}^O{#qN;NcrMoQ-pF_7s(Y=l9ehhxuyeu?kL-;m)fTUirw`Tjclb{U7xfkv7mv zS{D8<=p`u_($*W#8iy3X6b&)a{nG=je_c-ZEhLL=Uv)Ub)Pi5ewtq{(+jk^&CI^P%DU9=vVQQPhhz zIOnYyVYE%xaY=!Vynex1^s35z1RZ)}8RgzY@lflAkM6!;v^R6s^Dzt9-A+5{6^TM~ zi92d4q$alC5?RN>1#m5Q^E86NiZh9{4AB2 z?4;>Wk#(3OFQS<+YK;hU!!VW@+7#{kt7-VcsNTe{%@o6^vy=Fr zYGSdMO$LX`#PevvI3+IDn-JvvYPb{q=j^@#NA{EneeE?Z=P|>5l*QKt5}pkb-()`5 z$-VOilbud{Tg@Zui75EZG6Som#x+5`?D2hZ!fPyL7WZAoQm~(Uum_jm*d)O$}a#Yl>X ztgWu|x)y#C4Rens2a*`fswqBFP@0CQ4EXWW+H;^xfh~%BH&{m`!rW_-2d&PaaMH=p zTuj!Fe!oDQRiqAG7+;u;+l#=X>z#|^@0=NXE`<;SR;Sd@;WO%>WrKIC5GKBU^b9G0 zVw8l-{3Thv`E&}H7suE$VMy&7Xu2BRwx{`nB}MAxo?=KdjTJ7Wp`xOg_%g3UyF@vg zT@2juvejBX{$;9p{;`cxGTg4<4h}SL=?i=xmsfG1A0j+4pXHeM1Q?1Y;oPC?=1B9A zem4^Kmw2^AnMZ*#H-3y&6r^-N&ndW9O}$&9r*hNm<2u6A=P;i&UZ;>za!r zW0gF>Rj(Sni|q-(F!`@|yzf4~x@#ARy_bxzwk|vwRT59$UH*nFeO|rrB;=cwR`B!z z|0{OVNxxSsky91Br>|cO_nnr0ecw;C5}M-Sb1@%Nb^%q%$Ps}l!5I06+Bah+i<%10 zYp@5n`?pHD^&aJ$M*+`!Bv%D1$Ukx=lGr~lqgTxwn;5;3{nskA!Wakr9PSpc z9Pc3tT~-o29F5u^Pk$X`CYZhG%+_vY>}N!BmW^b zL`)$Tr)o8vPiJ<>w$i#$a6&jJ&GsYl&5eO&G7u3EM`%^ted>o9I*l(BJ^Tgp8VnYL zYzSYSOr>E+?M~o;;jn>Y_~oYV zkCXkj>|>ZVw|=>;J2ZQ~B5QmlA3*oIsMWpG!p0Bpyzjtwj`eCu0)rmzOwh1t ze#19=@Flv4lwy=w+wrq&XtKS{8uqvGH@J817)dk3YuZGWjBct~bOcm7HnHOKgxW>! z?47yWmMq(xufhLp3ht(>`)qBMOi^R1lVz}64x>uUo9`l*(S7#@#v2YRjk4WU=cMt( zP%IW)s*ew)5OY#BJFz?D+b2^Sjf#P55*5hYi9ffZlihmbi;qjTB|@1ejcF8|ht91a zq`NAE5OY6?cF#q8I?6*(oaLX9)f^h0wJ0LigIx065&M@x^$gt(OW5~bAoKZ}G`y^1 zD=?eB;kI#-!m~M1px_>8)@7Qj=&3H>es-bGg6;WP;)q0_X^BBT5d}nI5HP=2m?`Sr< zG5)-)ug2?Kvao5@-IjJ?J0ol1WEcasXOh_>$`ibclPGYu$NfYc0xXT`^x2%du)r6Dj zYWw*g`oq!ZM8(RL1|Q=3&hmhJd9(O!`fNik(yn;4^Kf`W{+2qQX|VD^xe#XFi-SQv zM`O--9(YVg*Z1b%ZK(Ll&SblD2$-xeh|Szzx}_ECA$Yi#ImZ(Yrrc&32O&%j*@>(QUBN7u6=v??;^zHjc0T3L`RB5hq) z!`Gka+Cl2~tplbnSKexy-=M*V;ciBor+siZ`e4|6Epq&0{*ZnJ-nixm2@DJ-IfH4o zsp$f5%viHkaR*G+uvoRr27y^-ECqJEuOSa!!e_x@eN2Ojm~(!&J*JxZ*gI*%sq0;54@M`=4L zJFfLd;&$=VuCE|Y3f9y2ZBdz`t#=se*omdRiZJ_~LU5IL|BGxte*@Zmyv2Yo(olDM zbYXa;DQa((#`kCX?y0r?bVqD^?&F7ph*;5de=9Xtxfi1v1)Kjk!Sig;_TUs&EcW0G z?Fx)A;IX5E{1ncIv~^#R_(bobC~^4DmE8ZmYKE zygTOlv&)p0k1uFVqR{}WjA$$xb`cWJbs=r$_f@9i21eFLJKXI{(Q7i^*}xw;AEf~v z?N1h&KnAI@UtCW=H;^V6<43mSn)sTmF@wgIh z6-#ww^&eE^7_}{_l2W=GEZ@v{qnypzO}*AzD3F@pEx1}U)wKABAiVi;xU0@WC7W|w zI*>m}b8CV?e|W9w>+5^-CjvRAfK2(WS(h>C8FkOSm_PbvqU|@w9*>F1gh0b3i*`3m zV{$sgVN0RH1hbg4Z*oCb^a#Tg)#LrhFuHEYgPHY=`T#WlAw4s8_`5s%qoX^M;n6~& z;{hv5;;=Vo3R^AK;1mK0l)E`(Mq5pQKsw#{M&%#uu`t^8*iw~zU!ckWcMruNG{%M3T2+x^q^8cw8iaJs#WJaYe`1U-+~_USY=3d7+s4=&_zZU69yJQ`WpH z&#OGAj)E6wT2qLF0%@=jKI=aXlLl2yL+lS+d;tL`2Bo1_E?Pe$v!(6I4sz|ErcJN^ z^R5^kV9@^8(u~UxOOhjzNI57b)j!MuO#TY9l08&3eB#vu+H20nI{TlOcVUsezzrcq zzc;G&Fw}`VIuA&brK^cxTuB?RCLzG`Wq8MKrp=d7Lvy2`Z;muP#@l(0LYc9SD5(3*Wa;3H~&lzf|0LDgwU-?PeMLL_XEBb zQ2=$r2&z%F$ROH*v2j7`_SZxo;myus6*M4wEuO%`z!3f{q8i}ZgQ(a7nRK2+e zoPOW0TCB_wrj`{Q_2iZg$(me zh4vhQCNvDB0MDh|x4Hzqdvz?m{%LF|w{6_Su7PE>q-1fzpr1Ffe3^Mo=Mffg=$ptQ zee8UFzHYbgeJSGp<#^yb{wZ!ZxI`C47}6|+T}&)e9gVb~I*pnW&~cU*^BQvSR<&p_ zLZ3fI*-trZB0zy$4!UQ&-p9lgT`HZj_w*P;gE;VC0y&L>yfYp`A>+RDu6+plDAZ@S zPM409FTdQDCjLXwh+vjLCWKuR9N}f9)*yh* z5m@#dNhq$BOrzE}p}tSZqS|mu$Wi9S z&+6#qyARSYu7414^!-m^u;mM1mOJ0@A~+Lex(x@K)tj$)ViP*uM2?qU>z-Ssaq~6^hY+ijjuyn(ei4yuwk-z|p<;&&Pqxl2- z`n#sV%5YT>=38O~4bQ@+gWhqQ@KzDvw9?yWCCk}ZNRP&%Tp9*z&*`uo;i4Ee zFPTsts~x9TG5T%M#ziI%)13TOqly*_e{yA4o=vx>T2>dBvDnnky@fo&15!0DU;S%Y z`tC<|Vya_$Ze_t%@ao~9*wWrh&-(m?+y%Dm#(hD>Eg23fIGd(C-q2aPs z$>H$*Gk9TR9pfOK?BV7@zB&pRD9z-|cl>YZtE!n9Sx#zylb^%)R=K>|K9$HKHjXM|7l-)Nnd`M=78?>vC3DT>H=O^n7+>jC5rGk%5`TTBscX-sDT{ki&SM9r_8Et90NkWmNnu zb)V;^Nun#ByChZA=KrdRcn+=t-CKBo4I0FwC@^b+;{4OiUCXAAx&*bX?3ymlq%_+x zda%3&3=t%W_V@hgZ~OBfNK+SkM%v~7PAMH8SwT#gxxRnPr`j(j_R6&O@2nXBX_K6s zseTh8wvjaIjO9m+z8f;`95DQGOYQ}bQpdW+2KL@pu3B(YPyh&FXSY>>R{7lYJ-`h3 zrrg5FA76@bq7eZvAZcpCJz_@VogvTQ~)1L+E?s?Y|7>K3}i& z2C_(N;Kc%`7FpDkQ6Y%xp9K@ln43JrS!0VQOC9{*14%S6kaL6k=U&!@Rg0Yao`F<= z;okD$Uv8*mu z;6lCu+yQpW4z0lQI+$k~3oN68LX+>oI?V41s{5_6WsleBFWu3+pcsD=r76zMKfrkl z0zZwkLAP@2^)SnQ;5a{>wdtyfI~k5@3UO1YtcrBRkYdC@$QgVw2BHc7zYL$eap70e zrU31zdA^WFAUNmEkM6RyggTImT9+l-K+w%`=hnL@qKo-4Afrq2c<0T|yn8S}tD-gj7;9ExD~NM0j~ZsZvqB+lf`1VeJ$}*m zR7q{R71Xt#ISC8~2oCqpE=Qa@NZ@qQ4hxIzv|@O^6CkF?OpnjDsa8m8GvmpQf_zR- z{f6VwqYzJFbEv?zQVM(u!^YvSIkpd+(?(Pul|8P$uoIW0k;o@j#WAMlVU%7Tl}C8b z-+`yKUTfpSvv2PZZnJBF3j!$W+O8|A&98@f40RTq7HS@V9MQN|o%X376C0)!DcMRs z-V}bb*dlnk_%VKf!7hkxM40or8Bg&qp3f(V@z-R1TjY0cxu9Ul_Z5!Z+ruXx*`J2& zp4ReLD`{)MPXq0X_pVr!x|Ucyw3Nx{PZ}O-VXg=x@_S>S+Qim77UB2#m>DUnDNr6T26Oh=?~BFOS`cS2T41Wc3W8_ZL>=YU`@aoNOW3KDZazn^q0 z<&H4M!aAZYPYzorJ!h>>JvAyBuYK5Xz-)cYkA7DLaq7*aM&lv7{O>?fw%-G1Z=_qq zgb>M}QzL6Y6HK>i!q`WWh7nLxk>kQl#6Ks{v3%qNx_VFN9c3W8Qe$|e%ZAUE^K^%D zAhzk<>B+?KzS#jLz!G_|tgQ^RxF1uwZ^v%L5Clyj+^A|Q?bKL|(WFJDf}BrY7y~WA z?Uw)Oq1FgoF4CEO*qkBQqF~D|3-^bD+jeltbjsG?SUBvfG*oHI?|!~FdW_k@e>-Sk zuW@IwmOnT0Xk9JFNb`Jb@kWE@ndTB^ftpP*ewA$1ty#?8a9;lM?WF4GCj;#1u4Co2$tl^yJ3W+2nzky%rBa+PJ1m)Y0M-0HjD*jG=n9Y%YKzzZ<`6IOSBPg-4XP)W z=GYeL(SrqEWiSnU4HSrxC zjaAMDk}cm8U){{?#!bBF&C4GLkO9|V5Nrr0D6k@a!?`an7|2bXT|+;dH1pwyJ6mhz zodWE4ZYAc%zK>t}kk|O;Ok-dVN2rAEl80zFb|Ua^bPJu4#;JpWQ=eN>nRG9a7pj6f zh<)^F*hgddeGwL2yyzpJ)+d?EzR#f&k(_^hn6#PG@V zPk?Ntq&>zslh(^?b<@*2jH#R&PUS4ouM@Faqj1i=nBbm7tn;X4{|kO*!sb+okFbkG zN9Q$&i4DDW1|U{4e>|UJ9|TtCc*8(rYP}MZuXfS!uQ(6HfwnjDH|Ky;`;%=VCx2;P zU!rJvfyu&+iUK(VlQ(0~g%~-hSUmd(_P+My$r>@G{sCadFY@6es)ksnC`{SSgxhHY z&YuXN&gnLJgKBdB5&#~5+MA?DcK7_7`ojsM6$~SD z5%2iq1avfetf_bSFGE%IKV46T+@Nu9ZelXK0WW@{T6;voC~HJTOCu27uUKsOYpf6n zQSgk$k(dfe{Oj*lxcOTrhZE*x>l39o)RCS`|MvG@YuC^Iu6`68VepEpp-+;CMCmKNdU-X}Lx}(QMFs-_4agNcP4AYYlv@zt$R_)N_duR3Vh5y|OD*!*?#;u3KFPxpur^yCpJG z`x*16GQj1!AXKz1)xjB05|lncFL>iVL@s0AZzF^U#W=EU)Eu~#7(2`Zs|?U#I_H^h zm%%N^kRZSzW?JvZVusZw2I217KEl=t%-Cf*t)=d{i!eL&w`S9?~_uj5f z79p-)1*MFxN8Qkx2-0|NvIxlA|L!{$&H1 zbfTj>*b|^@0iLOd(3@61cN0`2qF6%==Vn?2k=g~5yrvuY^3S5}LEBx4jqYp;>;S-O zAk!-x=e7VOsX<~r%JqxW2(*=v790_`Ycm^3*2(JjAB zxFeeoz!)xh&a8dq-3H%2fLVlC<14@Jv1{9EoFMs67t>3?F(8>83$$pk;n|7d%d#*MRd zHCM5~c{_yZAFU8(wd8+8AFJ3pk(+NV8k!~3SV;h|kp?9i_!P?^V5@NcwBhAv#D9gJH$D+I<9qXko@aqkJP2 zK`jqQTmcd)DLZfG6m2uLEvJkI*ejFM^$Xf=_EVeJd}}+UY3ZYh0VQ*6tQ)tKvT?Em z@nrJIg+M|h(HthZEPzSDWape^y%A{^; zGc#rvY)J0t>THLM5S^=`?_#VZ9^)BG9o>o#|2upnC2LvKRUDyl%d}l>TkxfJ0JO10+U|0mzZ8Q$sN|-uc(6Ed!x4Og#$^z^Iy{Jr~yJS3o)~aHK)Q{nw($&&7|p zs^;Nean%u@^~_&9MuoiLVSThAk~*{#IG1m&-j7xk*C{A(MC-UuUKlrgTW0}d5_FE# zwmY;(6`@)MI9(R(W8qXMV(u@f9zN_()MeKX|CDc7#gIOnu;L#HuZEycvODX}8)S70BIU|`!Q?Bl#bzDt+U*ztZe zQgW-f9ym`iO-!d8jn=MuGh_PsPstyYK9SOJ$xbS~L5oo<FR#;%5uD%i{~7Jq*W-?c?rN3KEkw5Cek?9)P zbU~7c5_Tc;Q+ePZTzEOu`Cjh*g%@_~w-**`2o}nMxXLQjn10d{!L~7Bo*>6zR1pB2 z16VNYb^pg%AR`5=B+VxO8;fZB#@|^)G*SPlMYJ7IQd{s{PT4f+Xw7?>73o;5H_Tsx zG#q~>W&I_?8VyL)D_{!`8B@={DEqTRag`|pG~r(r`RYnVAn>)93{u*nBNsR4W;Ug4 zDXZB12J|o1%>GG~ubP|?-IV&&^E*Tn?RQM)g&@{K=g$VQReL*K|ypiLvNO93FWig7Lc*7YACXS@){l4;VEH!*bwo&spV`sQQw zLTP)4?L#GRQ}N!DYU}k9W1WJBfnUoZh>!&Ny#O;sp;5~WSib?`h%vOsYH1bn)&r1- zqTk<}G2;^)e=eheeTdw4c>x3Kg($;c8JZZ#(2g(zb8(~8VrtUEFyo9OX%ALSyOLi% zw1#*0tXzSrzWqa#|KB<*8<&wza-eSMOFh63j|eoQNstod`!KN_O=A7UmId3JOS|bL zWbF4odd$w*$IDjm2j5y*pavE69ZvySkrd(As-BhBoTXmCPCs3S?{Khf`g5)S-;+(h zW9YwMa1;5zMOFIVW@x*>r&I_pL3#MV14gc?oyQ+dHls|4uRAtv@UEjsaeW!HBW}vGjDOw2n@a!47xx^IpmLYJbfHO z4cmO?pIYyFy=~IZll98J?DuO$6&FJX7|4bMSXj1|X=iq7u({3KfCmwXmE%#Dh-^;5 z*DDu+!NBHA*_(MD+?YYYCh1+5AW~B=IYG0y_`Za%h1iUHqMWB#iiszq5x9|58>?tB z&eMJE&P!H$Ce$wrS`A9iOD5mZG^m5GIXLejVvRYO76pg6rb-=eY$@bFc?WsW$6l-v zc!imAoQVJW?goCtGJsBk2&An_Z|E1~D@}Z!(UxF#v0s4)Zy72zr~JK?hcrIkPDQ#| zcVR5WJ#s2MNwJM>My+rRjrMI%w+Ina_16A`w6!0HU97?a2(9H|gT??xEWLjyyqV1Lt`>NuLvCovL?i9GU$Vt8PLQ znk6e*!#on63wA>U)<0;2g{WXuCf9XAIxe}Ld%vv+*NohAlsZ4BAmc=k$TPyvrduMf z)06)NOfA$HfQ2e_TeiHH6!_*RA~{h?T_xF+?C8x-%Hag^mBGv)8n~et`!&d(&9vdq zNKRLikzp@AP%%JezK2$sjt5X_zif3dE_w)Pd{66cua39zt?W4xnUZn#CAK|ArXJwy z6k1pN)}8Hse$T?3kRT;n(aW9EN3j_l$6^CAB6ly8V}Pd_Qc*Gyzg1a&R5ehJXzJOp zpjc2qZz!ZP1EWNgb)Gt1{tDnOXeZ)v*eZm$^+x+1N5Ie1o_f|>e5L>%RQ|*&i?8r; z5i4L3f9>?9(IaNJx>ELQHJ2KR)82c!Cr1Z^0w& zRTw!P&O@*wqS{g8J`It_X-vn3t{$jRK*->ybTC%&RS11MEzC)hyjw{^;Ooxo>S$*q zS(!I+)4WrEy|aVp?ge#A7`7RH+ODaOT09C9pyP2Qwhpw&|5)FqnqzOVtu`xLv-tk3QE0)yo{+(=wOEcDgI{( z!~^^aZ)Xt=4UOQT7JQM`Da=!?3FmdLT5HBuvaX8oIsGl(nnyF?B09uqU=#4;qSJJf zj(@-<$z7OiQXwKw)NTJ{ijDgUWU;)J$17b8fzs9Plt?oxOHq^XBF&KQgx3?L?$Qp# zf&VhkaYK!CBZ|c=W5PGSuT~2*4|=6eLG^-|n_6Tqxxg+-^)qaM$zq9s9caM^0II?b z0=vpDV1NH0*d?iSTCXr?N1lNZU6E`xB!X+ZH>{&+W^*0d?EVOoR;<-nw6DHIOniah z{`=nz!pBLgk?}?Mb6Gok4!VlxU>9A%%(6mN<$Q*zcFqb;jC*aFI=tg-3p0LVtXk;W z)$W+WV0fAHMxgs4eR$3pvmmyhy6gEO|GaQK6r~|)6^}TQV)F|EPd_p-@!T71{;%pA z*J>ai$euwNX0qZ61I=@o<@c-H%@0Ypw|K-hQa0qxdUvYZg*zpM;F|0HiF0DcQT|Zt zbEwX3`$e#!ki%2IeTB&l5;fks8x1^%3bl=0SI^Q?hlogSLZ9Q7xBUf)`lv4V?HQ)u z8|5Qy9bZsnzVy9kR*Q0!CsO0iEvZ*ap<$A*`~*mj{6l)KGQ=J)nHW*_)5c;k@C_u9 z_2e>{@9j^dhK*tD=i;CFph0{ zE;0Q{P@6PH_{9TG>0Z%yUz{9WH{`-!8JEN=< z)>YN5DeehMnX%;S$ucNw_B?;m&7W)gobm+L$W!jH*SK8qdC%fx5^tH6jj+6YfFEZq zhuD|#(G1xTtoRY%>hhWsD#87@@|puL8$wm%-Ts~%C=lzL2IIt0(PyA)r{-moEICyO zz;sU*Y){z4*S@7lfQ^m7vY3sv*qD{B4|t4p`x@6v@I`a8&B$IEhtv9(lC0gv68Ug= znWe01#`);20R!n6wcm(Bf~2{uQy9514n`?bT{;PV)l`dw^sn2oCSRxg=ipU3}P1^2fd=GPlmu&xV-Nl8v zVZ@A!PNRD_Z71tr&+=rE4R+}ktt*;OGQ|Gx^=hJy3j#hOh}ad{BYElt9mXftQh@!e^aTPrQV_LGZ=g9b8>Q` z(zU4l0Ot=chV*C0NU^Xi$H5yGVp+X8Q$Q%_MAe6Nm#*R-{)^p_4fmlRw?Q1%Qo9Y? zLjhm|1icpqhyb-!e#}i?Uiw9pVId^|4rt|l{BH`=zm%~42mIgsg_^9pT#I_{fo~^K zjf`EsDx8>})k1n=l_UUpczZo~UwOQD{Cw>hlrLo=@gG;fe|e&RTPyo{MT)7SqA;C_ zNScz`dCDWAR*h|NC23&rTJGyw{1y=~=R_I?(^I;tTefcriVZj7-s$JrCDkMxxulQI zyzrhM4rTzK^1G)~XL7l>b7t{PQAetz#nOs*zkJR!(J3Cv&CyT0ZIOdBN1|quTc*9> zA%SrJ$Qhj*Sa-ie1bgi|(}z-Z_;<`JXRklIt5HTBu2q5iJC~m&)%)T;)p6>W_~73u zig?F;ZKJBm$TNl*lW^?vQ$)s+ZCkT-k!jFTe%X1o^AqRWo6WnebxXs|0@;@4yuvXj zDS>rK^&;4Oql1-2$MvaOirSPlB&+LdzNz}pa(eIf^vd#)9~0^BGz!PtN<1e(+sT|c z&4?IwqViwrmkXC@8{`%v+QVQ>rQZpTBij`_9se4iRC2h%Rfa-i*q+ zV|#truD-WrJ1M5rkmoDL3~Ll;@1RCS&SdNE7cGZxygVDeQK?Jd9s4p~=A3DRy)pM- z6vNMU-$bz;J6|GZ0dnIucKqsFyr@XB?b@n|y9u6?vSF}V&{cMUv7b9n z(%squ`pNpHEGmVrNGfo-_x>!c_r(?>6%sr^Pnq#u5t{fYX$hTN8mRQo~Yh=5C@~H^|v>$z>z^Xw~mutA4YCa6d12M;>3iBZQ^bG`S=< zzH2`)%x3+dKdM3(>i~a9%ZFq90S^)k$n|Bc#@el&jbusvz@FDr6E5X2IMa;X*S!0w zvEhbArihhTcALnEGapYw#DnLSsq9WlDM zo5f+)$D8RLglQY|IB{zuZOxq7*=AEhcktqU*_zQj5(Ol0Qa(Sufu%!|ZJfPJa$IC8 z#H?zM<~}((x>Zc_i6@Nq4GxVdIYMZ`Fo!M0U*c|*)B#y5qFR>c>saK6e(|I>I^(3v zO!kfJ>{+3AF~)B&wI`nl!h&b(r{4<0hi#@MbHJrcO^$~;A}1eIP+-qYg4H)SW~urv zcuhTajDyMChcha6IN-@qgkIsRGk~T4|FD!*1*d+-Fy*t zkJ0yrjp6P-D|T-Qg<9N(Qg_ z8TfLh7yfpO$1FOp`^2>AWd<;8XJ;9>de-q;sqm({Pt_JB#NcA)T^MSs`|6`=1VPAN zWyhoxT_9T@(X;DvxW5#kPSEIdscx6(5v1s<{ETbhacFP)` zas2iN2EQ-?FfpY&lHv?p_A%QC zU#gY4Q$9|*bIn7An0}EGe9{I}wBsMXudOrZ*5T#+>UjhWb(Vd6%yyr4B|5yTkEM@N zswQ&_jT0#)2VV)o!k9F1`aU&*@Q zG$?S!(0smcFa0pCRlMmK=80-?2{cZ~!9HrT*clPYrc~|n5|vaqXD4f+D)^GW3%g%G zSH48qOPi#Kov$8)IrghRYs&5vhsioSaAIzFMn?{wdN{!0wCXq|Hnk7I z!_rIvm5dAx3r)!sEd{L*NQG5I3J~^JJG0XtJJamHch0>t_s%(IK4;Fk-#`I}t%qYe zKPQ3uqR`flPM$tboq69^6Y7?HYb4q!5o~BEi$5{8sJ$(p8PLZsQYm$D`&wCUe`L@O zEn!AsKqvXT(VrU4!#qbsYb?9kvYW3|(Hf$NWa?)~XrXgjV}oWMPJP%u*;%Wn-dsA! zA7hQV!8k8ZJ+<7E;V*`*^6rzG0OX86 zJC}W|RN+Drw>+*^dPU6k&26#6n!97Y3zLw=Kq--Qyk78EkAQE>(` zg*?L$@xfN6=`55AZpRiZHfp2pyK&xZyydym?A5VXDlcS|p93Vss69FM=~9G~m^(2& zsd}7gukPt;ZW??`6>EMyd%UKnkSAxw;PC3pHrXq@E5sHrd(H4uOk?BBx~l;&$v2Fw zEa6dSerkIHpc$qdlcX_vT6=~yCQS;O+y-icXx@L@r}_dtR8&OCLJ324hCg(WBiir0$f-xeF3FzhEsOt=34 zQ04m>W@w0?`mD`CT-%HxzMu-(9`=#EcSEYcek`}mhwuErPuQq+MDUNw##_W?5JXE80U3FK`=xoX4KgJ?(heWv%%OQE zk-v4BNA~Io7LvE!w}lW;nERBMER}Kk7(B;W+VX}O`2O+SkXbDg6u_37=4{e~6 z%vL8R{%E@f+R+%Lzo$18ox%kxeY88&mj3fHX*dJipx zS&&XWh;-(KpH9;9{>qe`8h0Wc-DetG)vjp>6&*y0UQ0^mKjzH_b<_``sbr|qzl*E} z=V{ADA+!1`!IdR$>x>HCuskL>T&QBcm~450P6XHAMPe%JRT1tpPw4b0!nFya>R#yP z2cshFAm|>v{%xjzH&|K2x#W2>$jW_-6>{>P6!6Ed$KB_G>fZ>>@%rdu4?)19Rldhm zzgshS=EN_8I>$?ym(@#;rOxtQWG@RF6ZxkLaOh12-g>4;u(AHbG;5H_a`+jVD%KDt zKggAjY6wpK*=k(>G;P=*7%;LlCX$=*ofHEO=uub8GGn0%%%1XXw6`v1F% EZ_Iug3IG5A literal 31100 zcmdqJby!qy`!70*B8tE$B_W7N*H^kxLZmw+r8|dC6_5r20qO4UW>87#Ziepe<~-;d z-+JHcxA)n9oPDlK1d$om^Q^V*`&0Mwm6jAnyN7=d0)e23ycU#&KyI3Xk9R0H!FTel zO8mj6yOyutz#tIx#_Nw8;WX$35XcjVh~P_khuHN=BV|k_1oC#+t(znwH1}TKdXPZ9 zSxmW_k|=G0jVmaaljir-coKI)Mb!j5yqcl%iQkZ`5Z0&1x)1!*UYg~p;2K@LTx zhpH;q@0H8V7h%GfD)c#hBWNV|IEARfPw8(!UeJh&?yX_-W7*uxsf6^X^)N@kz~SMe z{r+IkFq75tXYAL8^)*EHW9G-7U^42HJoGJL^5TE5@p0JtnF~C)LdioMtVQCJA_>0L zqUTcU%z@1#UQ zWn(LI8`!oprpB`iQ8z9QJolw*0vm&VlGCXdn1$2I(w6jY2q*#^NH43(KQYk8$)xRm(+zy$GoO>91T8)K+jrZu0rzygEZ>U>< zn>(10ugZ1~xxP*FjkdAz=}GKusl8pqK+;eLcl=JiSxi48BOgAi@p%5^OB2O6<>u<$ z=U3mg19ze-T^6z-?tDvAb>a@SNN4vsN?OHkLgVrmQtxZG)J{lIM+Wy>435 zj8Wo>-8d%L9ShbKX7}ntBW7keSUJh_1JlSnzN1zb8C1PJWRI`U0V@a;@!7mTN)sXq zYphC3)7vl|8*&<|iVTx}g2Dr(4-sW(X^yOQn3-QrbTVE$RJY%n+#0PLzuwuZf)XYn zIqzV?$k%CjQEy@@Hh*KB9b$ryh3HVEhKEm)J)G$Ytab-^XySsC)iyfNh*|oKoJlP6`DFRYRNFIgT-?6c=jqsu{+|J^@UT^eA z?V%4mTbLSgeEM|w(|Kj$`M%j(uGy)d3DGl#N^o$V%Jt)L4EGJvJqikMAz>ma}edNf+^*Bb<^|H27#~UL%C#S}4rPk^KKc?KQN{ybxvs3%} zs5r*v=12%xRn-FyZ^x7y?&KDJN~WX>dTN+vOJg^7iF4;EO$)~J_}bO*RKMJliN>U` zuPH7I(MWv>o0PeN>!0iB%84S~I6IUZbRPR$gdTCyZ>C^+avQyfd0&d+ONNj$symfdQBCT1vBrGM2Kej`!qb8YIW9h>E*v2xDE z>Bj0OrtmA5%a2#orQed5PHx{Ot!o}MmG=sZcx=2+%gDwi$7fTx6%wf=y|m3{G&z=B znVMRcw~@^d$#7pS-gyrv(&S47)=_$!0=z(@#?7;(Cvh-GzgtsK=JVlgTpk8iHa4A- z(bnFNgS)PWp9dGD{60S*P;FET?>@=bwj};O+3x9KaCM-;nonXfpB#y9hpa{8g;zDG z6H$w`Jwoxpljx$#cR1%e9H&{oZOJTmc_O~VxvDVp^D0l3O}F(|26xJ%*gWRxPe__~ zO(bZt8aM9VxI{CI&gG)4D?D*k3@l(tp>%Ssl=qK0H5%GxQ-v-~J@M>_e((f}GB!Sz zueui)^(LocH8EmOjy}m3VUQ5L?cf^gyfIPTHZr0dGb2at+B}EH#W~{6JiiWY3dRx%%-z952_%Q3ozDW3DBq=Nc*F;DfL*Ejbc&v+BIKz+#9 zX>qrFGULul7&>(+Fw@g3ZTM}>wpvzJL{l^j7oHkhb9n%7*hg zKK4bbbd>@BU%7H6 zZjX9)66cM!r}qf$N}D9CZINEBnATEDZU1(bpwp?Epp}(K=N@a|W8=fv3d1(PG=GCz z$GAAyx9QJz+xjy+KMi{i6AJb3w?{INcE@QyP&8C_2x5S|xG z7DM&VklNEoP*X=1lF1o1XLn+!6^?ZV1xW>mHd2W}OrdY<=eQ%ataE3hX%P~Z)|D%| z^#KmEl=a(?<+h+O1ybZkcg2-GKUv?coBLdvq_59$h}R6hB)XT=k4N;^=h^K?vm(!Cs~;>?9{}{{)UUPw)O7HN}wbJmI2=8 zZL;sD?$d_o5=Rom#OE|C`9m|W)X?~=yHanztqwL#_iMdc3f1+5W(Abv6a`k&>i6G# zv1d)Ic=(&tqsLQ=G1S>E}3zqIu>fp`=;Kks6nMM>bx^&IAgF4{l>dZd79+|Z)ycN^K_|l z*X=l`@dY&qgy)+zJp}SX=jHz#1pEOtI4^yWoa92tBFI4VY;P;5zC>ro{D~wCk`v|d zuT<|qdIF}!{{x=*^;X|%U@wOa{UX}4Dbqrqme!lxfMf=qaO1f2b)s^ z@L@=);$>*eFW$H^SBAgT5lzz7wLYFq*mK{|ncP^Xf2hiy;XnWh35lUQ1WSlT zQUSbw=eu$er-Lz!IoIY)Vz*`9%yR`t4Y&GLEv}gIq#AqOG%}%Bj_1an-q}Te>hPKP zd3kwz(G5nzXeJmh0)a4)P;T~G7rpz&QB&R?`-S22G8aNP|v0p?Td*tK_; zi~9Q6-gC7rvAnon7p(7+sL|UWb>+dQ>ey^jhfk*^-uo{etI8vQjD<~+)i|0yHLVY_Fc%))E|A zT8oP#9UV{DEG{PU<2qH%z=lre8J^M6s|jqbz#f<=x!X?n9L+U5sRso{zJB%*boj=J z30ipKi<6^`iTK3@v}Hthu0oE1msc5mw2HEixPj4f)YT|U1mzPhce$La9yfOt))qD4}VK64!6j80-z4H$rLNw-#yK<|ovt*oA z#}zc`H6K-SA1%_+5C29-B^e`QdwsDazO0>G66}tx*>2h;J0d{Z+KIbS`pBf)p&2!dKKPMkys4h{~&aBkUc z^l|W#GcJ!8034VQ9YOM|d%U%;s9Z)R^>&_c-+l*u`m>Ssr3hbt6Wn9xJ4@eZXCKnB zU9@j#2FJ0NnT)5)31gISm{zHo8)+q34e*UV1(okYImi`xxdz$6`!)5C1NEDbcjd=7 zwJsi8Zh`9fPu}x~lJ`F$N`HO9>v4t!WJBm=we6t&T?k~%>3CV>;xQByN$z;xdNELI zegpg{i_3m%rREmo9kPeXf2U|uaj4a=PO6e3>y{OpU*D7a~C25AC{~*M zD)`-yJW8t00;OxVmOFd%1?*)%Xcyr4n19a%>jX%Q4GkX=?&oD0pQ8wiqrZe_z-f zD8RGp9XU#&K}7dpwkrjzpQ52Dmvxdf5%iaPGbw$2#jmm**V55$T)G7^JEm~D9-fa4 zYL=OsC2jFTAklc8*GE=qKYU0~VeS~Pd$=1#8P;ZRKHjKpR39ub_Xb+okyX$>twq`` zBxsJVVPIyKX657Iia{aljpSWF{L-;gQcI>cEwkN!v3t;-1XS~YMQE9+S#P#Nd#hOQ z!>oN!3CbUnUbKupZaE**|Y z9m(_w_Y>siDzkv5ka-Hu%e1=V-#k}D+lQHzD*!kcg1t? zxLi$f28+{$xl%N@2$DCd0h*j+T=ZwIslZ}ayykG+P;au*_8=n|!`e2B1z^4RGiOKm zczA<*727+9e$|$&EIYd$5ck1MbM=~7ZYN4oGB-I(Xo5wuaqXkWOn}wiF}V+cT#-i$ z>>FuZGE}%vR$FIMOXrzBoN-yL>J@nLIYpUwh6rsME?q z$Y!;>8=j+oOZ;G$ki%+sK+5I9iG$-l-g?)9wf)w_>Dr{TfUr{@#uQeip&OjH3+WC< zO=x@(1#^dcbFNd6cxQC;vA9~jwZc1i0&GizoO|lMD~UmuP$k+(av3b?^az#Gry@Ky%csXCK@`U=F+S^9J;pl_5wKE$RB6xuGj6`-2-&lod@ev z8xjYx?6&F2$xqi*4*mQ3U=Kp{^cePbcP*w)tJJw->bvSB9LPzW`PQ7=dIG^ID&}hIX_|*?aBc0)!L6L(eBzns%!UWqqkFhASZNpU>6|o7&CS#4>(h^> zFc%ka4ds|1kW533u8W1k4R97DcH^F4-8*)TLCrSs2XhOs_@|=o>XS5e30}u_yjH+1a{`qZ3}>`d`MubtSPm3gWwTQC{;D zCu60b`Rak#MZJeYqJl1SqTgBl!NAKKV_hsL_;8ny#bPE@1vSs}Q_dT{^UE{hOSdq2 z425A)1-_UM9>#~Y5f?gFaKvu*q2fWNf|64A8|VnQWRJhJCRzaKZ|`obf=n{6Ex-Q) zH&T6^%_KG=eI=4X^Hn_8!DKZ{lGY5z?1U!JOn=e+I8LW8omxNXK5CRWt3~farN~R) zA{m+pWaM9h!Kw(QpGuwG_W`P~b?3t#H{to+)h03cqxDU&Dy@0T;4=)O%&`*}wK_Eom_T*+ zVt@`IX4I=T5P1pi50<9B%yY>gyqJY>aFTa;b;>vH_x|kR9W@V=JwI)+TCC{~?_3-q zhV4ltx;(9P;Ar>U4s)duUJwW~)7B4bdgccJ?!42#^@}Lp{ykCrt04YYocBvX|Fn3G z_Fnw|1?^qL2n_H)duU*A&+Dcu?oH6~B>^$^#<;{Rb`^87R&%nR_?S2d=wzUJ#8?O9 zYJZ-cl$6v~%$Prbol7n@?nFmXMZPf#D9JefLMXoMuy3$=%eZS5K9NHZ4GryRbIfgS zh%Q)WN`n_ukMX3-EJnU+aJi{yG(P2HyY4QD$D~O)1B%+>z;IfD|CwJw{SMIvhgEGb zx*s47nL;ohmGW}K!EC+xG(%tc)8tZ{@gS&QGA%7AwI(Jxt|VN=eW8+%xz^tz!p`eH zRKMNKr4cC_XT8IqUi*Tj?ZxnoNQQWWBIN^&72V;Y#q_g7t8^o$tFHn&uY#TGe)CHP z*E~V*o~N6fN*MITNpB$v~}B zrS`2kkT!Z&3xpOIJ)uh)lKk(V?7yfKq^=_*(A=H%y1vm`hc;?9r)u0cyiHkSChJs{ zSYvp4n#fprU(P|J#M7tpvyA*-=F{)^iU9CXQn2JO$c{Jp6MyWT4$^=PrXbHC;&IE4 zKUG&p7JszGWNJvUWHh#C4I8C(rfiilZR1qE!oZOf6b z-_<$oPhFZzJ(6nb_;$()p;6K0tyK~Eo8%9p5boOqB!adudB5b@*)U}HW&OqloXF4I z#~Vigst)j_l6OD&4tfZp=IR~vVdSSuhp)Atc#`gUeSj7}v3&DNKO(|;d1nWT9+MXj z?<&?~91SlG__}GHrxMNUwmU!bU06jD9Rk^6>?Gl|X%!}}#!$Ij820=G_*tG>895I} zeq5l$zj?G>In>vfHy}`I1go~Ir{M`bZwNHG6IuoumD%{HPYKf?ojHUU30{8Merf zpPe1rrVGQRU+GOzRonOKndG`W-qW(OYFqoRGxv~)`!2&)xu0lOE3|iRw3ZS4tI(O&+hlwqe(R3w%4D=;VcBYSFAzl)URhHP*qziTOT*k5@86OUH?{ z43+2&m-Xe(dhy}ri+;feDoOJ4qGZfzCwkXnCRwCHua6YOaMzH(T~7hO=9iEIYXkp_*qEVxpf37>_j{@(h9pU43pp?-HJ_TG;nlkeQ%lrES0|UF`X}*JQ z=_O@w6$X`}_3rRa;daHJlaMYqM>``7akupIKMAv|38>`*^(u=lhE?w33#TsU5J!!4Wm?n_$|aT403$%a=mB$o<&J=<9jFp!>K)J=Ib|o`u z%|p@h4Yd0xO#Ts5R{y5msO{0!G6H>f-nxpgl=tsnwjR;}0hGJ(`(Eo#m@1q3bj+7b zWV=mWwy(n$E4$(S84^Xs#wo`8hxp;2zJz{>?MrcH8@FRUkpK4gH2Uv#=iF6`|Hke$ z3>N;Ds`FTG7t*ZYIrD!%H|WS#hx)TL8_WWM;O6;7tn-m9+c50b6mxkO5FyXEhVn)$ z9b?W~NUbd<#-ODt<85ntrl#Eb`;CD~) zS5-GskcWrYb=1etDQvYFY8n{$rK1yb0OOm9v4quPQcJgS<8SqCcWk>=aUmh0p|W+{ zw6vwCPuqWJEG{e{Y7byF@RVxBg~5AG^F~YzLYkmr`}z4|-@BcCMOoYSCv!a2ykH5X z*2pw2Mv)TmlW+Mtciz8)Gpsj!Xjo8@yd>u;HwBldei~Wl7gpP;WHO+5tDplq%4t+j zuA-swbpe0(#Kkr!I=cLcXDu~D!QkF#$v1G*)6oio^;zeq<%|pclY(1B|1`|Kp|c#? z$3V1kJ|c$l&pv#J%kTxam_j~DI2?e6u z`G=be2(~ci=t#CicesidRrn8Mp|VwbQ4u^#c3VY_zf$f9MT=UeUw-=l#v1p{gWx<^ zKVy{X;kl4omL*@F%B?cU9zv1@G){Re)9spZaP<32ER<;}E=ZInGQ@ zPPRyxqRKML#sToi3+%g4x6tC%)?|6lJ#W&aolR`aR#$+w1uDmDwN zs*gq@WA*J>wkH0wp;u<5U8K@iBb;oLeFLS%#m2p%*o5c?pxq61el;g{9N=>tZI8qf zDr07$qjF3#HX1Bmm}`+TKCFs9_zu1Rsgnsz{PYEM(5T{=6N~`sBj&U=eKY+H7)RsD zO=t8MS6uF!xgOp@ncXN9LBteT_f8M2h}L;2IannA4sfi-j<)rF9n0=)A$`SlU@LPW zsNqL6chHPI8Jl2zSK^%aY{0W+ZEnJ3X-J~ExtU{DElZx$L?4pSZC|*x!el2tpGHY% zU$3~}QtvA(qVW3J%^U8qN00bXV!o4m$}1|qQ7w~+doskV*Jr>TUzFl;A?z_37J7wy zK1TRnMQ3fIG)-?!K+E~@lhh*n{x~z++6Nq z;y4h^%k%yv1`PV$1YxK2L18s?SNB}7l_#M>d33b9dwWM8i84=4B90L546_|~qm%8o zQ03|xyVWao!9D>`LDqMFzshHJHk7kluqA*f{Ihl1qVHT0W=2;myY^_cZJb`KZn2|( zM6PnR!>f1hS~4=z#S97DuA(SWB~P~f_1^D12MU{B&C#ZmM7tpn0z2L6G)}F8+Vc{^(!VLv7;ZSL!{KCBg5En=2{yPPoYPEg@!d42R+@_})<7d&j& zEglYj(YaC23u@WbahStic^q(`^oD+{&!h;q*Q*Kjmo~S--;|h7#dc#5kAPFUIZ?+4 zv;oA`ami4SX6Nb55#nn5D5g~JjGn(Wh>l=TiY!CpP4z5|Sr8EfQV8(aTkaA5XMTZx zkM?^DcX$1eb4Bh%TP$?Or6wPy*+AZr|3qasDlR~B>L1au9zK<>+YYM#Cn&U{rWbOL zbhYhT_SQoPr1W6BH|&(_2|zjy+?dziZqM6m_r!j4s{h(hK|>z??`gJLE?k?ybYOP) z$x>7Cuhrl{-i?iTHZ59KzG|jr74z?ca0<%Vq{$~sQ-4Z?^f1eKDpF#H%ifhRa*Mi# zQ*C+*%(Iw~Xzu79>o?)e=GXp+Xx6~>?^cnKjn*un(jpt4eu}?IA*=1ep~Wy5}m@(@_eoKC;!2T z>D=tA>Q6^|2`=o$l@c^raLyZI40$nGn3-c)_4@2%D)iJkf%GR*7h3N+9KUiYr%W5+ z_4+f6&J_sDcc0_V={%)4laxm*DlZ?yEhYsRW1#3e`XNsnVl5N6=`y%o>Es&mc+&;( za0_CC==39EZf4M01DggAQOTL(@F@{dk+O=dEGuCBT&La?;(F^fwudAL6lv9SS2?Cy z&eq#f@F_swu@3|ejF|x`6veIbGNpRKNixXG>-9563eUn(Qyl*VrkRpK6Sj^^7bgn} zVG!l|w4rJ~b?WHjy%aJ>QDHvO!1i1QZGEE6{6hhvZ-FL5Dn?sx@YUe*1`9RGbF$|g zX<*OFH%wO<(l5JAS;s*;tazUfuB<$FB7|=OJI5GEGLeV1ty*|q0m7^8PZs5Z@!;+#3| z-u}>oGy^;&Yg3mte_Y8R^z3*pfkV;MK0+&VBcnGUAm9lFu`&-e^;yiYA3rqoHM@7R zk914pyM8rg4Y-ru_~LZq{6I>J)F4yv@6&9tP+5boD|kv0;^Iy=TOgBkw7Kx*Qvfh5 z-;FVAV$v}bOk1O!xYTnBOGv0f`Q`7*_m>!Wf$-h$d}x2cMd0(kLXkl*D~M2W5TcVb z!Wy?T98Zom`JQ+-l0Bo>_>DnVDw97cvn14sGbi8IxT>3o7-ObzbX9wmR+&6ZU8Eg| zdqdf2D{~Z5M7%Dk3-bWpZvvmgo!3QPMy9tyYU}d=vw}a2A>$ipvB^tMVSCX$nSb1ms z)86wZJ-J~5G9J>no$Xb?KlTnHRK=;>hF_e5R9(H;)m2vl*V=Rv>1xb9NkTqJO0500 zIqnfxX=~$X_9J8x-Jt-;`xiYxop1t3BCv%@~5 zFHfF*lYh@_KNG6=9(696;Wb%b|I$-{3Rbx>A2T`0%l-%lHnwg1zZ%T}-+JzYKP9RP zaK%w%sKb3lsKD(<%;Wa$El@_c3tFt#ncnTZ1D zb9;1XuViJ;WH})(!kVtbOWWp?hf>bS$jH~yi$m-5mr950j4wpuvtoPIRwAH-2~tOh zdJ`!vt&r=S&O7o|V!EZ9wRE&gH(xM)xK0$kYyR-R=a8cufmG7!P+rcp8{RYYT^PUq z9ws2WO>iO7j02Ri2rwN;F{>0`&N^tjGfuL>UA!&`eIt>iwOC zQNQSoiVboPNpvzz+Cwkd_qT4pOZRckbi`j^-ORJ%kyTIq>JYUwQm)-O{8VGeLy{Z1>)6G{%?|kGl8o z>3Ijo3iXg64e$|F&*gv0q$#I0W|O5P4T!A-1qFi0_Q^^&pzf8c9J<#H0B#KEIXl;0 zOKxeV4-;^_X>t6a4}Yba(NyU`q?@!9O@=jQgTuH|oSc|(q)rAkJ4}t9m~mncm{$Yo zdoiNwKMij&P23}FP1iY|G1#m_LNgT(s9V@$9}R!cU$b!ri!(redcdnB%Qw;h`S_b*ncA{N`66#5W0>amkI&)$gwU*X4ue&RawY9G~ zK2dk2JwuD9F7QbjYJ_r*YMg=|0m<@e>f(;)r%KPpCe%f*^}7l@Y4sS{p|aNOvBNDx zqb~;y&CMe;r4!H3cr8IJ9eICC$>bRHC6V}E z%dbqRs1IT~(<%L+eM4i}m6fTA39M$c{$iKI%F6P(z_oN+ev2Rl?bFf9>LQIiEw$dv z>`xA-g7Q8$Jn?PnaHYzm_8extGCp9|4+|quLF?&Fh$9zelRxcW;Rsq{}AL=*AjQMXKQh=Cj?L-+k{rLqBzs+#cz zK(>IY_4wYn$#4-8$uk~~E>Nh~qxu2+xM&eZiVmEri@ksU65)PBRaJSS#&wS;NqSCs z!}%s;KnmtlUSZZUhvyHo$W{dbluP3Sd?0r_FAZsKO%ZVRnYeDJHwf*W9at?|SBd*iRHTwACAFih$qf?kuubqtFpht*cHYiSRzTA@F;Xtqfl z+*)U4BG6;Z-}?IomUAN*m01svM6PmkV+&w*78?v7jwW>X*b70ngZgS?+m~XbvB*MR z571eIDU-+=^ks{0Om{~yv*oEldh=JBM*?#UM6+nme-o1V$J7h-Uj;sca@E@O9Kc!* z4o+RQ&g`o2I1*C<#E`G!eFncMXIOvH*d&JL#-8}* zQ=2XlGTM+mHi$_-!owR?uE^TUYLVIlJ{w=G=3bPckr7@@XTIYm@IwJP`&X(4w*sVU z%xLL4Rmz+cm0!!qcj5sP_m`&VU%~_$6!CdR-D_P96PnettY2YSm^BCF#+{LUD)T6x&G}` zY*?IcjwxfcgA_wP4j;cPg_=Wk!*oO}WAf3c| zR)9CAqKb_hiS={pMUE{S3x}thfzlF~beA0NGujsfd9dAx^mK6j10l(hfRX#udID&C zgy>5fcDT_5IyxylHRTj!8I`X4R`X4n=9AS!F6H*{8*NlsTy`7r$D^7S-R8qx`U8&= zs!(ydqgX)n*OrH=L^(H!lp92L;&@*@}Sw^_voAAtZ52!Q?X2Y$0J1BW=O{vy~Z#_cNp6#8bYtI(`oBpR(O z%SCYz1Bqoa`MYB-nu<`BXi1jiow&YuQzFlf{kmYFF@b$iTsr^y<1NzHy4sSNgh zYxQVab4|gqX)eQ&MKyjNyOz2DtfuVLN9=eeg%-y|&hit;z5$={?o_4F!AS%M>P@&Z z4zNq!C@Pl!>SO$^vuT>SDplWr-FmRb0d$Hdo}iIcp+?f8sy)JW1qrEmUR)V9HQ6hi zoSdAsSeHMGZE4k9+fA9v(}^q*5fMxV-B;e;NYT-Xc`7(KI8@=E7xSsz5x=QqrQR=9 z&7`EIT^(o(dgBP`p#^?*H*20M!6Qo)lEFbga2HKUGnrt6M4;DWZbIOK=N$r(8qBJx zP}$F#ehU3MX6m*AMsV&tiDD$*$DQw zcC;1f3~*lJ4 zt}q^zy%^YsxDy2r*F^RgCJD^WViJJsEb*?x{xhkkA)?Rvgr(wHr%O>}4EyG-Pu3k# zd5LyqoJ40wo~I<7U!f9se%gBwLR?+twK1-~WSJ&gwK~q?kJBM0;0EI2rN6S~2ITaW%s|{3e6s3_phPc^P3wB<=UxAe0V!FT%W?q_ z=~AOtx`=;-Tj$t*c-)x!F&`C@i0<JA<(k zTG{D?z>omSj5Qiu70$XRnv8gch<3Gcfy%qDoxt=D)_c|2RBu6gxbpZvH)*?aYMnRj zWq|qF1Rh75%9fsH`n5a|w*f?1${N7DX0*y;dCK9(ZA?G@uZu1i60}|E}hZp-b zo#S9Yf|@4B?OeoV(WSAn2+TqNHSXi)6qqc=vRZ5es44q$vf7TrabNnC=z03|6Ocq4 zH2jk*U0)ymlb3h@LvA(Bf?3;Fya&d82bdzlDjx=VCjV)7!Z!=p?Y-dhM&ldiUU zOrYNauRD8~V}sL<{qo`~F{5mE0`DQh%@x6%!FqeRAUk_iTVC8zNXUSTD=>~CV9(dD zK3AR^4{L8DI~>Fcxj&+V%z%oIV1d+>^qul9{MB<+&43`h`BiOm{loG*Xdr;mOPIW2 zHooJ~JUbDy8d2!s4OglA*uJp&H(a|)ZL?hBt5P2$H+Rdcmd3K8(r2EJ{%(xfS0pcQ zWTf`Gk5;_4y^~-o#oE%L!?ZUdUT3r|m_ddUMoxoBT4Cn$!3n{9!_XUn zICm5N*B};iaR(zd&be;z0TKvOFnd!bSn$hyzE+1@7k0r25y-{FY>V?BdPYPl&HKQ* zV*?)zw2(OcGYhcBGuE4w0i1T_;~76cztVsXkXx2SvHClQd0^~E*QYv=vKEe2RZ$u0 zN;iuOqXDKazt(V($BPC8uHD<0)4QiMeAerprNxX#&7H@dsb4oeva$Wz`t>#Z>eWEr z3L6u=1f-^auAFW3zbD91NYFIy8zmyC8lc%;<|%-DSKm~5+@a7-2(w%?yeX};G@w&| zPeS~e?24w&{rdrxYA{&IPZ!tT>X(blSE85~kBu`_&2FGY!pLG40;5x(d>I_ia-EzzTD71P%PT3_Z}d6NowBf+ zjRTwu?8Byfn55Y~x3Jsq+Zh>4fR^yf*6p>#7e#qK69eU^eunM&$z4PI__7Y%v?wI) z^~a}6n&x51oQ1YS(h3R(rwA1x>TnQ54m?AL^h^MH3?L~0WI^bMQE!Ij>6nnx?N%mE zj@$aLAze(K6P}rg83>ce?$?e?Oo&fr%KlRcVBct`rgS(wou`wKprOgZp?mXn#E^*5A3P{69yk2RIBaZb#O+h{(u`$@PbZ;$}xE(y8*gou}$2fMhSX+qrJ> zRwGZ6*1<4&`0KkQ>2%_#{(hwlAHUBGaHw{&Y;ZD!Fmi!KO;6MeueqCq|B77?z~_K# zbTc{}YWe2X#7pGH&@y_i80QZ&;e?9(l?2n}w%5Zrv!Kcve7RO)` z&q5Ax3+BS#Mu|r_9*li+EE!i;0W;U}q~2Vd`{I&yvLxc~|FqBy&%1fEZL>{b#Q8Z9rMesMuReh5Uts6K+{d017Jd zclPWPPuf3?CnNFuh?B?EShf{6cvS)z#HqZEP0fRdxqT`gt8h zyiTR-?F!j7NSXnD=;$iIiROSEx*Xj9i;a7iq(w4ztjsKoKZ)CQvsR71qO|aMhhk?9 zW=wk=!?L#6d2$ZIMEpt9U6W^fIP@nM!F(ng-#~od?dk%<9WJ6;zV*~|eWY0CLf(2c zJdLJamhWe}+s)Oz>SAp}bIb5eM@*L>i?++#HSQ+`S9y0yTzKIyikdaP%f?)O$UA+{ zg%N{y^(GF(p!>QW3~Qc>ALmlJ^z0>^=}stv?8SnnoBC2osGs%YVW3+-(tMH&N7{_b zaj$T@6pD4$3EdKO#qEOtiUuZKO~>()e|ESVBxBE9DjZe812=#^R9}yJl0hCoaAqdG z4OG2T`dg3}xP`H(oL)dRH+%)Y0&;@#@2%4Rg$I%t9Vi$=(9%>Z@_#-;L=}UWCku}B zf34m^j++e13^Fp>`HF&o!1w7y3t-eSua``kaev_{0iUQB~fKFJBA-_^3bQBUm+bE(37+D=WohZty61dK_f&j)L?ggZG%0V z+$OCnOau&z9+0&)S0h+uvV3f0#>~VvstassRz8?Yu=ICCA&6UoMvbEJgnx_;@67~r zF2Q&cj+SEeHM%(Z+lE_eURJ9Y+3bLZr&&vB@oB2mV7RRPtLuX;r_XZ~?kZbXw(Sx6 zTTV}aC6~MrwR{t1#r8Jg&`!OYV`OXc zNC)XKD=mcdqfkPi>u?HI&e%%o&Snu1>fZJqg1yuY`#iCSE(P-Nj^s;S2=b;aXVQHU=r>Zhl3yjel5kA`tj zxk-(5fsw_2*Py*!ZpZyZ?asLtr>4gEFwr#Xvp03xE1^xO%Ml^YcAe4B<1SCcIX^o@xycd&dbKS4fH0ukk>iE3B%WyE0%!`|<@flEH z>ddimMgs>hVv1HUhRuT4X&%M({q*9-xEej1?%9#!2^`Jlh~4touxC-ig@*_9%})Iy zWsNzCvjPP3QF|0mHOIC?ZO`MEZ03_h+%5yn4Rmf`7JPH16)02Vh30!hX67Cr7c|-t zSkndUpTw?6BIWw+yo8TCqEoD4)lu)?e~sM35lry4h1qHqDSJY3GwN|e>D$i0KkA&1 zI!?)ApvlDv`!rEtzJtQE)NrTB>`sguz2yF|nTo33MXZFq_V`!*^JiPYzv+&B-uW$A z!4)%dcyD@!l)p9c#s!GUdGaUykleSiJ=#R)u%iB9n|QXuRP_Fa$>_RLOEO-;=*V3; zFoMX`ZhuM{JcF4od8Hj{e`J1$bXGISX7gsW1SGjRIqlq2Qb2&iLS!6@cIgI_RCIbJ z+Lw#he^p^9qBVorvN^eb`@?o!$hYil*p6>^Jm1#hTPZeP>+?oUZ0sndk}e8$YcQj_ zFd`tO#GtF>AS9$`C%NexCZoeGEcy)O1+{f1HJ(gw1&!1+SU9>0)2#>mLb z@_c1;`cvdp4fmCur{~?A>|&Vp^=;QbCfeD!;w1R=0g3MC7Rx4+D$%$B(Pwf79lQk2 z`RVE8MX4$jEfXOmYim;t~>@6BhzjIT=#^ zh*|kK-!{TVmLaX9Shvtat9i1{Xi-y?YAidDgH~lT)hP zry zi~TS=B}S%&xYmRN>aK~ESCX;6bu}d3c8z8+h$sM~0>kASA8ir% z1O&Zipcm&9Ff32@7Xcpb-$pgcQ3VpgF#R)->2$8i*U)(-oDle&&7z9oX~)+oN$(a+ zU%}8Z70&Vb>>LwXI+Yy0)oP?(-yW{ zoPx*!_KOboicacOEw6RIdfyB}Nr^Y=c689dTh`fcNfB>RAuJFlgWt&7&vD2b zPA^Ua$Hr8`_0AoyURsHWh^las2tEzby@;j5@gH2j5%O9d(P%_3>1vLsS5;9=PY**B zAlPl@N4m^ZpFLdJk>m4i(uOfH1!ja&dy{(ta6OLB%6GM!bJ}?WlBiWg=yYh-u-PFl zF{hJ!iuOoWMrQQgq_NHdBOHVdTZu8K>9)oen64}o9Rm|`ZPL#+Z@I%cILTR0tg{!> z(dPY&bU@o=&^(Yx32gmRSe%@79iza}Of0DX>U=c9hg_*ua!ppOwv~_0ef8B$V}7i$sx1H zrn!8V$8)dfbFBi1x%ACmO!sFrNvNYh8r&t3G`ns~V?@GkOl!{bS#jWklPB`zzsK_a z(@dSy9G@5RHY=z7=D+|LsW4poDzLy;kadgXUZALwQo;4&uAi7-C=Z@HVZI6ig8270 zeZfKh=Lgvx+t4$!;*nwc8&hU|ep;LkEWsCeKc8JEmBHcP*_ZtM1!<^E@hWwaAoa@+ zHyh4;pECN&%c_%omWf8BLz85gnei!j7iLxgAO4i&)V}nePp^{|-8%zM zufxaApS{@t_Y(j3QxM#`|NF<+ZLtbs2yX7|5~kG$Mr9dEDpuFmPsb_l^4B+b30jPo=w}U4-woQD+UErH3m#6LeO64P~dd##_?)@Ig2hMFrTVE zc#|Qi3LmG;#Y#_2gRjkkADS`%o|>(VO}b?CF*s=)Rm0%sbvt2MYLt@EX$c_rEHyI2 zCc=*m3B|IVZS~RGv^ndF<0Rs#WZ00Mjc9=IY6fcnlctF489OQ2%1F`$xEa=h7{_!3 zP50a8QJ3`3PJ6`U?KT%ztpT(FV9ND@7VFUe)7^K5!`ZG~OC=E{L_~k|PV`QK5To~A zLI@E=8KNg55d_hr4x{(pdqf>2I?=o6y$oi)OP;;o{gmwe9s50w@6Y$g4~`l4%zab?l9`BgcSrm9y_jx>cI^)Kc;>-lW)qz_`g>>-Y>!C3>|`Xzo#oz1c{MUd-bj;AgF)HP>YOA|0ZUt-yD6W5QY2f<@$owSDGl9m3fD0@A?XtBX- z{{B(ZC6x72&qa1zr*OA|Vi_m6DJXSb;~t_w>mv%m$}YRvX9*%dR#x^`mhKND_7;>g zN*o=)EcH^?;o{y2c#7*=e5mPcYdhrtsKML11l$lt0)M))GA9l#?GYWl(z%^i0b=dM+3TxAgU?(1 zf+ear)i6g;h5jwDXVu^Cl6bgvRk zsWY6PIUWN0&<$!(9_74~k2+yj_#kFTpDXOe0`cO|fnC5*g56)B4CmZwhKCE3z-wUK z;8|}1=hj2}CzJUL%V09#q+r_VrMI-Rk}bew=-Ss95bZhWxA?8ZPJILeP5q@a;O8c$ z@A0M{dHt0$7()DW@aMmqbN;tUp?`cq>JUuwiX~Hmh|BYfjk@$;X4dQg=VC*3f*!qhJ-#Rp8%3dK*xkp3uB@Qe@s85DM^xcVgu&qf*@?!q157+X6hJe!Mn(q z@%S?FvOrjDeqJ6;8gGL!wRE!TEO7Uo>9;?vypSlid;Sb$L+cL~D8Wt^ATLO+1bD-# zvcyH$AS0pRY=i z_Iqy&UdFhevcWtEP}u4R$yRBEH|hoKQ-d`T^~gTRRr z)&o;`PT=0e7!U1{Z%j<=($h4~TFHd zi0~Yk4(>Fc$0w}315E3k-*rHjA0-HKn|>CdPK~T`1=kyL6mk4HvsB){#M2X5Q`HI! zf;4zMnRoNX^-C$bM4f6&@04Yfm}05~Q>cGwTV8-?_+VLyB7|}x+L_s2-UI_ypgryN z$#^8MfJ$CIaFV)-gjuc)ccY?WExXj!^Pt^j60Fb;#4)w#Ai^;sGR<5`OE{!0#k~^W zyIi}M@_SdEEj^31Qr+j@B!YeM1+=3F7(*;l={5qfN{VcI*E6nR%t*D|mj+Dv-0Qq` zJ%$H-w2*sC_53E`^5~}jA)-k-9RR$7MGb_G|E|_T(`tDI!egFs`$R+S*s8Y-UtQK()|qZBDLZ z9=Z&SK2X-KsE53`z>0-Br<2tfo^)NEi|t{&{Hl?Xoa50wLbB*@of8I#N%$aMGy-P~ z(qMWyHIW_6oxy6Z?>az@S-%mJi^xxx<+$nVKp7J~e^{atP zc+!T5-DExOotqgwpFQ~x4HOj>LG4{%-#9oZJ(V7vLQ9`=NF~UYn+=ig9VNY!o6@}Y zOGewj5kmeS$YT)BbNVnO%h+uKhTQDVo(|6)c&(x}>)(;kswz4HX}>QG07WZ(s_4#VDiy`AU?u11Fq7r(VkL(-jGE}68@Uc2Gg+2zH8YnbiSWU!q z>vF@Q-XTi84Y*uiSLiz7=rP@sY3Pi^~bYPHo!{L)7R}IrXI^3>9Dc1 zR8do7@n-oN^I1^xu>j^Nc;bjMVIy;#w+NLXf?3cPyrWj1`3` zX(skw=6zaP8pPZ(Wpe^x;F^}h@+KA3T5DaFj-WfCq20Zc&X{UTHUztT3yYnJ$z#A2 z6c{F5-U7}2{a-w{8*98L_I45pszu=S78~Eo4NIP%NH#21xt@p`l|-aoZ7FM^r)OYj z-L>1;0ExeMiHOAbczDc9#+r00WY`#JPdqMck>otuE{$mZfeY$%y({HJu6B~ z0XEv6wTC6%-SpgZVy^JSd|TzVO%b1RUb`t#5ypz_?B_i%jKyJgb|XX9T`MakTy|Wt zc`T|Ce?#e*pwHp%LHOK%VYODq%0^yZd8y8=`&l4rWvHb}y$#p*Q&_)RvWj3*L)?Sk z?ul5GCckMK*M>`bk#~3NZw6vH7FG<*?tn@wwLGfHiVLF=^J9lt-(cVkkor(sT=?O` zgtm&xtCe;xz`5)N;{mpEC@G0l*lA8|AFT=CQV5khjz^hc9)V1`BApku77WI$1pcTB zOaBse81@bqYZUT?UH_mmDXF~NdFpb55`_)nJp9VA7a?gmqY(YZMn-l$!6QJB=h9eD zYpMX5r4%1L?=i#?pk1RS@UPdaOqGxBxlxqSEcF&$i+->Zp=T!MM1o%a&Pjb(+V&y& z+B;<|YPBq0lYuB#M~fC27?^M=FU?)wU{$4&2p+MUn5edCb3+$!CP)bMARNcC`M!$= zlVHp=y!{98AI$B<`nMEp+9sa=c=ht}Xv+dZQLk^DKue1k3RJQ0bJ+Vs0*z+qDeF-8 zFY!o^san^)A~PAXd!?08(bI!OmoOTwU#+aUvkTI_=sRGQWUR zMQ_u@u5B?Jl`x3pKdG46iS?|o_e3sYgIX}_MdR#ChvNQmZ=`?!?v+sYT2a}$)617I zW(=)9bg51ZzL}9DTC#R(AJ_Sn#N&(8s z^)st;qArJB?P5>%8B)Y#zRLWw0#L0?l(So06k=K0Un>jvS)dT6r=&<>6_u8bQV;-^ z>9qdM3y4dQx6Xw{QFe=ep^ZQOUW|P0?B`}=O=z3%!{PV}+ZZbGQ-gsm=4fj&A;yhs z^?25+N686M9oe2|Sn|EiJpkxXK$-_T7fYMwAXxn=m`7=bkO_i&jmv98Bi^1ofTSD+ zXhk~$)eelblS+(vpQl$D_pJAIzcI2kG1*?My>{+*t06wNoK^J8vte>K*R^3SV}*UA zYLA01k^we0J>~<8pQ1F8HKXD&JnrqJWP>?P=m*5&`xj_&PISeaO-f8$DxB}N{skEs zf-7(RLRRcfJWwuV!!}dZ-8SC}C#+B6E}z|?7Nz8LlKmE>UxA57RmSx)a2kG6=PsNb z^V{SzU>yK*kr2588Uusm=EWHS>izwgw@8X?o>?|l^DVLKXJ`51?`~A%oZ^v-N58Ds z=ggQ3BRH7feN}6tf0;8QwaWdYe~`STxvdgbpI164T(|y+wg)xTqLKC#>opEl6Cc{oBO@c3t$w(mE01J;-mq@6X+lqAsZOHDf!I~edV`-R z%x^u3iL+2RwyXIsAzD8sHg=-wRCe;=BbvU6lfc>xh24W4ul-7s)8KDDK9Yol^S>kg z{eK`mUpV}fmzS4`nK@DdKBu5SPW^Q%-+B_Vx+Z&G*(xLRJI+r?pT+Ibt!u}e}Dg?Gky0O4ltI*!kQJ* zX}@SK|2t2%MpJ5x|GWm7aYPkiYY^Z7` zW+#UK9k2)hb-1Y9E9D^nLDv`6nL@1$3dj1^>1oPNcI&KCM0ZEv~N)s;iPOokzRXn43s ze`X{r#m3aYsqwp=Wb+{?$K=xbUn}Y_b;l5qc@{e|j`tUHr{zo2^r7#v+dHp1Qj1%( zs7W-K=TH*gu{6FxExlN|TlO~~o~+qV`hEmrleUPWq$c;Y_~&*+BruyZi~7)E5>h5!x&PO!dg?gD;Yw{#Pl5>FPiS`A z-Y!C3ztBIctKAg;kw7Dv0$du->OW_rUn>8{$%6l)$Npb{^ncyOpGv_O!>?)sJyCV- zooVxb;-TfLWT~2Q8SbI80mo4gzZJprRgI7qQ=!puzE@%xjUTnBzoQ0}CCj~GZd}v{ zre6RTA~Z8|bNv>jST8=cRV+$0h}UUa#1Ks7vXo&KlSQmdDJD4RSvdEdK5&UsxAp;w z{X8EyoSBRX08auD_hf~78?w1}jEt}lLeuI^fo=uu1*r+deI(JB`VdN2uCq z6^eRVJN0F-KVw$A*S=Dnn$f(3aV1u2ur@6wH1x^-n9dW;p*>>G@@sx7cg!s;V%@l! zhFV-V*JD6|2jVFHq6nOv=2|ytJ903#fMTNjFHr8ZoFD=P3birlMzg=Uj>DnaY`vbY??k8oKYwXnlSzF>GDgLpW> zr=NVyHf{04!N$`d=IxIva{2s9wwEwE)WAHHS zI&Ojn1~@?_wE+|}lBNdxCzU*)rW#5}>U^u?GP@E=d#5*1nfyf6oF~mkb2v-qf!pr2 z7AhghcFU04MSYCnobhoRYrwSnQV>)Z8xrTfQ=LB>_#@Ut|5CN%n)O^NtWV~XkLR?b)?LE*2pnZTUl<#V8*Z1nhUWy91PAvZ z8c?wdV)H)+O#i4z`~6yraB~C`?wrdPjFF)D{IvFL`g*lHxu6HHo<7nVArrOyh{)@Wz|drEI>@b0~Qug+hp=N!@cE;j=l+1OcgY9ga(hbzMQD9HN( zN9d!KUBSL8U^lJB17ZgL>MZ!mX!Oz%nyl<>7>_i1T)m&o9LV?G`pfQ+wTEXdg}ak> zJh(ZT4i)j@gipmII5)nlL zv}9D03ECl-E4cZ+9*CAAh7xPJocO+5L3gw?69Sbz4$oW#Fx~&IdoUmBvW6-oaGkzb zuZoS0y%?_ce3O;6TbX604QS|@KIpo@nUYnQjA`x$A`r$bX$~Keikn_o*Y6-Z`hE?50 z4IH+U^*Ii#Kfn3xY3+;soQf?wE6e}*NkiLbPK&O@40I?8Z#KC*_IzGE%HMw{98Qy^ z0rA#X(P5Ivi?~e!oIPEGjY*H3Em#t+!o2!>1_!^6wy#*(b3IaJ!2|~EjlsUd*e4M5SjzEL9s6RqYn zt*DC8^;B1hPwC7nB@fM<>DRGI}i(+K=2fKa0fb8tOMsKml!zfV@*$7g6=jPKe2_~uliJ*s}TTWp4_@kraB zlq}}(QVv&7?YBwMrU~N&-IAA=UvoFil*-2ZECZySgH}Ed~laes?7VBmmo#I5P{W`19O3$u`PKMbLWD zXSM=%fLKigF_OKv8?n3-n?^@($ySzBSXf=%+BC%>MjLL#J?`Qrt3O_F7s<>YC{9+h zIX-kd^b}zX5`{azHO`EcEbp_>N`Zz3C8!KFv`5(VihY`AKkn_``cAqc+-SIXr%S^3o&cO@WF<~`4wA!xg+)1r7x-W<@)Uzf4!~M?_=K7|6@2J^I8jI&qNgJ-J3E$V zJi2#zd>nc}$|HY%uYE8Ku+P=ZnwpmnXKMew1Md0~RLW$t* z7GY7tjyBUWa^yK-_ic0Q>yuoR+fcUW91I!wlEq#zo;w4N7Z`+6DY2Qn0LI5VD?GIML{!3V2HF)T`&r>b#9A-` zj3-?gblCDaKp(ljnlbwo?;0z3cyD8nMlIp9d#Lg5Aqw1%R!OKF zg;DFufbaIiHB_djzsGlAU>I)cuv$LMD=RD8yL${>wT!z*Oi_3k_pMN@gV`z|;e#~1 z;4a6H{xnC)^&h~yC$S~L#Ols)cZ9>Jy0{_E8vJ%vY7%rK+v;Hd@>~Q{O!O`lwwQ8x zHbKOhV7=(WxJbZ+R3}(K{Q76d9sSE!)WYVXk?gl7&O9M{Zj({ZOpQDf7ssJw0f~1( zv*gs&be$>WHd?=OVG4FsFkM*%9S=T085;|G+r~#P7i(HS<&NoOW&&m=R(4s9lJE|R zE&p^Q4RP9rF#Ch4ItkC(43Ks+`|_+o*Xs1BO-&qioB~XT!=9(ml}#)pg)vLy1ptCJ zw_gb5fDMRy28Sx8C{vu;Hdk+dXkU*v2*Old{L*ygl#GNV->^i_s2q$ClpHOHfJS=2 zf-J1|S^)fP#4c9cCctongA~u&&~AS|S*I|nWuPDs?YI$*@BZEF>o-tt_~NIf=o*ud z8vA2>E7ZXiJLQMsC^n9cu5@M1ONogZqYg&=1mOKTKtS2yQa!(ZD=RZo<9U@sj4?Pk zWFZ7|8Y;Kicg?-`GjI!BiiGkf=U8Hig}~*5kO`7Ec$Bwii*}@Zo%4XJgBry>zqT?@P-5hZy_2O>Pgd> zpesH@)BZ15>JlbNy}tpaspjbHQ$o0i>E{|yS_3cGz5@uNEmtH9fU~f$k~XklWUfh& z!teJW_7E}R<1U*Zu*hmv?}09mGvOzD;#MPvsJDKB(RpDoCtOFWTGOoQ`YV15%UKxT zN>M4msRFDLQ||lP7l(bI4F7BX^8fM$6X~E0J2Tzc5xh9tRaC;jz^ zut7+FaW<35m*$vmn$-PwFNzyVOAYpz5vLug*VaBsw0(UI{!>2LJ*rmn=?J7J{OS?# z?tCsR0h##T?$r@d2!>Pnj_Co_-6vf;idoZ)kc*G6@a+go;^h%b3%tdrcD#?d8WQ(^4G?umsU=H=oA@hTgbq9s}?I+sU z-UiJZyUf;o&Q_4k9C}6p2Z=J1?nyRgcR8gODqdtR@kXpe+k^Wq!3gRS8He{GMYr~K zL*$>faj2+9qgB->t;#E&CX6-`X(40^K1Dr>q;mJou zERvxRcID_wR*h(7q5Mf5<|#|wpwGcN{mDuCdbk#wWc$FgVr+>9uiCtig;-W| z8I$=59royW&$VQ>d4cR2_O-PiX>$-`jdHO-zlj=ac0qDp&ola3DBVQd5)K_F>#_K4d%Bfh%0#-p>dhShOH28()`+sMv*!snG>1M)SZ zOk^-+P9{Os(0N1G_fyxjxl@<6Mkr@P`06S?e;n`~DTO&|u`>M;+bOU3q6 zTKWog&c1yFHW^tRDGz=~|8Axi?m9bqJ|1uB*4o}`X~)$2p>%rIJr{=qmoWrn?9&t* z#Z*q-91l2wZNxV9op@1&g~ThGnv)r5t4Dvp;uazq4Xv<2xhmY}a~U zCmiFhJ9bQ{rlm+A8=yDqhkK5*pYgxg~#$Rw}Tvw_UpO6wg`qe2ztq#~)z8<*q< zTm(hv717=x+YTKJo?`ol2Flvb9oS>ZSFN7jYB3Gnq!@J*e)>R}pZe*Bf>KXNw~S!X z4Qg6+y|gZE6g?VJ#`jE-w2Q&_>$5Xj=@tUDGXoiP&TWBns&A-`#EIXrRo6WIl_ zxL%0K`cLwz+y?^@m_G>wf<9MNJaRzX5`?(Oh0`x;UA(Lhqnu@f0N( zd^Y|`Jwntl_UmL@afCrO?8<2)>1Li^@n$(21?7#vFapUz1@*qSG-LIG&xaoex8hns z+j%rKQp$Yij8N@Ocs;57 z^&``Yil<^MQ8uHT=gcuQ-Y}=^_@#F+^lgVwuEC_l_3|aQ1B(8L!v~V-(pIx&B3mrd zx&V0V1HCR`h`$h$;HUL<+RL{A7LW&?jwhd%j?+uyMgA`y#{bkjGl7>H$Y60<^}X^& zpHL)cJW!VH_~U+jKhz}}UlRAk5j8|&*6#ythe@FlgTwI5g6$yZ;;KHF->zWXx93;< zJ{@Xp+6cLY8fgga#1aZmpF3Hn>L;Jp@H~~i(KZ2x(rR46*nZ=xpryDLyNJ zu1L&`j6!gG(>AqbvAVKco^Sp|BenwZ}#tK?Ge9A R7aK2z+#@CFd`T0Z{{w4WlzIRF diff --git a/progress.md b/progress.md index 6cd10a90..6899d5d8 100644 --- a/progress.md +++ b/progress.md @@ -2,8 +2,7 @@ Outstanding work only. When an item is done, delete it in the same commit and add a `#done` entry to `docs/updates/` (format and query commands: `docs/updates/README.md`). No finished items, no history, no rules (rules live in `CLAUDE.md`). Item numbers (`#n`) are never reused. Tags: [DECIDE] needs the owner's decision, [BLOCKED] waits on something else, [UNVERIFIED] observed but not confirmed. -Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X-1, X-8, X-13, X-17). +Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X-1, X-13, X-17). ## Open -- **#1** [BLOCKED] The prthinker integration (menu, settings, install; commit `99ef96f`) has never run a real review: this machine's PyBreeze venv is Python 3.11 and prthinker needs 3.12 or newer. Install prthinker with a 3.12+ interpreter and run one review (moved from JEditor's `PROGRESS.md`; workspace X-8). diff --git a/pybreeze/extend/prthinker_extend/prthinker_setting.py b/pybreeze/extend/prthinker_extend/prthinker_setting.py index 05738155..1529ed25 100644 --- a/pybreeze/extend/prthinker_extend/prthinker_setting.py +++ b/pybreeze/extend/prthinker_extend/prthinker_setting.py @@ -41,6 +41,20 @@ # 可選的程式碼託管平台 / The forges on offer PLATFORMS = ("github", "gitlab", "gitea") +# 規則檢索(RAG)的做法:關掉,或交給伺服器的 /rag。prthinker 的本機索引只跟著它的 +# 原始碼庫,不在安裝的套件裡,所以這裡裝的 prthinker 沒辦法在本機檢索。 +# How rules are retrieved (RAG): not at all, or through the server's /rag. +# prthinker's local index ships with its repository and not with its package, so +# the prthinker installed from here cannot retrieve locally. +RAG_MODES = ("off", "remote") + +# 每種做法交給子行程的環境變數;認不得的值一律當作關掉 +# The variables each way is given through; an unknown value counts as off +RAG_ENVIRONMENT = { + "off": {"PRTHINKER_RAG_ENABLED": "false"}, + "remote": {"PRTHINKER_REMOTE_RAG": "true"}, +} + # 每個設定項對應的環境變數 / The environment variable each setting is given through SETTING_ENVIRONMENT = { "backend": "PRTHINKER_BACKEND", @@ -73,6 +87,7 @@ "platform_base_url": "", "repository": "", "platform_token": "", + "rag": "off", "extra_arguments": "", "source_path": "", } @@ -148,17 +163,24 @@ def environment_for(setting: Dict[str, str]) -> Dict[str, str]: 把設定變成 prthinker 認得的環境變數 Turn the settings into the environment variables prthinker reads. - 空白的項目不放進去,prthinker 才用得到它自己的預設值。 - A blank setting is left out, so prthinker keeps its own default for it. + 空白的項目不放進去,prthinker 才用得到它自己的預設值。規則檢索例外:prthinker 預設 + 在本機檢索,而這裡裝的 prthinker 做不到,所以一定會明講要關掉或交給伺服器。 + A blank setting is left out, so prthinker keeps its own default for it. Rule + retrieval is the exception: prthinker's default is to retrieve locally, which + the prthinker installed from here cannot do, so it is always told to go + without or to ask the server. :param setting: 目前的設定 / the settings in use :return: 要加進子行程環境的變數 / the variables to add to the child's environment """ - return { + environment = { name: setting[key].strip() for key, name in SETTING_ENVIRONMENT.items() if setting.get(key, "").strip() } + rag_mode = setting.get("rag", "") + environment.update(RAG_ENVIRONMENT.get(rag_mode, RAG_ENVIRONMENT["off"])) + return environment def extra_arguments(setting: Dict[str, str]) -> List[str]: diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 3c11025f..5e8c3393 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -124,6 +124,7 @@ "prthinker_setting_model_name_label": "Model name", "prthinker_setting_remote_url_label": "Server URL (remote backend)", "prthinker_setting_remote_api_key_label": "Server API key", + "prthinker_setting_rag_label": "Rule retrieval (RAG)", "prthinker_setting_openai_base_url_label": "OpenAI-compatible base URL", "prthinker_setting_openai_api_key_label": "OpenAI API key", "prthinker_setting_anthropic_api_key_label": "Anthropic API key", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index fb416499..73af13e5 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -124,6 +124,7 @@ "prthinker_setting_model_name_label": "模型名稱", "prthinker_setting_remote_url_label": "伺服器網址(remote 後端)", "prthinker_setting_remote_api_key_label": "伺服器 API 金鑰", + "prthinker_setting_rag_label": "規則檢索(RAG)", "prthinker_setting_openai_base_url_label": "OpenAI 相容的 base URL", "prthinker_setting_openai_api_key_label": "OpenAI API 金鑰", "prthinker_setting_anthropic_api_key_label": "Anthropic API 金鑰", diff --git a/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py b/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py index fb19c40b..fe46fd3a 100644 --- a/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py +++ b/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py @@ -20,13 +20,16 @@ from je_editor import language_wrapper from pybreeze.extend.prthinker_extend.prthinker_setting import ( - BACKENDS, PLATFORMS, load_setting, save_setting, setting_path + BACKENDS, PLATFORMS, RAG_MODES, load_setting, save_setting, setting_path ) # 以圓點顯示的欄位 / The fields shown as dots SECRET_FIELDS = ( "remote_api_key", "openai_api_key", "anthropic_api_key", "platform_token") +# 從清單裡選的欄位,以及各自的選項 / The fields picked from a list, and each one's choices +CHOICE_FIELDS = {"backend": BACKENDS, "rag": RAG_MODES, "platform": PLATFORMS} + # 表格上的欄位順序,以及每一欄的說明用哪個語言鍵 # The fields in the order they are shown, and the language key labelling each FIELDS = ( @@ -34,6 +37,7 @@ ("model_name", "prthinker_setting_model_name_label"), ("remote_url", "prthinker_setting_remote_url_label"), ("remote_api_key", "prthinker_setting_remote_api_key_label"), + ("rag", "prthinker_setting_rag_label"), ("openai_base_url", "prthinker_setting_openai_base_url_label"), ("openai_api_key", "prthinker_setting_openai_api_key_label"), ("anthropic_api_key", "prthinker_setting_anthropic_api_key_label"), @@ -78,10 +82,8 @@ def __init__(self, parent=None) -> None: def _editor_for(self, key: str): """依欄位種類給對應的輸入元件 / The right kind of editor for a field.""" - if key == "backend": - editor = self._chooser(BACKENDS, self.setting.get(key, "")) - elif key == "platform": - editor = self._chooser(PLATFORMS, self.setting.get(key, "")) + if key in CHOICE_FIELDS: + editor = self._chooser(CHOICE_FIELDS[key], self.setting.get(key, "")) else: editor = QLineEdit(self.setting.get(key, "")) if key in SECRET_FIELDS: diff --git a/test/test_utils/test_prthinker_contract.py b/test/test_utils/test_prthinker_contract.py index 3b1e466f..8aca9608 100644 --- a/test/test_utils/test_prthinker_contract.py +++ b/test/test_utils/test_prthinker_contract.py @@ -94,9 +94,22 @@ def test_reviewing_a_file_is_accepted(tmp_path, closed_url, backend): "remote_url": closed_url, "openai_base_url": f"{closed_url}/v1", "openai_api_key": "not-a-real-key", - # prthinker's local RAG index ships only with its repository, not its - # package, so an installed prthinker needs it off (or remote). - "extra_arguments": "--no-rag --max-new-tokens 16", + "extra_arguments": "--max-new-tokens 16", + } + _assert_accepted(_run(review_file_arguments(str(source), setting), setting, tmp_path)) + + +def test_rule_retrieval_through_the_server_is_accepted(tmp_path, closed_url): + # An installed prthinker has no local RAG index: were the variable that + # asks for the server's /rag not read, it would stop on that import instead. + source = tmp_path / "main.py" + source.write_text("print(1)\n", encoding="utf-8") + setting = { + **DEFAULT_SETTING, + "backend": "remote", + "remote_url": closed_url, + "rag": "remote", + "extra_arguments": "--max-new-tokens 16", } _assert_accepted(_run(review_file_arguments(str(source), setting), setting, tmp_path)) diff --git a/test/test_utils/test_prthinker_setting.py b/test/test_utils/test_prthinker_setting.py index 671b551c..a1606456 100644 --- a/test/test_utils/test_prthinker_setting.py +++ b/test/test_utils/test_prthinker_setting.py @@ -8,7 +8,7 @@ from pybreeze.extend.prthinker_extend import prthinker_setting from pybreeze.extend.prthinker_extend.prthinker_setting import ( - DEFAULT_SETTING, INSTALL_EXTRAS, SECRET_SETTINGS, SETTING_ENVIRONMENT, + DEFAULT_SETTING, INSTALL_EXTRAS, RAG_MODES, SECRET_SETTINGS, SETTING_ENVIRONMENT, environment_for, extra_arguments, install_target, load_setting, loggable, review_file_arguments, review_pr_arguments, save_setting, split_arguments ) @@ -71,6 +71,29 @@ def test_every_setting_that_travels_has_a_variable(self): assert set(SETTING_ENVIRONMENT) <= set(DEFAULT_SETTING) +class TestRuleRetrieval: + # prthinker's local RAG index ships with its repository, not its package, so + # the prthinker PyBreeze installs can only do without it or ask the server. + def test_the_choices_are_off_and_the_server(self): + assert RAG_MODES == ("off", "remote") + + def test_it_starts_off(self): + environment = environment_for(DEFAULT_SETTING) + assert environment["PRTHINKER_RAG_ENABLED"] == "false" + assert "PRTHINKER_REMOTE_RAG" not in environment + + def test_remote_asks_the_server(self): + environment = environment_for({**DEFAULT_SETTING, "rag": "remote"}) + assert environment["PRTHINKER_REMOTE_RAG"] == "true" + assert "PRTHINKER_RAG_ENABLED" not in environment + + @pytest.mark.parametrize("stored", ["local", "", "REMOTE "]) + def test_anything_else_counts_as_off(self, stored): + environment = environment_for({**DEFAULT_SETTING, "rag": stored}) + assert environment["PRTHINKER_RAG_ENABLED"] == "false" + assert "PRTHINKER_REMOTE_RAG" not in environment + + class TestTheCommandsAreBuilt: def test_reviewing_a_file_names_that_file(self): assert review_file_arguments("main.py", DEFAULT_SETTING) == \ diff --git a/test/test_utils/test_prthinker_setting_dialog.py b/test/test_utils/test_prthinker_setting_dialog.py index a2b0c39d..b710682c 100644 --- a/test/test_utils/test_prthinker_setting_dialog.py +++ b/test/test_utils/test_prthinker_setting_dialog.py @@ -11,7 +11,7 @@ from pybreeze.extend.prthinker_extend import prthinker_setting from pybreeze.extend.prthinker_extend.prthinker_setting import ( - BACKENDS, DEFAULT_SETTING, PLATFORMS, SETTING_FILE_NAME, save_setting + BACKENDS, DEFAULT_SETTING, PLATFORMS, RAG_MODES, SETTING_FILE_NAME, save_setting ) from pybreeze.extend_multi_language.update_language_dict import update_language_dict from pybreeze.pybreeze_ui.dialog import prthinker_setting_dialog @@ -65,6 +65,11 @@ def test_the_platform_is_chosen_from_the_supported_list(self, dialog): editor = dialog.editors["platform"] assert [editor.itemText(i) for i in range(editor.count())] == list(PLATFORMS) + def test_rule_retrieval_is_chosen_from_the_supported_list(self, dialog): + editor = dialog.editors["rag"] + assert [editor.itemText(i) for i in range(editor.count())] == list(RAG_MODES) + assert editor.currentText() == DEFAULT_SETTING["rag"] + def test_a_stored_choice_comes_back_selected(self, app, data_dir): save_setting({**DEFAULT_SETTING, "backend": "anthropic", "platform": "gitea"}) made = PRThinkerSettingDialog() From e9a42b3b31d8d4f5593b0e41c3d9dbc4bc4bcbbe Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 00:25:30 +0800 Subject: [PATCH 020/312] Send the prthinker model name to the chosen backend's variable Only the local backend reads PRTHINKER_MODEL_NAME; the other backends each read their own PRTHINKER__MODEL, so the model typed into the settings was dropped for seven of the nine backends on offer. --- README.md | 2 +- architecture.md | 20 ++++++-- architecture_explore.md | 3 +- docs/updates/2026-09.md | 9 ++++ docs/updates/README.md | 3 +- .../prthinker_extend/prthinker_setting.py | 37 +++++++++++--- test/test_utils/test_prthinker_contract.py | 50 +++++++++++++++++++ test/test_utils/test_prthinker_setting.py | 31 +++++++++++- 8 files changed, 139 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index cb4b3b6c..3780c3a2 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Run the [prthinker](https://github.com/JE-Chen/Code-Review-Framework-Combining-L ![prthinker settings](images/prthinker_setting.png) -One settings form holds the inference backend (`remote`, `local`, OpenAI-compatible, Anthropic, Gemini, Cohere, Mistral, `claude-cli`, `codex-cli`), the code host (GitHub / GitLab / Gitea) and the repository. **Keys and tokens are handed to the review as environment variables, never on a command line** where a process list would show them — and they are masked in logs. Rule retrieval (RAG) is `off` unless set to `remote`, which asks the prthinker server's `/rag`: prthinker's local rule index ships with its repository, not with the package installed from it. The review runs with the interpreter chosen in `Python Env`, so PyBreeze itself can stay on an older Python than the 3.12 prthinker needs. +One settings form holds the inference backend (`remote`, `local`, OpenAI-compatible, Anthropic, Gemini, Cohere, Mistral, `claude-cli`, `codex-cli`), the code host (GitHub / GitLab / Gitea) and the repository. **Keys and tokens are handed to the review as environment variables, never on a command line** where a process list would show them — and they are masked in logs. The model name goes to whichever backend is chosen. Rule retrieval (RAG) is `off` unless set to `remote`, which asks the prthinker server's `/rag`: prthinker's local rule index ships with its repository, not with the package installed from it. The review runs with the interpreter chosen in `Python Env`, so PyBreeze itself can stay on an older Python than the 3.12 prthinker needs. ### CoT Prompt Editor diff --git a/architecture.md b/architecture.md index 1c9d5288..3a42408e 100644 --- a/architecture.md +++ b/architecture.md @@ -150,10 +150,22 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() (`extend/process_executor/python_task_process_manager.py`). TestPioneer runs as `python -m test_pioneer -e ` through the same manager's `start_module_process` (`extend/process_executor/test_pioneer/`). -- **prthinker**: runs as `python -m prthinker` via `TaskProcessManager.start_module_process` - (`extend/process_executor/prthinker/`). Secrets are passed as environment variables, never argv. - It is not on PyPI: the Install menu asks for a local source folder and installs `[runner]`. - README states it needs Python 3.12+. +- **prthinker**: runs as `python -m prthinker review-file ` or + `review-pr --pr-number ` via `TaskProcessManager.start_module_process` + (`extend/process_executor/prthinker/`), with the interpreter chosen in the IDE, which needs Python + 3.12+ (PyBreeze's own may be older). Settings travel as environment variables, never argv + (`prthinker_setting.environment_for`): `PRTHINKER_BACKEND`, the chosen backend's model variable + (`MODEL_ENVIRONMENT`: `PRTHINKER_MODEL_NAME` for `remote`/`local`, otherwise + `PRTHINKER__MODEL`), `PRTHINKER_REMOTE_URL`, `PRTHINKER_REMOTE_API_KEY`, + `PRTHINKER_OPENAI_API_KEY`, `PRTHINKER_OPENAI_BASE_URL`, `PRTHINKER_ANTHROPIC_API_KEY`, + `PRTHINKER_PLATFORM`, `PRTHINKER_PLATFORM_BASE_URL`, `GITHUB_REPOSITORY`, `GITHUB_TOKEN`, and + always one of `PRTHINKER_RAG_ENABLED=false` / `PRTHINKER_REMOTE_RAG=true`. `BACKENDS` must stay a + subset of `prthinker.config.BackendKind`. It is not on PyPI: the Install menu asks for a local + source folder and installs `[runner]`; that package excludes prthinker's `codes/` (the local + RAG index), which is why local retrieval is never left on. `test_prthinker_contract.py` runs the + real prthinker (CI's 3.12 leg) against the arguments and variables PyBreeze builds, and builds + prthinker's config (`prthinker.cli._build_parser`, `_build_config`, both in its `__all__`) to check + each backend gets the model. - **IDE_Plugins**: the plugin browser's default repo (set in JEditor). Its run configs execute here via `FileRunnerProcess`. - **PySide6 pin**: it must match JEditor and FrontEngine. PyBreeze pins it in `pyproject.toml`, diff --git a/architecture_explore.md b/architecture_explore.md index 89015cd8..6cbabed8 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -354,6 +354,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 - 設定存 `~/.pybreeze/prthinker_setting.json` - `environment_for()` 把設定轉成 `PRTHINKER_*` 環境變數交給子行程,**命令列只留「這次要審什麼」** — API key 不會出現在工作管理員或執行紀錄 - `SECRET_SETTINGS` 四個欄位在 `loggable()` 中一律縮成 `(set)` / 空字串 +- 模型名稱依後端交給不同變數(`MODEL_ENVIRONMENT`):`remote` / `local` 是 `PRTHINKER_MODEL_NAME`,其他後端是各自的 `PRTHINKER__MODEL`。prthinker 只有 local 讀 `PRTHINKER_MODEL_NAME`(remote 由伺服器決定模型,只拿來標示) - `extra_arguments()` 經 `split_arguments()` 以命令列規則斷詞(引號內空白不拆);反斜線是路徑分隔字元的平台(Windows)上反斜線不當跳脫字元,`C:\reviews` 才不會變成 `C:reviews`。解析失敗當作沒有而不是讓整次審查失敗 - `install_target()` 回傳 `[runner]`,因為 prthinker 不在 PyPI 上 - 規則檢索(`rag`,`RAG_MODES = ("off", "remote")`)**永遠明講**:`off` 給 `PRTHINKER_RAG_ENABLED=false`,`remote` 給 `PRTHINKER_REMOTE_RAG=true`(走伺服器的 `/rag`),認不得的值當 `off`。prthinker 的預設是本機 FAISS 檢索,但那份索引(`codes/`)只在它的原始碼庫、被排除在套件外,從這裡裝的 prthinker 一跑就 `ModuleNotFoundError: codes` @@ -420,7 +421,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 72 個 `test_*.py`、1068 個測試(5 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 72 個 `test_*.py`、1088 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index e34ced40..b2258e9e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -150,3 +150,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: review finished in 136 s with `Task exit with code 0`: five steps (`first_summary`, `first_code_review`, `linter`, `code_smell`, `total_summary`), their log lines streaming into the run window, the summary printed at the end, and five `*_result.md` files written to the backslashed `--output-dir`. Unit tests 1056 → 1063 passed, 5 skipped (local 3.11 venv, where the contract tests skip); contract and settings tests 40 passed against the prthinker the Install menu put in the 3.13 venv. `ruff check` clean. `images/prthinker_setting.png` re-rendered from the dialog. - **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/{test_prthinker_setting,test_prthinker_setting_dialog,test_prthinker_contract}.py`, `images/prthinker_setting.png`, `README.md`, `architecture_explore.md` §14, §18, `progress.md`. - **Open items**: none here. The prthinker side of workspace X-8 is closed in the root `progress.md`. + +## U-20260923-02 · 2026-09-23 · prthinker ignored the model name for seven backends · #incident #prthinker + +- **What**: the settings' "Model name" was always sent as `PRTHINKER_MODEL_NAME`, which only prthinker's `local` backend reads (`remote` just reports it). `openai`, `anthropic`, `gemini`, `cohere`, `mistral`, `claude-cli` and `codex-cli` each read their own `PRTHINKER__MODEL`, so for seven of the nine backends on offer the model typed in was dropped and prthinker's default used instead (for `anthropic`, `claude-opus-4-7`). The first real review (U-20260923-01) had to pass `--claude-cli-model` as an extra argument for this reason. +- **Fix**: `MODEL_ENVIRONMENT` maps each backend to the variable it reads, and `environment_for()` sends the model name only there. `model_name` left `SETTING_ENVIRONMENT`. +- **Tests**: `test_prthinker_setting.py` pins the variable per backend, that exactly one model variable is sent, and that every backend in `BACKENDS` has one. The contract test gains a case per backend: it builds prthinker's own review configuration from PyBreeze's environment alone (`prthinker.cli._build_parser`, `_build_config`, both in prthinker's `__all__`) and checks the chosen backend carries the model. Against the prthinker the Install menu put in a 3.13 venv: 7 of 9 failed before the fix, all passed after. +- **Result / numbers**: unit tests 1063 → 1074 passed; 14 skipped (the prthinker contract tests, local 3.11 venv); contract and settings tests 75 passed against the installed prthinker. `ruff check` clean. +- **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/{test_prthinker_setting,test_prthinker_contract}.py`, `README.md`, `architecture.md` §6 (now lists every variable PyBreeze sends), `architecture_explore.md` §14, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 02c2c902..5315fde9 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-02 | 2026-09-23 | prthinker ignored the model name for seven backends | #incident #prthinker | [2026-09](2026-09.md) | | U-20260923-01 | 2026-09-23 | First real prthinker review run from PyBreeze | #done #prthinker | [2026-09](2026-09.md) | | U-20260922-18 | 2026-09-22 | Run windows lost their output and exit line | #incident #process-executor | [2026-09](2026-09.md) | | U-20260922-17 | 2026-09-22 | Automation submenus came up empty | #incident #menus | [2026-09](2026-09.md) | @@ -82,4 +83,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 19 | +| [2026-09.md](2026-09.md) | 2026-09 | 20 | diff --git a/pybreeze/extend/prthinker_extend/prthinker_setting.py b/pybreeze/extend/prthinker_extend/prthinker_setting.py index 1529ed25..d80e3a00 100644 --- a/pybreeze/extend/prthinker_extend/prthinker_setting.py +++ b/pybreeze/extend/prthinker_extend/prthinker_setting.py @@ -55,10 +55,25 @@ "remote": {"PRTHINKER_REMOTE_RAG": "true"}, } -# 每個設定項對應的環境變數 / The environment variable each setting is given through +# 每個後端讀模型名稱的環境變數:prthinker 各後端各讀各的 +# The variable each backend reads its model from: each prthinker backend has its own +MODEL_ENVIRONMENT = { + "remote": "PRTHINKER_MODEL_NAME", + "local": "PRTHINKER_MODEL_NAME", + "openai": "PRTHINKER_OPENAI_MODEL", + "anthropic": "PRTHINKER_ANTHROPIC_MODEL", + "gemini": "PRTHINKER_GEMINI_MODEL", + "cohere": "PRTHINKER_COHERE_MODEL", + "mistral": "PRTHINKER_MISTRAL_MODEL", + "claude-cli": "PRTHINKER_CLAUDE_CLI_MODEL", + "codex-cli": "PRTHINKER_CODEX_CLI_MODEL", +} + +# 其餘每個設定項對應的環境變數;模型名稱依後端而定,見 MODEL_ENVIRONMENT +# The variable every other setting is given through; the model name's depends on +# the backend (MODEL_ENVIRONMENT) SETTING_ENVIRONMENT = { "backend": "PRTHINKER_BACKEND", - "model_name": "PRTHINKER_MODEL_NAME", "remote_url": "PRTHINKER_REMOTE_URL", "remote_api_key": "PRTHINKER_REMOTE_API_KEY", "openai_api_key": "PRTHINKER_OPENAI_API_KEY", @@ -163,12 +178,14 @@ def environment_for(setting: Dict[str, str]) -> Dict[str, str]: 把設定變成 prthinker 認得的環境變數 Turn the settings into the environment variables prthinker reads. - 空白的項目不放進去,prthinker 才用得到它自己的預設值。規則檢索例外:prthinker 預設 - 在本機檢索,而這裡裝的 prthinker 做不到,所以一定會明講要關掉或交給伺服器。 - A blank setting is left out, so prthinker keeps its own default for it. Rule - retrieval is the exception: prthinker's default is to retrieve locally, which - the prthinker installed from here cannot do, so it is always told to go - without or to ask the server. + 空白的項目不放進去,prthinker 才用得到它自己的預設值。模型名稱交給所選後端自己的 + 變數。規則檢索例外:prthinker 預設在本機檢索,而這裡裝的 prthinker 做不到,所以 + 一定會明講要關掉或交給伺服器。 + A blank setting is left out, so prthinker keeps its own default for it. The + model name goes to the chosen backend's own variable. Rule retrieval is the + exception: prthinker's default is to retrieve locally, which the prthinker + installed from here cannot do, so it is always told to go without or to ask + the server. :param setting: 目前的設定 / the settings in use :return: 要加進子行程環境的變數 / the variables to add to the child's environment @@ -178,6 +195,10 @@ def environment_for(setting: Dict[str, str]) -> Dict[str, str]: for key, name in SETTING_ENVIRONMENT.items() if setting.get(key, "").strip() } + model = setting.get("model_name", "").strip() + model_variable = MODEL_ENVIRONMENT.get(setting.get("backend", "").strip()) + if model and model_variable: + environment[model_variable] = model rag_mode = setting.get("rag", "") environment.update(RAG_ENVIRONMENT.get(rag_mode, RAG_ENVIRONMENT["off"])) return environment diff --git a/test/test_utils/test_prthinker_contract.py b/test/test_utils/test_prthinker_contract.py index 8aca9608..af5ea679 100644 --- a/test/test_utils/test_prthinker_contract.py +++ b/test/test_utils/test_prthinker_contract.py @@ -114,6 +114,56 @@ def test_rule_retrieval_through_the_server_is_accepted(tmp_path, closed_url): _assert_accepted(_run(review_file_arguments(str(source), setting), setting, tmp_path)) +# Build prthinker's review configuration the way its command line does, from the +# environment alone, and report the backend and the model it settled on. +# ``_build_parser`` and ``_build_config`` are in ``prthinker.cli.__all__``. +_REPORT_BACKEND_AND_MODEL = """ +import json +from prthinker.cli import _build_config, _build_parser +args = _build_parser().parse_args(["review-file", "main.py"]) +config = _build_config(args) +kind = getattr(config.backend, "value", config.backend) +chosen = getattr(config, kind.replace("-", "_")) +for attribute in ("model", "model_name"): + if hasattr(chosen, attribute): + model = getattr(chosen, attribute) + break +else: + # The remote server picks its own model; prthinker only reports this one. + model = args.model_name +print(json.dumps({"backend": kind, "model": model})) +""" + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_the_model_reaches_the_backend_it_was_set_for(closed_url, backend): + setting = { + **DEFAULT_SETTING, + "backend": backend, + "model_name": "pybreeze-contract-model", + "remote_url": closed_url, + "openai_api_key": "not-a-real-key", + "anthropic_api_key": "not-a-real-key", + } + environment = { + **utf8_subprocess_env("utf-8"), + # The three keys PyBreeze has no field for: prthinker reads them from + # the environment the IDE was started in. + "PRTHINKER_GEMINI_API_KEY": "not-a-real-key", + "PRTHINKER_COHERE_API_KEY": "not-a-real-key", + "PRTHINKER_MISTRAL_API_KEY": "not-a-real-key", + **environment_for(setting), + } + completed = subprocess.run( # noqa: S603 — fixed interpreter and a literal script + [PRTHINKER_PYTHON, "-c", _REPORT_BACKEND_AND_MODEL], + env=environment, capture_output=True, timeout=_RUN_TIMEOUT_SECONDS, check=False, + shell=False, + ) + assert completed.returncode == 0, completed.stderr.decode("utf-8", "replace")[-2000:] + assert json.loads(completed.stdout) == { + "backend": backend, "model": "pybreeze-contract-model"} + + def test_reviewing_a_pull_request_is_accepted(tmp_path, closed_url): setting = { **DEFAULT_SETTING, diff --git a/test/test_utils/test_prthinker_setting.py b/test/test_utils/test_prthinker_setting.py index a1606456..c7946cfa 100644 --- a/test/test_utils/test_prthinker_setting.py +++ b/test/test_utils/test_prthinker_setting.py @@ -8,7 +8,8 @@ from pybreeze.extend.prthinker_extend import prthinker_setting from pybreeze.extend.prthinker_extend.prthinker_setting import ( - DEFAULT_SETTING, INSTALL_EXTRAS, RAG_MODES, SECRET_SETTINGS, SETTING_ENVIRONMENT, + BACKENDS, DEFAULT_SETTING, INSTALL_EXTRAS, MODEL_ENVIRONMENT, RAG_MODES, SECRET_SETTINGS, + SETTING_ENVIRONMENT, environment_for, extra_arguments, install_target, load_setting, loggable, review_file_arguments, review_pr_arguments, save_setting, split_arguments ) @@ -71,6 +72,34 @@ def test_every_setting_that_travels_has_a_variable(self): assert set(SETTING_ENVIRONMENT) <= set(DEFAULT_SETTING) +class TestTheModelName: + # prthinker reads the model from a different variable for each backend. + @pytest.mark.parametrize("backend,variable", [ + ("remote", "PRTHINKER_MODEL_NAME"), + ("local", "PRTHINKER_MODEL_NAME"), + ("openai", "PRTHINKER_OPENAI_MODEL"), + ("anthropic", "PRTHINKER_ANTHROPIC_MODEL"), + ("gemini", "PRTHINKER_GEMINI_MODEL"), + ("cohere", "PRTHINKER_COHERE_MODEL"), + ("mistral", "PRTHINKER_MISTRAL_MODEL"), + ("claude-cli", "PRTHINKER_CLAUDE_CLI_MODEL"), + ("codex-cli", "PRTHINKER_CODEX_CLI_MODEL"), + ]) + def test_it_goes_to_the_chosen_backends_variable(self, backend, variable): + environment = environment_for( + {**DEFAULT_SETTING, "backend": backend, "model_name": " the-model "}) + model_variables = {name for name in environment if name.endswith(("_MODEL", "_MODEL_NAME"))} + assert model_variables == {variable} + assert environment[variable] == "the-model" + + def test_every_backend_on_offer_has_a_model_variable(self): + assert set(MODEL_ENVIRONMENT) == set(BACKENDS) + + def test_no_model_leaves_every_backend_its_default(self): + environment = environment_for({**DEFAULT_SETTING, "backend": "anthropic"}) + assert not [name for name in environment if name.endswith(("_MODEL", "_MODEL_NAME"))] + + class TestRuleRetrieval: # prthinker's local RAG index ships with its repository, not its package, so # the prthinker PyBreeze installs can only do without it or ask the server. From 760a852732aebdce19af846837490bfbcc1345a0 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 00:32:04 +0800 Subject: [PATCH 021/312] Describe each automation menu with one AutomationMenu Refactor only: build_automation_menu took 13 parameters, past the quality gate's 7. It now takes a frozen AutomationMenu of RunAction and HelpLink entries and builds each submenu in its own helper. The started menu bar is unchanged. --- architecture.md | 3 +- architecture_explore.md | 2 +- docs/updates/2026-09.md | 8 + docs/updates/README.md | 3 +- .../api_testka_menu/build_api_testka_menu.py | 42 +++-- .../build_autocontrol_menu.py | 42 +++-- .../build_automation_file_menu.py | 43 +++-- .../automation_menu_factory.py | 161 ++++++++++-------- .../build_load_density_menu.py | 43 +++-- .../build_mail_thunder_menu.py | 31 ++-- .../web_runner_menu/build_webrunner_menu.py | 43 +++-- .../test_automation_menu_factory.py | 38 ++--- 12 files changed, 236 insertions(+), 223 deletions(-) diff --git a/architecture.md b/architecture.md index 3a42408e..e5616b00 100644 --- a/architecture.md +++ b/architecture.md @@ -105,7 +105,8 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() - **New automation package**: - add a sub-package under `extend/process_executor/` with a `_PACKAGE` constant that calls `build_process()`; - - add a menu via `pybreeze_ui/menu/automation_menu/automation_menu_factory.py`, wired in + - add a menu: one `AutomationMenu(...)` passed to `build_automation_menu()` + (`pybreeze_ui/menu/automation_menu/automation_menu_factory.py`), wired in `menu/build_menubar.py`; - add an installer in `menu/install_menu/automation_menu/`; - add keywords in `pybreeze_ui/syntax/syntax_keyword.py`. diff --git a/architecture_explore.md b/architecture_explore.md index 6cbabed8..40051506 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -160,7 +160,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.1 `automation_menu_factory.py` — 選單工廠 -`build_automation_menu()` 用宣告式參數組出標準自動化子選單:`Run` 子選單 / `Help`(文件+GitHub,開內嵌瀏覽器分頁)/ `Project`(建立範本目錄)/ GUI 分頁。六個自動化模組全部靠它,`build_*_menu.py` 只剩一份設定表。每個 QAction 都以它所在的選單為 parent,由 Qt 持有;AutoControl 額外的 `Record` 子選單也一樣。 +`build_automation_menu(ui, spec)` 依一份 `AutomationMenu` 描述組出標準自動化子選單:`Run` 子選單(`RunAction` 列表)/ `Help`(`HelpLink` 列表,文件+GitHub,開內嵌瀏覽器分頁)/ `Project`(建立範本目錄)/ GUI 分頁,每一段各由一個小函式建(`_add_run_menu` 等),沒有項目的段落不建。三個描述都是 frozen dataclass。六個自動化模組全部靠它,`build_*_menu.py` 只剩一份 `AutomationMenu(...)`。每個 QAction 都以它所在的選單為 parent,由 Qt 持有;AutoControl 額外的 `Record` 子選單也一樣。 `safe_create_project(import_name)` 回傳延遲 import 的 closure,模組沒裝時只記 log 不炸選單。 diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index b2258e9e..001092a7 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -159,3 +159,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1063 → 1074 passed; 14 skipped (the prthinker contract tests, local 3.11 venv); contract and settings tests 75 passed against the installed prthinker. `ruff check` clean. - **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/{test_prthinker_setting,test_prthinker_contract}.py`, `README.md`, `architecture.md` §6 (now lists every variable PyBreeze sends), `architecture_explore.md` §14, §18. - **Open items**: none. + +## U-20260923-03 · 2026-09-23 · Automation menus described by one AutomationMenu · #done #refactor #menus + +- **What**: a refactor, no change in behaviour. `build_automation_menu()` took 13 parameters (the quality gate is 7) and was 83 lines long. It now takes the main window and one `AutomationMenu`, a frozen dataclass holding the label key, a tuple of `RunAction` (label key, callback), a tuple of `HelpLink` (URL, label key, tab label key), the project entry and the GUI entry. Each submenu is built by its own helper (`_add_run_menu`, `_add_help_menu`, `_add_project_menu`, `_add_gui_action`). The six `build_*_menu.py` callers each pass one `AutomationMenu(...)` instead of keyword arguments and dicts. +- **Guarded by**: `test_automation_menu_factory.py` (moved to the new call, same assertions) and `test_started_menus.py`. The whole started menu bar, walked after garbage collection, is identical before and after (413 lines, compared with `diff`). +- **Result / numbers**: `ruff check` with the gate's limits (`PLR0913` max 7 arguments, `C901` max 15) now reports one function in `pybreeze/`, `diagram_items.py:228` (9 arguments), down from three before U-20260922-17 and this entry. Unit tests 1074 passed, 14 skipped. +- **Files**: `pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py`, the six `pybreeze/pybreeze_ui/menu/automation_menu/*/build_*_menu.py`, `test/test_utils/test_automation_menu_factory.py`, `architecture.md` §5, `architecture_explore.md` §5.1. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 5315fde9..d66b0e98 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-03 | 2026-09-23 | Automation menus described by one AutomationMenu | #done #refactor #menus | [2026-09](2026-09.md) | | U-20260923-02 | 2026-09-23 | prthinker ignored the model name for seven backends | #incident #prthinker | [2026-09](2026-09.md) | | U-20260923-01 | 2026-09-23 | First real prthinker review run from PyBreeze | #done #prthinker | [2026-09](2026-09.md) | | U-20260922-18 | 2026-09-22 | Run windows lost their output and exit line | #incident #process-executor | [2026-09](2026-09.md) | @@ -83,4 +84,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 20 | +| [2026-09.md](2026-09.md) | 2026-09 | 21 | 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 6e51d354..5360e927 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 @@ -5,7 +5,7 @@ from je_api_testka.gui.main_widget import APITestkaWidget from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( - build_automation_menu, safe_create_project + AutomationMenu, HelpLink, RunAction, build_automation_menu, safe_create_project ) if TYPE_CHECKING: @@ -18,27 +18,25 @@ def set_apitestka_menu(ui_we_want_to_set: PyBreezeMainWindow): - 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/Integration-Automation/APITestka", - github_label_key="apitestka_github_label", - github_tab_label_key="apitestka_github_tab_label", - create_project_func=safe_create_project("je_api_testka"), + build_automation_menu(ui_we_want_to_set, AutomationMenu( + label_key="apitestka_menu_label", + run_actions=( + RunAction("apitestka_run_script_label", lambda: call_api_testka(ui_we_want_to_set)), + RunAction("apitestka_run_script_with_send_label", + lambda: call_api_testka_with_send(ui_we_want_to_set)), + RunAction("apitestka_run_multi_script_label", + lambda: call_api_testka_multi_file(ui_we_want_to_set)), + RunAction("apitestka_run_multi_script_with_send_label", + lambda: call_api_testka_multi_file_and_send(ui_we_want_to_set)), + ), + help_links=( + HelpLink("https://apitestka.readthedocs.io/en/latest/", + "apitestka_doc_label", "apitestka_doc_tab_label"), + HelpLink("https://github.com/Integration-Automation/APITestka", + "apitestka_github_label", "apitestka_github_tab_label"), + ), + create_project=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 79b5752f..6e6615ee 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 @@ -9,7 +9,7 @@ from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( - build_automation_menu, safe_create_project + AutomationMenu, HelpLink, RunAction, build_automation_menu, safe_create_project ) if TYPE_CHECKING: @@ -22,30 +22,28 @@ def set_autocontrol_menu(ui_we_want_to_set: PyBreezeMainWindow): - 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/Integration-Automation/AutoControlGUI", - github_label_key="autocontrol_github_label", - github_tab_label_key="autocontrol_github_tab_label", - create_project_func=safe_create_project("je_auto_control"), + menu = build_automation_menu(ui_we_want_to_set, AutomationMenu( + label_key="autocontrol_menu_label", + run_actions=( + RunAction("autocontrol_run_script_label", lambda: call_auto_control(ui_we_want_to_set)), + RunAction("autocontrol_run_script_with_send_label", + lambda: call_auto_control_with_send(ui_we_want_to_set)), + RunAction("autocontrol_run_multi_script_label", + lambda: call_auto_control_multi_file(ui_we_want_to_set)), + RunAction("autocontrol_run_multi_script_with_send_label", + lambda: call_auto_control_multi_file_and_send(ui_we_want_to_set)), + ), + help_links=( + HelpLink("https://autocontrol.readthedocs.io/en/latest/", + "autocontrol_doc_label", "autocontrol_doc_tab_label"), + HelpLink("https://github.com/Integration-Automation/AutoControlGUI", + "autocontrol_github_label", "autocontrol_github_tab_label"), + ), + create_project=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 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 99905576..43fe6f4b 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 @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( - build_automation_menu, safe_create_project + AutomationMenu, HelpLink, RunAction, build_automation_menu, safe_create_project ) if TYPE_CHECKING: @@ -16,25 +16,24 @@ def set_automation_file_menu(ui_we_want_to_set: PyBreezeMainWindow): - 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"), + build_automation_menu(ui_we_want_to_set, AutomationMenu( + label_key="file_automation_menu_label", + run_actions=( + RunAction("file_automation_run_script_label", + lambda: call_file_automation_test(ui_we_want_to_set)), + RunAction("file_automation_run_script_with_send_label", + lambda: call_file_automation_test_with_send(ui_we_want_to_set)), + RunAction("file_automation_run_multi_script_label", + lambda: call_file_automation_test_multi_file(ui_we_want_to_set)), + RunAction("file_automation_run_multi_script_with_send_label", + lambda: call_file_automation_test_multi_file_and_send(ui_we_want_to_set)), + ), + help_links=( + HelpLink("https://fileautomation.readthedocs.io/en/latest/", + "file_automation_doc_label", "file_automation_doc_tab_label"), + HelpLink("https://github.com/Integration-Automation/FileAutomation", + "file_automation_github_label", "file_automation_github_tab_label"), + ), + create_project=safe_create_project("file_automation"), create_project_label_key="file_automation_create_project_label", - ) + )) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py index f6ee209a..1d350b4c 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py @@ -1,10 +1,11 @@ from __future__ import annotations import importlib +from dataclasses import dataclass from typing import TYPE_CHECKING, Callable from PySide6.QtGui import QAction -from PySide6.QtWidgets import QMenu +from PySide6.QtWidgets import QMenu, QWidget from je_editor import language_wrapper from pybreeze.pybreeze_ui.menu.menu_utils import open_web_browser @@ -14,89 +15,99 @@ 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: +@dataclass(frozen=True) +class RunAction: + """One entry of the Run submenu: a language key for its label and what it runs.""" + label_key: str + callback: Callable[[], None] + + +@dataclass(frozen=True) +class HelpLink: + """One entry of the HELP submenu: a page opened in a browser tab.""" + url: str + label_key: str + tab_label_key: str + + +@dataclass(frozen=True) +class AutomationMenu: + """Everything one automation package's menu holds. + + A submenu is built only when it has entries; the project entry needs both + ``create_project`` and its label key, and the GUI entry both the widget + class and its label. """ - Factory function to build a standard automation sub-menu. + label_key: str + run_actions: tuple[RunAction, ...] = () + help_links: tuple[HelpLink, ...] = () + create_project: Callable[[], None] | None = None + create_project_label_key: str | None = None + gui_widget_class: type[QWidget] | None = None + gui_label: str | None = None + + +def build_automation_menu(ui: PyBreezeMainWindow, spec: AutomationMenu) -> QMenu: + """Add a standard automation submenu, described by *spec*, to ``ui.automation_menu``. + + In order: a Run submenu, a HELP submenu, a Project submenu and an entry that + opens the package's GUI as a tab. Every QAction takes the menu it sits in as + its parent: a menu does not own the actions added to it, so an action held + only here would be deleted when this returns and its entry would disappear. :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. + :param spec: What the menu holds. :return: The created QMenu. """ lang = language_wrapper.language_word_dict + menu = ui.automation_menu.addMenu(lang.get(spec.label_key)) + if spec.run_actions: + _add_run_menu(menu, spec.run_actions) + if spec.help_links: + _add_help_menu(ui, menu, spec.help_links) + if spec.create_project and spec.create_project_label_key: + _add_project_menu(menu, spec.create_project, spec.create_project_label_key) + if spec.gui_widget_class and spec.gui_label: + _add_gui_action(ui, menu, spec.gui_widget_class, spec.gui_label) + return menu + - # 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"]), run_menu) - 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), help_menu) - 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), help_menu) - 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), project_menu) - 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, menu) - gui_action.triggered.connect( - lambda checked=False: ui.tab_widget.addTab(gui_widget_class(), gui_label) +def _add_run_menu(menu: QMenu, run_actions: tuple[RunAction, ...]) -> None: + lang = language_wrapper.language_word_dict + run_menu = menu.addMenu(lang.get("run_label")) + for run_action in run_actions: + action = QAction(lang.get(run_action.label_key), run_menu) + action.triggered.connect(run_action.callback) + run_menu.addAction(action) + + +def _add_help_menu(ui: PyBreezeMainWindow, menu: QMenu, links: tuple[HelpLink, ...]) -> None: + lang = language_wrapper.language_word_dict + help_menu = menu.addMenu(lang.get("help_label")) + for link in links: + action = QAction(lang.get(link.label_key), help_menu) + action.triggered.connect( + lambda checked=False, url=link.url, tab_label_key=link.tab_label_key: + open_web_browser(ui, url, lang.get(tab_label_key)) ) - menu.addAction(gui_action) + help_menu.addAction(action) - return menu + +def _add_project_menu(menu: QMenu, create_project: Callable[[], None], label_key: str) -> None: + lang = language_wrapper.language_word_dict + project_menu = menu.addMenu(lang.get("project_label")) + action = QAction(lang.get(label_key), project_menu) + action.triggered.connect(create_project) + project_menu.addAction(action) + + +def _add_gui_action( + ui: PyBreezeMainWindow, menu: QMenu, widget_class: type[QWidget], label: str) -> None: + action = QAction(label, menu) + action.triggered.connect( + lambda checked=False: ui.tab_widget.addTab(widget_class(), label) + ) + menu.addAction(action) def safe_create_project(import_name: str) -> Callable: 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 ace3070c..edeb3b3d 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 @@ -5,7 +5,7 @@ from je_load_density.gui.main_widget import LoadDensityWidget from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( - build_automation_menu, safe_create_project + AutomationMenu, HelpLink, RunAction, build_automation_menu, safe_create_project ) if TYPE_CHECKING: @@ -18,27 +18,26 @@ def set_load_density_menu(ui_we_want_to_set: PyBreezeMainWindow): - 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/Integration-Automation/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"), + build_automation_menu(ui_we_want_to_set, AutomationMenu( + label_key="load_density_menu_label", + run_actions=( + RunAction("load_density_run_script_label", + lambda: call_load_density(ui_we_want_to_set)), + RunAction("load_density_run_script_with_send_label", + lambda: call_load_density_with_send(ui_we_want_to_set)), + RunAction("load_density_run_multi_script_label", + lambda: call_load_density_multi_file(ui_we_want_to_set)), + RunAction("load_density_run_multi_script_with_send_label", + lambda: call_load_density_multi_file_and_send(ui_we_want_to_set)), + ), + help_links=( + HelpLink("https://loaddensity.readthedocs.io/en/latest/", + "load_density_doc_label", "load_density_doc_tab_label"), + HelpLink("https://github.com/Integration-Automation/LoadDensity", + "load_density_github_label", "load_density_github_tab_label"), + ), + create_project=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 6a396a0d..3ba6aa92 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 @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( - build_automation_menu, safe_create_project + AutomationMenu, HelpLink, RunAction, build_automation_menu, safe_create_project ) if TYPE_CHECKING: @@ -13,19 +13,18 @@ def set_mail_thunder_menu(ui_we_want_to_set: PyBreezeMainWindow): - 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"), + build_automation_menu(ui_we_want_to_set, AutomationMenu( + label_key="mail_thunder_menu_label", + run_actions=( + RunAction("mail_thunder_run_script_label", + lambda: call_mail_thunder(ui_we_want_to_set)), + ), + help_links=( + HelpLink("https://mailthunder.readthedocs.io/en/latest/", + "mail_thunder_doc_label", "mail_thunder_doc_tab_label"), + HelpLink("https://github.com/Integration-Automation/MailThunder", + "mail_thunder_github_label", "mail_thunder_github_tab_label"), + ), + create_project=safe_create_project("je_mail_thunder"), create_project_label_key="mail_thunder_create_project_label", - ) + )) 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 721682a7..fbf9bcd6 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 @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( - build_automation_menu, safe_create_project + AutomationMenu, HelpLink, RunAction, build_automation_menu, safe_create_project ) if TYPE_CHECKING: @@ -16,25 +16,24 @@ def set_web_runner_menu(ui_we_want_to_set: PyBreezeMainWindow): - 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/Integration-Automation/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"), + build_automation_menu(ui_we_want_to_set, AutomationMenu( + label_key="web_runner_menu_label", + run_actions=( + RunAction("web_runner_run_script_label", + lambda: call_web_runner_test(ui_we_want_to_set)), + RunAction("web_runner_run_script_with_send_label", + lambda: call_web_runner_test_with_send(ui_we_want_to_set)), + RunAction("web_runner_run_multi_script_label", + lambda: call_web_runner_test_multi_file(ui_we_want_to_set)), + RunAction("web_runner_run_multi_script_with_send_label", + lambda: call_web_runner_test_multi_file_and_send(ui_we_want_to_set)), + ), + help_links=( + HelpLink("https://webrunner.readthedocs.io/en/latest/", + "web_runner_doc_label", "web_runner_doc_tab_label"), + HelpLink("https://github.com/Integration-Automation/WebRunner", + "web_runner_github_label", "web_runner_github_tab_label"), + ), + create_project=safe_create_project("je_web_runner"), create_project_label_key="web_runner_create_project_label", - ) + )) diff --git a/test/test_utils/test_automation_menu_factory.py b/test/test_utils/test_automation_menu_factory.py index d7241ae0..1b87c944 100644 --- a/test/test_utils/test_automation_menu_factory.py +++ b/test/test_utils/test_automation_menu_factory.py @@ -18,7 +18,7 @@ from pybreeze.extend_multi_language.update_language_dict import update_language_dict from pybreeze.pybreeze_ui.menu.automation_menu import automation_menu_factory from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( - build_automation_menu + AutomationMenu, HelpLink, RunAction, build_automation_menu ) @@ -54,13 +54,13 @@ def _submenu_actions(menu: QMenu, index: int) -> list[QAction]: def test_run_actions_survive_and_answer(window): calls = [] - menu = build_automation_menu( - window, "run_label", - run_actions=[ - {"label_key": "run_label", "callback": lambda: calls.append("first")}, - {"label_key": "help_label", "callback": lambda: calls.append("second")}, - ], - ) + menu = build_automation_menu(window, AutomationMenu( + "run_label", + run_actions=( + RunAction("run_label", lambda: calls.append("first")), + RunAction("help_label", lambda: calls.append("second")), + ), + )) gc.collect() run_actions = _submenu_actions(menu, 0) @@ -74,13 +74,13 @@ def test_help_actions_survive_and_open_their_page(window, monkeypatch): monkeypatch.setattr( automation_menu_factory, "open_web_browser", lambda ui, url, label: opened.append(url)) - menu = build_automation_menu( - window, "run_label", - doc_url="https://docs.example", doc_label_key="help_label", - doc_tab_label_key="help_label", - github_url="https://github.example", github_label_key="run_label", - github_tab_label_key="run_label", - ) + menu = build_automation_menu(window, AutomationMenu( + "run_label", + help_links=( + HelpLink("https://docs.example", "help_label", "help_label"), + HelpLink("https://github.example", "run_label", "run_label"), + ), + )) gc.collect() help_actions = _submenu_actions(menu, 0) @@ -92,12 +92,12 @@ def test_help_actions_survive_and_open_their_page(window, monkeypatch): def test_project_and_gui_actions_survive_and_answer(window): created = [] - menu = build_automation_menu( - window, "run_label", - create_project_func=lambda: created.append(True), + menu = build_automation_menu(window, AutomationMenu( + "run_label", + create_project=lambda: created.append(True), create_project_label_key="project_label", gui_widget_class=QWidget, gui_label="GUI", - ) + )) gc.collect() project_actions = _submenu_actions(menu, 0) From afb7c0bc322c23a45c03e587b24097ddfef8998e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 00:38:28 +0800 Subject: [PATCH 022/312] Pass a diagram node's colours and font size as one NodeStyle Refactor only: DiagramNode took nine parameters, past the quality gate's seven. The saved diagram format is unchanged. --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 8 +++++ docs/updates/README.md | 3 +- .../diagram_editor/diagram_items.py | 30 +++++++++++++------ test/test_utils/test_diagram_serialization.py | 4 +-- 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 40051506..54e4652b 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -229,13 +229,13 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,310 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,322 行,最大子系統) | 檔案 | 職責 | |---|---| | `diagram_editor_widget.py` (584) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框 | | `diagram_scene.py` (648) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()` | -| `diagram_items.py` (830) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | +| `diagram_items.py` (842) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | | `diagram_mermaid_parser.py` (520) | Mermaid flowchart → diagram dict。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (421) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | | `diagram_view.py` (175) | `QGraphicsView`:滾輪縮放(有上下界)、中鍵平移、`drawBackground` 畫格線 | diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 001092a7..f26131ef 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -167,3 +167,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: `ruff check` with the gate's limits (`PLR0913` max 7 arguments, `C901` max 15) now reports one function in `pybreeze/`, `diagram_items.py:228` (9 arguments), down from three before U-20260922-17 and this entry. Unit tests 1074 passed, 14 skipped. - **Files**: `pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py`, the six `pybreeze/pybreeze_ui/menu/automation_menu/*/build_*_menu.py`, `test/test_utils/test_automation_menu_factory.py`, `architecture.md` §5, `architecture_explore.md` §5.1. - **Open items**: none. + +## U-20260923-04 · 2026-09-23 · DiagramNode takes its colours and font size as one NodeStyle · #done #refactor #diagram + +- **What**: a refactor, no change in behaviour. `DiagramNode.__init__` took nine parameters, past the quality gate's seven: position, size, text, shape, and three that only set how the node is drawn. Those three (`fill_color`, `border_color`, `font_size`) now travel as one frozen `NodeStyle` (`style=None` means the defaults). `from_dict()` builds the `NodeStyle` from the same keys, so the `.diagram.json` format is unchanged, including the old `color` key still read as the fill. +- **Guarded by**: `test_diagram_serialization.py` (round trip, corrupt colours, zero sizes), the Mermaid import tests, and the rest of the diagram tests; one test moved to `style=NodeStyle(fill_color=...)`. +- **Result / numbers**: `ruff check pybreeze/` with the gate's limits (`PLR0913` max 7 arguments, `C901` max 15) now reports nothing. Unit tests 1074 passed, 14 skipped. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_serialization.py`, `architecture_explore.md` §7 (line counts re-measured). +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index d66b0e98..fd1718db 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-04 | 2026-09-23 | DiagramNode takes its style as one NodeStyle | #done #refactor #diagram | [2026-09](2026-09.md) | | U-20260923-03 | 2026-09-23 | Automation menus described by one AutomationMenu | #done #refactor #menus | [2026-09](2026-09.md) | | U-20260923-02 | 2026-09-23 | prthinker ignored the model name for seven backends | #incident #prthinker | [2026-09](2026-09.md) | | U-20260923-01 | 2026-09-23 | First real prthinker review run from PyBreeze | #done #prthinker | [2026-09](2026-09.md) | @@ -84,4 +85,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 21 | +| [2026-09.md](2026-09.md) | 2026-09 | 22 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index 78366f76..fbcf5d45 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +from dataclasses import dataclass from enum import Enum, auto from PySide6.QtCore import QPointF, QRectF, Qt @@ -121,6 +122,8 @@ def _safe_color(value: str | None, fallback: str) -> QColor: if color.isValid(): return color return QColor(fallback) + + _LABEL_FONT_FAMILY = "Segoe UI" _LABEL_FONT_SIZE = 10 _CONNECTION_COLOR = "#37474f" @@ -129,6 +132,14 @@ def _safe_color(value: str | None, fallback: str) -> QColor: _HANDLE_SIZE = 8.0 +@dataclass(frozen=True) +class NodeStyle: + """How a node is drawn. ``None`` colours mean the defaults; unparseable ones fall back too.""" + fill_color: str | None = None + border_color: str | None = None + font_size: int = _LABEL_FONT_SIZE + + # --------------------------------------------------------------------------- # Editable label — only enters edit mode on double-click # --------------------------------------------------------------------------- @@ -233,10 +244,9 @@ def __init__( h: float = _DEFAULT_NODE_H, text: str = "Node", shape: NodeShape = NodeShape.RECTANGLE, - fill_color: str | None = None, - border_color: str | None = None, - font_size: int = _LABEL_FONT_SIZE, + style: NodeStyle | None = None, ): + style = style or NodeStyle() # Clamp to a positive minimum so a zero/negative size from corrupted data # can't cause a divide-by-zero when computing edge intersection points. w = max(_MIN_NODE_W, w) @@ -257,9 +267,9 @@ def __init__( self._resizing = False # Colors - self._fill_color = _safe_color(fill_color, _NODE_BRUSH_COLOR) - self._border_color = _safe_color(border_color, _NODE_PEN_COLOR) - self._font_size = font_size + self._fill_color = _safe_color(style.fill_color, _NODE_BRUSH_COLOR) + self._border_color = _safe_color(style.border_color, _NODE_PEN_COLOR) + self._font_size = style.font_size # Shape body (child) self.body: QGraphicsItem = self._make_body(w, h) @@ -482,9 +492,11 @@ def from_dict(cls, data: dict) -> DiagramNode: h=data.get("h", _DEFAULT_NODE_H), text=data.get("text", "Node"), shape=NodeShape[data.get("shape", "RECTANGLE")], - fill_color=data.get("fill_color", data.get("color")), - border_color=data.get("border_color"), - font_size=data.get("font_size", _LABEL_FONT_SIZE), + style=NodeStyle( + fill_color=data.get("fill_color", data.get("color")), + border_color=data.get("border_color"), + font_size=data.get("font_size", _LABEL_FONT_SIZE), + ), ) diff --git a/test/test_utils/test_diagram_serialization.py b/test/test_utils/test_diagram_serialization.py index 7344b13f..110b7eeb 100644 --- a/test/test_utils/test_diagram_serialization.py +++ b/test/test_utils/test_diagram_serialization.py @@ -197,9 +197,9 @@ def test_invalid_colour_falls_back(self, qt_app): assert node._fill_color.isValid() def test_valid_colour_preserved(self, qt_app): - from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode, NodeStyle - node = DiagramNode(fill_color="#ff0000") + node = DiagramNode(style=NodeStyle(fill_color="#ff0000")) assert node._fill_color.name() == "#ff0000" From ef8bb9a607b517a8b365a85cd8e26f3225b31839 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 00:52:57 +0800 Subject: [PATCH 023/312] Split parse_mermaid under the cognitive complexity cap Refactor only: its cognitive complexity was 17, past the quality gate's 15. Line parsing and building the diagram dict are now their own helpers; old and new give the same result on 3,010 inputs. --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 8 +++++ docs/updates/README.md | 3 +- .../diagram_editor/diagram_mermaid_parser.py | 34 +++++++++++++------ 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 54e4652b..0fe52708 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -229,14 +229,14 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,322 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,334 行,最大子系統) | 檔案 | 職責 | |---|---| | `diagram_editor_widget.py` (584) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框 | | `diagram_scene.py` (648) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()` | | `diagram_items.py` (842) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | -| `diagram_mermaid_parser.py` (520) | Mermaid flowchart → diagram dict。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | +| `diagram_mermaid_parser.py` (532) | Mermaid flowchart → diagram dict。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (421) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | | `diagram_view.py` (175) | `QGraphicsView`:滾輪縮放(有上下界)、中鍵平移、`drawBackground` 畫格線 | | `diagram_commands.py` (28) | `DiagramSnapshotCommand(QUndoCommand)` — 快照式 undo,存變更前後完整場景狀態 | diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index f26131ef..98706ee9 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -175,3 +175,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: `ruff check pybreeze/` with the gate's limits (`PLR0913` max 7 arguments, `C901` max 15) now reports nothing. Unit tests 1074 passed, 14 skipped. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_serialization.py`, `architecture_explore.md` §7 (line counts re-measured). - **Open items**: none. + +## U-20260923-05 · 2026-09-23 · parse_mermaid back under the cognitive complexity cap · #done #refactor #diagram + +- **What**: a refactor, no change in behaviour. `parse_mermaid()` had a cognitive complexity of 17, the only function in `pybreeze/` past the gate's 15 (measured with `complexipy`, which computes the same metric as SonarQube). Line parsing moved to `_parse_lines()` and building the diagram dict to `_to_diagram_dict()`; the nested width helper became `_node_width()`, and its bare numbers became `_NODE_MIN_W`, `_NODE_MAX_W`, `_CHAR_W`, `_TEXT_PADDING`, with the height taken from the existing `_NODE_H` (the same 60). +- **Guarded by**: the Mermaid and diagram tests, including the Hypothesis ones; and a differential run of the old and the new `parse_mermaid()` over 3,010 inputs (10 hand-written flowcharts and 3,000 random strings from arrows, brackets, `;`, `%%` and direction words, 2,705 of which produce nodes): identical output, or the same exception type, for every one. +- **Result / numbers**: `complexipy pybreeze --max-complexity-allowed 15` reports nothing; the highest left is 14 (`AICodeReviewClient.send_request`, `_assign_layers`). Unit tests 1074 passed, 14 skipped. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py`, `architecture_explore.md` §7 (line counts re-measured). +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index fd1718db..e2133bf1 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-05 | 2026-09-23 | parse_mermaid back under the cognitive complexity cap | #done #refactor #diagram | [2026-09](2026-09.md) | | U-20260923-04 | 2026-09-23 | DiagramNode takes its style as one NodeStyle | #done #refactor #diagram | [2026-09](2026-09.md) | | U-20260923-03 | 2026-09-23 | Automation menus described by one AutomationMenu | #done #refactor #menus | [2026-09](2026-09.md) | | U-20260923-02 | 2026-09-23 | prthinker ignored the model name for seven backends | #incident #prthinker | [2026-09](2026-09.md) | @@ -85,4 +86,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 22 | +| [2026-09.md](2026-09.md) | 2026-09 | 23 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py index 62f2dedd..f670f537 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py @@ -350,6 +350,11 @@ def realign(group: list[str], fixed_layer: int, neighbours: dict[str, list[str]] _NODE_H = 60.0 _GAP_MAIN = 120.0 _GAP_CROSS = 80.0 +# Node width grows with its text: characters times _CHAR_W plus padding, clamped +_NODE_MIN_W = 100.0 +_NODE_MAX_W = 300.0 +_CHAR_W = 11 +_TEXT_PADDING = 40 def _position_node(node: _NodeInfo, layer_idx: int, cross_offset: float, @@ -468,39 +473,46 @@ def parse_mermaid(text: str) -> dict: """ nodes: dict[str, _NodeInfo] = {} edges: list[_EdgeInfo] = [] - direction = "TD" + direction = _parse_lines(text, nodes, edges) + _auto_layout(nodes, edges, direction) + return _to_diagram_dict(list(nodes.values()), edges) + +def _parse_lines(text: str, nodes: dict[str, _NodeInfo], edges: list[_EdgeInfo]) -> str: + """Parse every line of *text* into *nodes* and *edges*; return the flow direction.""" + direction = "TD" for raw_line in text.splitlines(): line = _COMMENT_RE.sub("", raw_line).strip() if not line: continue parsed_dir = _parse_direction(line) if parsed_dir is not None: - direction, remainder = parsed_dir - if not remainder: + direction, line = parsed_dir + if not line: continue - line = remainder if _SKIP_RE.match(line): continue for stmt in line.split(";"): _parse_statement(stmt, nodes, edges) + return direction - _auto_layout(nodes, edges, direction) - node_list = list(nodes.values()) - id_to_idx: dict[str, int] = {n.id: i for i, n in enumerate(node_list)} +def _node_width(node: _NodeInfo) -> float: + """Width that fits the node's text, within the minimum and maximum.""" + return max(_NODE_MIN_W, min(len(node.text) * _CHAR_W + _TEXT_PADDING, _NODE_MAX_W)) - def _node_w(n: _NodeInfo) -> float: - return max(100.0, min(len(n.text) * 11 + 40, 300.0)) +def _to_diagram_dict(node_list: list[_NodeInfo], edges: list[_EdgeInfo]) -> dict: + """The diagram dict for laid-out nodes; an edge to an unknown node is dropped.""" + id_to_idx: dict[str, int] = {n.id: i for i, n in enumerate(node_list)} return { "nodes": [ { "id": i, "x": n.x, "y": n.y, - "w": _node_w(n), - "h": 60.0, + "w": _node_width(n), + "h": _NODE_H, "text": n.text, "shape": n.shape.name, } From cc6c75c4585da084be74cf75f1b39adc3ee05019 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:01:31 +0800 Subject: [PATCH 024/312] Stop every run still going when the IDE closes A run's child is a separate process without a console, and nothing ended it when the IDE exited, so it kept running unseen. Each executor can now stop its child, and closing the main window stops every run window's run. --- architecture.md | 4 +- architecture_explore.md | 4 +- docs/updates/2026-09.md | 9 +++ docs/updates/README.md | 3 +- progress.md | 2 + .../process_executor/file_runner_process.py | 9 +++ .../python_task_process_manager.py | 9 +++ pybreeze/pybreeze_ui/editor_main/main_ui.py | 10 ++- .../show_code_window/code_window.py | 5 ++ test/test_utils/test_closing_the_ide.py | 37 +++++++++++ test/test_utils/test_run_output.py | 62 +++++++++++++++++++ 11 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 test/test_utils/test_closing_the_ide.py diff --git a/architecture.md b/architecture.md index e5616b00..4fc9d4f3 100644 --- a/architecture.md +++ b/architecture.md @@ -182,7 +182,9 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() alive: store it on the main window or parent it to its menu. Custom exceptions derive from `ITEException`. Log via `pybreeze_logger` (§ Conventions). - An executor is held by the run window it writes to (`CodeWindow.runner`): its QTimer connection does - not keep it alive, and a collected executor stops pumping output before the exit line. + not keep it alive, and a collected executor stops pumping output before the exit line. Closing the + IDE stops every run still going (`CodeWindow.stop_runner()`): a child is a separate, console-less + process that would otherwise outlive it unseen. - Every outbound request to a user URL passes SSRF validation (`utils/network/url_validation.py`) with timeouts and size caps (§ Security › Network). - SSH uses the interactive host-key policy, never auto-add (§ Security › SSH). diff --git a/architecture_explore.md b/architecture_explore.md index 0fe52708..b1081187 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -96,6 +96,7 @@ Template Method 定義的子行程生命週期: | 讀取 | 兩條 daemon Thread 各自經 `queue_pump.read_stream_into_queue()` 對 stdout / stderr `readline()`,原樣塞進 `Queue`(保留縮排、行尾與空行);**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | | 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則,交給 `CodeWindow.append_output()` | | 收尾 | `exit_program()`:join 執行緒(timeout 2s)→ drain queue(`max_messages=None` 一次抽乾)→ `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | +| 停止 | `stop()`:子行程還在跑就 `terminate()`(只有它本身,它再開的行程不管),之後照一般結束的路徑回報。經 `CodeWindow.stop_runner()` 呼叫;關閉 IDE 時 `PyBreezeMainWindow.closeEvent()` 對每個執行視窗都呼叫一次 | 三種啟動介面: @@ -143,6 +144,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - 編譯有 60 秒 timeout,QTimer 間隔 50 ms(比 Python 執行器更快) - 讀取、pump、drain 與寫進視窗都用 §4.5 的共用函式 - `run_current_file_with()` 建好後同樣掛在執行視窗的 `runner` 上 + - 有和 `TaskProcessManager` 相同語意的 `stop()` ### 4.5 `queue_pump.py` 與 `CodeWindow.append_output()` @@ -421,7 +423,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 72 個 `test_*.py`、1088 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 73 個 `test_*.py`、1093 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 98706ee9..4eeeac42 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -183,3 +183,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: `complexipy pybreeze --max-complexity-allowed 15` reports nothing; the highest left is 14 (`AICodeReviewClient.send_request`, `_assign_layers`). Unit tests 1074 passed, 14 skipped. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py`, `architecture_explore.md` §7 (line counts re-measured). - **Open items**: none. + +## U-20260923-06 · 2026-09-23 · Runs outlived the IDE · #incident #process-executor + +- **What**: a run's child kept running after the IDE closed. The children are separate processes started with `CREATE_NO_WINDOW`, nothing ended them, and `start_editor()` leaves through `os._exit()`, so a long run (a load test, a web run, anything waiting) went on with no window and no output until found in a task manager. `PyBreezeMainWindow.closeEvent()` only closed the run windows. Probe: `python -m http.server` started through `build_task_process()`, then the window closed and the process left with `os._exit(0)`, as `start_editor()` does; two seconds later the child's PID was still listed. +- **Fix**: `TaskProcessManager.stop()` and `FileRunnerProcess.stop()` terminate the child if it is still running (the child only; anything it started is its own business); `CodeWindow.stop_runner()` calls the window's executor; `closeEvent()` stops every run window's run before closing it. JEditor's own `closeEvent()` never cancels a close, so the IDE is really going away when this runs. `current_run_code_window` is now typed `list[CodeWindow]`. +- **Tests**: `test_closing_the_ide.py` starts the real IDE in a child, starts a 60-second run, closes the window, and waits up to 15 s for the run to end. It failed before the fix, once it pinned the interpreter: left unchosen, the run had picked the `python` on `PATH`, the Microsoft Store stub, which exits at once and made the test pass for the wrong reason. `test_run_output.py` gains four: stopping a Python run and a plugin run (each then reports its exit), stopping a finished run changes nothing, and a window that never ran anything can be stopped. +- **Result / numbers**: unit tests 1074 → 1079 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/{python_task_process_manager,file_runner_process}.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_run_output,test_closing_the_ide}.py`, `architecture.md` §7, `architecture_explore.md` §4.1, §4.4, §18, `progress.md` (#2, #3). +- **Open items**: `progress.md` #2 (whether closing one run window should stop its run) and #3 (a test that failed once under load, name not captured). diff --git a/docs/updates/README.md b/docs/updates/README.md index e2133bf1..d1cec1f9 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-06 | 2026-09-23 | Runs outlived the IDE | #incident #process-executor | [2026-09](2026-09.md) | | U-20260923-05 | 2026-09-23 | parse_mermaid back under the cognitive complexity cap | #done #refactor #diagram | [2026-09](2026-09.md) | | U-20260923-04 | 2026-09-23 | DiagramNode takes its style as one NodeStyle | #done #refactor #diagram | [2026-09](2026-09.md) | | U-20260923-03 | 2026-09-23 | Automation menus described by one AutomationMenu | #done #refactor #menus | [2026-09](2026-09.md) | @@ -86,4 +87,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 23 | +| [2026-09.md](2026-09.md) | 2026-09 | 24 | diff --git a/progress.md b/progress.md index 6899d5d8..b4c30817 100644 --- a/progress.md +++ b/progress.md @@ -6,3 +6,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open +- **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` already exists), ask first, or keep going on purpose (for example a long load test that mails its report)? `CodeWindow` has no `closeEvent` today. +- **#3** [UNVERIFIED] One unit test failed once, in a full run of `test/test_utils/` that took 282 s instead of the usual 35–60 s while the machine was busy; its name was not captured. Nine later full runs passed, three of them in parallel. Candidates are the tests that wait on a clock: `test_run_output.py` (30 s per child), `started_window.py` (120 s per IDE start). Record the name if it happens again. diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index de4820a3..f8f6e033 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -139,6 +139,15 @@ def _start_process(self, command: list[str], cleanup_binary: str | None = None) self.timer.timeout.connect(self._pull_text) self.timer.start() + def stop(self) -> None: + """Stop the child if it is still running. + + Only the child itself: processes it started are left to it. The run + window reports the exit on the next pump, as for any other exit. + """ + if self.process is not None and self.process.poll() is None: + self.process.terminate() + def _pull_text(self) -> None: """Timer callback: pump queues to UI.""" pump_message_queue(self.output_queue, self.main_window.append_output, is_error=False) diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 4973c29f..427c6ffd 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -157,6 +157,15 @@ def _spawn_and_pump( self.timer.timeout.connect(self.pull_text) self.timer.start() + def stop(self) -> None: + """Stop the child if it is still running. + + Only the child itself: processes it started are left to it. The run + window reports the exit on the next pump, as for any other exit. + """ + if self.process is not None and self.process.poll() is None: + self.process.terminate() + # Pyside UI update method def pull_text(self): pump_message_queue(self.run_output_queue, self.main_window.append_output, is_error=False) diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index d101b5d4..ddab77aa 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -16,6 +16,7 @@ from pybreeze.extend_multi_language.update_language_dict import update_language_dict from pybreeze.pybreeze_ui.editor_main.file_tree_context_menu import setup_file_tree_context_menu from pybreeze.pybreeze_ui.menu.build_menubar import add_menu_to_menubar +from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow from pybreeze.pybreeze_ui.syntax.syntax_extend import \ syntax_extend_package from pybreeze.utils.logging.logger import pybreeze_logger @@ -38,7 +39,7 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, # which auto-discovers jeditor_plugins/ in the current working directory. # Third-party plugins placed there will be loaded automatically. - self.current_run_code_window: list[QWidget] = [] + self.current_run_code_window: list[CodeWindow] = [] # Project compiler if user not choose this will use which to find self.python_compiler = None # Delete JEditor help @@ -82,8 +83,11 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, close_timer.start() def closeEvent(self, event) -> None: - for widget in self.current_run_code_window: - widget.close() + # A run's child outlives the IDE unless stopped here: it is a separate + # process, and without a console nobody would see it still running. + for run_window in self.current_run_code_window: + run_window.stop_runner() + run_window.close() super().closeEvent(event) @staticmethod diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index 8a1db9d1..4b1f6e55 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -59,6 +59,11 @@ def __init__(self): self.setLayout(self.grid_layout) self.setFocus() + def stop_runner(self) -> None: + """Stop the child this window shows, if one is still running.""" + if self.runner is not None: + self.runner.stop() + def append_output(self, text: str, is_error: bool = False, *, own_line: bool = False) -> None: """Append *text* to the end of the output, in the normal or the error colour. diff --git a/test/test_utils/test_closing_the_ide.py b/test/test_utils/test_closing_the_ide.py new file mode 100644 index 00000000..ec59bb26 --- /dev/null +++ b/test/test_utils/test_closing_the_ide.py @@ -0,0 +1,37 @@ +"""Closing the IDE stops the runs it started. + +A run's child is a separate process: nothing ends it when the IDE exits, and it +runs without a console, so one left behind keeps going unseen until it is found +in a task manager. +""" +from __future__ import annotations + +from test_utils.started_window import run_started_window + +_CLOSE_WITH_A_RUN_GOING = """ +import subprocess +from pybreeze.extend.process_executor.process_executor_utils import build_task_process +# This interpreter: left unchosen, a run looks for one on PATH, which may be a +# stub that exits at once instead of a Python that sleeps. +window.python_compiler = sys.executable +build_task_process(window).start_module_process( + "timeit", ["-n", "1", "-r", "1", "import time; time.sleep(60)"]) +child = window.current_run_code_window[0].runner.process +running_before = child.poll() is None +window.close() +try: + child.wait(timeout=15) +except subprocess.TimeoutExpired: + child.kill() + stopped = False +else: + stopped = True +result = {"running_before": running_before, "stopped": stopped} +""" + + +def test_closing_the_ide_stops_a_run_still_going(tmp_path): + seen = run_started_window(tmp_path, _CLOSE_WITH_A_RUN_GOING) + + assert seen["running_before"] + assert seen["stopped"] diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index 15ddbe8b..a91efc81 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -133,6 +133,68 @@ def test_run_with_finishes_with_no_one_else_holding_its_runner( assert "hello from the plugin run" in run_window.code_result.toPlainText() +_SLEEP_SECONDS = "60" + + +class TestStoppingARun: + """Stopping the child a run window shows: what closing the IDE does to every run.""" + + def test_a_python_run_is_stopped_and_reports_its_exit(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + main_window = MainWindow(sys.executable) + manager = build_task_process(main_window) + manager.start_module_process( + "timeit", ["-n", "1", "-r", "1", f"import time; time.sleep({_SLEEP_SECONDS})"]) + child = manager.process + run_window = main_window.current_run_code_window[0] + + run_window.stop_runner() + _run_events_until( + qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) + + assert child.poll() is not None + + def test_a_plugin_run_is_stopped_and_reports_its_exit(self, qt_app, tmp_path, monkeypatch): + from pybreeze.pybreeze_ui.menu.plugin_menu import build_run_with_menu as run_with + + script = tmp_path / "wait.py" + script.write_text(f"import time\ntime.sleep({_SLEEP_SECONDS})\n", encoding="utf-8") + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) + main_window = MainWindow() + + run_with.run_current_file_with( + main_window, {"name": "Python", "compiler": sys.executable, "suffixes": (".py",)}) + run_window = main_window.current_run_code_window[0] + child = run_window.runner.process + run_window.stop_runner() + _run_events_until( + qt_app, lambda: "[Process exited with code" in run_window.code_result.toPlainText()) + + assert child.poll() is not None + + def test_stopping_a_finished_run_changes_nothing(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + data = tmp_path / "data.json" + data.write_text("{}", encoding="utf-8") + main_window = MainWindow(sys.executable) + manager = build_task_process(main_window) + manager.start_module_process("json.tool", [str(data)]) + _run_events_until(qt_app, lambda: manager.process is None) + run_window = main_window.current_run_code_window[0] + before = run_window.code_result.toPlainText() + + run_window.stop_runner() + + assert run_window.code_result.toPlainText() == before + + def test_a_window_that_never_ran_anything_can_be_stopped(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + CodeWindow().stop_runner() + + class TestTestPioneerRun: def test_runs_the_chosen_yaml_through_the_task_manager(self, monkeypatch): from pybreeze.extend.process_executor.test_pioneer import test_pioneer_process_manager From 32b12bead041087ae8313c4df54da9d0bd902b11 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:04:51 +0800 Subject: [PATCH 025/312] Give a plugin run an empty stdin instead of a pipe nobody writes A program that read input waited for good on a pipe the IDE never wrote to or closed, with nothing in the run window to say why. It now reads end-of-file at once. --- architecture_explore.md | 3 ++- docs/updates/2026-09.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../process_executor/file_runner_process.py | 4 +++- test/test_utils/test_run_output.py | 18 ++++++++++++++++++ 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index b1081187..30ebecdc 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -145,6 +145,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - 讀取、pump、drain 與寫進視窗都用 §4.5 的共用函式 - `run_current_file_with()` 建好後同樣掛在執行視窗的 `runner` 上 - 有和 `TaskProcessManager` 相同語意的 `stop()` + - 子行程的 `stdin` 是 `DEVNULL`:執行視窗沒有輸入欄,讀取要立刻拿到 EOF,不能卡在沒人寫的管線上 ### 4.5 `queue_pump.py` 與 `CodeWindow.append_output()` @@ -423,7 +424,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 73 個 `test_*.py`、1093 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 73 個 `test_*.py`、1094 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 4eeeac42..7100d5c9 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -192,3 +192,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1074 → 1079 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/{python_task_process_manager,file_runner_process}.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_run_output,test_closing_the_ide}.py`, `architecture.md` §7, `architecture_explore.md` §4.1, §4.4, §18, `progress.md` (#2, #3). - **Open items**: `progress.md` #2 (whether closing one run window should stop its run) and #3 (a test that failed once under load, name not captured). + +## U-20260923-07 · 2026-09-23 · A plugin run that read input hung for good · #incident #process-executor + +- **What**: running a file through a plugin run config (`Run with…` / the Plugins menu) gave the child `stdin=subprocess.PIPE`, and nothing ever wrote to that pipe or closed it. A program that read input waited on it for good: `input('name? ')` in a Python file was still running after 8 s with nothing in the run window but the command line, because the prompt has no newline and a non-terminal stdout is block-buffered. The only way out was to stop it — and before U-20260923-06, closing the IDE did not even do that. +- **Fix**: `stdin=subprocess.DEVNULL`. A read now ends at once: the program gets `EOFError` (or its language's end-of-input) and the run window shows it and the exit code. The Python executor is unchanged — it passes no `stdin`, so its children inherit the IDE's, which is not a pipe nobody writes to. +- **Tests**: `test_run_output.py` runs a one-line script that calls `input()` and expects `EOFError` and `[Process exited with code 1]`. Before the fix it timed out after the harness's 30 s. +- **Result / numbers**: unit tests 1079 → 1080 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/file_runner_process.py`, `test/test_utils/test_run_output.py`, `architecture_explore.md` §4.4, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index d1cec1f9..3a5b927b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-07 | 2026-09-23 | A plugin run that read input hung for good | #incident #process-executor | [2026-09](2026-09.md) | | U-20260923-06 | 2026-09-23 | Runs outlived the IDE | #incident #process-executor | [2026-09](2026-09.md) | | U-20260923-05 | 2026-09-23 | parse_mermaid back under the cognitive complexity cap | #done #refactor #diagram | [2026-09](2026-09.md) | | U-20260923-04 | 2026-09-23 | DiagramNode takes its style as one NodeStyle | #done #refactor #diagram | [2026-09](2026-09.md) | @@ -87,4 +88,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 24 | +| [2026-09.md](2026-09.md) | 2026-09 | 25 | diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index f8f6e033..2cd2f982 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -116,7 +116,9 @@ def _start_process(self, command: list[str], cleanup_binary: str | None = None) command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - stdin=subprocess.PIPE, + # A run window has no input box: a read gets end-of-file at once + # instead of waiting on a pipe nobody writes to. + stdin=subprocess.DEVNULL, shell=False, creationflags=no_window_creationflags(), env=utf8_subprocess_env(self.program_encoding), diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index a91efc81..bcd25815 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -115,6 +115,24 @@ def test_indentation_and_blank_lines_survive(self, qt_app, tmp_path): assert " warned\n" in text assert text.endswith("[Process exited with code 0]\n") + def test_a_program_that_reads_input_gets_end_of_file(self, qt_app, tmp_path): + # A run window has no input box: a read must end at once rather than + # wait on a pipe nobody writes to, which hung the run for good. + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + script = tmp_path / "ask.py" + script.write_text("input('name? ')\n", encoding="utf-8") + window = CodeWindow() + window.runner = FileRunnerProcess(window) + + window.runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + _run_events_until(qt_app, lambda: window.runner.process is None) + + text = window.code_result.toPlainText() + assert "EOFError" in text + assert text.endswith("[Process exited with code 1]\n") + def test_run_with_finishes_with_no_one_else_holding_its_runner( self, qt_app, tmp_path, monkeypatch): from pybreeze.pybreeze_ui.menu.plugin_menu import build_run_with_menu as run_with From 2ad1eb07718e3bd4f490d82651db5ae8a573544a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:09:18 +0800 Subject: [PATCH 026/312] Say why an install did not start instead of doing nothing Every Install menu entry was a no-op whenever the tab in front was not an editor tab, with nothing said and nothing logged. It now reports what is needed, and install_package says whether it started. --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 9 ++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 2 + .../extend_traditional_chinese.py | 2 + .../menu/install_menu/install_utils.py | 45 +++++-- test/test_utils/test_install_menu.py | 118 ++++++++++++++++++ 7 files changed, 169 insertions(+), 14 deletions(-) create mode 100644 test/test_utils/test_install_menu.py diff --git a/architecture_explore.md b/architecture_explore.md index 30ebecdc..f75a7d52 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -198,7 +198,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.5 安裝選單 -`install_utils.install_package()` 借用 JEditor 的 `ShellManager` 跑 `pip install -U`,輸出進 shell 面板。 +`install_utils.install_package()` 借用 JEditor 的 `ShellManager` 跑 `pip install -U`,輸出進目前編輯器分頁的 shell 面板,pip 用 IDE 選定的直譯器(沒選才用 shell 自己找到的)。目前分頁不是編輯器分頁時不會安裝,並跳訊息說明(回傳 `False`),不是靜靜地什麼都不做。 - `automation_menu/` — 七個自動化套件的一鍵安裝。**prthinker 例外**:不在 PyPI 上,第一次會問來源資料夾、記進設定,之後裝 `[runner]` - `tools_menu/` — 安裝 setuptools / build / wheel @@ -424,7 +424,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 73 個 `test_*.py`、1094 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 74 個 `test_*.py`、1098 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 7100d5c9..3627ef39 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -201,3 +201,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1079 → 1080 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/file_runner_process.py`, `test/test_utils/test_run_output.py`, `architecture_explore.md` §4.4, §18. - **Open items**: none. + +## U-20260923-08 · 2026-09-23 · Install did nothing when the tab in front was not an editor · #incident #install + +- **What**: every entry of `Install ▸ Automation` (and `Install ▸ Tools`) did nothing at all when the tab in front was not an editor tab — a tool tab, the diagram editor, JupyterLab, a run window. `install_package()` ran its whole body inside `if isinstance(widget, EditorWidget):` with no `else`: no install, no message, nothing in the log. The install needs an editor tab because JEditor's `ShellManager` writes its output into that tab's shell panel. +- **Fix**: `install_package()` returns whether it started; with no editor tab in front it logs and shows a message box naming what to do (`install_need_editor_tab_message`, in both languages). The function also has a docstring saying which interpreter pip runs with, which was the other undocumented part. +- **Tests**: `test/test_utils/test_install_menu.py` (new) covers the command pip is given with and without an interpreter chosen in the IDE, a source-folder target with extras passing through unchanged, and the non-editor tab now being reported rather than ignored. The first three pass against the old code too — they record what it already did. +- **Result / numbers**: unit tests 1080 → 1084 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/install_menu/install_utils.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/test_install_menu.py` (new), `architecture_explore.md` §5.5, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 3a5b927b..3b6ab601 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-08 | 2026-09-23 | Install did nothing without an editor tab in front | #incident #install | [2026-09](2026-09.md) | | U-20260923-07 | 2026-09-23 | A plugin run that read input hung for good | #incident #process-executor | [2026-09](2026-09.md) | | U-20260923-06 | 2026-09-23 | Runs outlived the IDE | #incident #process-executor | [2026-09](2026-09.md) | | U-20260923-05 | 2026-09-23 | parse_mermaid back under the cognitive complexity cap | #done #refactor #diagram | [2026-09](2026-09.md) | @@ -88,4 +89,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 25 | +| [2026-09.md](2026-09.md) | 2026-09 | 26 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 5e8c3393..4b1994a3 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -92,6 +92,8 @@ "install_menu_automation_file": "Install Automation File", "install_menu_mail_thunder": "Install MailThunder", "install_menu_prthinker": "Install prthinker (code review)", + "install_need_editor_tab_message": + "Open a file tab first: the install runs in that tab's shell panel.", "install_menu_tools_install_menu_label": "Tools", "install_menu_tools_install_build_tools": "Install Build Tools", # Tools Menu diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 73af13e5..79fbb33f 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -92,6 +92,8 @@ "install_menu_automation_file": "安裝 Automation File", "install_menu_mail_thunder": "安裝 MailThunder", "install_menu_prthinker": "安裝 prthinker(程式碼審查)", + "install_need_editor_tab_message": + "請先開啟一個檔案分頁:安裝會在那個分頁的 shell 面板裡執行。", "install_menu_tools_install_menu_label": "工具", "install_menu_tools_install_build_tools": "安裝 Build Tools", # Tools Menu diff --git a/pybreeze/pybreeze_ui/menu/install_menu/install_utils.py b/pybreeze/pybreeze_ui/menu/install_menu/install_utils.py index ac797db3..5633b63b 100644 --- a/pybreeze/pybreeze_ui/menu/install_menu/install_utils.py +++ b/pybreeze/pybreeze_ui/menu/install_menu/install_utils.py @@ -2,20 +2,43 @@ from typing import TYPE_CHECKING -from je_editor import EditorWidget, ShellManager +from PySide6.QtWidgets import QMessageBox +from je_editor import EditorWidget, ShellManager, language_wrapper + +from pybreeze.utils.logging.logger import pybreeze_logger if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -def install_package(package_text: str, ui_we_want_to_set: PyBreezeMainWindow) -> None: +def install_package(package_text: str, ui_we_want_to_set: PyBreezeMainWindow) -> bool: + """Install *package_text* with pip, in the current editor tab's shell panel. + + pip runs with the interpreter chosen in the IDE, falling back to the one the + shell finds. An editor tab has to be in front: its panel is where the output + goes, so without one the install is not started and the user is told why + rather than left with a menu entry that does nothing. + + :param package_text: what pip is asked to install (a name, or a path with extras) + :param ui_we_want_to_set: the main window + :return: whether the install started + """ widget = ui_we_want_to_set.tab_widget.currentWidget() - if isinstance(widget, EditorWidget): - widget.python_compiler = ui_we_want_to_set.python_compiler - shell_manager = ShellManager(main_window=widget) - shell_manager.later_init() - if widget.python_compiler is not None: - compiler_path = widget.python_compiler - else: - compiler_path = shell_manager.compiler_path - shell_manager.exec_shell([f"{compiler_path}", "-m", "pip", "install", f"{package_text}", "-U"]) + if not isinstance(widget, EditorWidget): + pybreeze_logger.error("install needs an editor tab in front, not %r", type(widget).__name__) + messagebox = QMessageBox(ui_we_want_to_set) + messagebox.setWindowTitle( + language_wrapper.language_word_dict.get("install_menu_label")) + messagebox.setText( + language_wrapper.language_word_dict.get("install_need_editor_tab_message")) + messagebox.exec() + return False + widget.python_compiler = ui_we_want_to_set.python_compiler + shell_manager = ShellManager(main_window=widget) + shell_manager.later_init() + if widget.python_compiler is not None: + compiler_path = widget.python_compiler + else: + compiler_path = shell_manager.compiler_path + shell_manager.exec_shell([f"{compiler_path}", "-m", "pip", "install", f"{package_text}", "-U"]) + return True diff --git a/test/test_utils/test_install_menu.py b/test/test_utils/test_install_menu.py new file mode 100644 index 00000000..309fcf88 --- /dev/null +++ b/test/test_utils/test_install_menu.py @@ -0,0 +1,118 @@ +"""Installing an automation package: which interpreter pip runs with, and where it runs.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget, QWidget + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.install_menu import install_utils +from pybreeze.pybreeze_ui.menu.install_menu.install_utils import install_package + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class EditorTab(QWidget): + """Stands in for JEditor's editor tab, which is where the install's output goes.""" + + def __init__(self) -> None: + super().__init__() + self.python_compiler = None + + +class Window(QMainWindow): + def __init__(self, tab: QWidget, python_compiler=None) -> None: + super().__init__() + self.tab_widget = QTabWidget() + self.tab_widget.addTab(tab, "tab") + self.python_compiler = python_compiler + + +class Shell: + """Records the command instead of starting a shell.""" + + last: "Shell | None" = None + + def __init__(self, main_window) -> None: + self.main_window = main_window + self.compiler_path = "shell/python" + self.commands: list = [] + Shell.last = self + + def later_init(self) -> None: + self.initialised = True + + def exec_shell(self, command) -> None: + self.commands.append(command) + + +@pytest.fixture() +def shell(monkeypatch): + Shell.last = None + monkeypatch.setattr(install_utils, "EditorWidget", EditorTab) + monkeypatch.setattr(install_utils, "ShellManager", Shell) + return Shell + + +@pytest.fixture() +def told(monkeypatch) -> list: + """Collect what the user was told instead of opening a message box.""" + said: list = [] + + class Message: + def __init__(self, _parent=None) -> None: + pass + + def setWindowTitle(self, title) -> None: + self.title = title + + def setText(self, text) -> None: + said.append(text) + + def exec(self) -> None: + pass + + monkeypatch.setattr(install_utils, "QMessageBox", Message, raising=False) + return said + + +class TestInstallingAPackage: + def test_it_pips_with_the_interpreter_chosen_in_the_ide(self, app, shell, told): + window = Window(EditorTab(), python_compiler="C:/envs/py313/python.exe") + + assert install_package("je_web_runner", window) is True + + assert shell.last.commands == [ + ["C:/envs/py313/python.exe", "-m", "pip", "install", "je_web_runner", "-U"]] + assert told == [] + + def test_without_a_choice_the_shells_own_interpreter_is_used(self, app, shell, told): + window = Window(EditorTab()) + + assert install_package("je_web_runner", window) is True + + assert shell.last.commands == [ + ["shell/python", "-m", "pip", "install", "je_web_runner", "-U"]] + + def test_a_source_folder_target_is_passed_through(self, app, shell, told): + window = Window(EditorTab(), python_compiler="python") + + install_package(r"D:\src\prthinker[runner]", window) + + assert shell.last.commands[0][4] == r"D:\src\prthinker[runner]" + + def test_a_tab_that_is_not_an_editor_is_reported_not_ignored(self, app, shell, told): + window = Window(QWidget()) + + assert install_package("je_web_runner", window) is False + + assert shell.last is None + assert told, "the user was told nothing" From 847b8f27b4a91d6b4e8766853863d58cf42fa0b8 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:13:09 +0800 Subject: [PATCH 027/312] Say where the script comes from instead of running nothing With a non-editor tab in front, an automation run passed the package the literal null and the run window showed the package's parse error. It now says the run takes the script from the editor tab in front. --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 9 ++++ docs/updates/README.md | 3 +- .../process_executor_utils.py | 27 ++++++++++-- test/test_utils/test_build_task_process.py | 41 +++++++++++++++++++ 5 files changed, 77 insertions(+), 7 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index f75a7d52..f2aa3351 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -108,7 +108,7 @@ Template Method 定義的子行程生命週期: | 函式 | 用途 | |---|---| -| `build_process()` | 取當前分頁的程式碼(或傳入的 `exec_str`)→ `start_process()` | +| `build_process()` | 取當前分頁的程式碼(或傳入的 `exec_str`)→ `start_process()`。沒給 `exec_str` 又不是編輯器分頁時,開一個執行視窗寫明「腳本要在前面的編輯器分頁裡」(`report_no_script_tab()`),不會把 `None` 交給套件 | | `start_process()` | 建 `CodeWindow` + `TaskProcessManager` → `start_test_process()` | | `build_process_from_file()` | 以檔案路徑執行單一檔案 | | `run_dir_files_with_package()` | 問使用者選資料夾,對每個 `.json` 開一個執行視窗批次跑 | @@ -424,7 +424,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 74 個 `test_*.py`、1098 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 74 個 `test_*.py`、1100 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 3627ef39..607dfa56 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -210,3 +210,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1080 → 1084 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/menu/install_menu/install_utils.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/test_install_menu.py` (new), `architecture_explore.md` §5.5, §18. - **Open items**: none. + +## U-20260923-09 · 2026-09-23 · A run with no script tab handed the package nothing · #incident #process-executor + +- **What**: starting an automation run (`Automation ▸ ▸ Run ▸ …`) with something other than an editor tab in front sent the package no script at all. `build_process()` fell through to `test_format_code = exec_str`, which is `None` there, and `start_test_process()` turned it into the literal `null` on the command line. The run window then showed the package's own confusion: `python -m je_api_testka --execute_str null` answers `TypeError('the JSON object must be str, bytes or bytearray, not NoneType')`. +- **Fix**: `build_process()` looks for the tab only when it needs a script, and with no editor tab in front it opens a run window saying the run takes the script from the editor tab in front (`report_no_script_tab()`), the same way a missing interpreter is reported there rather than thrown at the menu. +- **Tests**: `test_build_task_process.py` gains a non-editor tab that gets the message and starts nothing, and a run given its script outright that still runs with no tab at all. +- **Result / numbers**: unit tests 1084 → 1086 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `test/test_utils/test_build_task_process.py`, `architecture_explore.md` §4.2, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 3b6ab601..50291836 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-09 | 2026-09-23 | A run with no script tab handed the package nothing | #incident #process-executor | [2026-09](2026-09.md) | | U-20260923-08 | 2026-09-23 | Install did nothing without an editor tab in front | #incident #install | [2026-09](2026-09.md) | | U-20260923-07 | 2026-09-23 | A plugin run that read input hung for good | #incident #process-executor | [2026-09](2026-09.md) | | U-20260923-06 | 2026-09-23 | Runs outlived the IDE | #incident #process-executor | [2026-09](2026-09.md) | @@ -89,4 +90,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 26 | +| [2026-09.md](2026-09.md) | 2026-09 | 27 | diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index fa0301b3..27ca20c9 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -24,12 +24,15 @@ def build_process( send_mail: bool = False, program_buffer: int = 1024000, ): + """Run *package* against a script: *exec_str*, or the code in the tab in front.""" try: - widget = main_window.tab_widget.currentWidget() - if isinstance(widget, EditorWidget) and exec_str is None: + test_format_code = exec_str + if test_format_code is None: + widget = main_window.tab_widget.currentWidget() + if not isinstance(widget, EditorWidget): + report_no_script_tab(main_window, package, program_buffer) + return test_format_code = widget.code_edit.toPlainText() - else: - test_format_code = exec_str start_process(main_window, package, test_format_code, send_mail, program_buffer) except json.decoder.JSONDecodeError as error: pybreeze_logger.error(f"{error!r}\n{wrong_test_data_format_exception_tag}") @@ -37,6 +40,22 @@ def build_process( pybreeze_logger.error(repr(error)) +def report_no_script_tab( + main_window: PyBreezeMainWindow, package: str, program_buffer: int = 1024000) -> None: + """Open a run window saying the run needs the script's tab in front. + + A run takes the code from the editor tab in front. With another kind of tab + there -- a tool tab, the diagram editor, a run window -- the package used to + be handed nothing and answered with a parse error of its own. + """ + pybreeze_logger.error("%s run needs an editor tab in front", package) + process = build_task_process(main_window, program_buffer=program_buffer) + process.main_window.append_output( + f"[Error] {package} runs the script in the editor tab in front; open it and try again\n", + is_error=True, own_line=True) + process.main_window.show() + + def start_process( main_window: PyBreezeMainWindow, package: str, diff --git a/test/test_utils/test_build_task_process.py b/test/test_utils/test_build_task_process.py index 24047079..3c194ac9 100644 --- a/test/test_utils/test_build_task_process.py +++ b/test/test_utils/test_build_task_process.py @@ -74,3 +74,44 @@ def test_the_report_mail_is_sent_only_when_asked(self, qt_app): assert build_task_process(MainWindow()).task_done_trigger_function is None assert build_task_process( MainWindow(), send_mail=True).task_done_trigger_function is send_after_test + + +class TestRunningWithoutAScriptTab: + """A run takes the code from the editor tab in front; with another tab there it says so.""" + + def test_a_non_editor_tab_is_reported_in_a_run_window(self, qt_app, monkeypatch): + from PySide6.QtWidgets import QTabWidget, QWidget + + from pybreeze.extend.process_executor import process_executor_utils + + started: list = [] + monkeypatch.setattr( + process_executor_utils, "start_process", + lambda *args, **kwargs: started.append(args)) + main_window = MainWindow() + main_window.tab_widget = QTabWidget() + main_window.tab_widget.addTab(QWidget(), "tools") + + process_executor_utils.build_process(main_window, "je_api_testka") + + assert started == [] + run_window = main_window.current_run_code_window[0] + assert "je_api_testka" in run_window.code_result.toPlainText() + assert "editor tab in front" in run_window.code_result.toPlainText() + + def test_a_script_given_outright_runs_without_any_tab(self, qt_app, monkeypatch): + from PySide6.QtWidgets import QTabWidget + + from pybreeze.extend.process_executor import process_executor_utils + + started: list = [] + monkeypatch.setattr( + process_executor_utils, "start_process", + lambda *args, **kwargs: started.append(args)) + main_window = MainWindow() + main_window.tab_widget = QTabWidget() + + process_executor_utils.build_process(main_window, "je_api_testka", exec_str="{}") + + assert started and started[0][2] == "{}" + assert main_window.current_run_code_window == [] From 92677cba4c3372a85ec716c89fb8f25b5057e758 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:20:06 +0800 Subject: [PATCH 028/312] Refuse a file that is not a diagram before clearing the canvas Opening a file that was not a diagram, or one bad connection in it, emptied the canvas and stopped half-way; the editor saves back to the file it opened last, so the next save wrote that over the user's own diagram. Loads are now checked before anything is cleared and every entry is guarded, sizes and pens read from a file are clamped, and a save replaces the target in one step. --- architecture_explore.md | 10 +- docs/updates/2026-09.md | 11 ++ docs/updates/README.md | 3 +- progress.md | 4 + .../diagram_editor/diagram_editor_widget.py | 14 +- .../diagram_editor/diagram_items.py | 47 ++++-- .../diagram_editor/diagram_scene.py | 67 ++++++-- test/test_utils/test_diagram_editor_widget.py | 92 +++++++++++ test/test_utils/test_diagram_serialization.py | 153 ++++++++++++++++++ 9 files changed, 369 insertions(+), 32 deletions(-) create mode 100644 test/test_utils/test_diagram_editor_widget.py diff --git a/architecture_explore.md b/architecture_explore.md index f2aa3351..309eeea5 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -232,13 +232,13 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,334 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,410 行,最大子系統) | 檔案 | 職責 | |---|---| -| `diagram_editor_widget.py` (584) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框 | -| `diagram_scene.py` (648) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()` | -| `diagram_items.py` (842) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | +| `diagram_editor_widget.py` (594) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | +| `diagram_scene.py` (685) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯 | +| `diagram_items.py` (871) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | | `diagram_mermaid_parser.py` (532) | Mermaid flowchart → diagram dict。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (421) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | | `diagram_view.py` (175) | `QGraphicsView`:滾輪縮放(有上下界)、中鍵平移、`drawBackground` 畫格線 | @@ -424,7 +424,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 74 個 `test_*.py`、1100 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 75 個 `test_*.py`、1120 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 607dfa56..06762c77 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -219,3 +219,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1084 → 1086 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `test/test_utils/test_build_task_process.py`, `architecture_explore.md` §4.2, §18. - **Open items**: none. + +## U-20260923-10 · 2026-09-23 · A diagram that failed to load took the open one with it · #incident #diagram + +- **What**: opening a `.diagram.json` that was not a diagram, or that held one bad connection, emptied the canvas and then stopped half-way — and the editor keeps saving to the file it opened last, so the next `Ctrl+S` wrote the wreckage over the user's own diagram. `load_from_dict()` called `_clear_items()` as its first statement, before anything in the data was looked at, and `_load_connections()` had no per-entry guard while nodes and images did. Measured on a scene holding two connected nodes: a top-level JSON list left 0 items (`AttributeError: 'list' object has no attribute 'get'`), `{"source": []}` left the nodes but no connection (`TypeError: unhashable type: 'list'`), `{"style": []}` and a string in place of an object the same, and a node with an unknown `shape` was dropped silently (`KeyError`), leaving the canvas empty with no message at all. +- **Fix**: `load_from_dict()` checks the shape of the data first (`_check_is_a_diagram()`: an object, and each of `nodes` / `connections` / `images` a list) and raises `ValueError` before touching the canvas. Each connection is built in its own `try` like nodes and images, an id that cannot be a dictionary key costs only that node's connections, and an unknown `shape` falls back to `RECTANGLE` the way an unknown connection `style` already fell back to `SOLID`. +- **Also from the same file**: sizes and pens are no longer taken on trust. `MAX_ITEM_SIZE = 10000` caps a node's or an image's width and height (`{"w": 40000, "h": 40000}` with a source asked `QPixmap.scaled` for a ~6 GB allocation on the UI thread; `1e30` overflowed the int PySide6 marshals), `_clamped_line_width()` puts a connection's pen back inside 0.5–10 wherever it comes from (`1e9` made `itemsBoundingRect()` astronomical, which left zoom-to-fit stuck and the canvas blank), and `_safe_color()` now falls back for anything that is not a string (`QColor(5)` is a valid near-black colour, `QColor([])` raises). +- **And saving**: `_write_json()` writes `.saving` beside the target and `os.replace()`s it into place, so a failure part-way through costs the new save and not the last good file; the half-written file is removed. +- **Tests**: `test_diagram_serialization.py` gains a class for loads that cannot work (four payloads that are not diagrams, each leaving the canvas as it was; three bad connections that cost only themselves; two odd pens and a non-string colour that still load; an unknown shape; an unusable id) and one for clamped sizes and pens. `test_diagram_editor_widget.py` (new) builds the real editor widget headless: a save whose replace fails leaves the previous file and no leftovers, a save writes the diagram, and opening a file that is not a diagram tells the user, leaves the canvas, and leaves the open file safe to save to. Confirmed the last one fails with the check removed. +- **Result / numbers**: unit tests 1086 → 1106 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_items,diagram_editor_widget}.py`, `test/test_utils/{test_diagram_serialization,test_diagram_editor_widget}.py`, `architecture_explore.md` §7, §18. +- **Open items**: from the same audit, still open: image sources from a saved diagram are handed to `Path.is_file()` before the extension is checked (a UNC path makes Windows authenticate to whatever host it names), image downloads run on the UI thread and are repeated on every undo, `undo_scope` has no `try/finally`, and node z-order is not serialised. Recorded in `progress.md` #4–#7. diff --git a/docs/updates/README.md b/docs/updates/README.md index 50291836..68f57438 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-10 | 2026-09-23 | A diagram that failed to load took the open one with it | #incident #diagram | [2026-09](2026-09.md) | | U-20260923-09 | 2026-09-23 | A run with no script tab handed the package nothing | #incident #process-executor | [2026-09](2026-09.md) | | U-20260923-08 | 2026-09-23 | Install did nothing without an editor tab in front | #incident #install | [2026-09](2026-09.md) | | U-20260923-07 | 2026-09-23 | A plugin run that read input hung for good | #incident #process-executor | [2026-09](2026-09.md) | @@ -90,4 +91,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 27 | +| [2026-09.md](2026-09.md) | 2026-09 | 28 | diff --git a/progress.md b/progress.md index b4c30817..bd4c3990 100644 --- a/progress.md +++ b/progress.md @@ -8,3 +8,7 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` already exists), ask first, or keep going on purpose (for example a long load test that mails its report)? `CodeWindow` has no `closeEvent` today. - **#3** [UNVERIFIED] One unit test failed once, in a full run of `test/test_utils/` that took 282 s instead of the usual 35–60 s while the machine was busy; its name was not captured. Nine later full runs passed, three of them in parallel. Candidates are the tests that wait on a clock: `test_run_output.py` (30 s per child), `started_window.py` (120 s per IDE start). Record the name if it happens again. +- **#4** An image source from a saved diagram reaches `Path.is_file()` before its extension is checked (`pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py:_try_load_image_source`). On Windows a UNC source (`\\host\share\x.png`) makes the SMB client authenticate to that host; an unreachable one also blocks the UI thread for the SMB timeout. Check the suffix first, refuse UNC and resolve the rest. +- **#5** A diagram's image downloads run on the UI thread (`_try_load_image_source` -> `safe_download_image`, 15 s each) and the pixmap is not part of the undo snapshot, so every undo and redo re-fetches every URL image: three unreachable ones freeze the IDE for ~45 s per Ctrl+Z, and an image whose host is offline silently blanks on an unrelated undo. Cache the pixmap on the item and fetch off the UI thread. +- **#6** `DiagramScene.undo_scope` has no `try/finally` (`diagram_scene.py`), so an exception inside a scope (the Mermaid import catches one a frame up) leaves `_pending_undo_snapshot` set; the next `mouseReleaseEvent` calls `end_undo()` with no matching `begin_undo` and pushes a command that undoes far more than the gesture. +- **#7** Node z-order is neither serialised (`DiagramNode.to_dict`) nor undoable (`DiagramScene._change_z` runs outside `undo_scope`), so "Bring to Front" is lost on save and reset by any unrelated undo. diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 6c78a943..63bb4c5e 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os from pathlib import Path from PySide6.QtCore import QMarginsF, QRectF, QSizeF, Qt @@ -446,11 +447,20 @@ def _import_mermaid(self) -> None: ) def _write_json(self, path: Path) -> None: + """Write the diagram to *path*, leaving whatever is there now if it fails. + + The text goes to a file beside it first and replaces the target in one + step, so a failure part-way through (a full disk, a file being read by + something else) costs the new save, never the last good one. + """ + beside = path.with_name(path.name + ".saving") try: data = self._scene.to_dict() - path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - except Exception as e: + beside.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + os.replace(beside, path) + except (OSError, TypeError, ValueError) as e: pybreeze_logger.error(f"Save diagram failed: {e}") + beside.unlink(missing_ok=True) QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), str(e)) # ------------------------------------------------------------------ diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index fbcf5d45..fc7a4824 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -106,6 +106,12 @@ def __init__(self, w: float, h: float): _DEFAULT_NODE_H = 60.0 _MIN_NODE_W = 40.0 _MIN_NODE_H = 20.0 +# A ceiling for any item's width or height. A size read from a file is not +# trusted: scaling a pixmap to 40,000 x 40,000 asks for a 6 GB allocation on the +# UI thread, and a pen or a bounding rect of 1e12 drives the view's fit-to- +# contents transform to a scale nothing can be drawn at. +MAX_ITEM_SIZE = 10000.0 +_MIN_IMAGE_SIDE = 40.0 _NODE_PEN_COLOR = "#455a64" _NODE_BRUSH_COLOR = "#e3f2fd" _NODE_SELECTED_COLOR = "#1565c0" @@ -116,14 +122,28 @@ def _safe_color(value: str | None, fallback: str) -> QColor: A corrupted/hand-edited diagram can carry an unparseable colour string; ``QColor`` would silently produce an invalid (black) colour, so validate it. + Anything that is not a string falls back too: ``QColor(5)`` is a valid + near-black colour and ``QColor([])`` raises. """ - if value: + if isinstance(value, str) and value: color = QColor(value) if color.isValid(): return color return QColor(fallback) +_MIN_LINE_WIDTH = 0.5 +_MAX_LINE_WIDTH = 10.0 + + +def _clamped_line_width(width: float) -> float: + """Return *width* within the pen widths a connection may be drawn with.""" + try: + return min(max(_MIN_LINE_WIDTH, float(width)), _MAX_LINE_WIDTH) + except (TypeError, ValueError): + return _CONNECTION_WIDTH + + _LABEL_FONT_FAMILY = "Segoe UI" _LABEL_FONT_SIZE = 10 _CONNECTION_COLOR = "#37474f" @@ -248,9 +268,10 @@ def __init__( ): style = style or NodeStyle() # Clamp to a positive minimum so a zero/negative size from corrupted data - # can't cause a divide-by-zero when computing edge intersection points. - w = max(_MIN_NODE_W, w) - h = max(_MIN_NODE_H, h) + # can't cause a divide-by-zero when computing edge intersection points, + # and to a ceiling so one from a file cannot ask for an absurd rect. + w = min(max(_MIN_NODE_W, w), MAX_ITEM_SIZE) + h = min(max(_MIN_NODE_H, h), MAX_ITEM_SIZE) super().__init__(0, 0, w, h) self.setPen(QPen(Qt.PenStyle.NoPen)) self.setBrush(QBrush(Qt.BrushStyle.NoBrush)) @@ -491,7 +512,7 @@ def from_dict(cls, data: dict) -> DiagramNode: w=data.get("w", _DEFAULT_NODE_W), h=data.get("h", _DEFAULT_NODE_H), text=data.get("text", "Node"), - shape=NodeShape[data.get("shape", "RECTANGLE")], + shape=NodeShape.__members__.get(data.get("shape", "RECTANGLE"), NodeShape.RECTANGLE), style=NodeStyle( fill_color=data.get("fill_color", data.get("color")), border_color=data.get("border_color"), @@ -519,8 +540,11 @@ def __init__( super().__init__() self.source = source self.target = target - self._line_color = QColor(line_color) if line_color else QColor(_CONNECTION_COLOR) - self._line_width = line_width + # Both come straight from a saved diagram: an unparseable colour would be + # drawn black instead of the default, and an unclamped width blows up the + # bounding rect (the setters below have always clamped and validated). + self._line_color = _safe_color(line_color, _CONNECTION_COLOR) + self._line_width = _clamped_line_width(line_width) self._style = style self._apply_pen() self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) @@ -554,7 +578,7 @@ def set_line_color(self, color: QColor) -> None: self._apply_pen() def set_line_width(self, width: float) -> None: - self._line_width = max(0.5, min(width, 10.0)) + self._line_width = _clamped_line_width(width) self._apply_pen() def set_style(self, style: ConnectionStyle) -> None: @@ -678,6 +702,10 @@ def __init__( source: str = "", pixmap: QPixmap | None = None, ): + # As for a node: a size read from a file is clamped before it reaches a + # rect or a pixmap scale. + w = min(max(_MIN_IMAGE_SIDE, w), MAX_ITEM_SIZE) + h = min(max(_MIN_IMAGE_SIDE, h), MAX_ITEM_SIZE) super().__init__(0, 0, w, h) self.setPen(_IMG_BORDER_PEN) self.setBrush(QBrush(Qt.BrushStyle.NoBrush)) @@ -743,7 +771,8 @@ def center_pos(self) -> QPointF: return self.pos() + QPointF(self.img_w / 2, self.img_h / 2) def set_size(self, w: float, h: float) -> None: - w, h = max(40.0, w), max(40.0, h) + w = min(max(_MIN_IMAGE_SIDE, w), MAX_ITEM_SIZE) + h = min(max(_MIN_IMAGE_SIDE, h), MAX_ITEM_SIZE) self.prepareGeometryChange() self.img_w, self.img_h = w, h self.setRect(0, 0, w, h) diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 7a2ab3f6..eed9e96b 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -153,6 +153,16 @@ def undo_scope(self, description: str): yield self.end_undo() + @staticmethod + def _check_is_a_diagram(data: dict) -> None: + """Raise ``ValueError`` unless *data* has the shape of a diagram.""" + if not isinstance(data, dict): + raise ValueError("a diagram file holds an object, not a %s" % type(data).__name__) + for section in ("nodes", "connections", "images"): + value = data.get(section, []) + if not isinstance(value, list): + raise ValueError(f"a diagram's '{section}' is a list, not a {type(value).__name__}") + def _restore_from_dict(self, data: dict) -> None: """Rebuild scene from serialised data (used by undo/redo).""" self.blockSignals(True) @@ -584,26 +594,42 @@ def _load_nodes(self, node_dicts: list) -> dict[int, DiagramNode]: pybreeze_logger.debug("Skipping malformed diagram node %r: %s", nd, err) continue self.addItem(node) - node_id = nd.get("id") - if node_id is not None: - id_to_node[node_id] = node + try: + node_id = nd.get("id") + if node_id is not None: + id_to_node[node_id] = node + except TypeError as err: + # An id that cannot be a key (a list, say): the node is on the + # canvas, only its connections cannot find it. + pybreeze_logger.debug("Diagram node with an unusable id %r: %s", nd, err) return id_to_node def _load_connections(self, conn_dicts: list, id_to_node: dict[int, DiagramNode]) -> None: for cd in conn_dicts: - src = id_to_node.get(cd.get("source")) - tgt = id_to_node.get(cd.get("target")) - if src is None or tgt is None: + try: + conn = self._connection_from_dict(cd, id_to_node) + except (AttributeError, KeyError, TypeError, ValueError) as err: + pybreeze_logger.debug("Skipping malformed diagram connection %r: %s", cd, err) continue - style = ConnectionStyle.__members__.get(cd.get("style", "SOLID"), ConnectionStyle.SOLID) - conn = DiagramConnection( - src, tgt, - label=cd.get("label", ""), - line_color=cd.get("line_color"), - line_width=cd.get("line_width", 2.0), - style=style, - ) - self.addItem(conn) + if conn is not None: + self.addItem(conn) + + @staticmethod + def _connection_from_dict( + cd: dict, id_to_node: dict[int, DiagramNode]) -> DiagramConnection | None: + """Build one connection, or ``None`` when either end is not on the canvas.""" + src = id_to_node.get(cd.get("source")) + tgt = id_to_node.get(cd.get("target")) + if src is None or tgt is None: + return None + style = ConnectionStyle.__members__.get(cd.get("style", "SOLID"), ConnectionStyle.SOLID) + return DiagramConnection( + src, tgt, + label=cd.get("label", ""), + line_color=cd.get("line_color"), + line_width=cd.get("line_width", 2.0), + style=style, + ) def _load_images(self, image_dicts: list) -> None: for img_d in image_dicts: @@ -642,6 +668,17 @@ def _try_load_image_source(self, img: DiagramImage, source: str) -> None: pybreeze_logger.debug("safe_download_image failed: %s", err) def load_from_dict(self, data: dict) -> None: + """Replace what is on the canvas with *data*. + + The canvas is cleared only once *data* is known to be a diagram: the + editor saves back to the file it opened last, so a load that emptied the + canvas half-way would be written over the user's own file by the next + save. + + :param data: a diagram, as :meth:`to_dict` writes it + :raises ValueError: when *data* is not a diagram; nothing is cleared then + """ + self._check_is_a_diagram(data) self._clear_items() self._load_items(data) self.undo_stack.clear() diff --git a/test/test_utils/test_diagram_editor_widget.py b/test/test_utils/test_diagram_editor_widget.py new file mode 100644 index 00000000..f6804dcd --- /dev/null +++ b/test/test_utils/test_diagram_editor_widget.py @@ -0,0 +1,92 @@ +"""The diagram editor's own file handling: what opening and saving do to files on disk.""" +from __future__ import annotations + +import json +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def editor(app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + + widget = DiagramEditorWidget() + yield widget + widget.deleteLater() + + +_A_DIAGRAM = { + "nodes": [{"id": 0, "x": 0, "y": 0, "w": 100, "h": 60, "text": "A", "shape": "RECTANGLE"}], + "connections": [], + "images": [], +} + + +class TestSaving: + def test_a_save_that_fails_leaves_the_last_good_file(self, editor, tmp_path, monkeypatch): + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + + target = tmp_path / "keep.diagram.json" + target.write_text(json.dumps(_A_DIAGRAM), encoding="utf-8") + editor._scene.load_from_dict(_A_DIAGRAM) + monkeypatch.setattr( + diagram_editor_widget.QMessageBox, "warning", + staticmethod(lambda *args, **kwargs: None)) + + def refuse(*_args, **_kwargs): + raise OSError("no room on the disk") + + monkeypatch.setattr(diagram_editor_widget.os, "replace", refuse) + editor._write_json(target) + + assert json.loads(target.read_text(encoding="utf-8")) == _A_DIAGRAM + assert list(tmp_path.iterdir()) == [target], "a half-written file was left behind" + + def test_a_save_writes_the_diagram(self, editor, tmp_path): + target = tmp_path / "out.diagram.json" + editor._scene.load_from_dict(_A_DIAGRAM) + + editor._write_json(target) + + stored = json.loads(target.read_text(encoding="utf-8")) + assert [node["text"] for node in stored["nodes"]] == ["A"] + + +class TestOpening: + def test_a_file_that_is_not_a_diagram_leaves_the_canvas_alone( + self, editor, tmp_path, monkeypatch): + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + + editor._scene.load_from_dict(_A_DIAGRAM) + editor._current_path = tmp_path / "work.diagram.json" + editor._current_path.write_text(json.dumps(_A_DIAGRAM), encoding="utf-8") + other = tmp_path / "other.diagram.json" + other.write_text(json.dumps([1, 2]), encoding="utf-8") + said: list = [] + monkeypatch.setattr( + diagram_editor_widget.QFileDialog, "getOpenFileName", + staticmethod(lambda *args, **kwargs: (str(other), ""))) + monkeypatch.setattr( + diagram_editor_widget.QMessageBox, "warning", + staticmethod(lambda *args, **kwargs: said.append(args))) + + editor._open_diagram() + + assert said, "the user was told nothing" + assert [n.text() for n in editor._scene.get_all_nodes()] == ["A"] + # The next save must still go to the file that is open, unharmed. + editor._write_json(editor._current_path) + assert json.loads(editor._current_path.read_text(encoding="utf-8"))["nodes"] diff --git a/test/test_utils/test_diagram_serialization.py b/test/test_utils/test_diagram_serialization.py index 110b7eeb..f1267a9c 100644 --- a/test/test_utils/test_diagram_serialization.py +++ b/test/test_utils/test_diagram_serialization.py @@ -239,3 +239,156 @@ def test_parse_then_load_preserves_shapes_arrows_styles(self, qt_app): assert {"DB", "B", "C", "Decision"} == texts styles = sorted(c._style.name for c in scene.get_all_connections()) assert styles == ["DOTTED", "DOTTED", "SOLID"] # two dotted fan-out, one thick + + +class TestALoadThatCannotWorkLeavesTheDiagramAlone: + """What is on the canvas must survive a file that turns out not to be a diagram. + + The editor keeps the path it opened last and saves back to it, so a load that + empties the canvas half-way is a file the next save would overwrite with the + wreckage. + """ + + _GOOD = { + "nodes": [ + {"id": 0, "x": 0, "y": 0, "w": 100, "h": 60, "text": "A", "shape": "RECTANGLE"}, + {"id": 1, "x": 200, "y": 0, "w": 100, "h": 60, "text": "B", "shape": "RECTANGLE"}, + ], + "connections": [{"source": 0, "target": 1}], + "images": [], + } + + def _loaded_scene(self): + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict(self._GOOD) + return scene + + @pytest.mark.parametrize("data", [ + [1, 2], + "a diagram", + {"nodes": "not a list"}, + {"nodes": [], "connections": {"source": 0}}, + ], ids=["top level list", "top level string", "nodes not a list", "connections not a list"]) + def test_a_file_that_is_not_a_diagram_is_refused_before_anything_is_cleared(self, qt_app, data): + scene = self._loaded_scene() + + with pytest.raises(ValueError): + scene.load_from_dict(data) + + assert {n.text() for n in scene.get_all_nodes()} == {"A", "B"} + assert len(scene.get_all_connections()) == 1 + + @pytest.mark.parametrize("connection", [ + {"source": [], "target": 1}, + "a string where an object belongs", + {"source": 0, "target": 1, "style": []}, + ], ids=["unhashable source", "string entry", "unhashable style"]) + def test_one_bad_connection_costs_only_that_connection(self, qt_app, connection): + scene = self._loaded_scene() + data = {**self._GOOD, "connections": [{"source": 0, "target": 1}, connection]} + + scene.load_from_dict(data) + + assert {n.text() for n in scene.get_all_nodes()} == {"A", "B"} + assert len(scene.get_all_connections()) == 1 + + @pytest.mark.parametrize("connection,attribute,expected", [ + ({"source": 0, "target": 1, "line_width": "3"}, "_line_width", 3.0), + ({"source": 0, "target": 1, "line_width": "wide"}, "_line_width", 2.0), + ], ids=["a width written as text", "a width that is not a number"]) + def test_a_connection_with_an_odd_pen_still_loads( + self, qt_app, connection, attribute, expected): + scene = self._loaded_scene() + + scene.load_from_dict({**self._GOOD, "connections": [connection]}) + + loaded = scene.get_all_connections() + assert len(loaded) == 1 + assert getattr(loaded[0], attribute) == expected + + def test_a_colour_that_is_not_text_falls_back_and_still_loads(self, qt_app): + scene = self._loaded_scene() + + scene.load_from_dict( + {**self._GOOD, "connections": [{"source": 0, "target": 1, "line_color": 5}]}) + + loaded = scene.get_all_connections() + assert len(loaded) == 1 + assert loaded[0]._line_color.name() == "#37474f" + + def test_a_node_with_an_unknown_shape_keeps_its_place_as_a_rectangle(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import NodeShape + + scene = self._loaded_scene() + + scene.load_from_dict({ + "nodes": [{"id": 0, "x": 0, "y": 0, "text": "Odd", "shape": "hexagon"}], + "connections": [], + }) + + nodes = scene.get_all_nodes() + assert [n.text() for n in nodes] == ["Odd"] + assert nodes[0].shape_type is NodeShape.RECTANGLE + + def test_an_unhashable_node_id_costs_only_that_node(self, qt_app): + scene = self._loaded_scene() + + scene.load_from_dict({ + "nodes": [{"id": [], "x": 0, "y": 0, "text": "Odd"}, + {"id": 2, "x": 0, "y": 0, "text": "Fine"}], + "connections": [], + }) + + assert {n.text() for n in scene.get_all_nodes()} == {"Odd", "Fine"} + + +class TestSizesAndPensFromAFile: + def test_an_enormous_image_is_clamped(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage, MAX_ITEM_SIZE + + image = DiagramImage.from_dict({"x": 0, "y": 0, "w": 40000, "h": 1e30}) + + assert image.img_w == MAX_ITEM_SIZE + assert image.img_h == MAX_ITEM_SIZE + + def test_a_negative_image_size_is_clamped(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage + + image = DiagramImage.from_dict({"x": 0, "y": 0, "w": -5, "h": -5}) + + assert image.img_w > 0 + assert image.img_h > 0 + + def test_an_enormous_node_is_clamped(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( + DiagramNode, MAX_ITEM_SIZE, + ) + + node = DiagramNode.from_dict( + {"x": 0, "y": 0, "w": 1e12, "h": 1e12, "text": "Big", "shape": "RECTANGLE"}) + + assert node.node_w == MAX_ITEM_SIZE + assert node.node_h == MAX_ITEM_SIZE + + def test_a_wild_line_width_is_clamped_like_the_setter_does(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( + DiagramConnection, DiagramNode, NodeShape, + ) + + node = DiagramNode(x=0, y=0, w=100, h=60, text="A", shape=NodeShape.RECTANGLE) + connection = DiagramConnection(node, node, line_width=1e9) + + assert connection._line_width == 10.0 + + def test_an_unparseable_line_colour_falls_back(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( + DiagramConnection, DiagramNode, NodeShape, + ) + + node = DiagramNode(x=0, y=0, w=100, h=60, text="A", shape=NodeShape.RECTANGLE) + connection = DiagramConnection(node, node, line_color="not-a-colour") + + assert connection._line_color.isValid() + assert connection._line_color.name() != "#000000" From da48ed50386e9fe6a729f6a8dffd1ae7309f57e7 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:25:46 +0800 Subject: [PATCH 029/312] Check an image source before touching it, and keep stacking order An image source from a saved diagram reached Path.is_file() before its extension was checked, so a UNC path made Windows authenticate to whatever host it named. undo_scope now closes in finally, and a node's z is saved and undoable. --- architecture_explore.md | 8 +- docs/updates/2026-09.md | 11 +++ docs/updates/README.md | 3 +- progress.md | 3 - .../diagram_editor/diagram_items.py | 15 ++- .../diagram_editor/diagram_scene.py | 45 +++++++-- test/test_utils/test_diagram_serialization.py | 96 +++++++++++++++++++ 7 files changed, 163 insertions(+), 18 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 309eeea5..859c7b34 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -232,13 +232,13 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,410 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,450 行,最大子系統) | 檔案 | 職責 | |---|---| | `diagram_editor_widget.py` (594) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | -| `diagram_scene.py` (685) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯 | -| `diagram_items.py` (871) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | +| `diagram_scene.py` (712) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統 | +| `diagram_items.py` (884) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | | `diagram_mermaid_parser.py` (532) | Mermaid flowchart → diagram dict。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (421) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | | `diagram_view.py` (175) | `QGraphicsView`:滾輪縮放(有上下界)、中鍵平移、`drawBackground` 畫格線 | @@ -424,7 +424,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 75 個 `test_*.py`、1120 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 75 個 `test_*.py`、1128 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 06762c77..7a6e4ac8 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -230,3 +230,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1086 → 1106 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_items,diagram_editor_widget}.py`, `test/test_utils/{test_diagram_serialization,test_diagram_editor_widget}.py`, `architecture_explore.md` §7, §18. - **Open items**: from the same audit, still open: image sources from a saved diagram are handed to `Path.is_file()` before the extension is checked (a UNC path makes Windows authenticate to whatever host it names), image downloads run on the UI thread and are repeated on every undo, `undo_scope` has no `try/finally`, and node z-order is not serialised. Recorded in `progress.md` #4–#7. + +## U-20260923-11 · 2026-09-23 · Image sources, undo scopes and stacking order · #done #diagram + +- **What**: `progress.md` #4, #6 and #7, all from the same audit as U-20260923-10. + - **#4 where an image may come from.** `_try_load_image_source()` asked `Path.is_file()` before checking the extension, so a `source` in a `.diagram.json` written by someone else decided what the IDE touched. On Windows that stat is the dangerous part: a UNC path makes the SMB client authenticate to whatever host it names, handing over the user's NTLM response, and an unreachable host blocks the UI thread until SMB gives up. The extension is now checked first, and `_is_on_this_machine()` refuses UNC paths in both spellings (`\host\share`, `//host/share`) before the filesystem is touched at all. + - **#6 undo scopes.** `undo_scope()` had no `try/finally`, so a body that raised — the Mermaid import catches one a frame up — left `_pending_undo_snapshot` set, and the next mouse release closed the scope: one unrelated click became an undo entry that reverted everything since. It now closes in `finally`. + - **#7 stacking order.** "Bring to Front" / "Send to Back" changed `zValue()` outside any undo scope and `to_dict()` never wrote it, so the order was lost on save and reset by any unrelated undo (which rebuilds every item from the dict). `z` is now part of a node's dict, read back through a numeric guard, and `_change_z()` runs in an undo scope. +- **Tests**: `test_diagram_serialization.py` gains three classes: where an image may come from (both UNC spellings and a non-image name never reach the filesystem, a local image path still does — all three fail with the order restored), a scope whose body raises leaving nothing pending, and stacking order surviving a save/load, being undoable, and ignoring a `z` that is not a number. +- **Result / numbers**: unit tests 1106 → 1114 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_items}.py`, `test/test_utils/test_diagram_serialization.py`, `architecture_explore.md` §7, §18, `progress.md` (#4, #6, #7 removed). +- **Open items**: `progress.md` #5 (image downloads on the UI thread, repeated on every undo) is the one left from that audit. diff --git a/docs/updates/README.md b/docs/updates/README.md index 68f57438..9834d72f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-11 | 2026-09-23 | Image sources, undo scopes and stacking order | #done #diagram | [2026-09](2026-09.md) | | U-20260923-10 | 2026-09-23 | A diagram that failed to load took the open one with it | #incident #diagram | [2026-09](2026-09.md) | | U-20260923-09 | 2026-09-23 | A run with no script tab handed the package nothing | #incident #process-executor | [2026-09](2026-09.md) | | U-20260923-08 | 2026-09-23 | Install did nothing without an editor tab in front | #incident #install | [2026-09](2026-09.md) | @@ -91,4 +92,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 28 | +| [2026-09.md](2026-09.md) | 2026-09 | 29 | diff --git a/progress.md b/progress.md index bd4c3990..11996db6 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` already exists), ask first, or keep going on purpose (for example a long load test that mails its report)? `CodeWindow` has no `closeEvent` today. - **#3** [UNVERIFIED] One unit test failed once, in a full run of `test/test_utils/` that took 282 s instead of the usual 35–60 s while the machine was busy; its name was not captured. Nine later full runs passed, three of them in parallel. Candidates are the tests that wait on a clock: `test_run_output.py` (30 s per child), `started_window.py` (120 s per IDE start). Record the name if it happens again. -- **#4** An image source from a saved diagram reaches `Path.is_file()` before its extension is checked (`pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py:_try_load_image_source`). On Windows a UNC source (`\\host\share\x.png`) makes the SMB client authenticate to that host; an unreachable one also blocks the UI thread for the SMB timeout. Check the suffix first, refuse UNC and resolve the rest. - **#5** A diagram's image downloads run on the UI thread (`_try_load_image_source` -> `safe_download_image`, 15 s each) and the pixmap is not part of the undo snapshot, so every undo and redo re-fetches every URL image: three unreachable ones freeze the IDE for ~45 s per Ctrl+Z, and an image whose host is offline silently blanks on an unrelated undo. Cache the pixmap on the item and fetch off the UI thread. -- **#6** `DiagramScene.undo_scope` has no `try/finally` (`diagram_scene.py`), so an exception inside a scope (the Mermaid import catches one a frame up) leaves `_pending_undo_snapshot` set; the next `mouseReleaseEvent` calls `end_undo()` with no matching `begin_undo` and pushes a command that undoes far more than the gesture. -- **#7** Node z-order is neither serialised (`DiagramNode.to_dict`) nor undoable (`DiagramScene._change_z` runs outside `undo_scope`), so "Bring to Front" is lost on save and reset by any unrelated undo. diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index fc7a4824..501f82db 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -136,6 +136,14 @@ def _safe_color(value: str | None, fallback: str) -> QColor: _MAX_LINE_WIDTH = 10.0 +def _number(value: object, fallback: float) -> float: + """Return *value* as a float, or *fallback* when it is not a number.""" + try: + return float(value) + except (TypeError, ValueError): + return fallback + + def _clamped_line_width(width: float) -> float: """Return *width* within the pen widths a connection may be drawn with.""" try: @@ -502,11 +510,12 @@ def to_dict(self, node_id: int) -> dict: "fill_color": self._fill_color.name(), "border_color": self._border_color.name(), "font_size": self._font_size, + "z": self.zValue(), } @classmethod def from_dict(cls, data: dict) -> DiagramNode: - return cls( + node = cls( x=data["x"], y=data["y"], w=data.get("w", _DEFAULT_NODE_W), @@ -519,6 +528,10 @@ def from_dict(cls, data: dict) -> DiagramNode: font_size=data.get("font_size", _LABEL_FONT_SIZE), ), ) + # Stacking is part of the diagram: without it, nodes the user brought + # to the front come back in whatever order the scene lists them. + node.setZValue(_number(data.get("z", 0.0), 0.0)) + return node # --------------------------------------------------------------------------- diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index eed9e96b..4a27755c 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -2,7 +2,7 @@ from contextlib import contextmanager from enum import Enum, auto -from pathlib import Path +from pathlib import Path, PureWindowsPath from PySide6.QtCore import QPointF, Qt, Signal from PySide6.QtGui import QColor, QPen, QPixmap, QUndoStack @@ -32,6 +32,17 @@ ) +def _is_on_this_machine(source: str) -> bool: + """Whether *source* names a path on this machine rather than another host. + + UNC paths (``\\\\host\\share\\x.png``, or ``//host/share/x.png``, which + Windows treats the same) are refused: see ``_try_load_image_source``. + """ + if source.startswith(("\\\\", "//")): + return False + return not PureWindowsPath(source).drive.startswith("\\\\") + + class ToolMode(Enum): """State pattern: each mode defines how mouse events behave on the canvas.""" SELECT = auto() @@ -149,9 +160,17 @@ def end_undo(self) -> None: @contextmanager def undo_scope(self, description: str): + """Take a snapshot, run the body, and record what it changed. + + The closing snapshot is taken even when the body raises: a scope left + open would be closed by the next mouse release instead, turning an + unrelated click into an undo entry that reverts everything since. + """ self.begin_undo(description) - yield - self.end_undo() + try: + yield + finally: + self.end_undo() @staticmethod def _check_is_a_diagram(data: dict) -> None: @@ -349,9 +368,11 @@ def _cancel_connection(self) -> None: self._temp_line = None def _change_z(self, direction: int) -> None: - for item in self.selectedItems(): - if isinstance(item, DiagramNode): - item.setZValue(item.zValue() + direction) + """Raise or lower the selected nodes. Undoable, and kept in the file.""" + with self.undo_scope("Change Z"): + for item in self.selectedItems(): + if isinstance(item, DiagramNode): + item.setZValue(item.zValue() + direction) # ------------------------------------------------------------------ # Operations (all undoable) @@ -647,12 +668,18 @@ def _load_images(self, image_dicts: list) -> None: def _try_load_image_source(self, img: DiagramImage, source: str) -> None: """Load pixmap from local path or URL into a DiagramImage. - Local paths are restricted to existing image files. + Local paths are restricted to existing image files on this machine. URLs are validated and size-limited via ``safe_download_image``. """ path = Path(source) - # Only load if the file actually exists and has an allowlisted extension. - if path.is_file() and path.suffix.lower() in _VALID_IMAGE_SUFFIXES: + # The extension is checked before the filesystem is touched, and a path + # on another machine is refused outright: on Windows, merely asking + # whether \\host\share\x.png is a file makes the SMB client authenticate + # to that host, so a diagram from someone else could collect the user's + # credentials, and an unreachable host would block the UI thread until + # SMB gives up. + if (path.suffix.lower() in _VALID_IMAGE_SUFFIXES + and _is_on_this_machine(source) and path.is_file()): pix = QPixmap(str(path)) if not pix.isNull(): img.set_pixmap(pix, source) diff --git a/test/test_utils/test_diagram_serialization.py b/test/test_utils/test_diagram_serialization.py index f1267a9c..06f27ab2 100644 --- a/test/test_utils/test_diagram_serialization.py +++ b/test/test_utils/test_diagram_serialization.py @@ -392,3 +392,99 @@ def test_an_unparseable_line_colour_falls_back(self, qt_app): assert connection._line_color.isValid() assert connection._line_color.name() != "#000000" + + +class TestWhereAnImageMayComeFrom: + """A saved diagram names its images; where those names may point is a boundary.""" + + def _scene_with_source(self, monkeypatch, source: str): + from pybreeze.pybreeze_ui.diagram_editor import diagram_scene as scene_module + + looked_at: list = [] + monkeypatch.setattr( + scene_module.Path, "is_file", + lambda self: looked_at.append(str(self)) or False) + scene = scene_module.DiagramScene() + scene.load_from_dict({ + "nodes": [], "connections": [], + "images": [{"x": 0, "y": 0, "w": 100, "h": 100, "source": source}], + }) + return looked_at + + @pytest.mark.parametrize("source", [ + "\\\\attacker.example\\share\\x.png", + "//attacker.example/share/x.png", + ], ids=["windows unc", "forward slash unc"]) + def test_a_path_on_another_machine_is_never_touched(self, qt_app, monkeypatch, source): + # Asking whether a UNC path is a file makes Windows authenticate to that + # host, so a diagram from someone else must not get that far. + assert self._scene_with_source(monkeypatch, source) == [] + + def test_a_name_that_is_not_an_image_is_never_touched(self, qt_app, monkeypatch): + assert self._scene_with_source(monkeypatch, r"C:\Windows\System32\config\SAM") == [] + + def test_a_local_image_path_is_still_looked_up(self, qt_app, monkeypatch): + assert self._scene_with_source(monkeypatch, r"C:\pictures\logo.png") == [ + r"C:\pictures\logo.png"] + + +class TestTheUndoScope: + def test_a_scope_whose_body_raises_leaves_nothing_pending(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode, NodeShape + + scene = DiagramScene() + with pytest.raises(RuntimeError): + with scene.undo_scope("Half done"): + scene.addItem(DiagramNode(x=0, y=0, text="A", shape=NodeShape.RECTANGLE)) + raise RuntimeError("something in the middle went wrong") + + assert scene._pending_undo_snapshot is None + pushed = scene.undo_stack.count() + # The next gesture ends with end_undo(); with a scope left open it would + # push a command undoing everything since. + scene.end_undo() + assert scene.undo_stack.count() == pushed + + +class TestStackingOrder: + def _scene_with_two_nodes(self): + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({ + "nodes": [ + {"id": 0, "x": 0, "y": 0, "text": "A", "shape": "RECTANGLE"}, + {"id": 1, "x": 10, "y": 10, "text": "B", "shape": "RECTANGLE"}, + ], + "connections": [], + }) + return scene + + def test_it_survives_a_save_and_a_load(self, qt_app): + scene = self._scene_with_two_nodes() + front = [n for n in scene.get_all_nodes() if n.text() == "B"][0] + front.setZValue(3) + + stored = scene.to_dict() + scene.load_from_dict(stored) + + assert {n.text(): n.zValue() for n in scene.get_all_nodes()} == {"A": 0.0, "B": 3.0} + + def test_bringing_a_node_to_the_front_can_be_undone(self, qt_app): + scene = self._scene_with_two_nodes() + node = [n for n in scene.get_all_nodes() if n.text() == "B"][0] + node.setSelected(True) + + scene._change_z(1) + assert [n.zValue() for n in scene.get_all_nodes() if n.text() == "B"] == [1.0] + + scene.undo_stack.undo() + assert [n.zValue() for n in scene.get_all_nodes() if n.text() == "B"] == [0.0] + + def test_a_z_that_is_not_a_number_is_ignored(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode + + node = DiagramNode.from_dict({"x": 0, "y": 0, "text": "A", "z": "front"}) + + assert node.zValue() == 0.0 From a36133d0f7a8cb50334b9d7a2581071ca36ffe46 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:31:02 +0800 Subject: [PATCH 030/312] Keep a fingerprint of a review URL, never the URL An API URL may carry a token, which this project treats as a credential, but the AI review panel wrote every URL to a plaintext file and logged one on failure. It now records fingerprints, rewrites an older file, logs no URL, reports a failed status, and two exception types that escaped its handler are turned into the errors callers catch. --- docs/source/Eng/ai_tools.rst | 3 +- docs/source/Zh/ai_tools.rst | 3 +- docs/updates/2026-09.md | 9 ++ docs/updates/README.md | 3 +- progress.md | 7 ++ .../connect_gui/url/ai_code_review_gui.py | 65 +++++++--- pybreeze/utils/network/http_client.py | 11 +- pybreeze/utils/network/url_validation.py | 5 +- test/test_utils/test_ai_code_review_client.py | 116 ++++++++++++++++++ test/test_utils/test_http_client.py | 9 ++ test/test_utils/test_url_validation.py | 18 +++ 11 files changed, 228 insertions(+), 21 deletions(-) create mode 100644 test/test_utils/test_ai_code_review_client.py diff --git a/docs/source/Eng/ai_tools.rst b/docs/source/Eng/ai_tools.rst index ee888944..7eb26c1a 100644 --- a/docs/source/Eng/ai_tools.rst +++ b/docs/source/Eng/ai_tools.rst @@ -25,7 +25,8 @@ Features ^^^^^^^^ - Tracks accept/reject statistics for AI responses -- Saves URL history to ``.pybreeze/urls.txt`` +- Records which endpoints have been used in ``.pybreeze/urls.txt``, as fingerprints: + an API URL can carry a token, so the URL itself is never written to disk - Stores response statistics in ``.pybreeze/response_stats.txt`` Usage diff --git a/docs/source/Zh/ai_tools.rst b/docs/source/Zh/ai_tools.rst index 62f90350..81b6d346 100644 --- a/docs/source/Zh/ai_tools.rst +++ b/docs/source/Zh/ai_tools.rst @@ -24,7 +24,8 @@ AI 程式碼審查用戶端 ^^^^ - 追蹤 AI 回應的接受/拒絕統計 -- 將 URL 歷史儲存到 ``.pybreeze/urls.txt`` +- 在 ``.pybreeze/urls.txt`` 記錄用過哪些端點,存的是指紋:API URL 可能帶著權杖, + 所以不會把 URL 本身寫進磁碟 - 將回應統計儲存到 ``.pybreeze/response_stats.txt`` 使用方式 diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 7a6e4ac8..15022aca 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -241,3 +241,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1106 → 1114 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_items}.py`, `test/test_utils/test_diagram_serialization.py`, `architecture_explore.md` §7, §18, `progress.md` (#4, #6, #7 removed). - **Open items**: `progress.md` #5 (image downloads on the UI thread, repeated on every undo) is the one left from that audit. + +## U-20260923-12 · 2026-09-23 · The AI review panel kept API URLs, tokens and all · #incident #security + +- **What**: from the same audit round. `CLAUDE.md` § Security says an API URL may embed a token and is to be treated as a credential. The AI review panel wrote every URL sent, verbatim, to `~/.pybreeze/urls.txt` — a plaintext file that only ever grew — and logged `%r` of a failed request, whose message carries the whole URL, into `PyBreeze.log`. Two more from the same widget: `validate_url()` let `UnicodeError` escape (`http://<70-character label>.example` and `http://.example` raise it out of `socket.getaddrinfo`, and it is not a `gaierror`), as did `LookupError` from `read_capped_text()` when a server names a charset Python does not know; neither is in the handler's tuple, so in a Qt slot they leave the slot. +- **Fix**: the file keeps a SHA-256 fingerprint of each URL instead of the URL — enough to answer "sent before?", which is all it was used for — and a file still holding URLs from an older version is rewritten as fingerprints the next time anything is sent, so the tokens stop sitting in the user's home directory. The failure log names the exception type only. `validate_url()` turns `UnicodeError` into `UnsafeURLError` like any other name that cannot be looked up, and `read_capped_text()` falls back to its default encoding when the response names one Python does not know. A response that is not `ok` now says so (`HTTP 500 Internal Server Error`) instead of appending an empty body — with `allow_redirects=False`, a 302 used to leave the panel blank. +- **Tests**: `test_ai_code_review_client.py` (new, 6) covers what reaches the file, recognising a URL sent before, the rewrite of an older plaintext file, the status line, and a failed request not logging the token. `test_url_validation.py` gains four hostnames that cannot be looked up, `test_http_client.py` an encoding Python does not know. All the new ones fail against the old code. +- **Result / numbers**: unit tests 1114 → 1125 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `pybreeze/utils/network/{url_validation,http_client}.py`, `test/test_utils/{test_ai_code_review_client,test_url_validation,test_http_client}.py`, `docs/source/{Eng,Zh}/ai_tools.rst`. +- **Open items**: the same audit found that this panel sends its request on the UI thread (`SkillsSendGUI` already uses a `QThread`), and that the SSH tab leaves its reader thread and sessions running when it is closed. Recorded in `progress.md` #8 and #9. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9834d72f..71bbf9d6 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-12 | 2026-09-23 | The AI review panel kept API URLs, tokens and all | #incident #security | [2026-09](2026-09.md) | | U-20260923-11 | 2026-09-23 | Image sources, undo scopes and stacking order | #done #diagram | [2026-09](2026-09.md) | | U-20260923-10 | 2026-09-23 | A diagram that failed to load took the open one with it | #incident #diagram | [2026-09](2026-09.md) | | U-20260923-09 | 2026-09-23 | A run with no script tab handed the package nothing | #incident #process-executor | [2026-09](2026-09.md) | @@ -92,4 +93,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 29 | +| [2026-09.md](2026-09.md) | 2026-09 | 30 | diff --git a/progress.md b/progress.md index 11996db6..5fc0d256 100644 --- a/progress.md +++ b/progress.md @@ -9,3 +9,10 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` already exists), ask first, or keep going on purpose (for example a long load test that mails its report)? `CodeWindow` has no `closeEvent` today. - **#3** [UNVERIFIED] One unit test failed once, in a full run of `test/test_utils/` that took 282 s instead of the usual 35–60 s while the machine was busy; its name was not captured. Nine later full runs passed, three of them in parallel. Candidates are the tests that wait on a clock: `test_run_output.py` (30 s per child), `started_window.py` (120 s per IDE start). Record the name if it happens again. - **#5** A diagram's image downloads run on the UI thread (`_try_load_image_source` -> `safe_download_image`, 15 s each) and the pixmap is not part of the undo snapshot, so every undo and redo re-fetches every URL image: three unreachable ones freeze the IDE for ~45 s per Ctrl+Z, and an image whose host is offline silently blanks on an unrelated undo. Cache the pixmap on the item and fetch off the UI thread. +- **#8** The AI review panel sends its request on the UI thread (`pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py:send_request`), so a slow endpoint freezes the whole IDE for the connect timeout plus 30 s per read, with no progress and no cancel, and repeated clicks queue more. `SkillsSendGUI`'s `RequestThread` (`extend_ai_gui/skills_send_gui.py`) is the pattern to follow, including its guard against a second send. +- **#9** The SSH tab and dock never tear down (`connect_gui/ssh/`): `SSHMainWidget` has no `closeEvent`, JEditor's `close_tab()` delivers the close event only to the widget it closes, and `SSHCommandWidget._cleanup()` is reachable only from the Disconnect button. Closing the tab leaves `SSHReaderThread` running while its widget is collected (Qt aborts on a QThread destroyed while running, and a queued signal into a deleted widget raises inside a slot), and leaks the paramiko transport and the SFTP session. `test_ai_gui_lifecycle.py` pins this invariant for the AI widgets; the SSH ones need the same. +- **#10** All SSH and SFTP work runs on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s), and `get()`/`put()`/`listdir_attr()` have no timeout at all, so a large download or an unresponsive host freezes the IDE until it finishes. Moving it off the UI thread has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. +- **#11** Five `SFTPClientWrapper` methods skip the `connected` guard the others have (`ssh_file_viewer_widget.py`: `mkdir`, `remove_file`, `remove_dir`, `rename`, `download`, `upload`), so acting on a stale tree after a dropped session shows the user `AttributeError: 'NoneType' object has no attribute 'mkdir'` instead of the "not connected" message. +- **#12** One Connect button drives two independent SSH sessions (`ssh_main_widget.py` wires `LoginWidget.connect_btn` to both the shell and the file tree), so a server with the SFTP subsystem disabled leaves the shell connected while the tree reports failure, and the status label shows whichever finished last. +- **#13** Run windows are never released: `current_run_code_window` only ever grows (`pybreeze_ui/editor_main/main_ui.py`), and each entry now holds its executor, two queues and a timer as well as the window. A long session keeps one per run, and one per mis-click that opens the "no script tab" window. + 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 bb74f6c1..4fc0a021 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 @@ -1,8 +1,9 @@ from __future__ import annotations import sys +from pathlib import Path + import requests -import os from PySide6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, QTextEdit, QComboBox, QLabel, QSizePolicy @@ -10,6 +11,7 @@ from je_editor import language_wrapper from pybreeze.utils.app_dirs import pybreeze_data_dir +from pybreeze.utils.hash_tools.hash_text import hash_text from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, @@ -17,6 +19,23 @@ from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url +# What the "seen this URL before" file keeps. An API URL can carry a token in +# its query, and this project treats one as a credential, so only a fingerprint +# of the URL is written to disk. +_FINGERPRINT_ALGORITHM = "sha256" +_FINGERPRINT_LENGTH = 64 + + +def url_fingerprint(url: str) -> str: + """Return the fingerprint recorded for *url* instead of the URL itself.""" + return hash_text(url, _FINGERPRINT_ALGORITHM) + + +def looks_like_a_fingerprint(line: str) -> bool: + """Whether *line* is already a fingerprint rather than a URL.""" + return len(line) == _FINGERPRINT_LENGTH and all(c in "0123456789abcdef" for c in line) + + class AICodeReviewClient(QWidget): def __init__(self): super().__init__() @@ -132,21 +151,13 @@ def send_request(self): f"{self.word_dict.get('ai_code_review_gui_message_error')}: {e}") return - # 檢查 URL 是否已紀錄 - if os.path.exists(self.url_file): - with open(self.url_file, encoding="utf-8") as f: - urls = [line.strip() for line in f.readlines()] - else: - urls = [] - - if url in urls: + # 這個 URL 之前送過嗎 / Has this URL been sent before? + if self.record_url(url): self.response_panel.setPlainText( - self.word_dict.get("ai_code_review_gui_message_url_already_recorded")) + self.word_dict.get("ai_code_review_gui_message_new_url_recorded")) else: - with open(self.url_file, "a", encoding="utf-8") as f: - f.write(url + "\n") self.response_panel.setPlainText( - self.word_dict.get("ai_code_review_gui_message_new_url_recorded")) + self.word_dict.get("ai_code_review_gui_message_url_already_recorded")) try: if method == "GET": @@ -162,12 +173,38 @@ def send_request(self): self.word_dict.get("ai_code_review_gui_message_unsupported_http_method")) return + if not response.ok: + self.response_panel.append( + f"{self.word_dict.get('ai_code_review_gui_message_error')}: " + f"HTTP {response.status_code} {response.reason}") self.response_panel.append(read_capped_text(response)) except (requests.RequestException, ResponseTooLargeError) as e: - pybreeze_logger.error("AI code review request failed: %r", e) + # Not %r: a requests error carries the whole URL, which may hold a token. + pybreeze_logger.error("AI code review request failed: %s", type(e).__name__) self.response_panel.setPlainText(f"{self.word_dict.get('ai_code_review_gui_message_error')}: {e}") + def record_url(self, url: str) -> bool: + """Record *url* as sent, and say whether it had not been sent before. + + Only a fingerprint is stored. A file still holding URLs from an older + version is rewritten as fingerprints, so the tokens they may carry stop + sitting in the user's home directory. + """ + path = Path(self.url_file) + lines = [] + if path.is_file(): + lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() + if line.strip()] + seen = {line if looks_like_a_fingerprint(line) else url_fingerprint(line) + for line in lines} + fingerprint = url_fingerprint(url) + is_new = fingerprint not in seen + if is_new or any(not looks_like_a_fingerprint(line) for line in lines): + seen.add(fingerprint) + path.write_text("\n".join(sorted(seen)) + "\n", encoding="utf-8") + return is_new + def accept_response(self): """Accept response code and save""" self.accept_count += 1 diff --git a/pybreeze/utils/network/http_client.py b/pybreeze/utils/network/http_client.py index 806cd38b..e1443a9c 100644 --- a/pybreeze/utils/network/http_client.py +++ b/pybreeze/utils/network/http_client.py @@ -36,7 +36,9 @@ def read_capped_text( The request must be issued with ``stream=True`` so the body is read here under the cap instead of being buffered in full by ``requests``. Raises ``ResponseTooLargeError`` once the accumulated body exceeds *max_bytes*. The - response is always closed before returning. + response is always closed before returning. The charset the response names + is used when Python knows it, and *default_encoding* otherwise: a server can + name anything, and an unknown one would raise ``LookupError`` here. """ total = 0 chunks: list[bytes] = [] @@ -52,8 +54,11 @@ def read_capped_text( chunks.append(chunk) finally: response.close() - encoding = response.encoding or default_encoding - return b"".join(chunks).decode(encoding, "replace") + body = b"".join(chunks) + try: + return body.decode(response.encoding or default_encoding, "replace") + except LookupError: + return body.decode(default_encoding, "replace") def truncate_for_display(text: str, limit: int = DISPLAY_TRUNCATE_CHARS) -> str: diff --git a/pybreeze/utils/network/url_validation.py b/pybreeze/utils/network/url_validation.py index bf91784d..66a5c23a 100644 --- a/pybreeze/utils/network/url_validation.py +++ b/pybreeze/utils/network/url_validation.py @@ -82,7 +82,10 @@ def validate_url(url: str) -> str: try: infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) - except socket.gaierror as exc: + except (socket.gaierror, UnicodeError) as exc: + # UnicodeError: the name cannot even be encoded for a lookup (a label + # over 63 characters, or an empty one). Callers catch UnsafeURLError, so + # anything else here would escape into a Qt slot. raise UnsafeURLError(f"Cannot resolve hostname '{hostname}': {exc}") from exc for *_unused, sockaddr in infos: diff --git a/test/test_utils/test_ai_code_review_client.py b/test/test_utils/test_ai_code_review_client.py new file mode 100644 index 00000000..7e37c22b --- /dev/null +++ b/test/test_utils/test_ai_code_review_client.py @@ -0,0 +1,116 @@ +"""The AI review panel: what it keeps on disk, what it logs, and what it shows. + +An API URL can carry a token in its query, which this project treats as a +credential, so the panel records only that a URL was seen before. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.url import ai_code_review_gui +from pybreeze.pybreeze_ui.connect_gui.url.ai_code_review_gui import ( + AICodeReviewClient, looks_like_a_fingerprint, url_fingerprint, +) + +_A_URL = "https://api.example/v1/review?api_key=sk-live-not-a-real-key" + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def client(app, tmp_path, monkeypatch): + monkeypatch.setattr(ai_code_review_gui, "pybreeze_data_dir", lambda: tmp_path) + made = AICodeReviewClient() + yield made + made.deleteLater() + + +def stored(client) -> str: + from pathlib import Path + + return Path(client.url_file).read_text(encoding="utf-8") + + +class TestRecordingAUrl: + def test_the_url_itself_never_reaches_the_file(self, client): + assert client.record_url(_A_URL) is True + + text = stored(client) + assert _A_URL not in text + assert "sk-live-not-a-real-key" not in text + assert url_fingerprint(_A_URL) in text + + def test_a_url_sent_before_is_recognised(self, client): + client.record_url(_A_URL) + + assert client.record_url(_A_URL) is False + + def test_a_different_url_is_new(self, client): + client.record_url(_A_URL) + + assert client.record_url("https://api.example/v2/review") is True + + def test_a_file_from_an_older_version_is_rewritten_as_fingerprints(self, client): + from pathlib import Path + + Path(client.url_file).write_text( + f"{_A_URL}\nhttps://other.example/review\n", encoding="utf-8") + + assert client.record_url(_A_URL) is False, "it was recorded before, in plain text" + + text = stored(client) + assert "api.example" not in text + assert "other.example" not in text + assert url_fingerprint("https://other.example/review") in text + assert all(looks_like_a_fingerprint(line) for line in text.split() if line) + + +class TestSending: + def _answer_with(self, monkeypatch, response): + monkeypatch.setattr(ai_code_review_gui, "validate_url", lambda url: url) + monkeypatch.setattr(ai_code_review_gui.requests, "get", lambda *a, **k: response) + monkeypatch.setattr(ai_code_review_gui, "read_capped_text", lambda resp: resp.text) + + def test_a_failed_status_is_reported_instead_of_an_empty_panel(self, client, monkeypatch): + class Response: + ok = False + status_code = 500 + reason = "Internal Server Error" + text = "" + + self._answer_with(monkeypatch, Response()) + client.url_input.setText(_A_URL) + + client.send_request() + + assert "500" in client.response_panel.toPlainText() + + def test_a_request_that_fails_does_not_log_the_url(self, client, monkeypatch): + logged: list = [] + monkeypatch.setattr(ai_code_review_gui, "validate_url", lambda url: url) + monkeypatch.setattr( + ai_code_review_gui.pybreeze_logger, "error", + lambda message, *args: logged.append(message % args)) + + def refuse(*_args, **_kwargs): + raise ai_code_review_gui.requests.ConnectionError( + f"HTTPSConnectionPool(host='api.example'): Max retries exceeded with url: {_A_URL}") + + monkeypatch.setattr(ai_code_review_gui.requests, "get", refuse) + client.url_input.setText(_A_URL) + + client.send_request() + + assert logged, "nothing was logged" + assert all("sk-live-not-a-real-key" not in line for line in logged) diff --git a/test/test_utils/test_http_client.py b/test/test_utils/test_http_client.py index 9353ca77..a956693f 100644 --- a/test/test_utils/test_http_client.py +++ b/test/test_utils/test_http_client.py @@ -74,3 +74,12 @@ def test_long_text_truncated_with_marker(self): assert result.startswith("x" * 100) assert "truncated" in result assert "5000" in result + + +class TestAnEncodingTheServerNames: + def test_an_encoding_python_does_not_know_falls_back(self): + # The charset comes from the response's Content-Type: a server naming + # one Python has never heard of must not raise out of the read. + resp = FakeResponse("héllo".encode("utf-8"), encoding="totally-made-up") + + assert read_capped_text(resp, default_encoding="utf-8") == "héllo" diff --git a/test/test_utils/test_url_validation.py b/test/test_utils/test_url_validation.py index 3cf3a6b1..5b7a1c07 100644 --- a/test/test_utils/test_url_validation.py +++ b/test/test_utils/test_url_validation.py @@ -60,3 +60,21 @@ class TestPublicAddressAllowed: ]) def test_public_ip_allowed(self, url): assert validate_url(url) == url + + +class TestHostnamesThatCannotBeLookedUp: + """Whatever a hostname is, the answer is an UnsafeURLError, not a stray exception. + + A caller catches ``UnsafeURLError``; anything else escapes its handler and, + in a Qt slot, takes the application with it. + """ + + @pytest.mark.parametrize("url", [ + "http://" + "a" * 70 + ".example", + "http://.example", + "http://exa mple.example", + "http://" + "b" * 300, + ], ids=["label too long", "empty label", "space in hostname", "name too long"]) + def test_an_impossible_hostname_is_refused_like_any_other(self, url): + with pytest.raises(UnsafeURLError): + validate_url(url) From 08144f6986b5a2201511dc57cf87db573e268e0d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:35:44 +0800 Subject: [PATCH 031/312] Ask once whether an install can start, and close the same way everywhere From a review of today's commits: the build tools repeated the same message box three times, prthinker's folder dialog ran for an install that could not start, a model with no backend variable was dropped silently, rule retrieval could still be turned off by an inherited variable, and debug mode's auto-close skipped closeEvent and with it stopping runs. --- architecture.md | 3 +- architecture_explore.md | 6 ++- docs/updates/2026-09.md | 12 +++++ docs/updates/README.md | 3 +- .../prthinker_extend/prthinker_setting.py | 20 ++++++-- pybreeze/pybreeze_ui/editor_main/main_ui.py | 12 +++-- .../build_automation_install_menu.py | 8 +++- .../menu/install_menu/install_utils.py | 35 ++++++++++---- .../tools_menu/build_tool_install_menu.py | 11 +++-- test/test_utils/test_closing_the_ide.py | 13 +++++ test/test_utils/test_install_menu.py | 47 +++++++++++++++++++ test/test_utils/test_prthinker_setting.py | 29 ++++++++++-- 12 files changed, 167 insertions(+), 32 deletions(-) diff --git a/architecture.md b/architecture.md index 4fc9d4f3..10e37704 100644 --- a/architecture.md +++ b/architecture.md @@ -160,7 +160,8 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() `PRTHINKER__MODEL`), `PRTHINKER_REMOTE_URL`, `PRTHINKER_REMOTE_API_KEY`, `PRTHINKER_OPENAI_API_KEY`, `PRTHINKER_OPENAI_BASE_URL`, `PRTHINKER_ANTHROPIC_API_KEY`, `PRTHINKER_PLATFORM`, `PRTHINKER_PLATFORM_BASE_URL`, `GITHUB_REPOSITORY`, `GITHUB_TOKEN`, and - always one of `PRTHINKER_RAG_ENABLED=false` / `PRTHINKER_REMOTE_RAG=true`. `BACKENDS` must stay a + always both `PRTHINKER_RAG_ENABLED` and `PRTHINKER_REMOTE_RAG` (the child inherits the IDE's + environment, so one left out would be decided by whatever is exported there). `BACKENDS` must stay a subset of `prthinker.config.BackendKind`. It is not on PyPI: the Install menu asks for a local source folder and installs `[runner]`; that package excludes prthinker's `codes/` (the local RAG index), which is why local retrieval is never left on. `test_prthinker_contract.py` runs the diff --git a/architecture_explore.md b/architecture_explore.md index 859c7b34..ffdef209 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -189,6 +189,8 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `_TAB_ACTIONS: tuple[...]` — (widget key, 主視窗屬性, 選單屬性, action 語言鍵, 分頁標籤鍵) - `_DOCK_ACTIONS` / `_DOCK_TITLES` — 同一組 widget 也能開成右側 dock +安裝選單的 `install_is_possible()` 也是先問再做:沒有編輯器分頁就說一聲並回傳 `False`,安裝三個建置工具或先問 prthinker 原始碼資料夾的路徑都靠它提早停下。 + `_register_action()` 有一段關鍵註解:QAction 必須 `setattr` 掛回主視窗,否則 Qt 不持有它、被 GC 後選單項就失效。另一種做法是建構時把選單當 parent(自動化選單工廠、插件選單用這種)。`test_started_menus.py` 在子行程啟動真的 IDE、GC 後走訪整條選單列,任何子選單變空就失敗(JEditor 的兩個字型選單除外:offscreen 平台沒有字型)。 ### 5.4 插件選單 @@ -360,7 +362,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 - 模型名稱依後端交給不同變數(`MODEL_ENVIRONMENT`):`remote` / `local` 是 `PRTHINKER_MODEL_NAME`,其他後端是各自的 `PRTHINKER__MODEL`。prthinker 只有 local 讀 `PRTHINKER_MODEL_NAME`(remote 由伺服器決定模型,只拿來標示) - `extra_arguments()` 經 `split_arguments()` 以命令列規則斷詞(引號內空白不拆);反斜線是路徑分隔字元的平台(Windows)上反斜線不當跳脫字元,`C:\reviews` 才不會變成 `C:reviews`。解析失敗當作沒有而不是讓整次審查失敗 - `install_target()` 回傳 `[runner]`,因為 prthinker 不在 PyPI 上 -- 規則檢索(`rag`,`RAG_MODES = ("off", "remote")`)**永遠明講**:`off` 給 `PRTHINKER_RAG_ENABLED=false`,`remote` 給 `PRTHINKER_REMOTE_RAG=true`(走伺服器的 `/rag`),認不得的值當 `off`。prthinker 的預設是本機 FAISS 檢索,但那份索引(`codes/`)只在它的原始碼庫、被排除在套件外,從這裡裝的 prthinker 一跑就 `ModuleNotFoundError: codes` +- 規則檢索(`rag`,`RAG_MODES = ("off", "remote")`)**永遠明講**,而且兩個變數都送:`off` 給 `PRTHINKER_RAG_ENABLED=false` + `PRTHINKER_REMOTE_RAG=false`,`remote` 兩個都 `true`(走伺服器的 `/rag`),認不得的值當 `off`。子行程繼承 IDE 的環境,少送一個就會被使用者 shell 裡的設定決定。prthinker 的預設是本機 FAISS 檢索,但那份索引(`codes/`)只在它的原始碼庫、被排除在套件外,從這裡裝的 prthinker 一跑就 `ModuleNotFoundError: codes` 支援的後端:`remote / local / openai / anthropic / gemini / cohere / mistral / claude-cli / codex-cli`;平台:`github / gitlab / gitea`。 @@ -424,7 +426,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 75 個 `test_*.py`、1128 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 76 個 `test_*.py`、1144 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 15022aca..c9a9a8c4 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -250,3 +250,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1114 → 1125 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `pybreeze/utils/network/{url_validation,http_client}.py`, `test/test_utils/{test_ai_code_review_client,test_url_validation,test_http_client}.py`, `docs/source/{Eng,Zh}/ai_tools.rst`. - **Open items**: the same audit found that this panel sends its request on the UI thread (`SkillsSendGUI` already uses a `QThread`), and that the SSH tab leaves its reader thread and sessions running when it is closed. Recorded in `progress.md` #8 and #9. + +## U-20260923-13 · 2026-09-23 · Four findings from reviewing the day's work · #incident #review + +- **What**: a review of everything committed today, against `origin/dev`, found four defects in it. All four are confirmed and fixed here. + 1. **Three message boxes instead of one.** U-20260923-08 made `install_package()` report a missing editor tab; `install_build_tools()` calls it three times in a row and ignored the answer, so `Install ▸ Tools ▸ Install Build Tools` with a tool tab in front put the same dialog up three times. And `install_prthinker()` asked for the source folder, validated it and saved it to the settings *before* the install refused to start. `install_is_possible()` now answers the question on its own; both callers ask it before doing anything else, and `install_build_tools()` stops at the first no. + 2. **A model with nowhere to go.** U-20260923-02 moved the model name onto the chosen backend's variable. A stored backend PyBreeze does not offer (hand-edited, or written by a newer build) has no variable, so the model was dropped without a word while the dialog kept showing it. It is logged now. + 3. **Rule retrieval could still be turned off by the environment.** `remote` sent only `PRTHINKER_REMOTE_RAG=true`, and the child inherits the IDE's environment: a user who exports `PRTHINKER_RAG_ENABLED=false` for their own prthinker runs and starts PyBreeze from that shell got `--no-rag` anyway, with `/rag` never consulted. Both variables are sent in both modes now, which is what `architecture.md` §6 already claimed. + 4. **Debug mode's auto-close skipped the cleanup.** `debug_close()` called `app.quit()`, which leaves the event loop without delivering a close event, so under `debug_mode` — the startup tests — a run's child still outlived the process, the very leak U-20260923-06 fixed. It closes the window first. +- **Tests**: the build tools stopping at the first refusal and installing all three when they can; prthinker's source folder not being asked for when nothing can be installed; a model with no backend to send it to being reported; rule retrieval sending both variables in both modes (the old tests pinned the absence of one, which was the weaker behaviour); and the debug-mode auto-close stopping a run, alongside the window-close test. +- **Result / numbers**: unit tests 1125 → 1130 passed, 14 skipped; the 14 prthinker contract tests pass against prthinker `main` installed the way CI installs it. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/install_menu/{install_utils,tools_menu/build_tool_install_menu,automation_menu/build_automation_install_menu}.py`, `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_install_menu,test_prthinker_setting,test_closing_the_ide}.py`, `architecture.md` §6, `architecture_explore.md` §5.5, §14, §18. +- **Open items**: the same review noted that run windows are never released (`progress.md` #13). diff --git a/docs/updates/README.md b/docs/updates/README.md index 71bbf9d6..9ac6bdb1 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-13 | 2026-09-23 | Four findings from reviewing the day's work | #incident #review | [2026-09](2026-09.md) | | U-20260923-12 | 2026-09-23 | The AI review panel kept API URLs, tokens and all | #incident #security | [2026-09](2026-09.md) | | U-20260923-11 | 2026-09-23 | Image sources, undo scopes and stacking order | #done #diagram | [2026-09](2026-09.md) | | U-20260923-10 | 2026-09-23 | A diagram that failed to load took the open one with it | #incident #diagram | [2026-09](2026-09.md) | @@ -93,4 +94,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 30 | +| [2026-09.md](2026-09.md) | 2026-09 | 31 | diff --git a/pybreeze/extend/prthinker_extend/prthinker_setting.py b/pybreeze/extend/prthinker_extend/prthinker_setting.py index d80e3a00..6d8ef0a0 100644 --- a/pybreeze/extend/prthinker_extend/prthinker_setting.py +++ b/pybreeze/extend/prthinker_extend/prthinker_setting.py @@ -48,11 +48,14 @@ # the prthinker installed from here cannot retrieve locally. RAG_MODES = ("off", "remote") -# 每種做法交給子行程的環境變數;認不得的值一律當作關掉 -# The variables each way is given through; an unknown value counts as off +# 每種做法交給子行程的環境變數;認不得的值一律當作關掉。兩個變數都一定送出: +# 子行程繼承 IDE 的環境,只送一個的話,使用者原本設的另一個會留下來作數。 +# The variables each way is given through; an unknown value counts as off. +# Both are always sent: the child inherits the IDE's environment, so leaving +# one out would let whatever the user has exported decide it. RAG_ENVIRONMENT = { - "off": {"PRTHINKER_RAG_ENABLED": "false"}, - "remote": {"PRTHINKER_REMOTE_RAG": "true"}, + "off": {"PRTHINKER_RAG_ENABLED": "false", "PRTHINKER_REMOTE_RAG": "false"}, + "remote": {"PRTHINKER_RAG_ENABLED": "true", "PRTHINKER_REMOTE_RAG": "true"}, } # 每個後端讀模型名稱的環境變數:prthinker 各後端各讀各的 @@ -196,9 +199,16 @@ def environment_for(setting: Dict[str, str]) -> Dict[str, str]: if setting.get(key, "").strip() } model = setting.get("model_name", "").strip() - model_variable = MODEL_ENVIRONMENT.get(setting.get("backend", "").strip()) + backend = setting.get("backend", "").strip() + model_variable = MODEL_ENVIRONMENT.get(backend) if model and model_variable: environment[model_variable] = model + elif model: + # Each backend reads its own variable, so without a backend there is + # nowhere to put the model; say so rather than drop it quietly. + pybreeze_logger.error( + "prthinker model %r not sent: %r is not a backend PyBreeze offers", + model, backend) rag_mode = setting.get("rag", "") environment.update(RAG_ENVIRONMENT.get(rag_mode, RAG_ENVIRONMENT["off"])) return environment diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index ddab77aa..1936807a 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -90,12 +90,14 @@ def closeEvent(self, event) -> None: run_window.close() super().closeEvent(event) - @staticmethod - def debug_close() -> None: - """ - Use to run CI test. - :return: None + def debug_close(self) -> None: + """Close the window and leave the event loop. Used by the startup tests. + + Closing first means the same cleanup as a user closing the IDE -- + stopping any run still going, saving the settings -- which quitting + the application on its own would skip. """ + self.close() app = QApplication.instance() if app is not None: app.quit() diff --git a/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py b/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py index 1f0e53cb..37d2d4f1 100644 --- a/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py +++ b/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py @@ -9,7 +9,9 @@ from pybreeze.extend.prthinker_extend.prthinker_setting import ( install_target, load_setting, save_setting ) -from pybreeze.pybreeze_ui.menu.install_menu.install_utils import install_package +from pybreeze.pybreeze_ui.menu.install_menu.install_utils import ( + install_is_possible, install_package +) if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -99,6 +101,10 @@ def install_prthinker(ui_we_want_to_set: PyBreezeMainWindow) -> None: prthinker is installed from source rather than from PyPI, so the folder is asked for once and then remembered in the prthinker settings. """ + if not install_is_possible(ui_we_want_to_set): + # Asked before the folder dialog: choosing and storing a source folder + # for an install that cannot start is work the user would repeat. + return setting = load_setting() target = install_target(setting.get("source_path", "")) if not target: diff --git a/pybreeze/pybreeze_ui/menu/install_menu/install_utils.py b/pybreeze/pybreeze_ui/menu/install_menu/install_utils.py index 5633b63b..40d2af08 100644 --- a/pybreeze/pybreeze_ui/menu/install_menu/install_utils.py +++ b/pybreeze/pybreeze_ui/menu/install_menu/install_utils.py @@ -11,6 +11,30 @@ from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow +def install_is_possible(ui_we_want_to_set: PyBreezeMainWindow) -> bool: + """Whether an install can start, telling the user once when it cannot. + + An editor tab has to be in front: the shell panel it writes to is that + tab's. A caller with work to do first -- asking for a source folder, + installing several packages in a row -- asks here before starting it. + + :param ui_we_want_to_set: the main window + :return: whether an install would start + """ + widget = ui_we_want_to_set.tab_widget.currentWidget() + if isinstance(widget, EditorWidget): + return True + pybreeze_logger.error( + "install needs an editor tab in front, not %r", type(widget).__name__) + messagebox = QMessageBox(ui_we_want_to_set) + messagebox.setWindowTitle( + language_wrapper.language_word_dict.get("install_menu_label")) + messagebox.setText( + language_wrapper.language_word_dict.get("install_need_editor_tab_message")) + messagebox.exec() + return False + + def install_package(package_text: str, ui_we_want_to_set: PyBreezeMainWindow) -> bool: """Install *package_text* with pip, in the current editor tab's shell panel. @@ -23,16 +47,9 @@ def install_package(package_text: str, ui_we_want_to_set: PyBreezeMainWindow) -> :param ui_we_want_to_set: the main window :return: whether the install started """ - widget = ui_we_want_to_set.tab_widget.currentWidget() - if not isinstance(widget, EditorWidget): - pybreeze_logger.error("install needs an editor tab in front, not %r", type(widget).__name__) - messagebox = QMessageBox(ui_we_want_to_set) - messagebox.setWindowTitle( - language_wrapper.language_word_dict.get("install_menu_label")) - messagebox.setText( - language_wrapper.language_word_dict.get("install_need_editor_tab_message")) - messagebox.exec() + if not install_is_possible(ui_we_want_to_set): return False + widget = ui_we_want_to_set.tab_widget.currentWidget() widget.python_compiler = ui_we_want_to_set.python_compiler shell_manager = ShellManager(main_window=widget) shell_manager.later_init() diff --git a/pybreeze/pybreeze_ui/menu/install_menu/tools_menu/build_tool_install_menu.py b/pybreeze/pybreeze_ui/menu/install_menu/tools_menu/build_tool_install_menu.py index 77ffc84e..4eec8ea7 100644 --- a/pybreeze/pybreeze_ui/menu/install_menu/tools_menu/build_tool_install_menu.py +++ b/pybreeze/pybreeze_ui/menu/install_menu/tools_menu/build_tool_install_menu.py @@ -24,6 +24,11 @@ def build_tool_install_menu(ui_we_want_to_set: PyBreezeMainWindow): def install_build_tools(ui_we_want_to_set: PyBreezeMainWindow) -> None: - install_package("setuptools", ui_we_want_to_set) - install_package("build", ui_we_want_to_set) - install_package("wheel", ui_we_want_to_set) + """Install the packaging tools, stopping at the first one that cannot start. + + Without an editor tab in front none of them can, and carrying on would + put the same message box in front of the user once per package. + """ + for package in ("setuptools", "build", "wheel"): + if not install_package(package, ui_we_want_to_set): + return diff --git a/test/test_utils/test_closing_the_ide.py b/test/test_utils/test_closing_the_ide.py index ec59bb26..41e79570 100644 --- a/test/test_utils/test_closing_the_ide.py +++ b/test/test_utils/test_closing_the_ide.py @@ -35,3 +35,16 @@ def test_closing_the_ide_stops_a_run_still_going(tmp_path): assert seen["running_before"] assert seen["stopped"] + + +_AUTO_CLOSE_WITH_A_RUN_GOING = _CLOSE_WITH_A_RUN_GOING.replace( + "window.close()", "window.debug_close()") + + +def test_the_auto_close_of_debug_mode_stops_a_run_too(tmp_path): + # The startup tests close the IDE this way; quitting the application on its + # own skips closeEvent, and with it every bit of cleanup. + seen = run_started_window(tmp_path, _AUTO_CLOSE_WITH_A_RUN_GOING) + + assert seen["running_before"] + assert seen["stopped"] diff --git a/test/test_utils/test_install_menu.py b/test/test_utils/test_install_menu.py index 309fcf88..6295a09f 100644 --- a/test/test_utils/test_install_menu.py +++ b/test/test_utils/test_install_menu.py @@ -40,12 +40,14 @@ class Shell: """Records the command instead of starting a shell.""" last: "Shell | None" = None + every: list = [] def __init__(self, main_window) -> None: self.main_window = main_window self.compiler_path = "shell/python" self.commands: list = [] Shell.last = self + Shell.every.append(self) def later_init(self) -> None: self.initialised = True @@ -57,6 +59,7 @@ def exec_shell(self, command) -> None: @pytest.fixture() def shell(monkeypatch): Shell.last = None + Shell.every = [] monkeypatch.setattr(install_utils, "EditorWidget", EditorTab) monkeypatch.setattr(install_utils, "ShellManager", Shell) return Shell @@ -116,3 +119,47 @@ def test_a_tab_that_is_not_an_editor_is_reported_not_ignored(self, app, shell, t assert shell.last is None assert told, "the user was told nothing" + + +class TestInstallingTheBuildTools: + def test_it_stops_at_the_first_one_that_cannot_start(self, app, shell, told): + from pybreeze.pybreeze_ui.menu.install_menu.tools_menu.build_tool_install_menu import ( + install_build_tools, + ) + + install_build_tools(Window(QWidget())) + + # One message, not one per package. + assert len(told) == 1 + assert shell.last is None + + def test_it_installs_all_three_when_it_can(self, app, shell, told): + from pybreeze.pybreeze_ui.menu.install_menu.tools_menu.build_tool_install_menu import ( + install_build_tools, + ) + + install_build_tools(Window(EditorTab(), python_compiler="python")) + + assert [one.commands[0][4] for one in shell.every] == ["setuptools", "build", "wheel"] + assert told == [] + + +class TestInstallingPrthinker: + def test_the_source_folder_is_not_asked_for_when_nothing_can_be_installed( + self, app, shell, told, monkeypatch): + from pybreeze.pybreeze_ui.menu.install_menu.automation_menu import ( + build_automation_install_menu as install_menu, + ) + + asked: list = [] + monkeypatch.setattr( + install_menu.QFileDialog, "getExistingDirectory", + lambda *args, **kwargs: asked.append(kwargs) or "") + monkeypatch.setattr(install_menu, "load_setting", lambda: {"source_path": ""}) + monkeypatch.setattr( + install_menu, "save_setting", lambda setting: asked.append(setting)) + + install_menu.install_prthinker(Window(QWidget())) + + assert asked == [], "the user was asked to choose a folder for an install that cannot start" + assert told diff --git a/test/test_utils/test_prthinker_setting.py b/test/test_utils/test_prthinker_setting.py index c7946cfa..03450cd8 100644 --- a/test/test_utils/test_prthinker_setting.py +++ b/test/test_utils/test_prthinker_setting.py @@ -99,6 +99,22 @@ def test_no_model_leaves_every_backend_its_default(self): environment = environment_for({**DEFAULT_SETTING, "backend": "anthropic"}) assert not [name for name in environment if name.endswith(("_MODEL", "_MODEL_NAME"))] + def test_a_model_with_no_backend_to_send_it_to_is_reported(self, monkeypatch): + # A stored backend PyBreeze does not offer (hand-edited, or from a newer + # build) has no variable to put the model in. Dropping it quietly would + # leave the dialog showing a model prthinker never sees. + logged: list = [] + monkeypatch.setattr( + prthinker_setting.pybreeze_logger, "error", + lambda message, *args: logged.append(message % args)) + + environment = environment_for( + {**DEFAULT_SETTING, "backend": "a-backend-from-the-future", + "model_name": "the-model"}) + + assert not [name for name in environment if name.endswith(("_MODEL", "_MODEL_NAME"))] + assert logged and "the-model" in logged[0] + class TestRuleRetrieval: # prthinker's local RAG index ships with its repository, not its package, so @@ -107,20 +123,23 @@ def test_the_choices_are_off_and_the_server(self): assert RAG_MODES == ("off", "remote") def test_it_starts_off(self): - environment = environment_for(DEFAULT_SETTING) - assert environment["PRTHINKER_RAG_ENABLED"] == "false" - assert "PRTHINKER_REMOTE_RAG" not in environment + # Both variables travel every time: the child inherits the IDE's + # environment, so one left out would be decided by whatever is exported + # in the shell PyBreeze was started from. + assert environment_for(DEFAULT_SETTING) | { + "PRTHINKER_RAG_ENABLED": "false", "PRTHINKER_REMOTE_RAG": "false", + } == environment_for(DEFAULT_SETTING) def test_remote_asks_the_server(self): environment = environment_for({**DEFAULT_SETTING, "rag": "remote"}) assert environment["PRTHINKER_REMOTE_RAG"] == "true" - assert "PRTHINKER_RAG_ENABLED" not in environment + assert environment["PRTHINKER_RAG_ENABLED"] == "true" @pytest.mark.parametrize("stored", ["local", "", "REMOTE "]) def test_anything_else_counts_as_off(self, stored): environment = environment_for({**DEFAULT_SETTING, "rag": stored}) assert environment["PRTHINKER_RAG_ENABLED"] == "false" - assert "PRTHINKER_REMOTE_RAG" not in environment + assert environment["PRTHINKER_REMOTE_RAG"] == "false" class TestTheCommandsAreBuilt: From 835a192c01d701e8087bc9b85dfacca4e01c255b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:39:16 +0800 Subject: [PATCH 032/312] End an SSH session when its widget closes Nothing in the SSH tab had a closeEvent, and Qt delivers one only to the widget being closed, so closing the tab left the reader thread running and the transport open. Every SFTP call now checks the connection through one guard instead of five of them reaching None. --- architecture_explore.md | 10 +- docs/updates/2026-09.md | 9 ++ docs/updates/README.md | 3 +- progress.md | 2 - .../connect_gui/ssh/ssh_command_widget.py | 10 ++ .../connect_gui/ssh/ssh_file_viewer_widget.py | 38 +++-- .../connect_gui/ssh/ssh_main_widget.py | 11 ++ test/test_utils/test_ssh_teardown.py | 132 ++++++++++++++++++ 8 files changed, 199 insertions(+), 16 deletions(-) create mode 100644 test/test_utils/test_ssh_teardown.py diff --git a/architecture_explore.md b/architecture_explore.md index ffdef209..df3579ec 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -289,14 +289,14 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,116 行) +### `ssh/`(1,159 行) | 檔案 | 職責 | |---|---| -| `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端 | +| `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | -| `ssh_command_widget.py` | 互動式 shell。`SSHReaderThread(QThread)` 輪詢 channel,ANSI escape 用 regex 剝除,terminal 有 block 上限,keepalive | -| `ssh_file_viewer_widget.py` (572) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序 | +| `ssh_command_widget.py` | 互動式 shell。`SSHReaderThread(QThread)` 輪詢 channel,ANSI escape 用 regex 剝除,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | +| `ssh_file_viewer_widget.py` (594) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`,`closeEvent` 關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU) | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | @@ -426,7 +426,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 76 個 `test_*.py`、1144 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 77 個 `test_*.py`、1155 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index c9a9a8c4..a140ec8e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -262,3 +262,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1125 → 1130 passed, 14 skipped; the 14 prthinker contract tests pass against prthinker `main` installed the way CI installs it. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/menu/install_menu/{install_utils,tools_menu/build_tool_install_menu,automation_menu/build_automation_install_menu}.py`, `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_install_menu,test_prthinker_setting,test_closing_the_ide}.py`, `architecture.md` §6, `architecture_explore.md` §5.5, §14, §18. - **Open items**: the same review noted that run windows are never released (`progress.md` #13). + +## U-20260923-14 · 2026-09-23 · Closing an SSH tab left its thread and session running · #incident #ssh + +- **What**: `progress.md` #9 and #11. Nothing in `connect_gui/ssh/` had a `closeEvent`. JEditor's `close_tab()` calls `close()` on the widget it removes, and Qt delivers that event to that widget alone, so closing the SSH tab (or its dock) reached neither half: `SSHCommandWidget._cleanup()` was reachable only from the Disconnect button. The reader `QThread` kept running while its widget was collected — Qt aborts the process when a running QThread is destroyed, and a queued `data_received` lands in a deleted widget — and the paramiko transport and the SFTP session stayed open, sending keepalives, for the rest of the session. Separately, five `SFTPClientWrapper` methods (`mkdir`, `remove_file`, `remove_dir`, `rename`, `download`, `upload`) skipped the connection check that `list_dir` and `is_dir` had, so acting on a stale tree showed the user `AttributeError: 'NoneType' object has no attribute 'mkdir'` through the tree's blanket handler. +- **Fix**: `SSHCommandWidget.closeEvent()` runs the same `_cleanup()` as Disconnect, `SSHFileTreeManager.closeEvent()` closes the SFTP session, and `SSHMainWidget.closeEvent()` passes the close to both halves. Every SFTP call goes through one `_require_connection()`, which raises the "not connected" message the guarded ones already raised. +- **Tests**: `test_ssh_teardown.py` (new, 11) closes a shell widget with a stand-in reader thread, channel and client and checks all three are stopped and dropped; closes a widget that never connected; closes the file tree and the whole tab; and asserts every SFTP call without a connection raises `RuntimeError` rather than reaching `None`. This is the invariant `test_ai_gui_lifecycle.py` already pins for the AI widgets. +- **Result / numbers**: unit tests 1130 → 1141 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/{ssh_command_widget,ssh_file_viewer_widget,ssh_main_widget}.py`, `test/test_utils/test_ssh_teardown.py` (new), `architecture_explore.md` §9, §18, `progress.md` (#9, #11 removed). +- **Open items**: `progress.md` #10 (SSH and SFTP work still runs on the UI thread) and #12 (one Connect button, two sessions) remain. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9ac6bdb1..9be3f3d0 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-14 | 2026-09-23 | Closing an SSH tab left its thread and session running | #incident #ssh | [2026-09](2026-09.md) | | U-20260923-13 | 2026-09-23 | Four findings from reviewing the day's work | #incident #review | [2026-09](2026-09.md) | | U-20260923-12 | 2026-09-23 | The AI review panel kept API URLs, tokens and all | #incident #security | [2026-09](2026-09.md) | | U-20260923-11 | 2026-09-23 | Image sources, undo scopes and stacking order | #done #diagram | [2026-09](2026-09.md) | @@ -94,4 +95,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 31 | +| [2026-09.md](2026-09.md) | 2026-09 | 32 | diff --git a/progress.md b/progress.md index 5fc0d256..b1e61a88 100644 --- a/progress.md +++ b/progress.md @@ -10,9 +10,7 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#3** [UNVERIFIED] One unit test failed once, in a full run of `test/test_utils/` that took 282 s instead of the usual 35–60 s while the machine was busy; its name was not captured. Nine later full runs passed, three of them in parallel. Candidates are the tests that wait on a clock: `test_run_output.py` (30 s per child), `started_window.py` (120 s per IDE start). Record the name if it happens again. - **#5** A diagram's image downloads run on the UI thread (`_try_load_image_source` -> `safe_download_image`, 15 s each) and the pixmap is not part of the undo snapshot, so every undo and redo re-fetches every URL image: three unreachable ones freeze the IDE for ~45 s per Ctrl+Z, and an image whose host is offline silently blanks on an unrelated undo. Cache the pixmap on the item and fetch off the UI thread. - **#8** The AI review panel sends its request on the UI thread (`pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py:send_request`), so a slow endpoint freezes the whole IDE for the connect timeout plus 30 s per read, with no progress and no cancel, and repeated clicks queue more. `SkillsSendGUI`'s `RequestThread` (`extend_ai_gui/skills_send_gui.py`) is the pattern to follow, including its guard against a second send. -- **#9** The SSH tab and dock never tear down (`connect_gui/ssh/`): `SSHMainWidget` has no `closeEvent`, JEditor's `close_tab()` delivers the close event only to the widget it closes, and `SSHCommandWidget._cleanup()` is reachable only from the Disconnect button. Closing the tab leaves `SSHReaderThread` running while its widget is collected (Qt aborts on a QThread destroyed while running, and a queued signal into a deleted widget raises inside a slot), and leaks the paramiko transport and the SFTP session. `test_ai_gui_lifecycle.py` pins this invariant for the AI widgets; the SSH ones need the same. - **#10** All SSH and SFTP work runs on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s), and `get()`/`put()`/`listdir_attr()` have no timeout at all, so a large download or an unresponsive host freezes the IDE until it finishes. Moving it off the UI thread has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. -- **#11** Five `SFTPClientWrapper` methods skip the `connected` guard the others have (`ssh_file_viewer_widget.py`: `mkdir`, `remove_file`, `remove_dir`, `rename`, `download`, `upload`), so acting on a stale tree after a dropped session shows the user `AttributeError: 'NoneType' object has no attribute 'mkdir'` instead of the "not connected" message. - **#12** One Connect button drives two independent SSH sessions (`ssh_main_widget.py` wires `LoginWidget.connect_btn` to both the shell and the file tree), so a server with the SFTP subsystem disabled leaves the shell connected while the tree reports failure, and the status label shows whichever finished last. - **#13** Run windows are never released: `current_run_code_window` only ever grows (`pybreeze_ui/editor_main/main_ui.py`), and each entry now holds its executor, two queues and a timer as well as the window. A long session keeps one per run, and one per mis-click that opens the "no script tab" window. 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 002b7e5e..0d87de57 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -255,6 +255,16 @@ def disconnect_ssh(self): self.login_widget.status_label.setText( self.word_dict.get('ssh_command_widget_status_label_disconnected')) + def closeEvent(self, event) -> None: + """End the session with the widget. + + The reader thread must not outlive it: Qt aborts the process when a + running QThread is destroyed, and a queued signal from one lands in a + widget that is already gone. + """ + self._cleanup() + super().closeEvent(event) + def _cleanup(self): try: if self.reader_thread: diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 00764a2f..8aaca4f7 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -132,15 +132,24 @@ def connected(self) -> bool: """ return self._ssh is not None and self._sftp is not None - def list_dir(self, path: str): - """ - List directory entries with stat attributes. - 列出目錄項目(含屬性)。 + def _require_connection(self) -> None: + """Raise unless a session is open. + + Every call below goes through this: without it an operation on a stale + tree (a dropped session, a closed connection) reaches ``None`` and the + tree's error box shows the user an ``AttributeError`` about ``NoneType``. """ if not self.connected: raise RuntimeError( self.word_dict.get("ssh_command_widget_dialog_title_not_connected") ) + + def list_dir(self, path: str): + """ + List directory entries with stat attributes. + 列出目錄項目(含屬性)。 + """ + self._require_connection() return self._sftp.listdir_attr(path) def is_dir(self, path: str) -> bool: @@ -148,10 +157,7 @@ def is_dir(self, path: str) -> bool: Determine if target path is a directory. 判斷路徑是否為目錄。 """ - if not self.connected: - raise RuntimeError(self.word_dict.get( - "ssh_command_widget_dialog_title_not_connected" - )) + self._require_connection() try: st = self._sftp.stat(path) return stat.S_ISDIR(st.st_mode) @@ -163,6 +169,7 @@ def mkdir(self, path: str): Create directory. 建立目錄。 """ + self._require_connection() self._sftp.mkdir(path) def remove_file(self, path: str): @@ -170,6 +177,7 @@ def remove_file(self, path: str): Remove file. 刪除檔案。 """ + self._require_connection() self._sftp.remove(path) def remove_dir(self, path: str): @@ -177,6 +185,7 @@ def remove_dir(self, path: str): Remove empty directory. 刪除空目錄。 """ + self._require_connection() self._sftp.rmdir(path) def rename(self, old_path: str, new_path: str): @@ -184,6 +193,7 @@ def rename(self, old_path: str, new_path: str): Rename file/folder. 重新命名檔案/資料夾。 """ + self._require_connection() self._sftp.rename(old_path, new_path) def download(self, remote_path: str, local_path: str): @@ -191,6 +201,7 @@ def download(self, remote_path: str, local_path: str): Download remote to local. 下載遠端檔案至本地。 """ + self._require_connection() self._sftp.get(remote_path, local_path) def upload(self, local_path: str, remote_path: str): @@ -198,6 +209,7 @@ def upload(self, local_path: str, remote_path: str): Upload local to remote. 上傳本地檔案至遠端。 """ + self._require_connection() self._sftp.put(local_path, remote_path) @@ -274,6 +286,16 @@ def _connect(self): self.word_dict.get("ssh_file_viewer_dialog_title_connection_failed"), f"{self.word_dict.get('ssh_file_viewer_dialog_message_connection_failed')}: {e}") + def closeEvent(self, event) -> None: + """Close the SFTP session with the widget. + + Qt delivers a close event only to the widget being closed, so a tab or a + dock closing takes this route; without it the paramiko transport stays + open, sending keepalives, for the rest of the session. + """ + self.client.close() + super().closeEvent(event) + def _disconnect(self): """ Disconnect SSH. diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py index f82f3825..c0efe001 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py @@ -44,6 +44,17 @@ def __init__(self, parent=None): self.setLayout(main_layout) + def closeEvent(self, event) -> None: + """Close both halves with the tab. + + Qt delivers the close event only to the widget being closed, and each + half owns its own session. + """ + self.command_widget.close() + self.file_tree.close() + super().closeEvent(event) + + if __name__ == "__main__": app = QApplication(sys.argv) win = SSHMainWidget() diff --git a/test/test_utils/test_ssh_teardown.py b/test/test_utils/test_ssh_teardown.py new file mode 100644 index 00000000..7d1cff61 --- /dev/null +++ b/test/test_utils/test_ssh_teardown.py @@ -0,0 +1,132 @@ +"""Closing an SSH tab, and what the SFTP wrapper does when nothing is connected. + +A closed tab must leave no reader thread running and no transport open: Qt aborts +the process when a running QThread is destroyed, and a queued signal from one +lands in a widget that is already gone. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SFTPClientWrapper + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeReaderThread: + def __init__(self) -> None: + self.stopped = False + self.waited = False + self.signals_blocked = False + + def blockSignals(self, blocked: bool) -> None: + self.signals_blocked = blocked + + def stop(self) -> None: + self.stopped = True + + def wait(self, _timeout: int) -> bool: + self.waited = True + return True + + +class FakeChannel: + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + +class FakeClient: + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + +class TestClosingTheShell: + def _connected_widget(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import SSHCommandWidget + + widget = SSHCommandWidget() + widget.reader_thread = FakeReaderThread() + widget.shell_channel = FakeChannel() + widget.ssh_client = FakeClient() + return widget + + def test_closing_the_widget_stops_the_reader_and_the_session(self, app): + widget = self._connected_widget(app) + reader, channel, client = widget.reader_thread, widget.shell_channel, widget.ssh_client + + widget.close() + + assert reader.stopped and reader.waited + assert channel.closed + assert client.closed + assert widget.reader_thread is None + + def test_closing_a_widget_that_never_connected_is_harmless(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import SSHCommandWidget + + SSHCommandWidget().close() + + +class TestClosingTheFileTree: + def test_closing_the_widget_closes_the_sftp_session(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SSHFileTreeManager + + widget = SSHFileTreeManager() + widget.client = FakeClient() + + widget.close() + + assert widget.client.closed + + +class TestClosingTheTab: + def test_both_halves_are_closed_with_it(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_main_widget import SSHMainWidget + + tab = SSHMainWidget() + tab.command_widget.reader_thread = FakeReaderThread() + tab.command_widget.ssh_client = FakeClient() + tab.file_tree.client = FakeClient() + reader = tab.command_widget.reader_thread + + tab.close() + + assert reader.stopped + assert tab.command_widget.ssh_client is None + assert tab.file_tree.client.closed + + +class TestTheSftpWrapperWithoutAConnection: + @pytest.mark.parametrize("call", [ + lambda w: w.mkdir("/tmp/x"), + lambda w: w.remove_file("/tmp/x"), + lambda w: w.remove_dir("/tmp/x"), + lambda w: w.rename("/tmp/x", "/tmp/y"), + lambda w: w.download("/tmp/x", "x"), + lambda w: w.upload("x", "/tmp/x"), + lambda w: w.list_dir("/tmp"), + ], ids=["mkdir", "remove_file", "remove_dir", "rename", "download", "upload", "list_dir"]) + def test_it_says_it_is_not_connected(self, app, call): + # Not an AttributeError about NoneType, which the tree's blanket handler + # would put in front of the user word for word. + wrapper = SFTPClientWrapper() + + with pytest.raises(RuntimeError): + call(wrapper) From 980fa3c356960374ac9a4469cd5b1dfba9b2d9da Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:45:43 +0800 Subject: [PATCH 033/312] Send the AI review request off the UI thread The request ran in the button's slot, so a slow endpoint froze the whole IDE for the timeouts with no way to cancel, and every further click queued another. It now runs on a QThread like the skills panel, ignores a click while one is in flight, and is waited for on close. --- architecture_explore.md | 8 +- docs/updates/2026-09.md | 9 ++ docs/updates/README.md | 3 +- progress.md | 1 - .../connect_gui/url/ai_code_review_gui.py | 115 ++++++++++++----- test/test_utils/test_ai_code_review_client.py | 120 +++++++++++++++--- 6 files changed, 204 insertions(+), 52 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index df3579ec..7612cb8c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -302,7 +302,11 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ### `url/ai_code_review_gui.py` -獨立的 HTTP client widget:送出程式碼給審查端點、接受/拒絕回覆並記錄統計到 `~/.pybreeze/response_stats.txt`、URL 歷史存 `urls.txt`。 +獨立的 HTTP client widget:送出程式碼給審查端點、接受/拒絕回覆並記錄統計到 `~/.pybreeze/response_stats.txt`。 + +- 請求走 `ReviewRequestThread(QThread)`,只有 `answered` / `failed` 兩個 signal 碰 UI(和 `SkillsSendGUI` 同一套);送出中再按不會重送,`closeEvent` 會等它結束 +- `urls.txt` 只存 URL 的 SHA-256 指紋(`url_fingerprint()`):API URL 可能帶權杖,依 CLAUDE.md 要當憑證看待;舊版留下的明文檔會在下次送出時改寫成指紋 +- 非 2xx(含不跟隨的轉址)會把狀態碼寫進面板,不會只留空白 --- @@ -426,7 +430,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 77 個 `test_*.py`、1155 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 77 個 `test_*.py`、1160 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index a140ec8e..d080080d 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -271,3 +271,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1130 → 1141 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/{ssh_command_widget,ssh_file_viewer_widget,ssh_main_widget}.py`, `test/test_utils/test_ssh_teardown.py` (new), `architecture_explore.md` §9, §18, `progress.md` (#9, #11 removed). - **Open items**: `progress.md` #10 (SSH and SFTP work still runs on the UI thread) and #12 (one Connect button, two sessions) remain. + +## U-20260923-15 · 2026-09-23 · The AI review request froze the IDE while it waited · #done #ai + +- **What**: `progress.md` #8. The AI review panel sent its request in the button's slot: `requests.*` with a 10 s connect and 30 s read timeout, then a streamed body under a 16 MB cap, all on the UI thread. A slow endpoint froze every tab, the terminal and the run windows with it, with no progress and no way to cancel, and each further click queued another blocking request. `SkillsSendGUI` — the same request shape, the same helpers — had been doing it on a `QThread` all along. +- **Fix**: `ReviewRequestThread(QThread)` runs one request and reports through `answered` / `failed`, the only two things that touch the UI. `send_request()` ignores a click while one is in flight (replacing a running QThread risks destroying it mid-run and letting a stale answer overwrite a newer one), disables the send button until the answer arrives, and refuses a method it does not offer before starting anything. `closeEvent()` waits for a request still in flight with its signals blocked, the pattern `test_ai_gui_lifecycle.py` pins for the other AI widgets. URL validation moved into the thread with the request: it resolves the hostname, which blocks too. +- **Tests**: `test_ai_code_review_client.py` now drives the thread directly — an answer, a failed status, a request that fails without logging the URL, and a hostname that cannot be resolved being reported rather than raised — plus the in-flight guard, the close that waits, and an unsupported method starting nothing. +- **Result / numbers**: unit tests 1141 → 1146 passed, 14 skipped; both startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `test/test_utils/test_ai_code_review_client.py`, `architecture_explore.md` §9, §18, `progress.md` (#8 removed). +- **Open items**: none for this panel. SSH is still on the UI thread (`progress.md` #10). diff --git a/docs/updates/README.md b/docs/updates/README.md index 9be3f3d0..3a49d39c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-15 | 2026-09-23 | The AI review request froze the IDE while it waited | #done #ai | [2026-09](2026-09.md) | | U-20260923-14 | 2026-09-23 | Closing an SSH tab left its thread and session running | #incident #ssh | [2026-09](2026-09.md) | | U-20260923-13 | 2026-09-23 | Four findings from reviewing the day's work | #incident #review | [2026-09](2026-09.md) | | U-20260923-12 | 2026-09-23 | The AI review panel kept API URLs, tokens and all | #incident #security | [2026-09](2026-09.md) | @@ -95,4 +96,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 32 | +| [2026-09.md](2026-09.md) | 2026-09 | 33 | diff --git a/progress.md b/progress.md index b1e61a88..82dccfeb 100644 --- a/progress.md +++ b/progress.md @@ -9,7 +9,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` already exists), ask first, or keep going on purpose (for example a long load test that mails its report)? `CodeWindow` has no `closeEvent` today. - **#3** [UNVERIFIED] One unit test failed once, in a full run of `test/test_utils/` that took 282 s instead of the usual 35–60 s while the machine was busy; its name was not captured. Nine later full runs passed, three of them in parallel. Candidates are the tests that wait on a clock: `test_run_output.py` (30 s per child), `started_window.py` (120 s per IDE start). Record the name if it happens again. - **#5** A diagram's image downloads run on the UI thread (`_try_load_image_source` -> `safe_download_image`, 15 s each) and the pixmap is not part of the undo snapshot, so every undo and redo re-fetches every URL image: three unreachable ones freeze the IDE for ~45 s per Ctrl+Z, and an image whose host is offline silently blanks on an unrelated undo. Cache the pixmap on the item and fetch off the UI thread. -- **#8** The AI review panel sends its request on the UI thread (`pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py:send_request`), so a slow endpoint freezes the whole IDE for the connect timeout plus 30 s per read, with no progress and no cancel, and repeated clicks queue more. `SkillsSendGUI`'s `RequestThread` (`extend_ai_gui/skills_send_gui.py`) is the pattern to follow, including its guard against a second send. - **#10** All SSH and SFTP work runs on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s), and `get()`/`put()`/`listdir_attr()` have no timeout at all, so a large download or an unresponsive host freezes the IDE until it finishes. Moving it off the UI thread has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. - **#12** One Connect button drives two independent SSH sessions (`ssh_main_widget.py` wires `LoginWidget.connect_btn` to both the shell and the file tree), so a server with the SFTP subsystem disabled leaves the shell connected while the tree reports failure, and the status label shows whichever finished last. - **#13** Run windows are never released: `current_run_code_window` only ever grows (`pybreeze_ui/editor_main/main_ui.py`), and each entry now holds its executor, two queues and a timer as well as the window. A long session keeps one per run, and one per mis-click that opens the "no script tab" window. 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 4fc0a021..d64e16a0 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 @@ -4,6 +4,7 @@ from pathlib import Path import requests +from PySide6.QtCore import QThread, Signal from PySide6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, QTextEdit, QComboBox, QLabel, QSizePolicy @@ -35,6 +36,54 @@ def looks_like_a_fingerprint(line: str) -> bool: """Whether *line* is already a fingerprint rather than a URL.""" return len(line) == _FINGERPRINT_LENGTH and all(c in "0123456789abcdef" for c in line) +# The methods the panel offers / 面板提供的方法 +SUPPORTED_METHODS = ("GET", "POST", "PUT", "DELETE") +# The methods that carry the code in a body / 會把程式碼放進 body 的方法 +METHODS_WITH_A_BODY = ("POST", "PUT") + + +class ReviewRequestThread(QThread): + """One review request, off the UI thread. + + The IDE must stay usable while an endpoint thinks: the request can take the + connect timeout plus 30 s per read, and the body is streamed under a cap + after that. Only these two signals touch the UI, and they are delivered on + the UI thread. + """ + + answered = Signal(str) + failed = Signal(str) + + def __init__(self, method: str, url: str, code_text: str) -> None: + super().__init__() + self._method = method + self._url = url + self._code_text = code_text + + def run(self) -> None: + try: + validate_url(self._url) + response = self._send() + body = read_capped_text(response) + if response.ok: + self.answered.emit(body) + else: + # Without this a 302 (redirects are not followed) or a 500 with + # an empty body would leave the panel looking like a success. + self.answered.emit(f"HTTP {response.status_code} {response.reason}\n{body}") + except (requests.RequestException, ResponseTooLargeError, UnsafeURLError) as error: + # Not %r: a requests error carries the whole URL, which may hold a token. + pybreeze_logger.error("AI code review request failed: %s", type(error).__name__) + self.failed.emit(str(error)) + + def _send(self) -> requests.Response: + """Send the request with the chosen method, code in the body where there is one.""" + send = getattr(requests, self._method.lower()) + options = {"timeout": (CONNECT_TIMEOUT, 30), "allow_redirects": False, "stream": True} + if self._method in METHODS_WITH_A_BODY: + return send(self._url, data={"code": self._code_text}, **options) + return send(self._url, **options) + class AICodeReviewClient(QWidget): def __init__(self): @@ -44,6 +93,8 @@ def __init__(self): "ai_code_review_gui_window_title" )) + # 目前在飛的請求 / The request in flight, if any + self.request_thread: ReviewRequestThread | None = None # 記錄接受/拒絕次數 self.accept_count = 0 self.reject_count = 0 @@ -134,7 +185,14 @@ def __init__(self): self.setLayout(main_layout) def send_request(self): - """Send HTTP request and display result""" + """Start a review request; the answer reaches the panel when it arrives. + + Nothing is sent while one is in flight: replacing a running QThread here + would risk destroying it mid-run and let a stale answer overwrite a newer + one. + """ + if self.request_thread is not None and self.request_thread.isRunning(): + return url = self.url_input.text().strip() method = self.method_box.currentText() code_content = self.code_input.toPlainText().strip() @@ -143,12 +201,9 @@ def send_request(self): self.response_panel.setPlainText( self.word_dict.get("ai_code_review_gui_message_enter_valid_url")) return - - try: - validate_url(url) - except UnsafeURLError as e: + if method not in SUPPORTED_METHODS: self.response_panel.setPlainText( - f"{self.word_dict.get('ai_code_review_gui_message_error')}: {e}") + self.word_dict.get("ai_code_review_gui_message_unsupported_http_method")) return # 這個 URL 之前送過嗎 / Has this URL been sent before? @@ -159,30 +214,30 @@ def send_request(self): self.response_panel.setPlainText( self.word_dict.get("ai_code_review_gui_message_url_already_recorded")) - try: - if method == "GET": - response = requests.get(url, timeout=(CONNECT_TIMEOUT, 30), allow_redirects=False, stream=True) - elif method == "POST": - response = requests.post(url, data={"code": code_content}, timeout=(CONNECT_TIMEOUT, 30), allow_redirects=False, stream=True) - elif method == "PUT": - response = requests.put(url, data={"code": code_content}, timeout=(CONNECT_TIMEOUT, 30), allow_redirects=False, stream=True) - elif method == "DELETE": - response = requests.delete(url, timeout=(CONNECT_TIMEOUT, 30), allow_redirects=False, stream=True) - else: - self.response_panel.setPlainText( - self.word_dict.get("ai_code_review_gui_message_unsupported_http_method")) - return - - if not response.ok: - self.response_panel.append( - f"{self.word_dict.get('ai_code_review_gui_message_error')}: " - f"HTTP {response.status_code} {response.reason}") - self.response_panel.append(read_capped_text(response)) - - except (requests.RequestException, ResponseTooLargeError) as e: - # Not %r: a requests error carries the whole URL, which may hold a token. - pybreeze_logger.error("AI code review request failed: %s", type(e).__name__) - self.response_panel.setPlainText(f"{self.word_dict.get('ai_code_review_gui_message_error')}: {e}") + self.send_button.setEnabled(False) + self.request_thread = ReviewRequestThread(method, url, code_content) + self.request_thread.answered.connect(self.on_answered) + self.request_thread.failed.connect(self.on_failed) + self.request_thread.start() + + def on_answered(self, body: str) -> None: + """Show what came back and let the next request be sent.""" + self.response_panel.append(body) + self.send_button.setEnabled(True) + + def on_failed(self, message: str) -> None: + """Show why nothing came back and let the next request be sent.""" + self.response_panel.setPlainText( + f"{self.word_dict.get('ai_code_review_gui_message_error')}: {message}") + self.send_button.setEnabled(True) + + def closeEvent(self, event) -> None: + """Wait for a request still in flight, so its thread is not destroyed mid-run.""" + thread = self.request_thread + if thread is not None and thread.isRunning(): + thread.blockSignals(True) + thread.wait() + super().closeEvent(event) def record_url(self, url: str) -> bool: """Record *url* as sent, and say whether it had not been sent before. diff --git a/test/test_utils/test_ai_code_review_client.py b/test/test_utils/test_ai_code_review_client.py index 7e37c22b..1749d4da 100644 --- a/test/test_utils/test_ai_code_review_client.py +++ b/test/test_utils/test_ai_code_review_client.py @@ -76,41 +76,125 @@ def test_a_file_from_an_older_version_is_rewritten_as_fingerprints(self, client) assert all(looks_like_a_fingerprint(line) for line in text.split() if line) -class TestSending: - def _answer_with(self, monkeypatch, response): +class TestTheRequestItself: + """The request runs on its own thread; its two signals are what reaches the UI.""" + + def _run(self, monkeypatch, response=None, raises=None, method="GET"): + from pybreeze.pybreeze_ui.connect_gui.url.ai_code_review_gui import ReviewRequestThread + monkeypatch.setattr(ai_code_review_gui, "validate_url", lambda url: url) - monkeypatch.setattr(ai_code_review_gui.requests, "get", lambda *a, **k: response) - monkeypatch.setattr(ai_code_review_gui, "read_capped_text", lambda resp: resp.text) + if raises is None: + monkeypatch.setattr( + ai_code_review_gui.requests, method.lower(), lambda *a, **k: response) + monkeypatch.setattr(ai_code_review_gui, "read_capped_text", lambda resp: resp.text) + else: + def refuse(*_args, **_kwargs): + raise raises + + monkeypatch.setattr(ai_code_review_gui.requests, method.lower(), refuse) + thread = ReviewRequestThread(method, _A_URL, "print(1)") + answered: list = [] + failed: list = [] + thread.answered.connect(answered.append) + thread.failed.connect(failed.append) + thread.run() + return answered, failed + + def test_an_answer_reaches_the_panel(self, app, monkeypatch): + class Response: + ok = True + status_code = 200 + reason = "OK" + text = "looks fine to me" + + answered, failed = self._run(monkeypatch, Response()) - def test_a_failed_status_is_reported_instead_of_an_empty_panel(self, client, monkeypatch): + assert answered == ["looks fine to me"] + assert failed == [] + + def test_a_failed_status_is_reported_instead_of_an_empty_panel(self, app, monkeypatch): class Response: ok = False status_code = 500 reason = "Internal Server Error" text = "" - self._answer_with(monkeypatch, Response()) - client.url_input.setText(_A_URL) - - client.send_request() + answered, failed = self._run(monkeypatch, Response()) - assert "500" in client.response_panel.toPlainText() + assert answered and "500" in answered[0] - def test_a_request_that_fails_does_not_log_the_url(self, client, monkeypatch): + def test_a_request_that_fails_does_not_log_the_url(self, app, monkeypatch): logged: list = [] - monkeypatch.setattr(ai_code_review_gui, "validate_url", lambda url: url) monkeypatch.setattr( ai_code_review_gui.pybreeze_logger, "error", lambda message, *args: logged.append(message % args)) + error = ai_code_review_gui.requests.ConnectionError( + f"HTTPSConnectionPool(host='api.example'): Max retries exceeded with url: {_A_URL}") + + answered, failed = self._run(monkeypatch, raises=error) + + assert failed, "the panel was told nothing" + assert logged and all("sk-live-not-a-real-key" not in line for line in logged) + + def test_a_url_that_cannot_be_sent_to_is_reported_not_raised(self, app, monkeypatch): + from pybreeze.pybreeze_ui.connect_gui.url.ai_code_review_gui import ReviewRequestThread + + thread = ReviewRequestThread("GET", "http://.example", "print(1)") + failed: list = [] + thread.failed.connect(failed.append) + + thread.run() + + assert failed - def refuse(*_args, **_kwargs): - raise ai_code_review_gui.requests.ConnectionError( - f"HTTPSConnectionPool(host='api.example'): Max retries exceeded with url: {_A_URL}") - monkeypatch.setattr(ai_code_review_gui.requests, "get", refuse) +class TestWhileARequestIsInFlight: + class NeverEnding: + def isRunning(self) -> bool: + return True + + def test_a_second_send_is_ignored(self, client, monkeypatch): + started: list = [] + monkeypatch.setattr( + ai_code_review_gui, "ReviewRequestThread", + lambda *args: started.append(args)) + client.url_input.setText(_A_URL) + client.request_thread = self.NeverEnding() + + client.send_request() + + assert started == [] + + def test_closing_waits_for_it(self, client): + class Waited: + def __init__(self) -> None: + self.waited = False + self.blocked = False + + def isRunning(self) -> bool: + return True + + def blockSignals(self, blocked: bool) -> None: + self.blocked = blocked + + def wait(self) -> None: + self.waited = True + + client.request_thread = Waited() + + client.close() + + assert client.request_thread.waited and client.request_thread.blocked + + def test_an_unsupported_method_says_so_and_starts_nothing(self, client, monkeypatch): + started: list = [] + monkeypatch.setattr( + ai_code_review_gui, "ReviewRequestThread", lambda *args: started.append(args)) client.url_input.setText(_A_URL) + client.method_box.addItem("TRACE") + client.method_box.setCurrentText("TRACE") client.send_request() - assert logged, "nothing was logged" - assert all("sk-live-not-a-real-key" not in line for line in logged) + assert started == [] + assert client.response_panel.toPlainText() From d5ee233bbda7fc7687055352c69c7ba1916e995c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:49:39 +0800 Subject: [PATCH 034/312] Fetch a diagram's images once, off the UI thread Images were downloaded in the UI thread from the same path undo and redo take, so every Ctrl+Z re-fetched every image and froze the IDE for as long as the host took. They are fetched on a QThread and kept for the session. --- architecture.md | 2 + architecture_explore.md | 8 +- docs/updates/2026-09.md | 9 + docs/updates/README.md | 3 +- progress.md | 1 - .../diagram_editor/diagram_editor_widget.py | 5 + .../diagram_editor/diagram_scene.py | 93 +++++++++-- test/test_utils/test_diagram_images.py | 156 ++++++++++++++++++ 8 files changed, 259 insertions(+), 18 deletions(-) create mode 100644 test/test_utils/test_diagram_images.py diff --git a/architecture.md b/architecture.md index 10e37704..7414730a 100644 --- a/architecture.md +++ b/architecture.md @@ -189,6 +189,8 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() - Every outbound request to a user URL passes SSRF validation (`utils/network/url_validation.py`) with timeouts and size caps (§ Security › Network). - SSH uses the interactive host-key policy, never auto-add (§ Security › SSH). +- Work that waits on a network — an AI review request, a diagram's image downloads — runs on a + `QThread` and reaches the UI only through signals; the widget or scene waits for it on close. - Subprocesses use argument lists, `shell=False` and a `timeout`, and pass secrets through `env` (§ Security › Subprocess). - The JupyterLab server stays localhost-only (§ Security › JupyterLab). diff --git a/architecture_explore.md b/architecture_explore.md index 7612cb8c..56e8fa0f 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -234,12 +234,12 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,450 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,524 行,最大子系統) | 檔案 | 職責 | |---|---| -| `diagram_editor_widget.py` (594) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | -| `diagram_scene.py` (712) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統 | +| `diagram_editor_widget.py` (599) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | +| `diagram_scene.py` (781) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路 | | `diagram_items.py` (884) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | | `diagram_mermaid_parser.py` (532) | Mermaid flowchart → diagram dict。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (421) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | @@ -430,7 +430,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 77 個 `test_*.py`、1160 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 78 個 `test_*.py`、1166 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index d080080d..25a28047 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -280,3 +280,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1141 → 1146 passed, 14 skipped; both startup tests exit 0. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `test/test_utils/test_ai_code_review_client.py`, `architecture_explore.md` §9, §18, `progress.md` (#8 removed). - **Open items**: none for this panel. SSH is still on the UI thread (`progress.md` #10). + +## U-20260923-16 · 2026-09-23 · A diagram fetched its images again on every undo · #done #diagram + +- **What**: `progress.md` #5. A diagram's URL images were fetched with `safe_download_image()` straight from `_try_load_image_source()`, on the UI thread, with a 15 s timeout each — and that path is reached from `_restore_from_dict()`, which is what undo and redo call. So a diagram with three images on a slow host froze the IDE for up to 45 s on opening it and again on every Ctrl+Z, and an image whose host had gone away came back blank after an undo of something else entirely, because the pixmap was never part of the snapshot. +- **Fix**: `ImageDownloadThread(QThread)` fetches one source and reports through `fetched` / `failed`; the scene keeps what comes back in `_pixmap_cache` for the session and hands it to every image with that source. A second image with the same source joins the fetch already going instead of starting another. Undo and redo take the cached pixmap and touch the network not at all. `DiagramEditorWidget.closeEvent()` waits for anything still in flight with its signals blocked, so no QThread is destroyed while running. `http.client.HTTPException` — what a hostile or broken server can raise through `urlopen` — is caught with the rest. +- **Tests**: `test_diagram_images.py` (new, 6): a fetch that has not answered yet does not hold up the load (the fake blocks on an event, and the load is back before it answers), what comes back reaches the image, two undos fetch nothing again, two images from one source share a fetch, a failed fetch is only logged, and closing the editor leaves no fetch going. +- **Result / numbers**: unit tests 1146 → 1152 passed, 14 skipped. `ruff check` clean, and both quality gates (`PLR0913`/`C901`, and `complexipy` at 15) report nothing. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_editor_widget}.py`, `test/test_utils/test_diagram_images.py` (new), `architecture.md` §7, `architecture_explore.md` §7, §18, `progress.md` (#5 removed). +- **Open items**: none for the diagram editor. diff --git a/docs/updates/README.md b/docs/updates/README.md index 3a49d39c..c1cebd4d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-16 | 2026-09-23 | A diagram fetched its images again on every undo | #done #diagram | [2026-09](2026-09.md) | | U-20260923-15 | 2026-09-23 | The AI review request froze the IDE while it waited | #done #ai | [2026-09](2026-09.md) | | U-20260923-14 | 2026-09-23 | Closing an SSH tab left its thread and session running | #incident #ssh | [2026-09](2026-09.md) | | U-20260923-13 | 2026-09-23 | Four findings from reviewing the day's work | #incident #review | [2026-09](2026-09.md) | @@ -96,4 +97,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 33 | +| [2026-09.md](2026-09.md) | 2026-09 | 34 | diff --git a/progress.md b/progress.md index 82dccfeb..2b9892aa 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` already exists), ask first, or keep going on purpose (for example a long load test that mails its report)? `CodeWindow` has no `closeEvent` today. - **#3** [UNVERIFIED] One unit test failed once, in a full run of `test/test_utils/` that took 282 s instead of the usual 35–60 s while the machine was busy; its name was not captured. Nine later full runs passed, three of them in parallel. Candidates are the tests that wait on a clock: `test_run_output.py` (30 s per child), `started_window.py` (120 s per IDE start). Record the name if it happens again. -- **#5** A diagram's image downloads run on the UI thread (`_try_load_image_source` -> `safe_download_image`, 15 s each) and the pixmap is not part of the undo snapshot, so every undo and redo re-fetches every URL image: three unreachable ones freeze the IDE for ~45 s per Ctrl+Z, and an image whose host is offline silently blanks on an unrelated undo. Cache the pixmap on the item and fetch off the UI thread. - **#10** All SSH and SFTP work runs on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s), and `get()`/`put()`/`listdir_attr()` have no timeout at all, so a large download or an unresponsive host freezes the IDE until it finishes. Moving it off the UI thread has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. - **#12** One Connect button drives two independent SSH sessions (`ssh_main_widget.py` wires `LoginWidget.connect_btn` to both the shell and the file tree), so a server with the SFTP subsystem disabled leaves the shell connected while the tree reports failure, and the status label shows whichever finished last. - **#13** Run windows are never released: `current_run_code_window` only ever grows (`pybreeze_ui/editor_main/main_ui.py`), and each entry now holds its executor, two queues and a timer as well as the window. A long session keeps one per run, and one per mis-click that opens the "no script tab" window. diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 63bb4c5e..52aed218 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -446,6 +446,11 @@ def _import_mermaid(self) -> None: str(e), ) + def closeEvent(self, event) -> None: + """Wait for the scene's image fetches before the editor goes.""" + self._scene.stop_image_downloads() + super().closeEvent(event) + def _write_json(self, path: Path) -> None: """Write the diagram to *path*, leaving whatever is there now if it fails. diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 4a27755c..5ca4f6dc 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -1,10 +1,11 @@ from __future__ import annotations from contextlib import contextmanager +from http.client import HTTPException from enum import Enum, auto from pathlib import Path, PureWindowsPath -from PySide6.QtCore import QPointF, Qt, Signal +from PySide6.QtCore import QPointF, Qt, QThread, Signal from PySide6.QtGui import QColor, QPen, QPixmap, QUndoStack from PySide6.QtWidgets import QGraphicsLineItem, QGraphicsScene, QMenu from je_editor import language_wrapper @@ -62,6 +63,31 @@ class ToolMode(Enum): } +class ImageDownloadThread(QThread): + """Fetch one image for the canvas, off the UI thread. + + A download is bounded by ``safe_download_image``'s own 15 s timeout, which is + 15 s the IDE would otherwise spend frozen -- once per image, and again on + every undo, because an undo rebuilds every item from the saved dictionary. + Only the two signals reach the UI. + """ + + fetched = Signal(str, bytes) + failed = Signal(str, str) + + def __init__(self, source: str) -> None: + super().__init__() + self._source = source + + def run(self) -> None: + try: + data = safe_download_image(self._source) + except (ImageDownloadError, OSError, HTTPException) as err: + self.failed.emit(self._source, str(err)) + else: + self.fetched.emit(self._source, data) + + class DiagramScene(QGraphicsScene): """QGraphicsScene with tool-mode state, undo/redo, grid, copy/paste, and align.""" @@ -84,6 +110,10 @@ def __init__(self, parent=None): self._pending_undo_snapshot: dict | None = None self._pending_undo_desc: str | None = None + # Images already loaded, and the fetches still going, by source + self._pixmap_cache: dict[str, QPixmap] = {} + self._image_downloads: dict[str, ImageDownloadThread] = {} + # Grid self._grid_enabled = False self._grid_size = 20 @@ -666,11 +696,18 @@ def _load_images(self, image_dicts: list) -> None: self._try_load_image_source(img, source) def _try_load_image_source(self, img: DiagramImage, source: str) -> None: - """Load pixmap from local path or URL into a DiagramImage. + """Put the image *source* names into *img*, fetching it if it is a URL. - Local paths are restricted to existing image files on this machine. - URLs are validated and size-limited via ``safe_download_image``. + Local paths are restricted to existing image files on this machine. A URL + is validated and size-limited by ``safe_download_image``, on its own + thread, and what comes back is kept for the rest of the session: an undo + rebuilds every item, and re-fetching each time froze the IDE for as long + as the host took to answer. """ + cached = self._pixmap_cache.get(source) + if cached is not None: + img.set_pixmap(cached, source) + return path = Path(source) # The extension is checked before the filesystem is touched, and a path # on another machine is refused outright: on Windows, merely asking @@ -682,17 +719,49 @@ def _try_load_image_source(self, img: DiagramImage, source: str) -> None: and _is_on_this_machine(source) and path.is_file()): pix = QPixmap(str(path)) if not pix.isNull(): + self._pixmap_cache[source] = pix img.set_pixmap(pix, source) return if source.startswith(("http://", "https://")): # NOSONAR S5332 — scheme detection; actual fetch goes through safe_download_image with SSRF validation - try: - data = safe_download_image(source) - pix = QPixmap() - pix.loadFromData(data) - if not pix.isNull(): - img.set_pixmap(pix, source) - except (ImageDownloadError, OSError) as err: - pybreeze_logger.debug("safe_download_image failed: %s", err) + self._start_image_download(source) + + def _start_image_download(self, source: str) -> None: + """Fetch *source* in the background, unless a fetch for it is already going.""" + if source in self._image_downloads: + return + thread = ImageDownloadThread(source) + thread.fetched.connect(self._on_image_fetched) + thread.failed.connect(self._on_image_download_failed) + thread.finished.connect(lambda: self._image_downloads.pop(source, None)) + self._image_downloads[source] = thread + thread.start() + + def _on_image_fetched(self, source: str, data: bytes) -> None: + """Hand a fetched image to every item still waiting for it. UI thread.""" + pix = QPixmap() + pix.loadFromData(data) + if pix.isNull(): + pybreeze_logger.debug("Fetched image is not an image: %s", source) + return + self._pixmap_cache[source] = pix + for image in self.get_all_images(): + if image.source() == source: + image.set_pixmap(pix, source) + + @staticmethod + def _on_image_download_failed(source: str, message: str) -> None: + pybreeze_logger.debug("safe_download_image failed for %s: %s", source, message) + + def stop_image_downloads(self) -> None: + """Wait for every fetch still going, with its signals blocked. + + Called when the editor closes: a running QThread destroyed with the + scene aborts the process, and a late signal would reach a dead scene. + """ + for thread in tuple(self._image_downloads.values()): + thread.blockSignals(True) + thread.wait() + self._image_downloads.clear() def load_from_dict(self, data: dict) -> None: """Replace what is on the canvas with *data*. diff --git a/test/test_utils/test_diagram_images.py b/test/test_utils/test_diagram_images.py new file mode 100644 index 00000000..6d833af0 --- /dev/null +++ b/test/test_utils/test_diagram_images.py @@ -0,0 +1,156 @@ +"""Where a diagram's images come from, and when the IDE waits for them. + +A URL image is fetched on its own thread and kept for the session: an undo +rebuilds every item from the saved dictionary, and fetching again each time was +15 seconds of frozen IDE per image and per undo. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtGui import QColor, QImage +from PySide6.QtWidgets import QApplication + +from pybreeze.pybreeze_ui.diagram_editor import diagram_scene as scene_module +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + +_A_URL = "https://pictures.example/logo.png" + +_A_DIAGRAM = { + "nodes": [], + "connections": [], + "images": [{"x": 0, "y": 0, "w": 100, "h": 100, "source": _A_URL}], +} + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def an_image() -> bytes: + """A real PNG, as bytes, so QPixmap has something to load.""" + from PySide6.QtCore import QBuffer, QByteArray + + image = QImage(4, 4, QImage.Format.Format_RGB32) + image.fill(QColor("red")) + data = QByteArray() + buffer = QBuffer(data) + buffer.open(QBuffer.OpenModeFlag.WriteOnly) + image.save(buffer, "PNG") + buffer.close() + return bytes(data.data()) + + +@pytest.fixture() +def downloads(monkeypatch) -> list: + """Record every fetch, and answer each with a real image.""" + asked: list = [] + + def fetch(source: str) -> bytes: + asked.append(source) + return an_image() + + monkeypatch.setattr(scene_module, "safe_download_image", fetch) + return asked + + +def wait_for_downloads(scene: DiagramScene, app) -> None: + for thread in tuple(scene._image_downloads.values()): + thread.wait(5000) + app.processEvents() + + +class TestFetchingAnImage: + def test_the_load_does_not_wait_for_the_network(self, app, monkeypatch): + import threading + + answering = threading.Event() + started = threading.Event() + + def fetch(_source: str) -> bytes: + started.set() + answering.wait(10) + return an_image() + + monkeypatch.setattr(scene_module, "safe_download_image", fetch) + scene = DiagramScene() + + scene.load_from_dict(_A_DIAGRAM) + + # The host has not answered yet and the load is already back. + assert started.wait(5), "the fetch never started" + assert scene._image_downloads, "the load waited for the fetch" + answering.set() + wait_for_downloads(scene, app) + scene.stop_image_downloads() + + def test_what_comes_back_reaches_the_image(self, app, downloads): + scene = DiagramScene() + scene.load_from_dict(_A_DIAGRAM) + + wait_for_downloads(scene, app) + + assert downloads == [_A_URL] + assert scene._pixmap_cache[_A_URL].isNull() is False + scene.stop_image_downloads() + + def test_an_undo_does_not_fetch_it_again(self, app, downloads): + scene = DiagramScene() + scene.load_from_dict(_A_DIAGRAM) + wait_for_downloads(scene, app) + + # What undo/redo does: rebuild every item from the same dictionary. + scene._restore_from_dict(_A_DIAGRAM) + scene._restore_from_dict(_A_DIAGRAM) + + assert downloads == [_A_URL], "the image was fetched again" + images = scene.get_all_images() + assert images and not images[0]._pix_item.pixmap().isNull() + scene.stop_image_downloads() + + def test_two_images_from_one_source_share_a_single_fetch(self, app, downloads): + scene = DiagramScene() + + scene.load_from_dict({ + **_A_DIAGRAM, + "images": [ + {"x": 0, "y": 0, "w": 100, "h": 100, "source": _A_URL}, + {"x": 200, "y": 0, "w": 100, "h": 100, "source": _A_URL}, + ], + }) + wait_for_downloads(scene, app) + + assert downloads == [_A_URL] + scene.stop_image_downloads() + + def test_a_fetch_that_fails_is_only_logged(self, app, monkeypatch): + def refuse(source: str) -> bytes: + raise scene_module.ImageDownloadError("nothing answered") + + monkeypatch.setattr(scene_module, "safe_download_image", refuse) + scene = DiagramScene() + + scene.load_from_dict(_A_DIAGRAM) + wait_for_downloads(scene, app) + + assert _A_URL not in scene._pixmap_cache + assert scene.get_all_images() + scene.stop_image_downloads() + + +class TestClosingTheEditor: + def test_it_waits_for_a_fetch_still_going(self, app, downloads): + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + update_language_dict() + editor = DiagramEditorWidget() + editor._scene.load_from_dict(_A_DIAGRAM) + + editor.close() + + assert editor._scene._image_downloads == {} From 890162fcc5f3013a6db56141285d0cf63d56dbf1 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:56:11 +0800 Subject: [PATCH 035/312] Let go of a run window the user closed Every run left a window, its executor, its queues and its timer on the main window for the rest of the session. A closed window with nothing running in it is dropped; one whose run is still going stays, since that is where the output goes. --- architecture_explore.md | 5 ++- docs/updates/2026-09.md | 9 ++++ docs/updates/README.md | 3 +- progress.md | 1 - .../process_executor_utils.py | 28 +++++++++++- pybreeze/pybreeze_ui/editor_main/main_ui.py | 3 +- .../menu/plugin_menu/build_run_with_menu.py | 7 ++- .../show_code_window/code_window.py | 22 ++++++++++ test/test_utils/test_build_task_process.py | 43 +++++++++++++++++++ test/test_utils/test_prthinker_process.py | 10 +++++ 10 files changed, 120 insertions(+), 11 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 56e8fa0f..e12e0118 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -112,7 +112,8 @@ Template Method 定義的子行程生命週期: | `start_process()` | 建 `CodeWindow` + `TaskProcessManager` → `start_test_process()` | | `build_process_from_file()` | 以檔案路徑執行單一檔案 | | `run_dir_files_with_package()` | 問使用者選資料夾,對每個 `.json` 開一個執行視窗批次跑 | -| `build_task_process()` | 共用建構:建 `CodeWindow` 並帶上主視窗選定的直譯器、掛進 `main_window.current_run_code_window`、決定要不要接 `send_after_test`。建好的 `TaskProcessManager` 掛在執行視窗的 `runner` 上,所以呼叫端可以不留參考。prthinker 審查也走這裡 | +| `open_run_window()` | 開一個執行視窗、掛進 `main_window.current_run_code_window`,並接上 `finished_and_closed`:使用者關掉一個已經跑完的執行視窗時主視窗就放掉它(以前這份清單只增不減,每次執行都留下一個視窗、一個執行器、兩個 queue 和一個 timer)。插件執行也走這裡 | +| `build_task_process()` | 共用建構:`open_run_window()` 並帶上主視窗選定的直譯器、決定要不要接 `send_after_test`。建好的 `TaskProcessManager` 掛在執行視窗的 `runner` 上,所以呼叫端可以不留參考。prthinker 審查也走這裡 | ### 4.3 各自動化模組(Strategy) @@ -430,7 +431,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 78 個 `test_*.py`、1166 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 78 個 `test_*.py`、1169 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 25a28047..267b1678 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -289,3 +289,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1146 → 1152 passed, 14 skipped. `ruff check` clean, and both quality gates (`PLR0913`/`C901`, and `complexipy` at 15) report nothing. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_editor_widget}.py`, `test/test_utils/test_diagram_images.py` (new), `architecture.md` §7, `architecture_explore.md` §7, §18, `progress.md` (#5 removed). - **Open items**: none for the diagram editor. + +## U-20260923-17 · 2026-09-23 · Run windows were kept for the whole session · #done #process-executor + +- **What**: `progress.md` #13. `current_run_code_window` only ever grew: every run, and every mis-click that opens the "no script tab" window, left a `CodeWindow` on the main window for the rest of the session — and since U-20260922-18 each also holds its executor, its two queues and its timer. Closing the window changed nothing. +- **Fix**: `CodeWindow.closeEvent()` emits `finished_and_closed` when nothing is running in it, and `open_run_window()` — the one place a run window is opened and registered now, used by both executors — has the main window drop it then. A window whose run is still going stays in the list: that is where the rest of the output goes, and it is the list the IDE's close stops the runs from. The IDE's close now walks a copy of the list, since a window that closes drops itself from it. +- **Tests**: `test_build_task_process.py` gains a closed window with nothing running being let go of, one with a run still going being kept, and closing three in a row leaving none behind. +- **Result / numbers**: unit tests 1152 → 1155 passed, 14 skipped; the startup test exits 0. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/extend/process_executor/process_executor_utils.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_build_task_process,test_prthinker_process}.py`, `architecture_explore.md` §4.2, §18. +- **Open items**: `progress.md` #2 is still the owner's to decide: whether closing a run window should stop the run it shows. diff --git a/docs/updates/README.md b/docs/updates/README.md index c1cebd4d..c4f23aff 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-17 | 2026-09-23 | Run windows were kept for the whole session | #done #process-executor | [2026-09](2026-09.md) | | U-20260923-16 | 2026-09-23 | A diagram fetched its images again on every undo | #done #diagram | [2026-09](2026-09.md) | | U-20260923-15 | 2026-09-23 | The AI review request froze the IDE while it waited | #done #ai | [2026-09](2026-09.md) | | U-20260923-14 | 2026-09-23 | Closing an SSH tab left its thread and session running | #incident #ssh | [2026-09](2026-09.md) | @@ -97,4 +98,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 34 | +| [2026-09.md](2026-09.md) | 2026-09 | 35 | diff --git a/progress.md b/progress.md index 2b9892aa..d85d9911 100644 --- a/progress.md +++ b/progress.md @@ -10,5 +10,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#3** [UNVERIFIED] One unit test failed once, in a full run of `test/test_utils/` that took 282 s instead of the usual 35–60 s while the machine was busy; its name was not captured. Nine later full runs passed, three of them in parallel. Candidates are the tests that wait on a clock: `test_run_output.py` (30 s per child), `started_window.py` (120 s per IDE start). Record the name if it happens again. - **#10** All SSH and SFTP work runs on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s), and `get()`/`put()`/`listdir_attr()` have no timeout at all, so a large download or an unresponsive host freezes the IDE until it finishes. Moving it off the UI thread has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. - **#12** One Connect button drives two independent SSH sessions (`ssh_main_widget.py` wires `LoginWidget.connect_btn` to both the shell and the file tree), so a server with the SFTP subsystem disabled leaves the shell connected while the tree reports failure, and the status label shows whichever finished last. -- **#13** Run windows are never released: `current_run_code_window` only ever grows (`pybreeze_ui/editor_main/main_ui.py`), and each entry now holds its executor, two queues and a timer as well as the window. A long session keeps one per run, and one per mis-click that opens the "no script tab" window. diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index 27ca20c9..25deafb7 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -114,6 +114,31 @@ def run_dir_files_with_package( pybreeze_logger.error("%s multi file error: %r", package, error) +def open_run_window(main_window: PyBreezeMainWindow, title: str = "") -> CodeWindow: + """Open a run window and keep it on the main window until it is closed. + + The main window holds every run window (that is what stops the runs when + it closes) and lets go of one that the user closes once its run is over. + + :param main_window: the main window + :param title: the window's title, when it has one of its own + :return: the run window + """ + code_window = CodeWindow() + if title: + code_window.setWindowTitle(title) + main_window.current_run_code_window.append(code_window) + code_window.finished_and_closed.connect( + lambda: forget_run_window(main_window, code_window)) + return code_window + + +def forget_run_window(main_window: PyBreezeMainWindow, code_window: CodeWindow) -> None: + """Drop *code_window* from the main window's list, if it is still there.""" + if code_window in main_window.current_run_code_window: + main_window.current_run_code_window.remove(code_window) + + def build_task_process( main_window: PyBreezeMainWindow, send_mail: bool = False, @@ -127,9 +152,8 @@ def build_task_process( ``venv`` / ``.venv`` in the working directory, then to ``PATH``. The run window holds the manager (``CodeWindow.runner``), so a caller may drop it. """ - code_window = CodeWindow() + code_window = open_run_window(main_window) code_window.python_compiler = main_window.python_compiler - main_window.current_run_code_window.append(code_window) main_window.clear_code_result() code_window.runner = TaskProcessManager( code_window, diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index 1936807a..5cc66d5f 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -85,7 +85,8 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, def closeEvent(self, event) -> None: # A run's child outlives the IDE unless stopped here: it is a separate # process, and without a console nobody would see it still running. - for run_window in self.current_run_code_window: + # Over a copy: a window that closes drops itself from the list. + for run_window in tuple(self.current_run_code_window): run_window.stop_runner() run_window.close() super().closeEvent(event) diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index 3195819a..f6d865ff 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -18,7 +18,7 @@ from je_editor.utils.file.save.save_file import write_file_with_encoding from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess -from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow +from pybreeze.extend.process_executor.process_executor_utils import open_run_window if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -71,9 +71,8 @@ def run_current_file_with(main_window: PyBreezeMainWindow, run_config: dict) -> msg.exec() return - code_window = CodeWindow() - code_window.setWindowTitle(f"{run_config['name']} - {Path(file_path).name}") - main_window.current_run_code_window.append(code_window) + code_window = open_run_window( + main_window, f"{run_config['name']} - {Path(file_path).name}") code_window.runner = FileRunnerProcess( main_window=code_window, diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index 4b1f6e55..04cc47cd 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict +from PySide6.QtCore import Signal from PySide6.QtGui import QGuiApplication, QTextCharFormat, QTextCursor from PySide6.QtWidgets import QWidget, QGridLayout, QTextEdit, QScrollArea @@ -27,6 +28,11 @@ def normalize_line_endings(text: str) -> str: class CodeWindow(QWidget): + # Emitted when a window closes with nothing left running in it, so the + # main window can let go of it: run windows are kept in a list that only + # ever grew, one per run, each holding its executor, queues and timer. + finished_and_closed = Signal() + def __init__(self): # UI used to show run code or shell command result. super().__init__() @@ -59,6 +65,22 @@ def __init__(self): self.setLayout(self.grid_layout) self.setFocus() + def closeEvent(self, event) -> None: + """Let the main window forget this one, unless its run is still going. + + A run that is still going keeps its window in the list: it is where + the rest of the output goes, and closing the IDE stops it from there. + """ + if not self.is_running(): + self.finished_and_closed.emit() + super().closeEvent(event) + + def is_running(self) -> bool: + """Whether the executor writing here still has a child running.""" + runner = self.runner + process = getattr(runner, "process", None) if runner is not None else None + return process is not None and process.poll() is None + def stop_runner(self) -> None: """Stop the child this window shows, if one is still running.""" if self.runner is not None: diff --git a/test/test_utils/test_build_task_process.py b/test/test_utils/test_build_task_process.py index 3c194ac9..86709cd4 100644 --- a/test/test_utils/test_build_task_process.py +++ b/test/test_utils/test_build_task_process.py @@ -115,3 +115,46 @@ def test_a_script_given_outright_runs_without_any_tab(self, qt_app, monkeypatch) assert started and started[0][2] == "{}" assert main_window.current_run_code_window == [] + + +class TestLettingGoOfARunWindow: + """The main window holds every run window; a closed one whose run is over goes.""" + + def test_a_closed_window_with_nothing_running_is_let_go_of(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import open_run_window + + main_window = MainWindow() + run_window = open_run_window(main_window) + + run_window.close() + + assert main_window.current_run_code_window == [] + + def test_a_window_whose_run_is_still_going_is_kept(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import open_run_window + + class StillRunning: + class process: + @staticmethod + def poll(): + return None + + main_window = MainWindow() + run_window = open_run_window(main_window) + run_window.runner = StillRunning() + + run_window.close() + + assert main_window.current_run_code_window == [run_window] + + def test_closing_one_window_does_not_skip_the_others(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import open_run_window + + main_window = MainWindow() + windows = [open_run_window(main_window) for _ in range(3)] + + for window in tuple(main_window.current_run_code_window): + window.close() + + assert main_window.current_run_code_window == [] + assert all(not window.isVisible() for window in windows) diff --git a/test/test_utils/test_prthinker_process.py b/test/test_utils/test_prthinker_process.py index f74e04c4..9f8e3b5c 100644 --- a/test/test_utils/test_prthinker_process.py +++ b/test/test_utils/test_prthinker_process.py @@ -33,11 +33,21 @@ def clear_code_result(self) -> None: self.cleared = True +class Signalled: + """The one signal a run window offers its main window.""" + + def connect(self, _slot) -> None: + """Nothing listens here: the stand-in is never closed.""" + + class RunWindow: """Stands in for the run window the review's output goes to.""" python_compiler = None + def __init__(self) -> None: + self.finished_and_closed = Signalled() + class RecordingProcess: """Stands in for the task process manager and remembers the call.""" From edbd126b9d4a42663ab44c33f0605fa16f825902 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 01:59:21 +0800 Subject: [PATCH 036/312] Copy and paste a diagram's images with the rest An image could not be copied or duplicated: the clipboard took nodes only, and a copy with just an image selected left the previous clipboard in place for the next paste. --- architecture_explore.md | 6 +-- docs/updates/2026-09.md | 9 ++++ docs/updates/README.md | 3 +- .../diagram_editor/diagram_scene.py | 35 ++++++++++++-- test/test_utils/test_diagram_images.py | 47 +++++++++++++++++++ 5 files changed, 93 insertions(+), 7 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index e12e0118..133f3d87 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -235,12 +235,12 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,524 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,553 行,最大子系統) | 檔案 | 職責 | |---|---| | `diagram_editor_widget.py` (599) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | -| `diagram_scene.py` (781) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路 | +| `diagram_scene.py` (810) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路 | | `diagram_items.py` (884) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | | `diagram_mermaid_parser.py` (532) | Mermaid flowchart → diagram dict。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (421) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | @@ -431,7 +431,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 78 個 `test_*.py`、1169 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 78 個 `test_*.py`、1172 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 267b1678..5038295f 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -298,3 +298,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1152 → 1155 passed, 14 skipped; the startup test exits 0. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/extend/process_executor/process_executor_utils.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_build_task_process,test_prthinker_process}.py`, `architecture_explore.md` §4.2, §18. - **Open items**: `progress.md` #2 is still the owner's to decide: whether closing a run window should stop the run it shows. + +## U-20260923-18 · 2026-09-23 · Copy and duplicate ignored images · #done #diagram + +- **What**: one of the smaller items from the diagram audit. `copy_selected()` collected only nodes and returned early when none were selected, so `Ctrl+C` / `Ctrl+D` with an image selected did nothing — or, worse, `Ctrl+V` afterwards pasted whatever nodes had been copied earlier, because the clipboard was left as it was. An image could never be copied or duplicated at all. +- **Fix**: the clipboard carries images alongside the nodes and connections, and a paste adds them at the same offset and selects them with the rest. The offset both use is `_PASTE_OFFSET` rather than a repeated 30. Loading a diagram that has two nodes with the same `id` now says so in the log: the later one still wins, but connections to that id silently pointing at a different node was worth a line. +- **Tests**: `test_diagram_images.py` gains duplicating an image on its own (two images, one offset by 30), copying a node and an image together, and a copy with nothing selected leaving the clipboard as it was. +- **Result / numbers**: unit tests 1155 → 1158 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `test/test_utils/test_diagram_images.py`, `architecture_explore.md` §7, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c4f23aff..775d4e53 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-18 | 2026-09-23 | Copy and duplicate ignored images | #done #diagram | [2026-09](2026-09.md) | | U-20260923-17 | 2026-09-23 | Run windows were kept for the whole session | #done #process-executor | [2026-09](2026-09.md) | | U-20260923-16 | 2026-09-23 | A diagram fetched its images again on every undo | #done #diagram | [2026-09](2026-09.md) | | U-20260923-15 | 2026-09-23 | The AI review request froze the IDE while it waited | #done #ai | [2026-09](2026-09.md) | @@ -98,4 +99,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 35 | +| [2026-09.md](2026-09.md) | 2026-09 | 36 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 5ca4f6dc..b21722a3 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -28,6 +28,9 @@ # Allowlist of image extensions that a saved diagram may reference on disk. # Defined once at module scope because it is a security boundary (only these # local files are read back when reloading a ``.diagram.json``). +# How far a pasted copy sits from the original, so it is visible as a copy +_PASTE_OFFSET = 30 + _VALID_IMAGE_SUFFIXES = frozenset( {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".svg", ".webp", ".ico"} ) @@ -434,8 +437,14 @@ def select_all(self) -> None: item.setSelected(True) def copy_selected(self) -> None: + """Put the selected items on the clipboard, in the diagram's own format. + + A connection comes along only when both of its ends do; an image comes + along on its own, which is also what ``Ctrl+D`` on one needs. + """ nodes = [i for i in self.selectedItems() if isinstance(i, DiagramNode)] - if not nodes: + images = [i for i in self.selectedItems() if isinstance(i, DiagramImage)] + if not nodes and not images: return node_set = set(nodes) connections = [ @@ -447,6 +456,7 @@ def copy_selected(self) -> None: self._clipboard = { "nodes": [n.to_dict(node_map[n]) for n in nodes], "connections": [c.to_dict(node_map) for c in connections], + "images": [image.to_dict(index) for index, image in enumerate(images)], } def paste_clipboard(self) -> None: @@ -457,8 +467,8 @@ def paste_clipboard(self) -> None: id_to_node: dict[int, DiagramNode] = {} for nd in self._clipboard["nodes"]: data = dict(nd) - data["x"] += 30 - data["y"] += 30 + data["x"] += _PASTE_OFFSET + data["y"] += _PASTE_OFFSET node = DiagramNode.from_dict(data) self.addItem(node) node.setSelected(True) @@ -475,8 +485,22 @@ def paste_clipboard(self) -> None: style=ConnectionStyle.__members__.get(cd.get("style", "SOLID"), ConnectionStyle.SOLID), ) self.addItem(conn) + self._paste_images() self.item_count_changed.emit() + def _paste_images(self) -> None: + """Add the clipboard's images, offset like its nodes and selected with them.""" + for image_dict in self._clipboard.get("images", ()): + data = dict(image_dict) + data["x"] += _PASTE_OFFSET + data["y"] += _PASTE_OFFSET + image = DiagramImage.from_dict(data) + self.addItem(image) + image.setSelected(True) + source = data.get("source", "") + if source: + self._try_load_image_source(image, source) + def duplicate_selected(self) -> None: self.copy_selected() self.paste_clipboard() @@ -648,6 +672,11 @@ def _load_nodes(self, node_dicts: list) -> dict[int, DiagramNode]: try: node_id = nd.get("id") if node_id is not None: + if node_id in id_to_node: + # Later wins, as before; connections to that id now + # point at this node, which is worth a line in the log. + pybreeze_logger.debug( + "Diagram has more than one node with id %r", node_id) id_to_node[node_id] = node except TypeError as err: # An id that cannot be a key (a list, say): the node is on the diff --git a/test/test_utils/test_diagram_images.py b/test/test_utils/test_diagram_images.py index 6d833af0..49842bbc 100644 --- a/test/test_utils/test_diagram_images.py +++ b/test/test_utils/test_diagram_images.py @@ -154,3 +154,50 @@ def test_it_waits_for_a_fetch_still_going(self, app, downloads): editor.close() assert editor._scene._image_downloads == {} + + +class TestCopyingAnImage: + def _scene_with_an_image(self): + scene = DiagramScene() + scene.load_from_dict({ + "nodes": [], "connections": [], + "images": [{"x": 0, "y": 0, "w": 100, "h": 100, "source": "", "caption": "Logo"}], + }) + return scene + + def test_an_image_on_its_own_can_be_duplicated(self, app): + scene = self._scene_with_an_image() + scene.get_all_images()[0].setSelected(True) + + scene.duplicate_selected() + + images = scene.get_all_images() + assert len(images) == 2 + assert {image.text() for image in images} == {"Logo"} + assert {(image.pos().x(), image.pos().y()) for image in images} == {(0.0, 0.0), (30.0, 30.0)} + + def test_a_node_and_an_image_are_copied_together(self, app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode, NodeShape + + scene = self._scene_with_an_image() + node = DiagramNode(x=0, y=0, w=100, h=60, text="A", shape=NodeShape.RECTANGLE) + scene.addItem(node) + for item in scene.get_all_images() + scene.get_all_nodes(): + item.setSelected(True) + + scene.copy_selected() + scene.clearSelection() + scene.paste_clipboard() + + assert len(scene.get_all_images()) == 2 + assert len(scene.get_all_nodes()) == 2 + + def test_nothing_selected_leaves_the_clipboard_alone(self, app): + scene = self._scene_with_an_image() + scene.get_all_images()[0].setSelected(True) + scene.copy_selected() + + scene.clearSelection() + scene.copy_selected() + + assert scene._clipboard["images"], "the clipboard was emptied by a copy of nothing" From f4e236adfa65b130657554d5242f3a7c3e246766 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 02:03:49 +0800 Subject: [PATCH 037/312] Run an SFTP transfer off the UI thread A download or upload has no timeout and a file can be any size, so a transfer in the context-menu handler froze the IDE until it finished. One runs at a time, in the background, and the close waits for it. --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 9 ++ docs/updates/README.md | 3 +- progress.md | 2 +- .../extend_multi_language/extend_english.py | 3 + .../extend_traditional_chinese.py | 3 + .../connect_gui/ssh/ssh_file_viewer_widget.py | 106 +++++++++++++++--- test/test_utils/test_ssh_teardown.py | 98 ++++++++++++++++ 8 files changed, 211 insertions(+), 19 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 133f3d87..49ba7d71 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -290,14 +290,14 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,159 行) +### `ssh/`(1,237 行) | 檔案 | 職責 | |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。`SSHReaderThread(QThread)` 輪詢 channel,ANSI escape 用 regex 剝除,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (594) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`,`closeEvent` 關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (672) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個,`closeEvent` 等它結束再關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU) | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | @@ -431,7 +431,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 78 個 `test_*.py`、1172 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 78 個 `test_*.py`、1176 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 5038295f..1a087fbd 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -307,3 +307,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1155 → 1158 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `test/test_utils/test_diagram_images.py`, `architecture_explore.md` §7, §18. - **Open items**: none. + +## U-20260923-19 · 2026-09-23 · An SFTP transfer held the IDE until it finished · #done #ssh + +- **What**: part of `progress.md` #10. `SFTPClient.get()` and `put()` have no timeout of their own and a file can be any size, and both ran in the context-menu handler: downloading a large file, or one over a link that stalls, froze every tab, every run window and the editor until it finished, with no progress and no way to stop it. +- **Fix**: `SftpTransferThread(QThread)` runs one transfer and reports through `done` / `failed`; the tree starts it, says so when one is already going (a paramiko SFTP session is not meant to be used from two places at once), shows where the file ended up when it finishes, and refreshes the folder after an upload only then. `closeEvent()` waits for a transfer still going with its signals blocked before closing the session. +- **Tests**: `test_ssh_teardown.py` gains four: a download whose fake blocks on an event returns to the caller while the thread is still going, a second transfer is refused and the user told, a transfer that fails is reported rather than raised, and closing waits for one still going. +- **Result / numbers**: unit tests 1158 → 1162 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/test_ssh_teardown.py`, `architecture_explore.md` §9, §18. +- **Open items**: the rest of `progress.md` #10 — connecting and listing a directory still run on the UI thread, and moving `connect()` needs the host-key prompt routed back through a signal. diff --git a/docs/updates/README.md b/docs/updates/README.md index 775d4e53..22e03a6e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-19 | 2026-09-23 | An SFTP transfer held the IDE until it finished | #done #ssh | [2026-09](2026-09.md) | | U-20260923-18 | 2026-09-23 | Copy and duplicate ignored images | #done #diagram | [2026-09](2026-09.md) | | U-20260923-17 | 2026-09-23 | Run windows were kept for the whole session | #done #process-executor | [2026-09](2026-09.md) | | U-20260923-16 | 2026-09-23 | A diagram fetched its images again on every undo | #done #diagram | [2026-09](2026-09.md) | @@ -99,4 +100,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 36 | +| [2026-09.md](2026-09.md) | 2026-09 | 37 | diff --git a/progress.md b/progress.md index d85d9911..d6459df5 100644 --- a/progress.md +++ b/progress.md @@ -8,6 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` already exists), ask first, or keep going on purpose (for example a long load test that mails its report)? `CodeWindow` has no `closeEvent` today. - **#3** [UNVERIFIED] One unit test failed once, in a full run of `test/test_utils/` that took 282 s instead of the usual 35–60 s while the machine was busy; its name was not captured. Nine later full runs passed, three of them in parallel. Candidates are the tests that wait on a clock: `test_run_output.py` (30 s per child), `started_window.py` (120 s per IDE start). Record the name if it happens again. -- **#10** All SSH and SFTP work runs on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s), and `get()`/`put()`/`listdir_attr()` have no timeout at all, so a large download or an unresponsive host freezes the IDE until it finishes. Moving it off the UI thread has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. +- **#10** SSH connecting and directory listing still run on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s) and `listdir_attr()` by nothing at all, so an unresponsive host or a directory with tens of thousands of entries freezes the IDE. Transfers moved off it in U-20260923-19; moving `connect()` has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. - **#12** One Connect button drives two independent SSH sessions (`ssh_main_widget.py` wires `LoginWidget.connect_btn` to both the shell and the file tree), so a server with the SFTP subsystem disabled leaves the shell connected while the tree reports failure, and the status label shows whichever finished last. diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 4b1994a3..6123cb15 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -195,6 +195,9 @@ "ssh_file_viewer_dialog_title_invalid_selection": "Invalid selection", "ssh_file_viewer_dialog_message_select_file_to_download": "Select a file to download.", "ssh_file_viewer_dialog_title_save_as": "Save as", + "ssh_file_viewer_dialog_title_transfer_running": "A transfer is already running", + "ssh_file_viewer_dialog_message_transfer_running": + "One file at a time: wait for the transfer that is running to finish.", "ssh_file_viewer_dialog_title_downloaded": "Downloaded", "ssh_file_viewer_dialog_message_saved_to": "Saved to", "ssh_file_viewer_dialog_title_select_local_file": "Select local file to upload", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 79fbb33f..8390432c 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -195,6 +195,9 @@ "ssh_file_viewer_dialog_title_invalid_selection": "選取無效", "ssh_file_viewer_dialog_message_select_file_to_download": "請選擇要下載的檔案。", "ssh_file_viewer_dialog_title_save_as": "另存新檔", + "ssh_file_viewer_dialog_title_transfer_running": "已有傳輸進行中", + "ssh_file_viewer_dialog_message_transfer_running": + "一次只傳一個檔案:請等目前的傳輸結束。", "ssh_file_viewer_dialog_title_downloaded": "已下載", "ssh_file_viewer_dialog_message_saved_to": "已儲存至", "ssh_file_viewer_dialog_title_select_local_file": "選擇要上傳的本地檔案", diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 8aaca4f7..52e1b426 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -6,7 +6,7 @@ import stat import paramiko -from PySide6.QtCore import Qt, QEvent +from PySide6.QtCore import Qt, QEvent, QThread, Signal from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QLineEdit, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu, QFileDialog, QMessageBox, QSplitter, QInputDialog, QStyle @@ -213,6 +213,39 @@ def upload(self, local_path: str, remote_path: str): self._sftp.put(local_path, remote_path) +class SftpTransferThread(QThread): + """One SFTP transfer, off the UI thread. + + A transfer has no timeout of its own, and a file can be any size: run in the + button's slot, a download over a stalled link holds every tab, every run + window and the editor itself until it finishes. Only the two signals reach + the UI, and only one transfer runs at a time -- a paramiko SFTP session is + not meant to be used from two places at once. + """ + + done = Signal(str) + failed = Signal(str) + + def __init__(self, client: "SFTPClientWrapper", downloading: bool, + remote_path: str, local_path: str) -> None: + super().__init__() + self._client = client + self._downloading = downloading + self._remote_path = remote_path + self._local_path = local_path + + def run(self) -> None: + try: + if self._downloading: + self._client.download(self._remote_path, self._local_path) + else: + self._client.upload(self._local_path, self._remote_path) + except (OSError, RuntimeError, paramiko.SSHException) as error: + self.failed.emit(str(error)) + else: + self.done.emit(self._local_path if self._downloading else self._remote_path) + + class SSHFileTreeManager(QWidget): """ QWidget: connection form + tree + context menu. @@ -228,6 +261,8 @@ def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: self.add_login_widget = add_login_widget self.client = SFTPClientWrapper() + # The transfer in flight, if any / 正在進行的傳輸 + self._transfer: SftpTransferThread | None = None if self.add_login_widget: # 使用獨立的登入介面 @@ -291,8 +326,14 @@ def closeEvent(self, event) -> None: Qt delivers a close event only to the widget being closed, so a tab or a dock closing takes this route; without it the paramiko transport stays - open, sending keepalives, for the rest of the session. - """ + open, sending keepalives, for the rest of the session. A transfer still + going is waited for first, with its signals blocked: a QThread destroyed + while running aborts the process. + """ + transfer = self._transfer + if transfer is not None and transfer.isRunning(): + transfer.blockSignals(True) + transfer.wait() self.client.close() super().closeEvent(event) @@ -543,11 +584,10 @@ def action_download(self, item: QTreeWidgetItem | None): suggested) if not local_path: return - self.client.download(remote_path, local_path) - QMessageBox.information( - self, - self.word_dict.get("ssh_file_viewer_dialog_title_downloaded"), - f"{self.word_dict.get('ssh_file_viewer_dialog_message_saved_to')}: {local_path}") + self._start_transfer( + downloading=True, remote_path=remote_path, local_path=local_path, + title=self.word_dict.get("ssh_file_viewer_dialog_title_downloaded"), + message=self.word_dict.get("ssh_file_viewer_dialog_message_saved_to")) def action_upload(self, item: QTreeWidgetItem | None): """ @@ -563,13 +603,51 @@ def action_upload(self, item: QTreeWidgetItem | None): return filename = os.path.basename(local_path) # local path: OS-native separator is correct remote_path = remote_join(target_dir, filename) - self.client.upload(local_path, remote_path) - QMessageBox.information( + self._start_transfer( + downloading=False, remote_path=remote_path, local_path=local_path, + title=self.word_dict.get("ssh_file_viewer_dialog_title_uploaded"), + message=self.word_dict.get("ssh_file_viewer_dialog_message_uploaded_to"), + # The folder only holds the new file once the upload is done. + after=lambda: self.action_refresh(item)) + + def _start_transfer(self, *, downloading: bool, remote_path: str, local_path: str, + title: str, message: str, after=None) -> bool: + """Start one transfer in the background, unless one is already going. + + :param downloading: download when true, upload when false + :param remote_path: the path on the server + :param local_path: the path on this machine + :param title: the title of the message shown when it is done + :param message: what that message says, before the path it ended at + :param after: what to do once it is done, on the UI thread + :return: whether it started + """ + if self._transfer is not None and self._transfer.isRunning(): + QMessageBox.information( + self, + self.word_dict.get("ssh_file_viewer_dialog_title_transfer_running"), + self.word_dict.get("ssh_file_viewer_dialog_message_transfer_running")) + return False + self._transfer = SftpTransferThread(self.client, downloading, remote_path, local_path) + self._transfer.done.connect( + lambda path: self._transfer_done(title, message, path, after)) + self._transfer.failed.connect(self._transfer_failed) + self._transfer.start() + return True + + def _transfer_done(self, title: str, message: str, path: str, after=None) -> None: + """Say where the file ended up, and do whatever was waiting on it. UI thread.""" + QMessageBox.information(self, title, f"{message}: {path}") + if after is not None: + after() + + def _transfer_failed(self, error_message: str) -> None: + """Say why the transfer did not finish. UI thread.""" + QMessageBox.critical( self, - self.word_dict.get("ssh_file_viewer_dialog_title_uploaded"), - f"{self.word_dict.get('ssh_file_viewer_dialog_message_uploaded_to')}: {remote_path}") - # Refresh folder contents - self.action_refresh(item) + self.word_dict.get("ssh_file_viewer_dialog_title_operation_failed"), + f"{self.word_dict.get('ssh_file_viewer_dialog_message_operation_failed')}: " + f"{error_message}") def get_text(self, title: str, label: str): """ diff --git a/test/test_utils/test_ssh_teardown.py b/test/test_utils/test_ssh_teardown.py index 7d1cff61..e65ab375 100644 --- a/test/test_utils/test_ssh_teardown.py +++ b/test/test_utils/test_ssh_teardown.py @@ -130,3 +130,101 @@ def test_it_says_it_is_not_connected(self, app, call): with pytest.raises(RuntimeError): call(wrapper) + + +class TestATransfer: + """A transfer runs in the background: a big file must not hold the IDE.""" + + def _tree(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SSHFileTreeManager + + return SSHFileTreeManager() + + def test_a_download_does_not_hold_the_caller(self, app, monkeypatch): + import threading + + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + + answering = threading.Event() + started = threading.Event() + shown: list = [] + monkeypatch.setattr( + viewer.QMessageBox, "information", + staticmethod(lambda *args: shown.append(args))) + tree = self._tree(app) + + def slow_download(_remote, _local): + started.set() + answering.wait(10) + + tree.client.download = slow_download + + assert tree._start_transfer( + downloading=True, remote_path="/tmp/big.bin", local_path="big.bin", + title="Downloaded", message="Saved to") is True + + assert started.wait(5), "the transfer never started" + assert tree._transfer.isRunning(), "the caller waited for the transfer" + answering.set() + tree._transfer.wait(5000) + + def test_a_second_transfer_is_refused_while_one_runs(self, app, monkeypatch): + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + + said: list = [] + monkeypatch.setattr( + viewer.QMessageBox, "information", staticmethod(lambda *args: said.append(args))) + tree = self._tree(app) + + class Running: + @staticmethod + def isRunning() -> bool: + return True + + tree._transfer = Running() + + assert tree._start_transfer( + downloading=True, remote_path="/tmp/x", local_path="x", + title="Downloaded", message="Saved to") is False + assert said, "the user was told nothing" + + def test_a_transfer_that_fails_is_reported(self, app, monkeypatch): + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + + problems: list = [] + monkeypatch.setattr( + viewer.QMessageBox, "critical", staticmethod(lambda *args: problems.append(args))) + tree = self._tree(app) + tree.client.download = lambda *_args: (_ for _ in ()).throw(OSError("link went away")) + thread = viewer.SftpTransferThread(tree.client, True, "/tmp/x", "x") + thread.failed.connect(tree._transfer_failed) + + thread.run() + + assert problems and "link went away" in problems[0][2] + + def test_closing_waits_for_a_transfer(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SSHFileTreeManager + + class Waited: + def __init__(self) -> None: + self.waited = False + self.blocked = False + + def isRunning(self) -> bool: + return True + + def blockSignals(self, blocked: bool) -> None: + self.blocked = blocked + + def wait(self) -> None: + self.waited = True + + tree = SSHFileTreeManager() + tree.client = FakeClient() + tree._transfer = Waited() + + tree.close() + + assert tree._transfer.waited and tree._transfer.blocked + assert tree.client.closed From b52197362523b63d0d0e9bcb5a9dce99b47a93e6 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 02:13:17 +0800 Subject: [PATCH 038/312] Say which SSH session is up, and close the window the tests open The tab's one Connect button opens two sessions and each set the shared label, so it showed whichever finished last. The child-window test harness now closes the window before the child ends: leaving it open let Qt abort on a JEditor toolbar thread, which is the flake progress.md #3 recorded without a name. --- docs/updates/2026-09.md | 10 ++++++ docs/updates/README.md | 3 +- progress.md | 2 -- .../extend_multi_language/extend_english.py | 4 +++ .../extend_traditional_chinese.py | 4 +++ .../connect_gui/ssh/ssh_command_widget.py | 4 +++ .../connect_gui/ssh/ssh_main_widget.py | 21 ++++++++++++ test/test_utils/started_window.py | 6 ++++ test/test_utils/test_ssh_teardown.py | 33 +++++++++++++++++++ 9 files changed, 84 insertions(+), 3 deletions(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 1a087fbd..c8f606b6 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -316,3 +316,13 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1158 → 1162 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/test_ssh_teardown.py`, `architecture_explore.md` §9, §18. - **Open items**: the rest of `progress.md` #10 — connecting and listing a directory still run on the UI thread, and moving `connect()` needs the host-key prompt routed back through a signal. + +## U-20260923-20 · 2026-09-23 · One Connect button, two sessions, one honest label · #done #ssh + +- **What**: `progress.md` #12, and the name of the flaky test in #3. + - **The label.** The SSH tab's one Connect button opens two independent sessions — the shell and the file tree, each authenticating on its own — and each half set the shared status label, so it showed whichever finished last. A server with the SFTP subsystem disabled (a failure `test_sftp_remote_path.py` already expects) left "Connected" showing with no file tree, and a shell auth failure left "Disconnected" showing over a working file tree. `SSHMainWidget.report_connection_state()` now runs after both and says which of the two is up (four strings, both languages). + - **The flake.** `progress.md` #3 recorded a test that failed once under load without its name. It happened again, and this time with both: `test_startup_language.py::test_the_ide_starts_with_the_saved_language[Traditional_Chinese]`, exit `3221226505` (`0xC0000409`) — the fast-fail Qt uses when a running `QThread` is destroyed. The child started the IDE, reported, and ended without closing the window, so the interpreter tore down whatever JEditor toolbar thread was still running (its git branch scan). `started_window.py` closes the window before the child ends, which is also the path that stops those threads. Three runs of the three child-window tests, five times over, are green; it had been about one in ten under load. +- **Tests**: `test_ssh_teardown.py` gains the four states of the status label. The harness change is exercised by every test that uses it (`test_startup_language.py`, `test_started_menus.py`, `test_closing_the_ide.py`). +- **Result / numbers**: unit tests 1162 → 1166 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/{ssh_main_widget,ssh_command_widget}.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/{test_ssh_teardown,started_window}.py`, `progress.md` (#3, #12 removed). +- **Open items**: the two sessions themselves remain two — one SSH connection carrying both the shell and the SFTP session would be a redesign of both widgets, and there is no server here to try it against. diff --git a/docs/updates/README.md b/docs/updates/README.md index 22e03a6e..be0b953b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-20 | 2026-09-23 | One Connect button, two sessions, one honest label | #done #ssh | [2026-09](2026-09.md) | | U-20260923-19 | 2026-09-23 | An SFTP transfer held the IDE until it finished | #done #ssh | [2026-09](2026-09.md) | | U-20260923-18 | 2026-09-23 | Copy and duplicate ignored images | #done #diagram | [2026-09](2026-09.md) | | U-20260923-17 | 2026-09-23 | Run windows were kept for the whole session | #done #process-executor | [2026-09](2026-09.md) | @@ -100,4 +101,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 37 | +| [2026-09.md](2026-09.md) | 2026-09 | 38 | diff --git a/progress.md b/progress.md index d6459df5..bf7ccbc4 100644 --- a/progress.md +++ b/progress.md @@ -7,7 +7,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` already exists), ask first, or keep going on purpose (for example a long load test that mails its report)? `CodeWindow` has no `closeEvent` today. -- **#3** [UNVERIFIED] One unit test failed once, in a full run of `test/test_utils/` that took 282 s instead of the usual 35–60 s while the machine was busy; its name was not captured. Nine later full runs passed, three of them in parallel. Candidates are the tests that wait on a clock: `test_run_output.py` (30 s per child), `started_window.py` (120 s per IDE start). Record the name if it happens again. - **#10** SSH connecting and directory listing still run on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s) and `listdir_attr()` by nothing at all, so an unresponsive host or a directory with tens of thousands of entries freezes the IDE. Transfers moved off it in U-20260923-19; moving `connect()` has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. -- **#12** One Connect button drives two independent SSH sessions (`ssh_main_widget.py` wires `LoginWidget.connect_btn` to both the shell and the file tree), so a server with the SFTP subsystem disabled leaves the shell connected while the tree reports failure, and the status label shows whichever finished last. diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 6123cb15..81b9af47 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -195,6 +195,10 @@ "ssh_file_viewer_dialog_title_invalid_selection": "Invalid selection", "ssh_file_viewer_dialog_message_select_file_to_download": "Select a file to download.", "ssh_file_viewer_dialog_title_save_as": "Save as", + "ssh_state_shell_and_files": "Connected: shell and files", + "ssh_state_shell_only": "Connected: shell only (the file tree did not connect)", + "ssh_state_files_only": "Connected: files only (the shell did not connect)", + "ssh_state_neither": "Disconnected", "ssh_file_viewer_dialog_title_transfer_running": "A transfer is already running", "ssh_file_viewer_dialog_message_transfer_running": "One file at a time: wait for the transfer that is running to finish.", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 8390432c..2bb1a9c2 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -195,6 +195,10 @@ "ssh_file_viewer_dialog_title_invalid_selection": "選取無效", "ssh_file_viewer_dialog_message_select_file_to_download": "請選擇要下載的檔案。", "ssh_file_viewer_dialog_title_save_as": "另存新檔", + "ssh_state_shell_and_files": "已連線:終端與檔案", + "ssh_state_shell_only": "已連線:只有終端(檔案樹沒連上)", + "ssh_state_files_only": "已連線:只有檔案(終端沒連上)", + "ssh_state_neither": "未連線", "ssh_file_viewer_dialog_title_transfer_running": "已有傳輸進行中", "ssh_file_viewer_dialog_message_transfer_running": "一次只傳一個檔案:請等目前的傳輸結束。", 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 0d87de57..35a41027 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -255,6 +255,10 @@ def disconnect_ssh(self): self.login_widget.status_label.setText( self.word_dict.get('ssh_command_widget_status_label_disconnected')) + def is_connected(self) -> bool: + """Whether a shell session is open here.""" + return self.shell_channel is not None and not self.shell_channel.closed + def closeEvent(self, event) -> None: """End the session with the widget. diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py index c0efe001..c249a835 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py @@ -4,6 +4,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import QWidget, QVBoxLayout, QSplitter, QApplication, QSizePolicy +from je_editor import language_wrapper from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import SSHCommandWidget from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SSHFileTreeManager @@ -43,6 +44,26 @@ def __init__(self, parent=None): main_layout.addWidget(splitter) self.setLayout(main_layout) + # One Connect button, two sessions: the shell and the file tree each open + # their own, and each used to set the shared label, so whichever finished + # last decided what it said. This runs after both and reports the pair. + self.login_widget.connect_btn.clicked.connect(self.report_connection_state) + self.login_widget.disconnect_btn.clicked.connect(self.report_connection_state) + + def report_connection_state(self) -> None: + """Put what actually happened to both sessions in the shared status label.""" + word_dict = language_wrapper.language_word_dict + shell_up = self.command_widget.is_connected() + files_up = self.file_tree.client.connected + if shell_up and files_up: + state = word_dict.get("ssh_state_shell_and_files") + elif shell_up: + state = word_dict.get("ssh_state_shell_only") + elif files_up: + state = word_dict.get("ssh_state_files_only") + else: + state = word_dict.get("ssh_state_neither") + self.login_widget.status_label.setText(state) def closeEvent(self, event) -> None: """Close both halves with the tab. diff --git a/test/test_utils/started_window.py b/test/test_utils/started_window.py index 6a0f3732..466fe2f0 100644 --- a/test/test_utils/started_window.py +++ b/test/test_utils/started_window.py @@ -26,7 +26,13 @@ gc.collect() """ +# The window is closed before the child ends, even when the body did not close +# it: leaving it open lets the interpreter tear down a JEditor toolbar thread +# that may still be running, and Qt aborts the process (exit 0xC0000409) when a +# running QThread is destroyed. That made this harness fail about one run in ten +# on a busy machine, with nothing in the output to say why. _REPORT = """ +window.close() with open(sys.argv[1], "w", encoding="utf-8") as handle: json.dump(result, handle, ensure_ascii=False) """ diff --git a/test/test_utils/test_ssh_teardown.py b/test/test_utils/test_ssh_teardown.py index e65ab375..df660510 100644 --- a/test/test_utils/test_ssh_teardown.py +++ b/test/test_utils/test_ssh_teardown.py @@ -228,3 +228,36 @@ def wait(self) -> None: assert tree._transfer.waited and tree._transfer.blocked assert tree.client.closed + + +class TestWhatTheStatusLabelSays: + """One Connect button opens two sessions; the label has to report both.""" + + def _tab(self, shell_up: bool, files_up: bool): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_main_widget import SSHMainWidget + + tab = SSHMainWidget() + tab.command_widget.is_connected = lambda: shell_up + + class Client: + connected = files_up + + @staticmethod + def close() -> None: + """Nothing to close in a stand-in.""" + + tab.file_tree.client = Client() + return tab + + @pytest.mark.parametrize("shell_up,files_up,expected", [ + (True, True, "shell and files"), + (True, False, "shell only"), + (False, True, "files only"), + (False, False, "Disconnected"), + ], ids=["both", "shell only", "files only", "neither"]) + def test_it_reports_both_halves(self, app, shell_up, files_up, expected): + tab = self._tab(shell_up, files_up) + + tab.report_connection_state() + + assert expected in tab.login_widget.status_label.text() From 0e6393ba11befeca5f311c89957b45aa61d4a061 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 05:06:33 +0800 Subject: [PATCH 039/312] Keep the embedded JupyterLab to its own origin and stop it with its tab The tokenless server also allowed any origin, so any page the user visited could drive its API and kernels. It also kept running after its tab closed, because only a still-running launcher thread was stopped, and at IDE exit its tab was never closed at all. Its output now goes to a file instead of pipes nobody read. --- CLAUDE.md | 2 +- architecture.md | 2 +- architecture_explore.md | 8 +- docs/updates/2026-09.md | 11 ++ docs/updates/README.md | 3 +- progress.md | 14 +- pybreeze/pybreeze_ui/editor_main/main_ui.py | 19 +- .../jupyter_lab_gui/jupyter_lab_thread.py | 48 +++-- .../jupyter_lab_gui/jupyter_lab_widget.py | 15 +- test/test_utils/test_jupyter_lifecycle.py | 180 ++++++++++++++++++ test/test_utils/test_jupyter_ready.py | 6 + 11 files changed, 283 insertions(+), 25 deletions(-) create mode 100644 test/test_utils/test_jupyter_lifecycle.py diff --git a/CLAUDE.md b/CLAUDE.md index 8d4b5382..8524e9dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,7 @@ Reference implementations: `utils/network/url_validation.py` (`validate_url`), ` **Subprocess** — always argument lists, explicit `shell=False`, `timeout` on every `subprocess.run()`. Never interpolate user input into a command string. Secrets travel as `env`, never argv (see `prthinker_setting.environment_for`). The IDE intentionally runs user-authored scripts — this hardening guards against accidental shell injection, not against malicious local files. -**JupyterLab** — the embedded server is localhost-only; the empty `--ServerApp.token`/`password` and `--ServerApp.disable_check_xsrf=True` are safe *only* because of that. Never change `--ServerApp.ip` to an externally reachable address. +**JupyterLab** — the embedded server is localhost-only; the empty `--ServerApp.token`/`password` and `--ServerApp.disable_check_xsrf=True` are safe *only* because of that. Never change `--ServerApp.ip` to an externally reachable address, and never set `--ServerApp.allow_origin`: a loopback bind does not stop a browser, and with the origin open any page the user visits can drive a tokenless server. The view loads from the same origin and needs nothing relaxed. The server outlives its launcher thread, so its tab stops it on close whatever the thread's state. **File I/O** — dialog-chosen paths are trusted; paths loaded from saved data (`.diagram.json`) are not: check `is_file()` and an extension allowlist, or run URLs through SSRF validation. Use `pathlib`, never string concatenation. Write to `~/.pybreeze/` via `app_dirs.pybreeze_data_dir()` with `encoding="utf-8"`. Resolve symlinks with `Path.resolve(strict=True)` and verify the result stays in bounds. diff --git a/architecture.md b/architecture.md index 7414730a..b028ff31 100644 --- a/architecture.md +++ b/architecture.md @@ -128,7 +128,7 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() | Name | JEditor module | Used in | | --- | --- | --- | | `PluginBrowserWidget` | `pyside_ui.main_ui.plugin_browser.plugin_browser_widget` | `menu/plugin_menu/build_plugin_menu.py` | - | `DestroyDock` | `pyside_ui.main_ui.dock.destroy_dock` | `menu/tools/tools_menu.py` | + | `DestroyDock` | `pyside_ui.main_ui.dock.destroy_dock` | `menu/tools/tools_menu.py`, `editor_main/main_ui.py` | | `check_and_choose_venv` | `utils.venv_check.check_venv` | `extend/process_executor/python_task_process_manager.py` | | `choose_file_get_save_file_path` | `pyside_ui.dialog.file_dialog.save_file_dialog` | `menu/plugin_menu/build_run_with_menu.py` | | `write_file_with_encoding` | `utils.file.save.save_file` | `menu/plugin_menu/build_run_with_menu.py` | diff --git a/architecture_explore.md b/architecture_explore.md index 49ba7d71..7a9aca09 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -313,10 +313,10 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 10. `pybreeze_ui/jupyter_lab_gui/` -- `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `get_venv_python()` → `is_jupyter_installed()`(缺就自動裝)→ 啟動 server → `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)` -- `jupyter_lab_widget.py` — 收到 URL 後用 `QWebEngineView.setUrl()` 載入;`closeEvent` 負責關掉 server +- `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `get_venv_python()` → `is_jupyter_installed()`(缺就自動裝)→ 啟動 server → `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)`。server 的輸出寫進暫存檔而不是管線(server 起來後沒人讀管線,緩衝區滿了它會卡在 `write()`);提早結束時錯誤訊息取這個檔案的尾巴 +- `jupyter_lab_widget.py` — 收到 URL 後用 `QWebEngineView.setUrl()` 載入;`closeEvent` 一律關掉 server(launcher 執行緒在 lab 載入完就結束了,只停「還在跑的執行緒」等於從不停 server)。IDE 關閉時 `PyBreezeMainWindow._close_tool_tabs_and_docks()` 會關掉所有非編輯器分頁與 `DestroyDock`,這個 `closeEvent` 才會被呼叫到 -安全前提(CLAUDE.md 已明列):server 只綁 localhost,因此 token/password 刻意留空、`disable_check_xsrf=True` 才能內嵌。 +安全前提(CLAUDE.md 已明列):server 只綁 localhost,因此 token/password 刻意留空、`disable_check_xsrf=True` 才能內嵌。**不設 `allow_origin`**:loopback 擋不住瀏覽器,開放來源的話使用者逛到的任何網頁都能操作這個沒有 token 的 server。 --- @@ -431,7 +431,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 78 個 `test_*.py`、1176 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 79 個 `test_*.py`、1186 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index c8f606b6..4b4f6b45 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -326,3 +326,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1162 → 1166 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/{ssh_main_widget,ssh_command_widget}.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/{test_ssh_teardown,started_window}.py`, `progress.md` (#3, #12 removed). - **Open items**: the two sessions themselves remain two — one SSH connection carrying both the shell and the SFTP session would be a redesign of both widgets, and there is no server here to try it against. + +## U-20260923-21 · 2026-09-23 · The embedded JupyterLab took any origin and outlived its tab · #incident #security #jupyter + +- **What**: from an audit of `jupyter_lab_gui/`, `extend_ai_gui/` and `editor_main/`. Three things about the embedded JupyterLab, the first a security hole. + - **Any origin.** The server ran with an empty token and password and `--ServerApp.disable_check_xsrf=True` — which `CLAUDE.md` allows *only* because it binds to localhost — and also `--ServerApp.allow_origin=*`. A loopback bind does not stop a browser: with the origin open, any web page the user visited while the tab was open could call the server's REST API and read the answers (the port is found by trying, since the answers are readable) and open its kernel websockets, i.e. run code as the user. The view PyBreeze embeds loads `http://localhost:/lab` from the same origin and needs none of it. The flag is gone. + - **It never stopped.** `JupyterLabWidget.closeEvent()` stopped the server only while the launcher thread was running, but that thread ends the moment the lab is ready, so closing a loaded tab left `python -m jupyterlab` running, holding its port, reachable as above. And at IDE exit the widget's `closeEvent` was never called at all: JEditor's closes editor tabs only, and `start_editor()` leaves through `os._exit()`. The tab now always stops the server, and `PyBreezeMainWindow._close_tool_tabs_and_docks()` closes every non-editor tab and every `DestroyDock` before JEditor's close — which also gives the SSH tab, the diagram editor and the AI panels the closes they were written to expect. + - **Pipes nobody read.** The server's stdout and stderr were pipes read only if it died during startup; a server that kept logging would fill the buffer and block in `write()`, freezing the lab with nothing to show why. Its output now goes to a temporary file, which the early-exit message reads its tail from. +- **Tests**: `test_jupyter_lifecycle.py` (new, 6): the launch binds to localhost and sets no origin, its output does not go to a pipe, an early exit still reports what the server said, stopping twice is harmless, a tab whose launcher has finished still stops the server, and closing the real IDE (in a child) closes a tool tab and a dock. The last fails with the new call removed. `test_jupyter_ready.py`'s early-exit test feeds its message through the output file instead of a fake pipe. +- **Result / numbers**: unit tests 1166 → 1172 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/jupyter_lab_gui/{jupyter_lab_thread,jupyter_lab_widget}.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_jupyter_lifecycle,test_jupyter_ready}.py`, `CLAUDE.md` § Security › JupyterLab, `architecture.md` §6 (`DestroyDock` is now also used by `main_ui.py`), `architecture_explore.md` §10. +- **Open items**: the same audit's other findings are recorded in `progress.md` #14–#25. diff --git a/docs/updates/README.md b/docs/updates/README.md index be0b953b..0dc84894 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-21 | 2026-09-23 | The embedded JupyterLab took any origin and outlived its tab | #incident #security #jupyter | [2026-09](2026-09.md) | | U-20260923-20 | 2026-09-23 | One Connect button, two sessions, one honest label | #done #ssh | [2026-09](2026-09.md) | | U-20260923-19 | 2026-09-23 | An SFTP transfer held the IDE until it finished | #done #ssh | [2026-09](2026-09.md) | | U-20260923-18 | 2026-09-23 | Copy and duplicate ignored images | #done #diagram | [2026-09](2026-09.md) | @@ -101,4 +102,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 38 | +| [2026-09.md](2026-09.md) | 2026-09 | 39 | diff --git a/progress.md b/progress.md index bf7ccbc4..087f1378 100644 --- a/progress.md +++ b/progress.md @@ -6,6 +6,18 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open -- **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` already exists), ask first, or keep going on purpose (for example a long load test that mails its report)? `CodeWindow` has no `closeEvent` today. +- **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#10** SSH connecting and directory listing still run on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s) and `listdir_attr()` by nothing at all, so an unresponsive host or a directory with tens of thousands of entries freezes the IDE. Transfers moved off it in U-20260923-19; moving `connect()` has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. +- **#14** Renaming an open file from the file tree leaves JEditor's auto-save thread on the old path (`pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, the rename action updates `current_file` but not `code_save_thread.file`): the next tick writes the buffer back to the old name, recreating it, and the new name never gets another auto-save while the tab claims to be saved. Renaming a directory does it to every open file under it. +- **#15** The regex tool runs the user's pattern on the UI thread with nothing to bound it (`tools_gui/regex_gui.py` -> `utils/regex_tools/regex_tester.py:find_matches`): `(a+)+$` against 26 `a`s and a `b` takes about 5 s, and each further character doubles it. `_MAX_MATCHES` caps how many matches are reported, not how long one attempt takes. Only a separate process can be stopped mid-match. +- **#16** `reformat_json()` / `minify_json()` let `RecursionError` out on deeply nested input (`utils/json_format/json_process.py`), and `JsonFormatGUI` catches only `ITEJsonException`, so `"[" * 10000 + "]" * 10000` escapes the slot — including from `JsonFormatGUI.__init__` when the response inspector opens a body. `test_fuzz_pure_logic.py` states the contract but `st.text()` never generates the input. +- **#17** `convert_to_json()` in the URL builder lets `ValueError("Invalid IPv6 URL")` from `urlsplit()` escape (`utils/url_tools/url_convert.py`, `tools_gui/url_builder_gui.py`): `http://[::1` crashes the slot, and the cURL importer builds the tab with such a URL in `__init__`. +- **#18** HAR query values are stored percent-encoded and then encoded again (`utils/har_import/har_parser.py:_apply_query`): `?q=hello%20world` becomes `params = {"q": "hello%20world"}` and the generated script sends `hello%2520world`. The cURL importer decodes (`parse_qsl`), so the two disagree on the same URL. +- **#19** `curl -G` data is split on the first `=` only, never on `&` (`utils/curl_import/curl_parser.py:_finalise_method` via `parse_query_pairs`): `-d 'a=1&b=2'` becomes `{"a": "1&b=2"}`, and `--data-urlencode 'q=hello world'` is encoded twice. +- **#20** `parse_url()` keeps only the last value of a repeated query key (`utils/url_tools/url_convert.py`, `dict(parse_qsl(...))`), so the URL builder's round trip drops `?tag=a&tag=b` to `?tag=b`; `query_tools/query_convert.py` already turns repeats into a list. +- **#21** `PyBreeze.log` is opened relative to the working directory and in the locale's code page (`utils/logging/logger.py`): it lands wherever the IDE was started (against `CLAUDE.md` § File I/O, which says `~/.pybreeze/`; workspace X-6 is the same problem across repos), a read-only working directory makes importing `pybreeze.utils` fail, and on a cp1252 machine any record with CJK text is dropped with `--- Logging error ---`. +- **#22** Prompt files: a prompt saved as ANSI raises `UnicodeDecodeError` out of `prompt_store.load_prompt` and `PromptEditorWidget.load_file_content`, which catch only `OSError` (so the skills tab or the prompt editor fails to open); the editor's file watcher reloads over unsaved edits without asking (`prompt_edit_gui/prompt_editor_widget.py:on_file_changed`); and `prompt_file_io.save_prompt_text` truncates before it writes. +- **#23** `SkillsSendGUI`'s `RequestThread` declares `finished = Signal(str)`, hiding `QThread.finished`, so an exception outside its except tuple ends the thread with no signal and the send button stays disabled for the session; and `closeEvent` waits with no timeout on a request that can take 30 s (`extend_ai_gui/skills/skills_send_gui.py`). +- **#24** A registered `EDITOR_EXTEND_TAB` whose constructor raises aborts `PyBreezeMainWindow.__init__` (`editor_main/main_ui.py`, the loop that adds them is unguarded): one bad third-party tab means no IDE at all instead of the IDE without that tab. +- **#25** The HAR importer reads the chosen file with `encoding="utf-8"` inside `except OSError` only (`tools_gui/har_import_gui.py:open_file`), so a UTF-16 `.har` raises `UnicodeDecodeError` out of the slot; and its error message is `str(OSError)`, which carries the absolute path. diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index 5cc66d5f..5585453d 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -10,7 +10,8 @@ from PySide6.QtCore import QTimer, QCoreApplication from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication, QWidget -from je_editor import EditorMain, language_wrapper +from je_editor import EditorMain, EditorWidget, language_wrapper +from je_editor.pyside_ui.main_ui.dock.destroy_dock import DestroyDock from qt_material import apply_stylesheet from pybreeze.extend_multi_language.update_language_dict import update_language_dict @@ -89,8 +90,24 @@ def closeEvent(self, event) -> None: for run_window in tuple(self.current_run_code_window): run_window.stop_runner() run_window.close() + self._close_tool_tabs_and_docks() super().closeEvent(event) + def _close_tool_tabs_and_docks(self) -> None: + """Close PyBreeze's own tabs and docks so each can stop what it started. + + JEditor's ``closeEvent`` closes editor tabs only, and PyBreeze's tabs and + docks own processes and sessions of their own -- a JupyterLab server, an + SSH session, a diagram's downloads. Without this, ``start_editor``'s + ``os._exit`` leaves them running. + """ + for index in range(self.tab_widget.count() - 1, -1, -1): + widget = self.tab_widget.widget(index) + if widget is not None and not isinstance(widget, EditorWidget): + widget.close() + for dock in self.findChildren(DestroyDock): + dock.close() + def debug_close(self) -> None: """Close the window and leave the event loop. Used by the startup tests. 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 9a3f547c..8a57adc9 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py @@ -4,6 +4,7 @@ import socket import subprocess import sys +import tempfile import time import traceback @@ -69,6 +70,8 @@ class JupyterLauncherThread(QThread): def __init__(self, parent=None, startup_timeout: int = JUPYTER_STARTUP_TIMEOUT): super().__init__(parent) self.process = None + # The server's output, kept in a file rather than a pipe + self._output = None self.startup_timeout = startup_timeout def run(self): @@ -99,10 +102,14 @@ def run(self): # Launch embedded JupyterLab. Server binds to localhost only (see # CLAUDE.md JupyterLab integration notes); shell=False. nosec B603. - # The bind address is pinned explicitly: with token/password empty, - # XSRF disabled and a wildcard origin, the loopback-only binding is - # the sole barrier, so we never rely on the jupyter default staying - # localhost. + # The bind address is pinned explicitly: with token and password + # empty, the loopback-only binding is the sole barrier, so we never + # rely on the jupyter default staying localhost. No wildcard origin: + # a loopback bind does not stop a browser, and with the origin open + # any page the user visits could drive this server's API and kernel + # sockets. The view this serves loads from the same origin, so it + # needs nothing relaxed. + self._output = tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") self.process = subprocess.Popen([ # nosec B603 # nosemgrep # noqa: S603 python_exe, "-m", @@ -112,9 +119,8 @@ def run(self): f"--ServerApp.port={port}", "--ServerApp.token=", "--ServerApp.password=", - "--ServerApp.allow_origin=*", "--ServerApp.disable_check_xsrf=True", - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ], stdout=self._output, stderr=subprocess.STDOUT, text=True, creationflags=no_window_creationflags()) self._wait_until_ready(port) @@ -151,13 +157,9 @@ def _wait_until_ready(self, port: int) -> None: # Fail fast if the server died (port conflict, bad install, ...) # instead of polling a dead port until the full timeout elapses. if process.poll() is not None: - stderr_tail = "" - if process.stderr is not None: - # Bounded read: the process has exited so the pipe holds at - # most its buffer; cap explicitly and keep the tail message. - stderr_tail = process.stderr.read(65536)[-500:] raise RuntimeError( - f"JupyterLab exited early (code {process.returncode}): {stderr_tail}") + f"JupyterLab exited early (code {process.returncode}): " + f"{self._output_tail()}") self.status_update.emit( f"{language_wrapper.language_word_dict.get('jupyterlab_loading')} " @@ -167,9 +169,31 @@ def _wait_until_ready(self, port: int) -> None: return time.sleep(0.2) + def _output_tail(self, characters: int = 500) -> str: + """The end of what the server wrote, for an error message. + + Its output goes to a temporary file rather than a pipe: nothing reads a + pipe once the server is up, and a server that keeps logging would block + in ``write()`` when the pipe buffer filled, freezing the lab with + nothing to show for it. + """ + if self._output is None: + return "" + try: + self._output.seek(0) + return self._output.read()[-characters:] + except (OSError, ValueError) as error: + pybreeze_logger.debug("JupyterLab output could not be read: %r", error) + return "" + def stop(self): + """Stop the server and let go of its output file. Safe to call twice.""" if self.process is not None: try: self.process.terminate() except OSError as error: pybreeze_logger.debug("JupyterLab terminate failed: %r", error) + self.process = None + if self._output is not None: + self._output.close() + self._output = 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 cadb171e..004ec58b 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py @@ -52,11 +52,18 @@ def show_error(self, msg): pybreeze_logger.error(msg) def closeEvent(self, event): + """Stop the server with the tab. + + The server outlives the launcher thread, which ends as soon as the lab + is ready, so stopping only a thread that is still running left a + JupyterLab process behind for every tab that had finished loading -- + holding its port, and reachable for as long as the machine was up. + """ + # Block signals first so a late status/error emit can't reach a slot + # on the widget being torn down. + self.thread.blockSignals(True) + self.thread.stop() if self.thread.isRunning(): - # Block signals first so a late status/error emit can't reach a slot - # on the widget being torn down. - self.thread.blockSignals(True) - self.thread.stop() self.thread.quit() self.thread.wait() event.accept() diff --git a/test/test_utils/test_jupyter_lifecycle.py b/test/test_utils/test_jupyter_lifecycle.py new file mode 100644 index 00000000..c4839b0b --- /dev/null +++ b/test/test_utils/test_jupyter_lifecycle.py @@ -0,0 +1,180 @@ +"""The embedded JupyterLab: how its server is started, and that it goes when the tab does. + +The server runs with no token, so the loopback bind and the same-origin rule are +what keep other software away from it; and it outlives its launcher thread, so the +tab has to stop it by name. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread, jupyter_lab_widget +from test_utils.started_window import run_started_window + +# The readiness wait as the launcher has it, before a test replaces it +_REAL_WAIT_UNTIL_READY = jupyter_lab_thread.JupyterLauncherThread._wait_until_ready + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeServer: + """Stands in for the JupyterLab process.""" + + def __init__(self, argv, **options) -> None: + self.argv = argv + self.options = options + self.terminated = False + self.returncode = None + + def terminate(self) -> None: + self.terminated = True + + def poll(self): + return self.returncode + + +@pytest.fixture() +def launched(monkeypatch) -> list: + """Start the launcher's run() against a fake server and collect what it started.""" + started: list = [] + + def popen(argv, **options): + server = FakeServer(argv, **options) + started.append(server) + return server + + monkeypatch.setattr(jupyter_lab_thread, "get_venv_python", lambda: "python") + monkeypatch.setattr(jupyter_lab_thread, "is_jupyter_installed", lambda _python: True) + monkeypatch.setattr(jupyter_lab_thread.subprocess, "Popen", popen) + monkeypatch.setattr( + jupyter_lab_thread.JupyterLauncherThread, "_wait_until_ready", lambda self, port: None) + return started + + +class TestHowTheServerIsStarted: + def test_it_is_bound_to_this_machine_and_takes_no_other_origin(self, app, launched): + thread = jupyter_lab_thread.JupyterLauncherThread() + + thread.run() + + argv = launched[0].argv + assert "--ServerApp.ip=localhost" in argv + # With no token, a wildcard origin would let any page the user visits + # drive this server's API and kernel sockets. + assert not [arg for arg in argv if arg.startswith("--ServerApp.allow_origin")] + thread.stop() + + def test_its_output_goes_to_a_file_not_a_pipe_nobody_reads(self, app, launched): + import subprocess + + thread = jupyter_lab_thread.JupyterLauncherThread() + + thread.run() + + options = launched[0].options + assert options["stdout"] is not subprocess.PIPE + assert options["stderr"] is subprocess.STDOUT + thread.stop() + + def test_an_early_exit_reports_what_the_server_said(self, app, launched, monkeypatch): + thread = jupyter_lab_thread.JupyterLauncherThread(startup_timeout=5) + thread.run() + thread._output.write("ImportError: jupyterlab is broken\n") + thread._output.flush() + launched[0].returncode = 1 + monkeypatch.setattr( + jupyter_lab_thread.JupyterLauncherThread, "_port_open", staticmethod(lambda port: False)) + + with pytest.raises(RuntimeError, match="jupyterlab is broken"): + _REAL_WAIT_UNTIL_READY(thread, 1) + thread.stop() + + def test_stopping_twice_is_harmless(self, app, launched): + thread = jupyter_lab_thread.JupyterLauncherThread() + thread.run() + + thread.stop() + thread.stop() + + assert launched[0].terminated + + +class FinishedLauncher: + """A launcher whose thread has finished: the server it started is still up.""" + + def __init__(self) -> None: + self.stopped = False + from PySide6.QtCore import QObject, Signal + + class Signals(QObject): + status_update = Signal(str) + server_ready = Signal(str) + error_occurred = Signal(str) + + self._signals = Signals() + self.status_update = self._signals.status_update + self.server_ready = self._signals.server_ready + self.error_occurred = self._signals.error_occurred + + def start(self) -> None: + """Nothing to start in a stand-in.""" + + def isRunning(self) -> bool: + return False + + def blockSignals(self, _blocked: bool) -> None: + """Nothing to block in a stand-in.""" + + def stop(self) -> None: + self.stopped = True + + +class TestClosingTheTab: + def test_the_server_is_stopped_after_the_lab_has_loaded(self, app, monkeypatch): + monkeypatch.setattr(jupyter_lab_widget, "JupyterLauncherThread", FinishedLauncher) + tab = jupyter_lab_widget.JupyterLabWidget() + + tab.close() + + assert tab.thread.stopped + + +_CLOSE_WITH_A_TOOL_TAB_AND_DOCK = """ +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QWidget +from je_editor.pyside_ui.main_ui.dock.destroy_dock import DestroyDock +closed = [] +class Tool(QWidget): + def __init__(self, name): + super().__init__() + self.name = name + def closeEvent(self, event): + closed.append(self.name) + super().closeEvent(event) +window.tab_widget.addTab(Tool("tab"), "Tool") +dock = DestroyDock() +dock.setWidget(Tool("dock")) +window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock) +window.close() +result = {"closed": sorted(closed)} +""" + + +def test_closing_the_ide_closes_its_own_tabs_and_docks(tmp_path): + # JEditor closes editor tabs only; a JupyterLab server, an SSH session or a + # diagram's downloads live in PyBreeze's own tabs and docks. + seen = run_started_window(tmp_path, _CLOSE_WITH_A_TOOL_TAB_AND_DOCK) + + assert "tab" in seen["closed"] + assert "dock" in seen["closed"] diff --git a/test/test_utils/test_jupyter_ready.py b/test/test_utils/test_jupyter_ready.py index a4f4ba0e..fc9d166a 100644 --- a/test/test_utils/test_jupyter_ready.py +++ b/test/test_utils/test_jupyter_ready.py @@ -51,12 +51,18 @@ def _thread(qt_app): class TestWaitUntilReady: def test_early_exit_fails_fast(self, qt_app): + import tempfile + thread = _thread(qt_app) thread.process = _DeadProcess() + # What the server wrote goes to a file the launcher keeps, not a pipe. + thread._output = tempfile.TemporaryFile(mode="w+", encoding="utf-8") + thread._output.write("ImportError: jupyterlab not installed\n") with pytest.raises(RuntimeError) as exc: thread._wait_until_ready(59999) assert "exited early" in str(exc.value) assert "jupyterlab not installed" in str(exc.value) + thread._output.close() def test_returns_when_port_open(self, qt_app, monkeypatch): thread = _thread(qt_app) From 70720294ea29339e7991202437b4304e50669944 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 05:15:23 +0800 Subject: [PATCH 040/312] Move an open tab's auto-save with the file it is renamed to Renaming an open file left JEditor's save thread writing the buffer to the old name, which brought the file back and never saved the new one. The rename stops each affected tab's auto-save first and starts it again on the new path, for a renamed folder too. --- architecture.md | 1 + architecture_explore.md | 4 +- docs/updates/2026-09.md | 10 +++ docs/updates/README.md | 3 +- progress.md | 1 - .../editor_main/file_tree_context_menu.py | 59 ++++++++++++++--- .../test_utils/test_file_tree_context_menu.py | 8 ++- test/test_utils/test_file_tree_rename.py | 64 +++++++++++++++++++ test/test_utils/test_jeditor_contract.py | 29 +++++++-- 9 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 test/test_utils/test_file_tree_rename.py diff --git a/architecture.md b/architecture.md index b028ff31..48ce18e5 100644 --- a/architecture.md +++ b/architecture.md @@ -129,6 +129,7 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() | --- | --- | --- | | `PluginBrowserWidget` | `pyside_ui.main_ui.plugin_browser.plugin_browser_widget` | `menu/plugin_menu/build_plugin_menu.py` | | `DestroyDock` | `pyside_ui.main_ui.dock.destroy_dock` | `menu/tools/tools_menu.py`, `editor_main/main_ui.py` | + | `init_new_auto_save_thread`, `auto_save_manager_dict`, `file_is_open_manager_dict` | `pyside_ui.code.auto_save.auto_save_manager` | `editor_main/file_tree_context_menu.py` (a rename restarts the tab's auto-save on the new path) | | `check_and_choose_venv` | `utils.venv_check.check_venv` | `extend/process_executor/python_task_process_manager.py` | | `choose_file_get_save_file_path` | `pyside_ui.dialog.file_dialog.save_file_dialog` | `menu/plugin_menu/build_run_with_menu.py` | | `write_file_with_encoding` | `utils.file.save.save_file` | `menu/plugin_menu/build_run_with_menu.py` | diff --git a/architecture_explore.md b/architecture_explore.md index 7a9aca09..b53904bc 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -73,7 +73,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE - `add_menu_to_menubar()` — 建構全部選單(見 §5) - `syntax_extend_package()` — 註冊 `.json` / `.yml` 自動化關鍵字高亮 - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁 - - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單 + - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) 3. `apply_stylesheet()` 套 qt_material 主題(預設 `dark_amber.xml`) 4. `showMaximized()` → `startup_setting()` → `app.exec()` @@ -431,7 +431,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 79 個 `test_*.py`、1186 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1192 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 4b4f6b45..b945182c 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -337,3 +337,13 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1166 → 1172 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/jupyter_lab_gui/{jupyter_lab_thread,jupyter_lab_widget}.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_jupyter_lifecycle,test_jupyter_ready}.py`, `CLAUDE.md` § Security › JupyterLab, `architecture.md` §6 (`DestroyDock` is now also used by `main_ui.py`), `architecture_explore.md` §10. - **Open items**: the same audit's other findings are recorded in `progress.md` #14–#25. + +## U-20260923-22 · 2026-09-23 · Renaming an open file brought the old name back · #done #editor + +- **What**: `progress.md` #14. Renaming a file from the file tree while it was open changed the tab's `current_file` but not JEditor's auto-save thread, which loops for as long as the path it *started* with is a file and writes to its own `file`. So five seconds later it wrote the buffer back to the old name — recreating the file the user had just renamed away, which kept its loop going for the rest of the session — and the new name never received another save, while `rename_self_tab()` marked the tab as saved. Renaming a folder did the same to every open file under it, since the lookup matched exact file paths only. +- **Fix**: the rename finds every tab open on the path or under it (`_editors_under()`), stops each tab's auto-save *before* renaming so none writes to the old path mid-rename, and afterwards starts a fresh one on the new path — or on the old one again if the rename failed — through JEditor's own `init_new_auto_save_thread()`, which also carries the tab's encoding and line ending. The thread cannot be pointed elsewhere, only replaced: its loop holds the original path. The open-file registry is re-keyed the same way. +- **Contract**: that uses three more JEditor internals (`init_new_auto_save_thread`, `auto_save_manager_dict`, `file_is_open_manager_dict`), now listed in `architecture.md` §6 and pinned by `test_jeditor_contract.py`, with a shape check on the save thread (`still_run`, `file`). The contract test's check that every internal import is listed only read single-line imports; a parenthesised one would have slipped past it unlisted. It reads both now. +- **Tests**: `test_file_tree_rename.py` (new, 2) starts the real IDE in a child, opens a file, renames it — or its folder — through the context-menu action, edits the buffer, and waits for JEditor's auto-save: the edit reaches the new path and the old one does not come back. Both fail against the old code. The existing rename test moved to the new lookup. +- **Result / numbers**: unit tests 1172 → 1178 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/{test_file_tree_rename,test_file_tree_context_menu,test_jeditor_contract}.py`, `architecture.md` §6, `architecture_explore.md` §3, §18, `progress.md` (#14 removed). +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 0dc84894..2a2983b5 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-22 | 2026-09-23 | Renaming an open file brought the old name back | #done #editor | [2026-09](2026-09.md) | | U-20260923-21 | 2026-09-23 | The embedded JupyterLab took any origin and outlived its tab | #incident #security #jupyter | [2026-09](2026-09.md) | | U-20260923-20 | 2026-09-23 | One Connect button, two sessions, one honest label | #done #ssh | [2026-09](2026-09.md) | | U-20260923-19 | 2026-09-23 | An SFTP transfer held the IDE until it finished | #done #ssh | [2026-09](2026-09.md) | @@ -102,4 +103,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 39 | +| [2026-09.md](2026-09.md) | 2026-09 | 40 | diff --git a/progress.md b/progress.md index 087f1378..407566f9 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#10** SSH connecting and directory listing still run on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s) and `listdir_attr()` by nothing at all, so an unresponsive host or a directory with tens of thousands of entries freezes the IDE. Transfers moved off it in U-20260923-19; moving `connect()` has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. -- **#14** Renaming an open file from the file tree leaves JEditor's auto-save thread on the old path (`pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, the rename action updates `current_file` but not `code_save_thread.file`): the next tick writes the buffer back to the old name, recreating it, and the new name never gets another auto-save while the tab claims to be saved. Renaming a directory does it to every open file under it. - **#15** The regex tool runs the user's pattern on the UI thread with nothing to bound it (`tools_gui/regex_gui.py` -> `utils/regex_tools/regex_tester.py:find_matches`): `(a+)+$` against 26 `a`s and a `b` takes about 5 s, and each further character doubles it. `_MAX_MATCHES` caps how many matches are reported, not how long one attempt takes. Only a separate process can be stopped mid-match. - **#16** `reformat_json()` / `minify_json()` let `RecursionError` out on deeply nested input (`utils/json_format/json_process.py`), and `JsonFormatGUI` catches only `ITEJsonException`, so `"[" * 10000 + "]" * 10000` escapes the slot — including from `JsonFormatGUI.__init__` when the response inspector opens a body. `test_fuzz_pure_logic.py` states the contract but `st.text()` never generates the input. - **#17** `convert_to_json()` in the URL builder lets `ValueError("Invalid IPv6 URL")` from `urlsplit()` escape (`utils/url_tools/url_convert.py`, `tools_gui/url_builder_gui.py`): `http://[::1` crashes the slot, and the cURL importer builds the tab with such a URL in `__init__`. diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index 8f108c5c..711306e0 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -14,6 +14,9 @@ QMessageBox, QApplication, ) from je_editor import EditorWidget, language_wrapper +from je_editor.pyside_ui.code.auto_save.auto_save_manager import ( + auto_save_manager_dict, file_is_open_manager_dict, init_new_auto_save_thread, +) from pybreeze.utils.logging.logger import pybreeze_logger @@ -201,6 +204,45 @@ def _find_editor_for_file(main_window, file_path: Path) -> EditorWidget | None: return None +def _editors_under(main_window, path: Path) -> list[tuple[EditorWidget, Path]]: + """The editor tabs open on *path*, or on any file under it, with their files.""" + found = [] + for index in range(main_window.tab_widget.count()): + widget = main_window.tab_widget.widget(index) + if not isinstance(widget, EditorWidget) or widget.current_file is None: + continue + file_path = Path(widget.current_file) + if file_path == path or path in file_path.parents: + found.append((widget, file_path)) + return found + + +def _stop_auto_save(editor: EditorWidget) -> None: + """Stop the tab's auto-save and forget the path it was registered under. + + JEditor's save thread loops for as long as the path it *started* with is a + file, writing to whatever ``file`` holds; after a rename it would write the + buffer back to the old name -- recreating it, so it never stops -- and never + save the new one. It cannot be pointed elsewhere, only replaced. + """ + if editor.code_save_thread is not None: + editor.code_save_thread.still_run = False + editor.code_save_thread = None + old = str(editor.current_file) + auto_save_manager_dict.pop(old, None) + file_is_open_manager_dict.pop(str(Path(old)), None) + + +def _start_auto_save(editor: EditorWidget, file_path: Path) -> None: + """Point the tab at *file_path* and start its auto-save there.""" + editor.code_edit.current_file = str(file_path) + file_is_open_manager_dict[str(file_path)] = str(file_path) + # Sets current_file, carries the tab's encoding and line ending, and starts + # the thread, as JEditor does when it opens a file. + init_new_auto_save_thread(str(file_path), editor) + editor.rename_self_tab() + + def _action_rename(tree_view: QTreeView, main_window, path: Path | None) -> None: if path is None: return @@ -222,14 +264,15 @@ def _action_rename(tree_view: QTreeView, main_window, path: Path | None) -> None ) return - # If this file is currently open in an editor tab, update the tab - editor = _find_editor_for_file(main_window, path) - if not _perform_file_op(tree_view, lambda: path.rename(target)): - return - if editor is not None and target.is_file(): - editor.current_file = str(target) - editor.code_edit.current_file = str(target) - editor.rename_self_tab() + # Every tab open on the file, or on a file under the folder, follows it. Their + # auto-save stops first, so none writes to the old path mid-rename. + moving = _editors_under(main_window, path) + for editor, _old in moving: + _stop_auto_save(editor) + renamed = _perform_file_op(tree_view, lambda: path.rename(target)) + for editor, old in moving: + now = target / old.relative_to(path) if renamed else old + _start_auto_save(editor, now) def _action_delete(tree_view: QTreeView, main_window, path: Path | None) -> None: diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py index 69de26ec..6fa2f5c8 100644 --- a/test/test_utils/test_file_tree_context_menu.py +++ b/test/test_utils/test_file_tree_context_menu.py @@ -217,8 +217,14 @@ def test_an_open_tab_follows_the_rename(self, tree, tmp_path, monkeypatch): original.touch() window = FakeWindow() editor = FakeEditor(str(original)) + editor.code_save_thread = None monkeypatch.setattr( - ctx, "_find_editor_for_file", lambda _w, _p: editor) + ctx, "_editors_under", lambda _w, _p: [(editor, original)]) + # The real one starts JEditor's save thread; the stand-in only records + # where the tab now points, the way it does. + monkeypatch.setattr( + ctx, "init_new_auto_save_thread", + lambda file_path, widget: setattr(widget, "current_file", file_path)) answer(monkeypatch, "renamed.py") _action_rename(tree, window, original) assert editor.current_file == str(tmp_path / "renamed.py") diff --git a/test/test_utils/test_file_tree_rename.py b/test/test_utils/test_file_tree_rename.py new file mode 100644 index 00000000..4d54f1f6 --- /dev/null +++ b/test/test_utils/test_file_tree_rename.py @@ -0,0 +1,64 @@ +"""Renaming an open file, or its folder, from the file tree. + +The open tab and its auto-save have to follow the file. Left behind, JEditor's +auto-save wrote the buffer back to the old name -- recreating the file the user +had just renamed away -- and never saved the new one again. +""" +from __future__ import annotations + +from test_utils.started_window import run_started_window + +_RENAME = """ +import time +from pathlib import Path +from PySide6.QtWidgets import QApplication, QTreeView +from pybreeze.pybreeze_ui.editor_main import file_tree_context_menu as menu + +here = Path.cwd() +folder = here / "project" +folder.mkdir() +original = folder / "a.py" +original.write_text("x = 1\\n", encoding="utf-8") +window.go_to_new_tab(original) +editor = window.tab_widget.currentWidget() + +RENAME_WHAT, NEW_NAME = {what!r}, {new_name!r} +renamed = original if RENAME_WHAT == "file" else folder +menu.QInputDialog.getText = staticmethod(lambda *args, **kwargs: (NEW_NAME, True)) +menu._action_rename(QTreeView(), window, renamed) +expected = (folder / NEW_NAME) if RENAME_WHAT == "file" else (here / NEW_NAME / "a.py") + +editor.code_edit.setPlainText("x = 2\\n") +deadline = time.monotonic() + 15 +while time.monotonic() < deadline: + QApplication.processEvents() + if expected.is_file() and "x = 2" in expected.read_text(encoding="utf-8"): + break + time.sleep(0.1) + +result = {{ + "tab_file": str(Path(editor.current_file)) == str(expected), + "saved_to_new": expected.is_file() and "x = 2" in expected.read_text(encoding="utf-8"), + "old_came_back": original.exists(), +}} +""" + + +def _rename(tmp_path, what: str, new_name: str) -> dict: + return run_started_window(tmp_path, _RENAME.format(what=what, new_name=new_name)) + + +def test_a_renamed_file_is_saved_under_its_new_name(tmp_path): + seen = _rename(tmp_path, "file", "b.py") + + assert seen["tab_file"] + assert seen["saved_to_new"], "the new name never got the edit" + assert not seen["old_came_back"], "auto-save recreated the old name" + + +def test_a_file_in_a_renamed_folder_follows_it(tmp_path): + seen = _rename(tmp_path, "folder", "renamed_project") + + assert seen["tab_file"] + assert seen["saved_to_new"], "the file under the new folder never got the edit" + assert not seen["old_came_back"], "auto-save recreated the old folder" diff --git a/test/test_utils/test_jeditor_contract.py b/test/test_utils/test_jeditor_contract.py index 44d63692..0a6658a1 100644 --- a/test/test_utils/test_jeditor_contract.py +++ b/test/test_utils/test_jeditor_contract.py @@ -23,7 +23,13 @@ ("je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget", "PluginBrowserWidget", "menu/plugin_menu/build_plugin_menu.py"), ("je_editor.pyside_ui.main_ui.dock.destroy_dock", "DestroyDock", - "menu/tools/tools_menu.py"), + "menu/tools/tools_menu.py, editor_main/main_ui.py"), + ("je_editor.pyside_ui.code.auto_save.auto_save_manager", "init_new_auto_save_thread", + "editor_main/file_tree_context_menu.py"), + ("je_editor.pyside_ui.code.auto_save.auto_save_manager", "auto_save_manager_dict", + "editor_main/file_tree_context_menu.py"), + ("je_editor.pyside_ui.code.auto_save.auto_save_manager", "file_is_open_manager_dict", + "editor_main/file_tree_context_menu.py"), ("je_editor.utils.venv_check.check_venv", "check_and_choose_venv", "extend/process_executor/python_task_process_manager.py"), ("je_editor.pyside_ui.dialog.file_dialog.save_file_dialog", "choose_file_get_save_file_path", @@ -120,6 +126,17 @@ def test_the_widgets_build_without_arguments(self): for name, parameter in inspect.signature(browser.__init__).parameters.items() if name != "self") + def test_auto_save_can_be_stopped_and_started_again_on_another_path(self): + # A rename in the file tree stops a tab's save thread and starts another. + module = "je_editor.pyside_ui.code.auto_save.auto_save_manager" + start = _internal(module, "init_new_auto_save_thread") + assert _parameters(start)[:2] == ["file_path", "widget"] + assert isinstance(_internal(module, "auto_save_manager_dict"), dict) + assert isinstance(_internal(module, "file_is_open_manager_dict"), dict) + thread = _internal("je_editor.pyside_ui.code.auto_save.auto_save_thread", "CodeEditSaveThread") + source = inspect.getsource(thread) + assert "self.still_run" in source and "self.file" in source + def test_the_language_wrapper_has_what_pybreeze_reads(self): from je_editor import language_wrapper @@ -141,9 +158,13 @@ def test_the_list_matches_the_code(): import pybreeze - pattern = re.compile(r"^\s*from (je_editor\.[\w.]+) import ([\w, ]+)$", re.MULTILINE) + # Both "import a, b" and "import (\n a, b,\n)": a parenthesised import that + # the check could not read would slip past it unlisted. + pattern = re.compile( + r"^\s*from (je_editor\.[\w.]+) import (?:\(([^)]*)\)|([\w, ]+)$)", re.MULTILINE) found = set() for path in pathlib.Path(pybreeze.__file__).parent.rglob("*.py"): - for module, names in pattern.findall(path.read_text(encoding="utf-8")): - found.update((module, name.strip()) for name in names.split(",")) + for module, wrapped, plain in pattern.findall(path.read_text(encoding="utf-8")): + names = (wrapped or plain).replace("\n", " ").split(",") + found.update((module, name.strip()) for name in names if name.strip()) assert found == {(module, name) for module, name, _ in INTERNAL} From 4d6660a9bd481800d0ec9f05b7eb4a6dd1537cad Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 05:19:05 +0800 Subject: [PATCH 041/312] Report deep JSON, an unsplittable URL and a non-UTF-8 HAR instead of raising The JSON formatter let RecursionError out, the URL builder's to-JSON path let urlsplit's ValueError out, and the HAR importer let UnicodeDecodeError out, each escaping its Qt slot. They are reported in the tab now, and a HAR read error no longer shows the file's path. --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 11 +++++++++ docs/updates/README.md | 3 ++- progress.md | 3 --- .../extend_multi_language/extend_english.py | 2 ++ .../extend_traditional_chinese.py | 2 ++ .../pybreeze_ui/tools_gui/har_import_gui.py | 8 +++++-- .../pybreeze_ui/tools_gui/url_builder_gui.py | 13 ++++++++--- pybreeze/utils/exception/exception_tags.py | 1 + pybreeze/utils/json_format/json_process.py | 8 ++++--- pybreeze/utils/url_tools/url_convert.py | 10 +++++++- test/test_utils/test_har_import_gui.py | 23 +++++++++++++++++++ test/test_utils/test_json_process.py | 20 ++++++++++++++++ test/test_utils/test_url_builder_gui.py | 14 +++++++++++ test/test_utils/test_url_convert.py | 9 ++++++++ 15 files changed, 115 insertions(+), 14 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index b53904bc..eca99753 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -431,7 +431,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1192 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1198 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index b945182c..bc4f21b6 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -347,3 +347,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1172 → 1178 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/{test_file_tree_rename,test_file_tree_context_menu,test_jeditor_contract}.py`, `architecture.md` §6, `architecture_explore.md` §3, §18, `progress.md` (#14 removed). - **Open items**: none. + +## U-20260923-23 · 2026-09-23 · Three tool tabs let an exception out of their slots · #done #tools + +- **What**: `progress.md` #16, #17 and #25, from the audit of `tools_gui/` and `utils/`. Each let an exception out of a Qt slot instead of saying what was wrong. + - **#16 JSON nested too deep.** `reformat_json()` and `minify_json()` caught `JSONDecodeError` only; the `json` module recurses once per level, so `"[" * 100000 + "]" * 100000` raised `RecursionError` straight through `JsonFormatGUI` — including from its constructor when the response inspector opens a body in it. `test_fuzz_pure_logic.py` already stated that contract, but `st.text()` never generates such input. Both now report it as bad JSON (`ITEJsonException`). + - **#17 A URL that cannot be split.** `url_to_json()` passed `urlsplit()`'s `ValueError("Invalid IPv6 URL")` through, and the URL builder's to-JSON path had no handler at all (its to-URL path did): `http://[::1` crashed the slot, and so did opening a cURL import's URL in the builder, which converts it in `__init__`. It raises `UrlConvertException` now and the tab shows `url_builder_parse_error`. + - **#25 A .har that is not UTF-8.** `open_file()` read with `encoding="utf-8"` inside `except OSError`, so a UTF-16 export raised `UnicodeDecodeError`. It is reported as `har_import_not_utf8` now, and a read error shows the OS's reason without the path — `str(OSError)` carries the file's full path, which `CLAUDE.md` § Security says is not to be shown. +- **Tests**: six new ones, all failing against the old sources: deep nesting for both JSON functions, the unclosed bracket in `url_to_json()` and in the builder tab, and in the HAR importer a UTF-16 file plus an error message that does not name the folder. +- **Result / numbers**: unit tests 1178 → 1184 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/utils/json_format/json_process.py`, `pybreeze/utils/url_tools/url_convert.py`, `pybreeze/utils/exception/exception_tags.py`, `pybreeze/pybreeze_ui/tools_gui/{url_builder_gui,har_import_gui}.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/{test_json_process,test_url_convert,test_url_builder_gui,test_har_import_gui}.py`, `progress.md` (#16, #17, #25 removed). +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 2a2983b5..834881b6 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-23 | 2026-09-23 | Three tool tabs let an exception out of their slots | #done #tools | [2026-09](2026-09.md) | | U-20260923-22 | 2026-09-23 | Renaming an open file brought the old name back | #done #editor | [2026-09](2026-09.md) | | U-20260923-21 | 2026-09-23 | The embedded JupyterLab took any origin and outlived its tab | #incident #security #jupyter | [2026-09](2026-09.md) | | U-20260923-20 | 2026-09-23 | One Connect button, two sessions, one honest label | #done #ssh | [2026-09](2026-09.md) | @@ -103,4 +104,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 40 | +| [2026-09.md](2026-09.md) | 2026-09 | 41 | diff --git a/progress.md b/progress.md index 407566f9..fd075af9 100644 --- a/progress.md +++ b/progress.md @@ -9,8 +9,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#10** SSH connecting and directory listing still run on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s) and `listdir_attr()` by nothing at all, so an unresponsive host or a directory with tens of thousands of entries freezes the IDE. Transfers moved off it in U-20260923-19; moving `connect()` has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. - **#15** The regex tool runs the user's pattern on the UI thread with nothing to bound it (`tools_gui/regex_gui.py` -> `utils/regex_tools/regex_tester.py:find_matches`): `(a+)+$` against 26 `a`s and a `b` takes about 5 s, and each further character doubles it. `_MAX_MATCHES` caps how many matches are reported, not how long one attempt takes. Only a separate process can be stopped mid-match. -- **#16** `reformat_json()` / `minify_json()` let `RecursionError` out on deeply nested input (`utils/json_format/json_process.py`), and `JsonFormatGUI` catches only `ITEJsonException`, so `"[" * 10000 + "]" * 10000` escapes the slot — including from `JsonFormatGUI.__init__` when the response inspector opens a body. `test_fuzz_pure_logic.py` states the contract but `st.text()` never generates the input. -- **#17** `convert_to_json()` in the URL builder lets `ValueError("Invalid IPv6 URL")` from `urlsplit()` escape (`utils/url_tools/url_convert.py`, `tools_gui/url_builder_gui.py`): `http://[::1` crashes the slot, and the cURL importer builds the tab with such a URL in `__init__`. - **#18** HAR query values are stored percent-encoded and then encoded again (`utils/har_import/har_parser.py:_apply_query`): `?q=hello%20world` becomes `params = {"q": "hello%20world"}` and the generated script sends `hello%2520world`. The cURL importer decodes (`parse_qsl`), so the two disagree on the same URL. - **#19** `curl -G` data is split on the first `=` only, never on `&` (`utils/curl_import/curl_parser.py:_finalise_method` via `parse_query_pairs`): `-d 'a=1&b=2'` becomes `{"a": "1&b=2"}`, and `--data-urlencode 'q=hello world'` is encoded twice. - **#20** `parse_url()` keeps only the last value of a repeated query key (`utils/url_tools/url_convert.py`, `dict(parse_qsl(...))`), so the URL builder's round trip drops `?tag=a&tag=b` to `?tag=b`; `query_tools/query_convert.py` already turns repeats into a list. @@ -18,5 +16,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#22** Prompt files: a prompt saved as ANSI raises `UnicodeDecodeError` out of `prompt_store.load_prompt` and `PromptEditorWidget.load_file_content`, which catch only `OSError` (so the skills tab or the prompt editor fails to open); the editor's file watcher reloads over unsaved edits without asking (`prompt_edit_gui/prompt_editor_widget.py:on_file_changed`); and `prompt_file_io.save_prompt_text` truncates before it writes. - **#23** `SkillsSendGUI`'s `RequestThread` declares `finished = Signal(str)`, hiding `QThread.finished`, so an exception outside its except tuple ends the thread with no signal and the send button stays disabled for the session; and `closeEvent` waits with no timeout on a request that can take 30 s (`extend_ai_gui/skills/skills_send_gui.py`). - **#24** A registered `EDITOR_EXTEND_TAB` whose constructor raises aborts `PyBreezeMainWindow.__init__` (`editor_main/main_ui.py`, the loop that adds them is unguarded): one bad third-party tab means no IDE at all instead of the IDE without that tab. -- **#25** The HAR importer reads the chosen file with `encoding="utf-8"` inside `except OSError` only (`tools_gui/har_import_gui.py:open_file`), so a UTF-16 `.har` raises `UnicodeDecodeError` out of the slot; and its error message is `str(OSError)`, which carries the absolute path. diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 81b9af47..4d6e5eed 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -396,6 +396,7 @@ "har_import_empty_hint": "Open a .har file saved from your browser's dev tools (Network → Save all as HAR).", "har_import_error": "Could not read the HAR export: {error}", + "har_import_not_utf8": "it is not UTF-8 text", "har_import_read_error": "Could not open the file: {error}", # Shared output actions (copy / open in editor / save to file) "output_actions_copy": "Copy", @@ -471,6 +472,7 @@ "url_builder_to_json_button": "URL → JSON", "url_builder_to_url_button": "JSON → URL", "url_builder_output_label": "Result:", + "url_builder_parse_error": "Could not read the URL: {error}", "url_builder_error": "Could not build URL: {error}", "url_builder_empty_hint": "Enter a URL or a JSON object of URL parts above.", # Regex Tester — Menu diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 2bb1a9c2..aedc8666 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -374,6 +374,7 @@ "har_import_empty_hint": "請開啟從瀏覽器開發者工具存出的 .har 檔(Network → Save all as HAR)。", "har_import_error": "無法讀取這份 HAR 匯出檔:{error}", + "har_import_not_utf8": "不是 UTF-8 文字檔", "har_import_read_error": "無法開啟檔案:{error}", # 共用輸出動作(複製 / 開成分頁 / 存檔) "output_actions_copy": "複製", @@ -449,6 +450,7 @@ "url_builder_to_json_button": "URL → JSON", "url_builder_to_url_button": "JSON → URL", "url_builder_output_label": "結果:", + "url_builder_parse_error": "無法解析這個 URL:{error}", "url_builder_error": "無法組建 URL:{error}", "url_builder_empty_hint": "請在上方輸入 URL 或描述 URL 各部分的 JSON 物件。", # 正規表示式測試器 — 選單 diff --git a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py index bb9848c6..7a1c404a 100644 --- a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py @@ -109,9 +109,13 @@ def open_file(self) -> str | None: return None try: text = Path(path).read_text(encoding="utf-8") - except OSError as error: + except (OSError, UnicodeDecodeError) as error: pybreeze_logger.info("har_import_gui.py read failed: %r", error) - self._report_error(word.get("har_import_read_error").format(error=str(error))) + # The reason without the path: str(OSError) carries the file's full + # path, which is not the user's business to be shown back. + reason = (word.get("har_import_not_utf8") if isinstance(error, UnicodeDecodeError) + else error.strerror or type(error).__name__) + self._report_error(word.get("har_import_read_error").format(error=reason)) return None self.load_text(text) return path diff --git a/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py b/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py index ee726dab..f46f6143 100644 --- a/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py @@ -61,14 +61,21 @@ def __init__(self, main_window=None, initial_url: str | None = None) -> None: def convert_to_json(self) -> None: """Parse the input URL into its JSON parts.""" + word = language_wrapper.language_word_dict text = self.input_edit.toPlainText().strip() if not text: self._valid_output = False - self.output_edit.setPlainText( - language_wrapper.language_word_dict.get("url_builder_empty_hint")) + self.output_edit.setPlainText(word.get("url_builder_empty_hint")) + return + try: + result = url_to_json(text) + except UrlConvertException as error: + pybreeze_logger.info("url_builder_gui.py to-json failed: %r", error) + self._valid_output = False + self.output_edit.setPlainText(word.get("url_builder_parse_error").format(error=str(error))) return self._valid_output = True - self.output_edit.setPlainText(url_to_json(text)) + self.output_edit.setPlainText(result) def convert_to_url(self) -> None: """Build a URL from the input JSON parts.""" diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index 1c2af4c5..82e7d9ab 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -69,3 +69,4 @@ # URL parse / build invalid_json_for_url_error: str = "can't parse the input as JSON" invalid_url_components_error: str = "the input must be a JSON object of URL parts" +unreadable_url_error: str = "the input is not a URL that can be read" diff --git a/pybreeze/utils/json_format/json_process.py b/pybreeze/utils/json_format/json_process.py index bcc040c9..6b522ecd 100644 --- a/pybreeze/utils/json_format/json_process.py +++ b/pybreeze/utils/json_format/json_process.py @@ -13,9 +13,11 @@ 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: + except (json.JSONDecodeError, RecursionError) as error: # Wrap in the project exception so reformat_json's caller sees a single, - # documented ITEJsonException type rather than a raw JSONDecodeError. + # documented ITEJsonException type rather than a raw JSONDecodeError -- + # or a RecursionError, which the json module raises on input nested + # deeper than the interpreter's recursion limit. pybreeze_logger.error(wrong_json_data_error) raise ITEJsonException(wrong_json_data_error) from error except TypeError: @@ -45,6 +47,6 @@ def minify_json(json_string: str) -> str: """ try: return dumps(loads(json_string), separators=_MINIFY_SEPARATORS) - except json.JSONDecodeError as error: + except (json.JSONDecodeError, RecursionError) as error: pybreeze_logger.error(wrong_json_data_error) raise ITEJsonException(wrong_json_data_error) from error diff --git a/pybreeze/utils/url_tools/url_convert.py b/pybreeze/utils/url_tools/url_convert.py index fe4d193b..b1c1fc94 100644 --- a/pybreeze/utils/url_tools/url_convert.py +++ b/pybreeze/utils/url_tools/url_convert.py @@ -12,6 +12,7 @@ from pybreeze.utils.exception.exception_tags import ( invalid_json_for_url_error, invalid_url_components_error, + unreadable_url_error, ) from pybreeze.utils.exception.exceptions import UrlConvertException from pybreeze.utils.logging.logger import pybreeze_logger @@ -53,8 +54,15 @@ def url_to_json(url: str) -> str: :param url: the URL to parse :return: a formatted JSON object of the URL's parts + :raises UrlConvertException: when *url* cannot be split into parts at all + (``http://[::1`` -- an unclosed IPv6 bracket -- is one) """ - return json.dumps(parse_url(url), indent=4, ensure_ascii=False) + try: + components = parse_url(url) + except ValueError as error: + pybreeze_logger.error(unreadable_url_error) + raise UrlConvertException(unreadable_url_error) from error + return json.dumps(components, indent=4, ensure_ascii=False) def _build_netloc(components: dict, host: str) -> str: diff --git a/test/test_utils/test_har_import_gui.py b/test/test_utils/test_har_import_gui.py index d5d1d5b6..fc29d631 100644 --- a/test/test_utils/test_har_import_gui.py +++ b/test/test_utils/test_har_import_gui.py @@ -171,3 +171,26 @@ def test_unreadable_file_reports_an_error(self, widget, tmp_path): ): assert widget.open_file() is None assert widget.output_edit.toPlainText() != "" + + +class TestReadingAFileThatIsNotUsable: + def test_a_file_that_is_not_utf8_is_reported_not_raised(self, widget, tmp_path): + exported = tmp_path / "export.har" + exported.write_text('{"log": {"entries": []}}', encoding="utf-16") + with patch( + "pybreeze.pybreeze_ui.tools_gui.har_import_gui.QFileDialog.getOpenFileName", + return_value=(str(exported), ""), + ): + assert widget.open_file() is None + assert "UTF-8" in widget.output_edit.toPlainText() + + def test_the_message_does_not_show_the_path(self, widget, tmp_path): + missing = tmp_path / "private-folder-name" / "gone.har" + with patch( + "pybreeze.pybreeze_ui.tools_gui.har_import_gui.QFileDialog.getOpenFileName", + return_value=(str(missing), ""), + ): + widget.open_file() + shown = widget.output_edit.toPlainText() + assert shown + assert "private-folder-name" not in shown diff --git a/test/test_utils/test_json_process.py b/test/test_utils/test_json_process.py index 5bc8031d..a132c5cc 100644 --- a/test/test_utils/test_json_process.py +++ b/test/test_utils/test_json_process.py @@ -58,3 +58,23 @@ def test_json_with_unicode(self): result = reformat_json('{"key": "中文"}') parsed = json.loads(result) assert parsed["key"] == "中文" + + +class TestInputNestedTooDeep: + """The json module recurses per level; deep nesting is a RecursionError, not a crash.""" + + _DEEP = "[" * 100_000 + "]" * 100_000 + + def test_reformat_reports_it_as_bad_json(self): + from pybreeze.utils.exception.exceptions import ITEJsonException + from pybreeze.utils.json_format.json_process import reformat_json + + with pytest.raises(ITEJsonException): + reformat_json(self._DEEP) + + def test_minify_reports_it_as_bad_json(self): + from pybreeze.utils.exception.exceptions import ITEJsonException + from pybreeze.utils.json_format.json_process import minify_json + + with pytest.raises(ITEJsonException): + minify_json(self._DEEP) diff --git a/test/test_utils/test_url_builder_gui.py b/test/test_utils/test_url_builder_gui.py index 1a5c63ac..90ef9d0a 100644 --- a/test/test_utils/test_url_builder_gui.py +++ b/test/test_utils/test_url_builder_gui.py @@ -84,3 +84,17 @@ def test_initial_url_is_kept_in_the_input(self, app): def test_without_initial_url_the_output_stays_empty(self, widget): assert widget.output_edit.toPlainText() == "" + + +class TestAUrlThatCannotBeRead: + def test_it_is_reported_in_the_output_not_raised(self, app): + from pybreeze.pybreeze_ui.tools_gui.url_builder_gui import UrlBuilderGUI + + gui = UrlBuilderGUI() + gui.input_edit.setPlainText("http://[::1") + + gui.convert_to_json() + + assert gui.output_edit.toPlainText() + assert gui._valid_output is False + gui.deleteLater() diff --git a/test/test_utils/test_url_convert.py b/test/test_utils/test_url_convert.py index 2f797302..8dcc208e 100644 --- a/test/test_utils/test_url_convert.py +++ b/test/test_utils/test_url_convert.py @@ -97,3 +97,12 @@ def test_invalid_json_raises(self): def test_non_object_raises(self): with pytest.raises(UrlConvertException): json_to_url('["a", "b"]') + + +class TestAUrlThatCannotBeSplit: + def test_an_unclosed_ipv6_bracket_is_a_convert_error(self): + from pybreeze.utils.exception.exceptions import UrlConvertException + from pybreeze.utils.url_tools.url_convert import url_to_json + + with pytest.raises(UrlConvertException): + url_to_json("http://[::1") From 78fea6f7128391a658beac9164c7bf5647c296c2 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 05:22:30 +0800 Subject: [PATCH 042/312] Decode an imported query once and keep repeated keys A HAR URL's values were encoded a second time, curl -G data was not split on '&', and the URL builder kept only the last value of a repeated key, so the generated request or URL differed from the recorded one. --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 12 ++++++++++ docs/updates/README.md | 3 ++- progress.md | 3 --- pybreeze/utils/curl_import/curl_parser.py | 20 ++++------------ pybreeze/utils/har_import/har_parser.py | 11 +++++---- pybreeze/utils/url_tools/url_convert.py | 9 +++++--- test/test_utils/test_curl_import.py | 28 +++++++++++++---------- test/test_utils/test_har_import.py | 15 ++++++++++++ test/test_utils/test_url_convert.py | 12 ++++++++++ 10 files changed, 76 insertions(+), 39 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index eca99753..abafe559 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -431,7 +431,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1198 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1201 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index bc4f21b6..d6e65d09 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -358,3 +358,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1178 → 1184 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/utils/json_format/json_process.py`, `pybreeze/utils/url_tools/url_convert.py`, `pybreeze/utils/exception/exception_tags.py`, `pybreeze/pybreeze_ui/tools_gui/{url_builder_gui,har_import_gui}.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/{test_json_process,test_url_convert,test_url_builder_gui,test_har_import_gui}.py`, `progress.md` (#16, #17, #25 removed). - **Open items**: none. + +## U-20260923-24 · 2026-09-23 · Imported requests sent a different query than was recorded · #done #tools + +- **What**: `progress.md` #18, #19 and #20. Three places where a query string came out different from the one that went in, silently — the generated script tested another request, or the URL builder dropped a parameter. + - **#18 HAR.** `_apply_query()` stored the URL's values still percent-encoded, and `CurlRequest.full_url` and the generated `params = {...}` encode them again: `?q=hello%20world` was sent as `hello%2520world`, the literal text `hello%20world`. The cURL importer decoded (`parse_qsl`), so the two importers disagreed on the same URL. The HAR importer decodes now. + - **#19 `curl -G`.** Each `-d` fragment was split on its first `=` only, never on `&`: `-d 'a=1&b=2'` became `{"a": "1&b=2"}`, sent as `a=1%26b%3D2`, and `--data-urlencode 'q=hello world'` was encoded twice. With `-G` curl appends each fragment to the URL as given, so each is now read as query text and decoded once. + - **#20 URL builder.** `parse_url()` built its query with `dict(parse_qsl(...))`, keeping only the last value of a repeated key, so `?tag=a&tag=b` round-tripped to `?tag=b`. It uses the query tool's `query_to_dict()` — a repeated key becomes a list, as that tool already did — and `build_url()` encodes lists back as repeated keys (`doseq=True`). + - `parse_query_pairs()` had no callers left and went, with its three tests. +- **Tests**: six new ones, all failing against the old code — `-G` with two pairs in one fragment and with `--data-urlencode`, a HAR URL's encoded values and its rebuilt URL, and a repeated key through `parse_url()` and back. +- **Result / numbers**: unit tests 1184 → 1187 passed, 14 skipped (six added, three removed with the helper). `ruff check` clean. +- **Files**: `pybreeze/utils/curl_import/curl_parser.py`, `pybreeze/utils/har_import/har_parser.py`, `pybreeze/utils/url_tools/url_convert.py`, `test/test_utils/{test_curl_import,test_har_import,test_url_convert}.py`, `progress.md` (#18, #19, #20 removed). +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 834881b6..bc99d0c0 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-24 | 2026-09-23 | Imported requests sent a different query than was recorded | #done #tools | [2026-09](2026-09.md) | | U-20260923-23 | 2026-09-23 | Three tool tabs let an exception out of their slots | #done #tools | [2026-09](2026-09.md) | | U-20260923-22 | 2026-09-23 | Renaming an open file brought the old name back | #done #editor | [2026-09](2026-09.md) | | U-20260923-21 | 2026-09-23 | The embedded JupyterLab took any origin and outlived its tab | #incident #security #jupyter | [2026-09](2026-09.md) | @@ -104,4 +105,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 41 | +| [2026-09.md](2026-09.md) | 2026-09 | 42 | diff --git a/progress.md b/progress.md index fd075af9..e769a74c 100644 --- a/progress.md +++ b/progress.md @@ -9,9 +9,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#10** SSH connecting and directory listing still run on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s) and `listdir_attr()` by nothing at all, so an unresponsive host or a directory with tens of thousands of entries freezes the IDE. Transfers moved off it in U-20260923-19; moving `connect()` has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. - **#15** The regex tool runs the user's pattern on the UI thread with nothing to bound it (`tools_gui/regex_gui.py` -> `utils/regex_tools/regex_tester.py:find_matches`): `(a+)+$` against 26 `a`s and a `b` takes about 5 s, and each further character doubles it. `_MAX_MATCHES` caps how many matches are reported, not how long one attempt takes. Only a separate process can be stopped mid-match. -- **#18** HAR query values are stored percent-encoded and then encoded again (`utils/har_import/har_parser.py:_apply_query`): `?q=hello%20world` becomes `params = {"q": "hello%20world"}` and the generated script sends `hello%2520world`. The cURL importer decodes (`parse_qsl`), so the two disagree on the same URL. -- **#19** `curl -G` data is split on the first `=` only, never on `&` (`utils/curl_import/curl_parser.py:_finalise_method` via `parse_query_pairs`): `-d 'a=1&b=2'` becomes `{"a": "1&b=2"}`, and `--data-urlencode 'q=hello world'` is encoded twice. -- **#20** `parse_url()` keeps only the last value of a repeated query key (`utils/url_tools/url_convert.py`, `dict(parse_qsl(...))`), so the URL builder's round trip drops `?tag=a&tag=b` to `?tag=b`; `query_tools/query_convert.py` already turns repeats into a list. - **#21** `PyBreeze.log` is opened relative to the working directory and in the locale's code page (`utils/logging/logger.py`): it lands wherever the IDE was started (against `CLAUDE.md` § File I/O, which says `~/.pybreeze/`; workspace X-6 is the same problem across repos), a read-only working directory makes importing `pybreeze.utils` fail, and on a cp1252 machine any record with CJK text is dropped with `--- Logging error ---`. - **#22** Prompt files: a prompt saved as ANSI raises `UnicodeDecodeError` out of `prompt_store.load_prompt` and `PromptEditorWidget.load_file_content`, which catch only `OSError` (so the skills tab or the prompt editor fails to open); the editor's file watcher reloads over unsaved edits without asking (`prompt_edit_gui/prompt_editor_widget.py:on_file_changed`); and `prompt_file_io.save_prompt_text` truncates before it writes. - **#23** `SkillsSendGUI`'s `RequestThread` declares `finished = Signal(str)`, hiding `QThread.finished`, so an exception outside its except tuple ends the thread with no signal and the send button stays disabled for the session; and `closeEvent` waits with no timeout on a request that can take 30 s (`extend_ai_gui/skills/skills_send_gui.py`). diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index 5c74a283..6ac76986 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -342,7 +342,11 @@ def _finalise_method(request: CurlRequest) -> None: if request.method == _DEFAULT_METHOD and request.has_body and not request.send_data_as_params: request.method = _METHOD_WITH_BODY if request.send_data_as_params: - request.params.update(parse_query_pairs(request.data_parts)) + # With -G, curl appends the data to the URL exactly as given, joined by + # '&': each fragment is query text already, so it is split on '&' and + # decoded here, or full_url would encode it a second time. + for part in request.data_parts: + request.params.update(parse_qsl(part, keep_blank_values=True)) request.data_parts = [] @@ -362,20 +366,6 @@ def _split_url_query(request: CurlRequest) -> None: request.params.setdefault(key, value) -def parse_query_pairs(parts: list[str]) -> dict[str, str]: - """Parse ``key=value`` fragments into a dict, ignoring pieces without ``=``. - - :param parts: body fragments such as ``["a=1", "b=2"]`` - :return: the parsed ``key -> value`` mapping - """ - pairs: dict[str, str] = {} - for part in parts: - key, separator, value = part.partition("=") - if separator: - pairs[key] = value - return pairs - - def parse_curl(command: str) -> CurlRequest: """Parse a ``curl`` command string into a :class:`CurlRequest`. diff --git a/pybreeze/utils/har_import/har_parser.py b/pybreeze/utils/har_import/har_parser.py index c4ac7581..121a0026 100644 --- a/pybreeze/utils/har_import/har_parser.py +++ b/pybreeze/utils/har_import/har_parser.py @@ -12,9 +12,9 @@ import json from dataclasses import dataclass, field -from urllib.parse import urlparse +from urllib.parse import parse_qsl, urlparse -from pybreeze.utils.curl_import.curl_parser import CurlRequest, parse_query_pairs +from pybreeze.utils.curl_import.curl_parser import CurlRequest from pybreeze.utils.exception.exception_tags import ( empty_har_error, invalid_har_json_error, @@ -130,9 +130,12 @@ def _apply_query(request: CurlRequest, raw_request: dict, query: str) -> None: """Collect the query parameters from the URL, then from ``queryString``. The URL is authoritative because it is what was actually sent; the recorded - ``queryString`` list only fills in what the URL did not carry. + ``queryString`` list only fills in what the URL did not carry. Values from + the URL are percent-decoded, as the cURL importer does: ``params`` holds + values, and :attr:`CurlRequest.full_url` and the generated scripts encode + them once on the way out. """ - for key, value in parse_query_pairs(query.split("&")).items(): + for key, value in parse_qsl(query, keep_blank_values=True): request.params.setdefault(key, value) for name, value in _header_pairs(raw_request.get("queryString")): request.params.setdefault(name, value) diff --git a/pybreeze/utils/url_tools/url_convert.py b/pybreeze/utils/url_tools/url_convert.py index b1c1fc94..f377f78e 100644 --- a/pybreeze/utils/url_tools/url_convert.py +++ b/pybreeze/utils/url_tools/url_convert.py @@ -7,7 +7,7 @@ from __future__ import annotations import json -from urllib.parse import SplitResult, parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.parse import SplitResult, urlencode, urlsplit, urlunsplit from pybreeze.utils.exception.exception_tags import ( invalid_json_for_url_error, @@ -16,6 +16,7 @@ ) from pybreeze.utils.exception.exceptions import UrlConvertException from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.utils.query_tools.query_convert import query_to_dict def _safe_port(split: SplitResult) -> int | None: @@ -39,7 +40,8 @@ def parse_url(url: str) -> dict: "host": split.hostname or "", "port": _safe_port(split), "path": split.path, - "query": dict(parse_qsl(split.query, keep_blank_values=True)), + # A repeated key keeps every value, as a list, like the query tool. + "query": query_to_dict(split.query), "fragment": split.fragment, } if split.username is not None: @@ -86,7 +88,8 @@ def _build_query(query: object) -> str: if not query: return "" if isinstance(query, dict): - return urlencode(query) + # doseq: a list value is a key that repeats, one pair per value. + return urlencode(query, doseq=True) return str(query) diff --git a/test/test_utils/test_curl_import.py b/test/test_utils/test_curl_import.py index f8f176a1..db09de6e 100644 --- a/test/test_utils/test_curl_import.py +++ b/test/test_utils/test_curl_import.py @@ -6,7 +6,6 @@ from pybreeze.utils.curl_import.curl_parser import ( CurlRequest, parse_curl, - parse_query_pairs, ) from pybreeze.utils.curl_import.request_codegen import to_requests_code from pybreeze.utils.exception.exceptions import CurlParseException @@ -476,17 +475,6 @@ def test_form_string_flag(self): assert request.form_fields == ["a=1"] -class TestParseQueryPairs: - def test_parses_pairs(self): - assert parse_query_pairs(["a=1", "b=2"]) == {"a": "1", "b": "2"} - - def test_ignores_fragments_without_equals(self): - assert parse_query_pairs(["a=1", "bad"]) == {"a": "1"} - - def test_empty(self): - assert parse_query_pairs([]) == {} - - class TestToRequestsCode: def test_simple_get(self): code = to_requests_code(parse_curl("curl https://example.com/api")) @@ -560,3 +548,19 @@ def test_defaults(self): assert request.headers == {} # The JSON round-trip in codegen should never see a stale shared dict. assert CurlRequest().headers is not request.headers + + +class TestGetWithData: + """With -G, curl appends each -d fragment to the URL as given, joined by '&'.""" + + def test_one_fragment_with_several_pairs_is_split(self): + request = parse_curl("curl -G https://x/api -d 'a=1&b=2'") + + assert request.params == {"a": "1", "b": "2"} + assert request.full_url == "https://x/api?a=1&b=2" + + def test_data_urlencode_is_encoded_once(self): + request = parse_curl("curl -G https://x/api --data-urlencode 'q=hello world'") + + assert request.params == {"q": "hello world"} + assert request.full_url == "https://x/api?q=hello+world" diff --git a/test/test_utils/test_har_import.py b/test/test_utils/test_har_import.py index 70b47195..4a84baa9 100644 --- a/test/test_utils/test_har_import.py +++ b/test/test_utils/test_har_import.py @@ -289,3 +289,18 @@ def test_loaddensity_script_keeps_every_request(self): def test_unknown_target_falls_back_to_requests(self): code = generate_har_script("nonsense", self._requests("https://x/one", "https://x/two")) assert "import requests" in code + + +class TestQueryValuesFromTheUrl: + """A value in the recorded URL is percent-encoded; params hold it decoded, once.""" + + def test_an_encoded_value_is_decoded(self): + entry = parse_har(_har(_entry(url="https://x/api?q=hello%20world&tag=a%2Bb")))[0] + + assert entry.request.params == {"q": "hello world", "tag": "a+b"} + + def test_the_rebuilt_url_is_encoded_once(self): + entry = parse_har(_har(_entry(url="https://x/api?q=hello%20world")))[0] + + assert "%2520" not in entry.request.full_url + assert entry.request.full_url == "https://x/api?q=hello+world" diff --git a/test/test_utils/test_url_convert.py b/test/test_utils/test_url_convert.py index 8dcc208e..ada17fa4 100644 --- a/test/test_utils/test_url_convert.py +++ b/test/test_utils/test_url_convert.py @@ -106,3 +106,15 @@ def test_an_unclosed_ipv6_bracket_is_a_convert_error(self): with pytest.raises(UrlConvertException): url_to_json("http://[::1") + + +class TestARepeatedKey: + def test_every_value_is_kept(self): + from pybreeze.utils.url_tools.url_convert import parse_url + + assert parse_url("https://x/?tag=a&tag=b")["query"] == {"tag": ["a", "b"]} + + def test_the_round_trip_keeps_them(self): + from pybreeze.utils.url_tools.url_convert import build_url, parse_url + + assert build_url(parse_url("https://x/?tag=a&tag=b&one=1")) == "https://x/?tag=a&tag=b&one=1" From aab5d166acde0c19ff2c5e806bedef18ed3a8c8f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 05:33:45 +0800 Subject: [PATCH 043/312] Pin PySide6 6.11.2 like the published je_editor je_editor 1.0.26 and frontengine 1.0.78 are on PyPI and both require PySide6==6.11.2 exactly, so PyBreeze follows. A fresh install resolves cleanly and the unit and startup tests pass on it. --- README.md | 4 ++-- README/README_zh-CN.md | 4 ++-- README/README_zh-TW.md | 4 ++-- architecture.md | 6 +++--- dev.toml | 2 +- docs/updates/2026-09.md | 8 ++++++++ docs/updates/README.md | 3 ++- pyproject.toml | 2 +- requirements.txt | 2 +- 9 files changed, 22 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3780c3a2..6fc0d517 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ pip install -r requirements.txt - **Python**: 3.10 – 3.14 - **OS**: Windows, macOS, Linux -- **GUI**: PySide6 6.11.1 (installed automatically) +- **GUI**: PySide6 6.11.2 (installed automatically) --- @@ -399,7 +399,7 @@ PyBreeze/ | Package | Purpose | |---|---| -| `PySide6` (6.11.1) | GUI framework (Qt for Python) | +| `PySide6` (6.11.2) | GUI framework (Qt for Python) | | `je-editor` | Base code editor engine | | `je_api_testka` | API testing automation | | `je_auto_control` | GUI/desktop automation | diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 3ff92521..b45e056a 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -236,7 +236,7 @@ pip install -r requirements.txt - **Python**:3.10 或更高版本 - **操作系统**:Windows、macOS、Linux -- **GUI 框架**:PySide6 6.11.1(自动安装) +- **GUI 框架**:PySide6 6.11.2(自动安装) --- @@ -382,7 +382,7 @@ PyBreeze/ | 包 | 用途 | |---|---| -| `PySide6` (6.11.1) | GUI 框架(Qt for Python)| +| `PySide6` (6.11.2) | GUI 框架(Qt for Python)| | `je-editor` | 基础代码编辑器引擎 | | `je_api_testka` | API 测试自动化 | | `je_auto_control` | GUI/桌面自动化 | diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 6905c447..08a57468 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -236,7 +236,7 @@ pip install -r requirements.txt - **Python**:3.10 或更高版本 - **作業系統**:Windows、macOS、Linux -- **GUI 框架**:PySide6 6.11.1(自動安裝) +- **GUI 框架**:PySide6 6.11.2(自動安裝) --- @@ -382,7 +382,7 @@ PyBreeze/ | 套件 | 用途 | |---|---| -| `PySide6` (6.11.1) | GUI 框架(Qt for Python)| +| `PySide6` (6.11.2) | GUI 框架(Qt for Python)| | `je-editor` | 基礎程式碼編輯器引擎 | | `je_api_testka` | API 測試自動化 | | `je_auto_control` | GUI/桌面自動化 | diff --git a/architecture.md b/architecture.md index 48ce18e5..f6304c7e 100644 --- a/architecture.md +++ b/architecture.md @@ -172,9 +172,9 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() - **IDE_Plugins**: the plugin browser's default repo (set in JEditor). Its run configs execute here via `FileRunnerProcess`. - **PySide6 pin**: it must match JEditor and FrontEngine. PyBreeze pins it in `pyproject.toml`, - `dev.toml` and `requirements.txt`. At last verification all three pinned 6.11.1, the pin of the - published je_editor 1.0.25 and frontengine; PySide6 6.11.2 is out and dependabot has proposed it - to JEditor and FrontEngine (workspace X-1). PyBreeze moves when the published je_editor does. + `dev.toml` and `requirements.txt`. All three pin 6.11.2, the exact pin of the published je_editor + 1.0.26 and frontengine 1.0.78 (both `==`, so PyBreeze cannot move ahead of them without becoming + uninstallable). PyBreeze moves when the published je_editor does (workspace X-1). ## 7. Design constraints diff --git a/dev.toml b/dev.toml index fceb3fbc..997770c1 100644 --- a/dev.toml +++ b/dev.toml @@ -16,7 +16,7 @@ license-files = ["LICENSE"] dependencies = [ "je-editor", "je_auto_control", "je_web_runner", "je_load_density", "je_api_testka", "je-mail-thunder", - "automation-file", "PySide6==6.11.1", "test_pioneer", "paramiko", + "automation-file", "PySide6==6.11.2", "test_pioneer", "paramiko", "jupyterlab", ] classifiers = [ diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index d6e65d09..b103d4a8 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -370,3 +370,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1184 → 1187 passed, 14 skipped (six added, three removed with the helper). `ruff check` clean. - **Files**: `pybreeze/utils/curl_import/curl_parser.py`, `pybreeze/utils/har_import/har_parser.py`, `pybreeze/utils/url_tools/url_convert.py`, `test/test_utils/{test_curl_import,test_har_import,test_url_convert}.py`, `progress.md` (#18, #19, #20 removed). - **Open items**: none. + +## U-20260923-25 · 2026-09-23 · PySide6 6.11.2, after je_editor and frontengine · #done #dependencies + +- **What**: PyBreeze's part of workspace X-1 (JEDITOR, PyBreeze and FrontEngine on one PySide6). The published je_editor and frontengine pin PySide6 *exactly*, so PyBreeze could only follow them: moving first would have made `pip install pybreeze` unsatisfiable. The order was agreed with the session working the workspace backlog — JEDITOR and FrontEngine released first. Checked on PyPI before the bump: je_editor 1.0.26 and frontengine 1.0.78 both require `PySide6==6.11.2`. +- **Change**: `PySide6==6.11.2` in `requirements.txt`, `pyproject.toml` and `dev.toml` (the three are kept identical), and the version named in `README.md` and both translated READMEs. `architecture.md` §6's pin line now says why PyBreeze moves last rather than recording a stale "at last verification". +- **Result / numbers**: a fresh Python 3.13 venv from `dev_requirements.txt` resolves PySide6 6.11.2, je_editor 1.0.26 and frontengine 1.0.78 with `pip check` clean; in it, `test/test_utils/` gives 1187 passed, 14 skipped — including the JEditor contract test against 1.0.26 — and `start_automation_test.py` and `extend_automation_test.py` both exit 0 offscreen. +- **Files**: `requirements.txt`, `pyproject.toml`, `dev.toml`, `README.md`, `README/README_zh-CN.md`, `README/README_zh-TW.md`, `architecture.md` §6. +- **Open items**: none here; the workspace X-1 table is the other session's to update. diff --git a/docs/updates/README.md b/docs/updates/README.md index bc99d0c0..b2db7a70 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-25 | 2026-09-23 | PySide6 6.11.2, after je_editor and frontengine | #done #dependencies | [2026-09](2026-09.md) | | U-20260923-24 | 2026-09-23 | Imported requests sent a different query than was recorded | #done #tools | [2026-09](2026-09.md) | | U-20260923-23 | 2026-09-23 | Three tool tabs let an exception out of their slots | #done #tools | [2026-09](2026-09.md) | | U-20260923-22 | 2026-09-23 | Renaming an open file brought the old name back | #done #editor | [2026-09](2026-09.md) | @@ -105,4 +106,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 42 | +| [2026-09.md](2026-09.md) | 2026-09 | 43 | diff --git a/pyproject.toml b/pyproject.toml index 7629637c..6562da86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ license-files = ["LICENSE"] dependencies = [ "je-editor", "je_auto_control", "je_web_runner", "je_load_density", "je_api_testka", "je-mail-thunder", - "automation-file", "PySide6==6.11.1", "test_pioneer", "paramiko", + "automation-file", "PySide6==6.11.2", "test_pioneer", "paramiko", "jupyterlab", ] classifiers = [ diff --git a/requirements.txt b/requirements.txt index ff35362d..09e9ef7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # What PyBreeze needs to run, kept in step with the dependencies in pyproject.toml # and dev.toml. It does not list pybreeze itself: that would install the published # build, so CI would be testing PyPI's code instead of the working tree. -PySide6==6.11.1 +PySide6==6.11.2 je-editor je_auto_control je_web_runner From 78b059702cf91f094c4de673b56630253c45cec3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 05:34:02 +0800 Subject: [PATCH 044/312] Have Dependabot open its PRs against dev Without a target branch its PRs went to main, where every push publishes a release. --- .github/dependabot.yml | 3 +++ docs/updates/2026-09.md | 7 +++++++ docs/updates/README.md | 3 ++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ba1c6b80..ff496c69 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,6 @@ updates: directory: "/" # Location of package manifests schedule: interval: "daily" + # Development work, dependency bumps included, goes to dev; main takes + # releases only, and every push to main publishes to PyPI. + target-branch: "dev" diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index b103d4a8..f28243f1 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -378,3 +378,10 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: a fresh Python 3.13 venv from `dev_requirements.txt` resolves PySide6 6.11.2, je_editor 1.0.26 and frontengine 1.0.78 with `pip check` clean; in it, `test/test_utils/` gives 1187 passed, 14 skipped — including the JEditor contract test against 1.0.26 — and `start_automation_test.py` and `extend_automation_test.py` both exit 0 offscreen. - **Files**: `requirements.txt`, `pyproject.toml`, `dev.toml`, `README.md`, `README/README_zh-CN.md`, `README/README_zh-TW.md`, `architecture.md` §6. - **Open items**: none here; the workspace X-1 table is the other session's to update. + +## U-20260923-26 · 2026-09-23 · Dependabot opens its PRs against dev · #done #ci + +- **What**: `.github/dependabot.yml` had no `target-branch`, so Dependabot opened its pip PRs against the default branch, `main` — where every push runs the `publish` job and uploads a release to PyPI. `CLAUDE.md` says development work targets `dev`. It now says `target-branch: "dev"`, as the other workspace repositories do since today. +- **Evidence**: the file parses (`yaml.safe_load`) with `target-branch` set to `dev`. +- **Files**: `.github/dependabot.yml`. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index b2db7a70..9ba7f0b9 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-26 | 2026-09-23 | Dependabot opens its PRs against dev | #done #ci | [2026-09](2026-09.md) | | U-20260923-25 | 2026-09-23 | PySide6 6.11.2, after je_editor and frontengine | #done #dependencies | [2026-09](2026-09.md) | | U-20260923-24 | 2026-09-23 | Imported requests sent a different query than was recorded | #done #tools | [2026-09](2026-09.md) | | U-20260923-23 | 2026-09-23 | Three tool tabs let an exception out of their slots | #done #tools | [2026-09](2026-09.md) | @@ -106,4 +107,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 43 | +| [2026-09.md](2026-09.md) | 2026-09 | 44 | From 2d3e3f03b4ff88d92f52010e6eeb078e3a4ef840 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 05:38:35 +0800 Subject: [PATCH 045/312] Write PyBreeze.log under the user's home, in UTF-8, on first use The log was opened for overwrite in the working directory at import, so every process left one wherever it started, wiped the last one, and could not be imported from a read-only directory; it was also written in the locale code page, dropping records with Chinese text. It follows the scheme JEditor and FrontEngine use now. --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 9 +++ docs/updates/README.md | 3 +- progress.md | 1 - pybreeze/utils/app_dirs.py | 10 ++- pybreeze/utils/logging/logger.py | 121 +++++++++++++++++++++++++------ test/test_utils/test_logger.py | 67 +++++++++++++++++ 7 files changed, 188 insertions(+), 29 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index abafe559..79fc9b3f 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -336,7 +336,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 |---|---| | `app_dirs.py` | `pybreeze_data_dir()` → `~/.pybreeze`,所有持久化資料的單一位置 | | `subprocess_util.py` | `utf8_subprocess_env()`(釘 `PYTHONIOENCODING`,解 Windows cp950 亂碼)、`no_window_creationflags()`(`CREATE_NO_WINDOW`,避免 GUI 程式彈出黑窗) | -| `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`,上限由 `PYBREEZE_LOG_MAX_BYTES` 控制(預設 100 MB) | +| `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`truncate_for_display()`、`CONNECT_TIMEOUT` | @@ -425,13 +425,13 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `response_stats.txt` | AI 審查接受/拒絕統計 | | `urls.txt` | AI 審查端點歷史 | -另有工作目錄下的 `PyBreeze.log`(rotating,預設 100 MB)與各自動化套件自己的 log。 +另有 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改;開檔時超過 100 MB 就輪替成 `.1`)與各自動化套件自己的 log。 --- ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1201 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1207 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index f28243f1..0f95e36e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -385,3 +385,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Evidence**: the file parses (`yaml.safe_load`) with `target-branch` set to `dev`. - **Files**: `.github/dependabot.yml`. - **Open items**: none. + +## U-20260923-27 · 2026-09-23 · PyBreeze.log leaves the working directory and becomes UTF-8 · #done #logging + +- **What**: `progress.md` #21, and PyBreeze's share of workspace X-6. `PyBreezeLogger` opened the relative path `PyBreeze.log` for overwrite at import: every process that imported `pybreeze` — the IDE, each test run, anything embedding it — left a log in whatever directory it started in and wiped the previous process's log, and from a read-only working directory `import pybreeze.utils…` failed outright. It wrote in the locale's code page, so on a cp1252 machine any record carrying Chinese text raised inside `emit()` and was dropped with `--- Logging error ---`. `CLAUDE.md` § File I/O already said PyBreeze writes under `~/.pybreeze/`. +- **Fix**: the scheme JEditor (`89d8dd5`) and FrontEngine adopted for X-6. The file is `~/.pybreeze/logs/PyBreeze.log`, or `$PYBREEZE_LOG_FILE`; it is opened on the first record (importing writes nothing), in UTF-8 with `backslashreplace`, for append, each line carrying the process id since every process on the account shares it. It is rotated to `.1` only when a process opens it — past `$PYBREEZE_LOG_MAX_BYTES`, 100 MB by default, the same variable as before — because Windows will not rename a file another process holds open, which would make rotation inside `emit()` fail on every later record. A file that cannot be opened turns file logging off with one `RuntimeWarning` instead of failing the caller. `app_dirs.pybreeze_data_path()` gives the data directory without creating it, for a caller that must not touch the disk at import. +- **Tests**: `test_logger.py` gains six: the default path, the environment override, nothing opened at import, Chinese, German and Cyrillic kept, a second run appending rather than wiping, and an unopenable path warning instead of raising. A real startup (`start_automation_test.py`) run from a scratch directory left no `PyBreeze.log` there and wrote `~/.pybreeze/logs/PyBreeze.log`. +- **Result / numbers**: unit tests 1187 → 1193 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/utils/logging/logger.py`, `pybreeze/utils/app_dirs.py`, `test/test_utils/test_logger.py`, `architecture_explore.md` §12, §17, §18, `progress.md` (#21 removed). +- **Open items**: none here; workspace X-6 is left with WebRunner. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9ba7f0b9..f57f257c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-27 | 2026-09-23 | PyBreeze.log leaves the working directory and becomes UTF-8 | #done #logging | [2026-09](2026-09.md) | | U-20260923-26 | 2026-09-23 | Dependabot opens its PRs against dev | #done #ci | [2026-09](2026-09.md) | | U-20260923-25 | 2026-09-23 | PySide6 6.11.2, after je_editor and frontengine | #done #dependencies | [2026-09](2026-09.md) | | U-20260923-24 | 2026-09-23 | Imported requests sent a different query than was recorded | #done #tools | [2026-09](2026-09.md) | @@ -107,4 +108,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 44 | +| [2026-09.md](2026-09.md) | 2026-09 | 45 | diff --git a/progress.md b/progress.md index e769a74c..6444c2ab 100644 --- a/progress.md +++ b/progress.md @@ -9,7 +9,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#10** SSH connecting and directory listing still run on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s) and `listdir_attr()` by nothing at all, so an unresponsive host or a directory with tens of thousands of entries freezes the IDE. Transfers moved off it in U-20260923-19; moving `connect()` has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. - **#15** The regex tool runs the user's pattern on the UI thread with nothing to bound it (`tools_gui/regex_gui.py` -> `utils/regex_tools/regex_tester.py:find_matches`): `(a+)+$` against 26 `a`s and a `b` takes about 5 s, and each further character doubles it. `_MAX_MATCHES` caps how many matches are reported, not how long one attempt takes. Only a separate process can be stopped mid-match. -- **#21** `PyBreeze.log` is opened relative to the working directory and in the locale's code page (`utils/logging/logger.py`): it lands wherever the IDE was started (against `CLAUDE.md` § File I/O, which says `~/.pybreeze/`; workspace X-6 is the same problem across repos), a read-only working directory makes importing `pybreeze.utils` fail, and on a cp1252 machine any record with CJK text is dropped with `--- Logging error ---`. - **#22** Prompt files: a prompt saved as ANSI raises `UnicodeDecodeError` out of `prompt_store.load_prompt` and `PromptEditorWidget.load_file_content`, which catch only `OSError` (so the skills tab or the prompt editor fails to open); the editor's file watcher reloads over unsaved edits without asking (`prompt_edit_gui/prompt_editor_widget.py:on_file_changed`); and `prompt_file_io.save_prompt_text` truncates before it writes. - **#23** `SkillsSendGUI`'s `RequestThread` declares `finished = Signal(str)`, hiding `QThread.finished`, so an exception outside its except tuple ends the thread with no signal and the send button stays disabled for the session; and `closeEvent` waits with no timeout on a request that can take 30 s (`extend_ai_gui/skills/skills_send_gui.py`). - **#24** A registered `EDITOR_EXTEND_TAB` whose constructor raises aborts `PyBreezeMainWindow.__init__` (`editor_main/main_ui.py`, the loop that adds them is unguarded): one bad third-party tab means no IDE at all instead of the IDE without that tab. diff --git a/pybreeze/utils/app_dirs.py b/pybreeze/utils/app_dirs.py index 3a2d02f2..9c7cec2a 100644 --- a/pybreeze/utils/app_dirs.py +++ b/pybreeze/utils/app_dirs.py @@ -6,6 +6,14 @@ _DATA_DIR_NAME = ".pybreeze" +def pybreeze_data_path() -> Path: + """Return where the user-level PyBreeze data directory is, without creating it. + + For callers that must not touch the disk yet, such as the logger at import. + """ + return Path.home() / _DATA_DIR_NAME + + def pybreeze_data_dir() -> Path: """Return the user-level PyBreeze data directory, creating it if needed. @@ -13,6 +21,6 @@ def pybreeze_data_dir() -> Path: known hosts, AI-review stats — stable regardless of the directory the IDE was launched from. """ - data_dir = Path.home() / _DATA_DIR_NAME + data_dir = pybreeze_data_path() data_dir.mkdir(parents=True, exist_ok=True) return data_dir diff --git a/pybreeze/utils/logging/logger.py b/pybreeze/utils/logging/logger.py index 416da75e..bbb3c2be 100644 --- a/pybreeze/utils/logging/logger.py +++ b/pybreeze/utils/logging/logger.py @@ -1,58 +1,133 @@ +"""The ``Pybreeze`` logger and the file it writes to. + +The log file is ``~/.pybreeze/logs/PyBreeze.log`` unless ``PYBREEZE_LOG_FILE`` +names another path (``os.devnull`` turns the file off). It used to be the +relative path ``PyBreeze.log``, opened for overwrite at import: every process +that imported the package left a log in whatever directory it started in, wiped +the previous process's log, and could not be imported at all from a read-only +working directory. It was also written in the locale's code page, so on a +cp1252 machine a record carrying Chinese text raised inside ``emit()`` and was +dropped. + +The handler opens the file on the first record, so importing writes nothing. +Every process on the account shares the file, so it is opened for append, each +line carries the process id, and it is rotated only when a process opens it: +Windows refuses to rename a file another process holds open, and a rotation +inside ``emit()`` would then fail on every later record. This is the scheme +JEditor's and FrontEngine's logs follow (workspace X-6). +""" from __future__ import annotations import logging import os +import warnings from logging.handlers import RotatingFileHandler +from pathlib import Path + +from pybreeze.utils.app_dirs import pybreeze_data_path # A library must not reconfigure the root logger: forcing it to DEBUG makes # every third-party logger verbose and robs the host application of control # over log levels. PyBreeze logs only through its own named logger below, which # carries its own DEBUG level and file handler. - -# 建立 AutoControlGUI 專用 logger Create dedicated logger pybreeze_logger = logging.getLogger("Pybreeze") pybreeze_logger.setLevel(logging.DEBUG) -# 日誌格式 Formatter formatter = logging.Formatter( - "%(asctime)s | %(name)s | %(levelname)s | %(message)s" + "%(asctime)s | %(process)d | %(name)s | %(levelname)s | %(message)s" ) -# Configurable max log size via environment variable (default: 100MB) -DEFAULT_MAX_LOG_BYTES = 100 * 1024 * 1024 # 100MB +#: Environment variable naming where the log file is written. +LOG_FILE_ENV = "PYBREEZE_LOG_FILE" +#: Environment variable overriding the size past which the file is rotated on open. +LOG_MAX_BYTES_ENV = "PYBREEZE_LOG_MAX_BYTES" +#: A file past this size is moved to ``.1`` when a process opens it. +DEFAULT_MAX_LOG_BYTES = 100 * 1024 * 1024 -class PyBreezeLogger(RotatingFileHandler): +def default_log_file() -> Path: + """Return the log file's path: ``$PYBREEZE_LOG_FILE``, else ``~/.pybreeze/logs/PyBreeze.log``.""" + configured = os.environ.get(LOG_FILE_ENV, "").strip() + if configured: + return Path(configured).expanduser() + return pybreeze_data_path() / "logs" / "PyBreeze.log" + + +def _rotate_at_bytes() -> int: + """The rotation size, from ``$PYBREEZE_LOG_MAX_BYTES`` when it is a number.""" + try: + return int(os.environ.get(LOG_MAX_BYTES_ENV, DEFAULT_MAX_LOG_BYTES)) + except ValueError: + return DEFAULT_MAX_LOG_BYTES + + +def _rotate_if_large(path: Path, limit: int) -> None: + """Move *path* to ``.1`` when it is larger than *limit* bytes. + + Best effort: while another process holds the file Windows refuses the + rename, and the file keeps growing until a later start. """ - 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 + try: + if limit <= 0 or not path.is_file() or path.stat().st_size <= limit: + return + os.replace(path, path.with_name(path.name + ".1")) + except OSError as error: + warnings.warn(f"PyBreeze log {path} not rotated: {error!r}", RuntimeWarning, stacklevel=2) + + +class PyBreezeLogger(RotatingFileHandler): + """PyBreeze's file handler: ``default_log_file()`` unless told otherwise, appended, UTF-8. + + A file that cannot be opened is swapped for ``os.devnull`` with one + ``RuntimeWarning`` rather than failing whoever logs. """ def __init__( self, - filename: str = "PyBreeze.log", - mode: str = "w", - max_bytes: int | None = None, - backup_count: int = 1, + filename: str | None = None, + mode: str = "a", + max_bytes: int = 0, + backup_count: int = 0, + delay: bool = False, ): - if max_bytes is None: - max_bytes = int(os.environ.get("PYBREEZE_LOG_MAX_BYTES", DEFAULT_MAX_LOG_BYTES)) + """ + :param filename: the log file, ``default_log_file()`` by default + :param mode: the open mode, append by default + :param max_bytes: rotation size inside ``emit()``; 0 rotates only on open + :param backup_count: how many rotated files to keep + :param delay: open the file on the first record instead of now + """ + # encoding is explicit: on the locale codec a character outside it raises + # inside emit(), which logging swallows, so the record vanishes. + # backslashreplace (what the logging module's own quick setup uses) + # because an escaped record beats a lost one. + path = filename if filename is not None else str(default_log_file()) super().__init__( - filename=filename, + filename=path, mode=mode, maxBytes=max_bytes, backupCount=backup_count, + encoding="utf-8", + delay=delay, + errors="backslashreplace", ) self.setFormatter(formatter) self.setLevel(logging.DEBUG) - def emit(self, record: logging.LogRecord) -> None: - super().emit(record) + def _open(self): + """Create the directory and rotate before opening; fall back to ``os.devnull``.""" + path = Path(self.baseFilename) + try: + path.parent.mkdir(parents=True, exist_ok=True) + _rotate_if_large(path, _rotate_at_bytes()) + return super()._open() + except OSError as error: + warnings.warn(f"PyBreeze log file {path} unavailable, file logging off: {error!r}", + RuntimeWarning, stacklevel=2) + # The handler owns and closes this stream. + return open(os.devnull, self.mode, encoding=self.encoding, errors=self.errors) # noqa: SIM115 -# 建立並加入檔案處理器 Add file handler to logger -file_handler = PyBreezeLogger() +# The package's own handler, opened on the first record. +file_handler = PyBreezeLogger(delay=True) pybreeze_logger.addHandler(file_handler) diff --git a/test/test_utils/test_logger.py b/test/test_utils/test_logger.py index 78801343..e9f369ce 100644 --- a/test/test_utils/test_logger.py +++ b/test/test_utils/test_logger.py @@ -57,3 +57,70 @@ def test_handler_formatter(self): handler = PyBreezeLogger(filename=log_file) assert handler.formatter is not None handler.close() + + +class TestWhereTheLogGoes: + """Importing writes nothing; the file is under the user's home, in UTF-8, appended.""" + + def test_the_default_is_under_the_data_directory(self, monkeypatch): + from pybreeze.utils.logging import logger as logger_module + + monkeypatch.delenv(logger_module.LOG_FILE_ENV, raising=False) + + path = logger_module.default_log_file() + + assert path.parts[-3:] == (".pybreeze", "logs", "PyBreeze.log") + + def test_the_environment_can_name_another_file(self, monkeypatch, tmp_path): + from pybreeze.utils.logging import logger as logger_module + + monkeypatch.setenv(logger_module.LOG_FILE_ENV, str(tmp_path / "elsewhere.log")) + + assert logger_module.default_log_file() == tmp_path / "elsewhere.log" + + def test_the_package_handler_opens_nothing_at_import(self): + from pybreeze.utils.logging.logger import file_handler + + # delay=True: no stream until the first record. + assert file_handler.delay is True + + def test_text_outside_the_locale_code_page_is_kept(self, tmp_path): + log_file = tmp_path / "unicode.log" + handler = PyBreezeLogger(filename=str(log_file)) + test_logger = logging.getLogger("test_pybreeze_unicode") + test_logger.addHandler(handler) + test_logger.setLevel(logging.DEBUG) + + test_logger.info("規則檢索 ÜÄÖ ключ") + handler.close() + test_logger.removeHandler(handler) + + assert "規則檢索 ÜÄÖ ключ" in log_file.read_text(encoding="utf-8") + + def test_a_second_process_appends_instead_of_wiping(self, tmp_path): + log_file = tmp_path / "shared.log" + for message in ("first run", "second run"): + handler = PyBreezeLogger(filename=str(log_file)) + test_logger = logging.getLogger("test_pybreeze_append") + test_logger.addHandler(handler) + test_logger.setLevel(logging.DEBUG) + test_logger.info(message) + handler.close() + test_logger.removeHandler(handler) + + text = log_file.read_text(encoding="utf-8") + assert "first run" in text and "second run" in text + + def test_a_file_that_cannot_be_opened_turns_logging_off_not_the_caller(self, tmp_path): + import pytest + + blocked = tmp_path / "a_file_not_a_folder" + blocked.write_text("", encoding="utf-8") + handler = PyBreezeLogger(filename=str(blocked / "PyBreeze.log"), delay=True) + test_logger = logging.getLogger("test_pybreeze_blocked") + test_logger.addHandler(handler) + + with pytest.warns(RuntimeWarning): + test_logger.error("goes nowhere") + handler.close() + test_logger.removeHandler(handler) From 82107d5e059ca344706251b3cd1336f237625f4f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 05:48:22 +0800 Subject: [PATCH 046/312] Build each registered extend tab on its own One third-party tab whose constructor raised aborted the main window before anything was shown. It is logged by name, and the IDE and the other tabs start. --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 9 +++++++ docs/updates/README.md | 3 ++- progress.md | 1 - pybreeze/pybreeze_ui/editor_main/main_ui.py | 17 ++++++++++-- test/test_utils/started_window.py | 14 +++++++--- test/test_utils/test_started_menus.py | 30 +++++++++++++++++++++ 7 files changed, 69 insertions(+), 9 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 79fc9b3f..37008cf4 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -72,7 +72,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE - 設定標題、Windows AppUserModelID、圖示 - `add_menu_to_menubar()` — 建構全部選單(見 §5) - `syntax_extend_package()` — 註冊 `.json` / `.yml` 自動化關鍵字高亮 - - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁 + - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁(`_add_extend_tabs()`:每一個分頁各自建,建不起來的只記 log,不會讓整個 IDE 起不來) - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) 3. `apply_stylesheet()` 套 qt_material 主題(預設 `dark_amber.xml`) @@ -431,7 +431,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1207 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1208 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 0f95e36e..6317d897 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -394,3 +394,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1187 → 1193 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/utils/logging/logger.py`, `pybreeze/utils/app_dirs.py`, `test/test_utils/test_logger.py`, `architecture_explore.md` §12, §17, §18, `progress.md` (#21 removed). - **Open items**: none here; workspace X-6 is left with WebRunner. + +## U-20260923-28 · 2026-09-23 · One broken extend tab no longer stops the IDE from starting · #done #editor + +- **What**: `progress.md` #24. `EDITOR_EXTEND_TAB` is exported from the package facade for third parties to register their own tabs, and `PyBreezeMainWindow.__init__` built each one in an unguarded loop, before anything was shown: one registered widget whose constructor raised meant `start_editor()` ended in a traceback and there was no IDE at all. +- **Fix**: `_add_extend_tabs()` builds each tab on its own and logs one that fails, by name; the rest of the window, and the other tabs, come up. The `except Exception` carries a `noqa` with its reason, the pattern `run_dir_files_with_package()` already uses for a batch that must not stop on one bad entry. +- **Tests**: `test_started_menus.py` starts the real IDE in a child with one tab that raises and one that does not registered first: the good one is there and the IDE started. It fails without the guard. The child-window harness (`started_window.py`) gained `before_window`, code run once the application exists but before the window is built. +- **Result / numbers**: unit tests 1193 → 1194 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_started_menus,started_window}.py`, `architecture_explore.md` §3, §18, `progress.md` (#24 removed). +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index f57f257c..52fcae7f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-28 | 2026-09-23 | One broken extend tab no longer stops the IDE from starting | #done #editor | [2026-09](2026-09.md) | | U-20260923-27 | 2026-09-23 | PyBreeze.log leaves the working directory and becomes UTF-8 | #done #logging | [2026-09](2026-09.md) | | U-20260923-26 | 2026-09-23 | Dependabot opens its PRs against dev | #done #ci | [2026-09](2026-09.md) | | U-20260923-25 | 2026-09-23 | PySide6 6.11.2, after je_editor and frontengine | #done #dependencies | [2026-09](2026-09.md) | @@ -108,4 +109,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 45 | +| [2026-09.md](2026-09.md) | 2026-09 | 46 | diff --git a/progress.md b/progress.md index 6444c2ab..4f3dad0e 100644 --- a/progress.md +++ b/progress.md @@ -11,5 +11,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#15** The regex tool runs the user's pattern on the UI thread with nothing to bound it (`tools_gui/regex_gui.py` -> `utils/regex_tools/regex_tester.py:find_matches`): `(a+)+$` against 26 `a`s and a `b` takes about 5 s, and each further character doubles it. `_MAX_MATCHES` caps how many matches are reported, not how long one attempt takes. Only a separate process can be stopped mid-match. - **#22** Prompt files: a prompt saved as ANSI raises `UnicodeDecodeError` out of `prompt_store.load_prompt` and `PromptEditorWidget.load_file_content`, which catch only `OSError` (so the skills tab or the prompt editor fails to open); the editor's file watcher reloads over unsaved edits without asking (`prompt_edit_gui/prompt_editor_widget.py:on_file_changed`); and `prompt_file_io.save_prompt_text` truncates before it writes. - **#23** `SkillsSendGUI`'s `RequestThread` declares `finished = Signal(str)`, hiding `QThread.finished`, so an exception outside its except tuple ends the thread with no signal and the send button stays disabled for the session; and `closeEvent` waits with no timeout on a request that can take 30 s (`extend_ai_gui/skills/skills_send_gui.py`). -- **#24** A registered `EDITOR_EXTEND_TAB` whose constructor raises aborts `PyBreezeMainWindow.__init__` (`editor_main/main_ui.py`, the loop that adds them is unguarded): one bad third-party tab means no IDE at all instead of the IDE without that tab. diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index 5585453d..ebd544af 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -71,8 +71,7 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, syntax_extend_package(self) # Tab - for widget_name, widget in EDITOR_EXTEND_TAB.items(): - self.tab_widget.addTab(widget(), widget_name) + self._add_extend_tabs() # File tree context menu (right-click) setup_file_tree_context_menu(self) @@ -83,6 +82,20 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, close_timer.timeout.connect(self.debug_close) close_timer.start() + def _add_extend_tabs(self) -> None: + """Add every registered ``EDITOR_EXTEND_TAB`` widget as a tab. + + The registry is open to third parties, so one widget whose constructor + raises must cost only its own tab: unguarded, it took down the whole + window before anything was shown, and the user got a traceback instead + of an IDE. + """ + for widget_name, widget in EDITOR_EXTEND_TAB.items(): + try: + self.tab_widget.addTab(widget(), widget_name) + except Exception as error: # noqa: BLE001 — a registered third-party tab may raise anything; the IDE must still start + pybreeze_logger.error("Extend tab %r could not be built: %r", widget_name, error) + def closeEvent(self, event) -> None: # A run's child outlives the IDE unless stopped here: it is a separate # process, and without a console nobody would see it still running. diff --git a/test/test_utils/started_window.py b/test/test_utils/started_window.py index 466fe2f0..133b9673 100644 --- a/test/test_utils/started_window.py +++ b/test/test_utils/started_window.py @@ -22,6 +22,9 @@ app = QApplication([]) from je_editor import language_wrapper from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow +""" + +_BUILD_WINDOW = """ window = PyBreezeMainWindow(debug_mode=True) gc.collect() """ @@ -38,14 +41,18 @@ """ -def run_started_window(tmp_path: Path, body: str, *, saved_language: str | None = None) -> object: +def run_started_window( + tmp_path: Path, body: str, *, saved_language: str | None = None, + before_window: str = "") -> object: """Start the IDE in a child, run *body* there, and return the ``result`` it set. *body* runs after the window is built and garbage has been collected, with ``window`` and ``language_wrapper`` in scope; it must assign a JSON-able value to ``result``. The child's working directory is *tmp_path*, which is where JEditor looks for its settings, so *saved_language* is written there - as the saved language before the start. + as the saved language before the start. *before_window* runs after the + application exists and before the window is built -- to register an + ``EDITOR_EXTEND_TAB``, say. """ if saved_language is not None: settings_dir = tmp_path / ".jeditor" @@ -60,7 +67,8 @@ def run_started_window(tmp_path: Path, body: str, *, saved_language: str | None filter(None, [str(_REPOSITORY_ROOT), os.environ.get("PYTHONPATH")])), } completed = subprocess.run( # noqa: S603 — fixed argv: this interpreter and a script built from literals - [sys.executable, "-c", _PRELUDE + body + _REPORT, str(result_file)], + [sys.executable, "-c", _PRELUDE + before_window + _BUILD_WINDOW + body + _REPORT, + str(result_file)], cwd=tmp_path, env=environment, capture_output=True, timeout=_START_TIMEOUT_SECONDS, check=False, shell=False, ) diff --git a/test/test_utils/test_started_menus.py b/test/test_utils/test_started_menus.py index ab4df27c..5149a0e1 100644 --- a/test/test_utils/test_started_menus.py +++ b/test/test_utils/test_started_menus.py @@ -43,3 +43,33 @@ def test_no_submenu_of_the_started_ide_is_empty(tmp_path): assert seen["automation_entries"] > 0 assert seen["empty"] == [] + + + +_REGISTER_A_BROKEN_AND_A_FINE_TAB = """ +from pybreeze import EDITOR_EXTEND_TAB +from PySide6.QtWidgets import QWidget + +class Broken(QWidget): + def __init__(self): + raise RuntimeError("a third-party tab that fails") + +class Fine(QWidget): + pass + +EDITOR_EXTEND_TAB.update({"broken": Broken, "fine": Fine}) +""" + +_REPORT_THE_TABS = """ +result = [window.tab_widget.tabText(index) for index in range(window.tab_widget.count())] +""" + + +def test_a_broken_extend_tab_costs_only_itself(tmp_path): + # EDITOR_EXTEND_TAB is open to third parties: one constructor that raises + # must not keep the IDE from starting. + tabs = run_started_window( + tmp_path, _REPORT_THE_TABS, before_window=_REGISTER_A_BROKEN_AND_A_FINE_TAB) + + assert "fine" in tabs + assert "broken" not in tabs From dcd35c6e9339e22460ab72b37bc8550b66fa710f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 05:55:13 +0800 Subject: [PATCH 047/312] Let a closing request panel's thread run out instead of waiting for it Both request panels waited on the UI thread for a request still in flight, freezing the IDE up to the read timeout; they now cut the thread off and keep it until it ends. The skills request also stops hiding QThread.finished, so its button comes back however the thread ends. --- CLAUDE.md | 1 + architecture.md | 4 +- architecture_explore.md | 6 +-- docs/updates/2026-09.md | 10 ++++ docs/updates/README.md | 3 +- progress.md | 1 - .../connect_gui/url/ai_code_review_gui.py | 11 +++-- .../extend_ai_gui/skills/skills_send_gui.py | 28 +++++++---- pybreeze/pybreeze_ui/thread_keeper.py | 42 ++++++++++++++++ test/test_utils/test_ai_code_review_client.py | 26 +++++----- test/test_utils/test_ai_gui_lifecycle.py | 49 +++++++++++++++++-- 11 files changed, 144 insertions(+), 37 deletions(-) create mode 100644 pybreeze/pybreeze_ui/thread_keeper.py diff --git a/CLAUDE.md b/CLAUDE.md index 8524e9dd..687c420e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ pybreeze/ │ ├── connect_gui/ # ssh/ (terminal + SFTP tree), url/ (AI review client) │ ├── jupyter_lab_gui/ # JupyterLab tab (QWebEngineView) │ ├── show_code_window/ # CodeWindow — subprocess output display +│ ├── thread_keeper.py # let_run_out: a worker QThread outlives its closed widget │ ├── dialog/ # prthinker settings dialog │ └── syntax/ # Automation keyword highlighting definitions ├── extend/ diff --git a/architecture.md b/architecture.md index f6304c7e..800e7815 100644 --- a/architecture.md +++ b/architecture.md @@ -191,7 +191,9 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() with timeouts and size caps (§ Security › Network). - SSH uses the interactive host-key policy, never auto-add (§ Security › SSH). - Work that waits on a network — an AI review request, a diagram's image downloads — runs on a - `QThread` and reaches the UI only through signals; the widget or scene waits for it on close. + `QThread` and reaches the UI only through signals. A request panel that closes mid-request hands + its thread to `thread_keeper.let_run_out()` (cut off from the panel, kept until it ends) rather + than waiting for it on the UI thread; a running `QThread` must never be destroyed. - Subprocesses use argument lists, `shell=False` and a `timeout`, and pass secrets through `env` (§ Security › Subprocess). - The JupyterLab server stays localhost-only (§ Security › JupyterLab). diff --git a/architecture_explore.md b/architecture_explore.md index 37008cf4..258a28f0 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -269,7 +269,7 @@ extend_ai_gui/ │ ├── prompt_file_io.py 共用存檔(失敗跳警告對話框) │ ├── cot_code_review_prompt_templates/ 8 個模板常數+global_rule │ └── skills_prompt_templates/ 2 個模板常數 -└── skills/skills_send_gui.py 單次 prompt 發送(RequestThread) +└── skills/skills_send_gui.py 單次 prompt 發送(RequestThread:回答走 `answered`,不再蓋掉 QThread 自己的 `finished`;關閉時交給 `thread_keeper.let_run_out()`) ``` **CoT 審查鏈**(`cot_chain.py` 定義接線,`code_review_thread.py` 執行)八個步驟: @@ -305,7 +305,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 獨立的 HTTP client widget:送出程式碼給審查端點、接受/拒絕回覆並記錄統計到 `~/.pybreeze/response_stats.txt`。 -- 請求走 `ReviewRequestThread(QThread)`,只有 `answered` / `failed` 兩個 signal 碰 UI(和 `SkillsSendGUI` 同一套);送出中再按不會重送,`closeEvent` 會等它結束 +- 請求走 `ReviewRequestThread(QThread)`,只有 `answered` / `failed` 兩個 signal 碰 UI(和 `SkillsSendGUI` 同一套);送出中再按不會重送;`closeEvent` 不等它,交給 `thread_keeper.let_run_out()`:斷開它和面板的連線、留著參考直到它結束(等它會讓 IDE 凍住最長一個讀取逾時) - `urls.txt` 只存 URL 的 SHA-256 指紋(`url_fingerprint()`):API URL 可能帶權杖,依 CLAUDE.md 要當憑證看待;舊版留下的明文檔會在下次送出時改寫成指紋 - 非 2xx(含不跟隨的轉址)會把狀態碼寫進面板,不會只留空白 @@ -431,7 +431,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1208 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1210 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 6317d897..de9bd988 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -403,3 +403,13 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1193 → 1194 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_started_menus,started_window}.py`, `architecture_explore.md` §3, §18, `progress.md` (#24 removed). - **Open items**: none. + +## U-20260923-29 · 2026-09-23 · Closing a request panel no longer waits for its request · #done #ai + +- **What**: `progress.md` #23, and the same flaw in the panel threaded earlier today (U-20260923-15). + - `SkillsSendGUI`'s `RequestThread` declared `finished = Signal(str)` for its answer, hiding `QThread.finished`, the signal that says the thread is over. So an exception outside `run()`'s handler ended the thread with no signal at all, and the send button stayed disabled for the rest of the session. The answer now travels on `answered`, and `QThread.finished` re-enables the button however `run()` ended. + - Both request panels (`SkillsSendGUI` and the AI review panel) waited on close for a request still in flight, on the UI thread, with no timeout: up to the 10 s connect plus a 30 s read, the IDE frozen. They hand the thread to `thread_keeper.let_run_out()` instead, which disconnects it from the panel and keeps a reference until its own `finished` — never destroyed while running, never waited for. +- **Tests**: `test_ai_gui_lifecycle.py`'s skills test pinned the waiting; it now pins the opposite (not awaited, cut off, kept), plus a real `RequestThread` let run out and released when it ends, and `RequestThread.finished` being `QThread.finished`. The AI review panel's close test likewise. +- **Result / numbers**: unit tests 1194 → 1196 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/thread_keeper.py` (new), `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `test/test_utils/{test_ai_gui_lifecycle,test_ai_code_review_client}.py`, `CLAUDE.md` (architecture tree), `architecture.md` §7, `architecture_explore.md` §8, §9, §18, `progress.md` (#23 removed). +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 52fcae7f..be0c822f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-29 | 2026-09-23 | Closing a request panel no longer waits for its request | #done #ai | [2026-09](2026-09.md) | | U-20260923-28 | 2026-09-23 | One broken extend tab no longer stops the IDE from starting | #done #editor | [2026-09](2026-09.md) | | U-20260923-27 | 2026-09-23 | PyBreeze.log leaves the working directory and becomes UTF-8 | #done #logging | [2026-09](2026-09.md) | | U-20260923-26 | 2026-09-23 | Dependabot opens its PRs against dev | #done #ci | [2026-09](2026-09.md) | @@ -109,4 +110,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 46 | +| [2026-09.md](2026-09.md) | 2026-09 | 47 | diff --git a/progress.md b/progress.md index 4f3dad0e..54f206f8 100644 --- a/progress.md +++ b/progress.md @@ -10,5 +10,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#10** SSH connecting and directory listing still run on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s) and `listdir_attr()` by nothing at all, so an unresponsive host or a directory with tens of thousands of entries freezes the IDE. Transfers moved off it in U-20260923-19; moving `connect()` has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. - **#15** The regex tool runs the user's pattern on the UI thread with nothing to bound it (`tools_gui/regex_gui.py` -> `utils/regex_tools/regex_tester.py:find_matches`): `(a+)+$` against 26 `a`s and a `b` takes about 5 s, and each further character doubles it. `_MAX_MATCHES` caps how many matches are reported, not how long one attempt takes. Only a separate process can be stopped mid-match. - **#22** Prompt files: a prompt saved as ANSI raises `UnicodeDecodeError` out of `prompt_store.load_prompt` and `PromptEditorWidget.load_file_content`, which catch only `OSError` (so the skills tab or the prompt editor fails to open); the editor's file watcher reloads over unsaved edits without asking (`prompt_edit_gui/prompt_editor_widget.py:on_file_changed`); and `prompt_file_io.save_prompt_text` truncates before it writes. -- **#23** `SkillsSendGUI`'s `RequestThread` declares `finished = Signal(str)`, hiding `QThread.finished`, so an exception outside its except tuple ends the thread with no signal and the send button stays disabled for the session; and `closeEvent` waits with no timeout on a request that can take 30 s (`extend_ai_gui/skills/skills_send_gui.py`). 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 d64e16a0..fe5f4753 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 @@ -11,6 +11,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.app_dirs import pybreeze_data_dir from pybreeze.utils.hash_tools.hash_text import hash_text from pybreeze.utils.logging.logger import pybreeze_logger @@ -232,11 +233,15 @@ def on_failed(self, message: str) -> None: self.send_button.setEnabled(True) def closeEvent(self, event) -> None: - """Wait for a request still in flight, so its thread is not destroyed mid-run.""" + """Let a request still in flight run out without this panel. + + Waiting for it froze the IDE for as long as the request took; it is cut + off from the panel instead and kept until it ends, so it is never + destroyed while running. + """ thread = self.request_thread if thread is not None and thread.isRunning(): - thread.blockSignals(True) - thread.wait() + let_run_out(thread, thread.answered, thread.failed) super().closeEvent(event) def record_url(self, url: str) -> bool: 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 3838706b..ed792ddd 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 @@ -12,6 +12,7 @@ SKILLS_TEMPLATE_FILES, SKILLS_TEMPLATE_RELATION ) from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import load_prompt +from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, truncate_for_display, @@ -20,7 +21,9 @@ class RequestThread(QThread): - finished = Signal(str) # 成功或錯誤訊息 + # Not "finished": that is QThread's own end-of-thread signal, and hiding it + # left nothing to re-enable the send button when run() ended some other way. + answered = Signal(str) # 成功或錯誤訊息 / the answer, or a status to show error = Signal(str) def __init__(self, api_url, code_text): @@ -37,9 +40,9 @@ def run(self): ) body = read_capped_text(response) if response.ok: - self.finished.emit(body) + self.answered.emit(body) elif response.is_redirect: - self.finished.emit( + self.answered.emit( language_wrapper.language_word_dict.get( "skills_error_status").format( status_code=response.status_code, @@ -57,7 +60,7 @@ def run(self): status_code=response.status_code, text=f"Server error: {truncate_for_display(body)}")) else: - self.finished.emit( + self.answered.emit( language_wrapper.language_word_dict.get( "skills_error_status").format( status_code=response.status_code, text=truncate_for_display(body))) @@ -148,8 +151,11 @@ def send_prompt(self): # 啟動 QThread self.send_button.setEnabled(False) self.thread = RequestThread(api_url, prompt_text) - self.thread.finished.connect(self.on_finished) + self.thread.answered.connect(self.on_finished) self.thread.error.connect(self.on_error) + # However run() ends -- including an exception outside its handler -- + # the button comes back. + self.thread.finished.connect(lambda: self.send_button.setEnabled(True)) self.thread.start() def on_finished(self, result): @@ -161,10 +167,14 @@ def on_error(self, error_msg): self.send_button.setEnabled(True) def closeEvent(self, event): + """Let a request still in flight finish on its own, without this panel. + + Its answers are cut off from the panel, and the thread is kept + referenced until it ends, so it is never destroyed while running. The + panel does not wait for it: the request can take the whole read timeout, + and waiting froze the IDE for that long. + """ thread = self.thread if thread is not None and thread.isRunning(): - # Block slots so a late finished/error emit can't hit the dying - # widget, then wait so the QThread is never destroyed while running. - thread.blockSignals(True) - thread.wait() + let_run_out(thread, thread.answered, thread.error) event.accept() diff --git a/pybreeze/pybreeze_ui/thread_keeper.py b/pybreeze/pybreeze_ui/thread_keeper.py new file mode 100644 index 00000000..b16b1c3b --- /dev/null +++ b/pybreeze/pybreeze_ui/thread_keeper.py @@ -0,0 +1,42 @@ +"""Let a worker QThread run out after the widget that started it has closed. + +A panel that closes while its request is still in flight has two bad options: +destroy the running QThread with it (Qt aborts the process), or wait for it on +the UI thread (the IDE freezes for as long as the request takes, up to its read +timeout). The third is to cut the thread off from the panel and keep it +referenced here until it ends. +""" +from __future__ import annotations + +import warnings + +from PySide6.QtCore import QThread + +from pybreeze.utils.logging.logger import pybreeze_logger + +# Threads whose widget has gone, each kept until its finished signal +_OUTLIVING_THEIR_WIDGET: set[QThread] = set() + + +def let_run_out(thread: QThread, *signals) -> None: + """Disconnect *signals* and *thread*'s own ``finished``, and keep it until it ends. + + :param thread: the worker still running + :param signals: the worker's signals the widget was listening to + """ + for signal in (*signals, thread.finished): + # A signal with nothing connected is not an error here: PySide6 warns + # about it (older versions raised), and there is nothing to cut off. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + try: + signal.disconnect() + except (RuntimeError, TypeError) as error: + pybreeze_logger.debug("Worker signal had nothing connected: %r", error) + _OUTLIVING_THEIR_WIDGET.add(thread) + thread.finished.connect(lambda: _OUTLIVING_THEIR_WIDGET.discard(thread)) + + +def is_kept(thread: QThread) -> bool: + """Whether *thread* is being kept until it ends.""" + return thread in _OUTLIVING_THEIR_WIDGET diff --git a/test/test_utils/test_ai_code_review_client.py b/test/test_utils/test_ai_code_review_client.py index 1749d4da..b92b25b7 100644 --- a/test/test_utils/test_ai_code_review_client.py +++ b/test/test_utils/test_ai_code_review_client.py @@ -165,26 +165,22 @@ def test_a_second_send_is_ignored(self, client, monkeypatch): assert started == [] - def test_closing_waits_for_it(self, client): - class Waited: - def __init__(self) -> None: - self.waited = False - self.blocked = False + def test_closing_lets_it_run_out_without_waiting(self, client): + from unittest.mock import MagicMock - def isRunning(self) -> bool: - return True + from pybreeze.pybreeze_ui import thread_keeper - def blockSignals(self, blocked: bool) -> None: - self.blocked = blocked - - def wait(self) -> None: - self.waited = True - - client.request_thread = Waited() + thread = MagicMock() + thread.isRunning.return_value = True + client.request_thread = thread client.close() - assert client.request_thread.waited and client.request_thread.blocked + thread.wait.assert_not_called() + thread.answered.disconnect.assert_called_once() + thread.failed.disconnect.assert_called_once() + assert thread_keeper.is_kept(thread) + thread_keeper._OUTLIVING_THEIR_WIDGET.discard(thread) def test_an_unsupported_method_says_so_and_starts_nothing(self, client, monkeypatch): started: list = [] diff --git a/test/test_utils/test_ai_gui_lifecycle.py b/test/test_utils/test_ai_gui_lifecycle.py index bd22554c..0632bd84 100644 --- a/test/test_utils/test_ai_gui_lifecycle.py +++ b/test/test_utils/test_ai_gui_lifecycle.py @@ -7,10 +7,19 @@ from unittest.mock import MagicMock +import pytest + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import SkillsSendGUI +@pytest.fixture(scope="module") +def qapp(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + def _running_thread(): thread = MagicMock() thread.isRunning.return_value = True @@ -53,16 +62,48 @@ def test_finished_thread_is_not_awaited(self): class TestSkillsCloseEvent: - def test_running_thread_is_blocked_and_awaited(self): + def test_a_running_request_is_let_go_of_without_waiting(self): + # Waiting froze the IDE for as long as the request took (up to the read + # timeout). The thread is cut off from the panel and kept referenced + # until it ends, so it is never destroyed while running. + from pybreeze.pybreeze_ui import thread_keeper + gui = SkillsSendGUI.__new__(SkillsSendGUI) - gui.thread = _running_thread() + thread = _running_thread() + gui.thread = thread event = MagicMock() SkillsSendGUI.closeEvent(gui, event) - gui.thread.blockSignals.assert_called_once_with(True) - gui.thread.wait.assert_called_once() + thread.wait.assert_not_called() + thread.answered.disconnect.assert_called_once() + thread.error.disconnect.assert_called_once() + assert thread_keeper.is_kept(thread) event.accept.assert_called_once() + thread_keeper._OUTLIVING_THEIR_WIDGET.discard(thread) + + def test_a_request_let_go_of_is_released_when_it_ends(self, qapp): + from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import RequestThread + from pybreeze.pybreeze_ui.thread_keeper import is_kept, let_run_out + + thread = RequestThread("http://.invalid", "prompt") + let_run_out(thread, thread.answered, thread.error) + assert is_kept(thread) + thread.start() + assert thread.wait(10000) + qapp.processEvents() + + assert not is_kept(thread) + + def test_its_answer_signal_does_not_hide_the_thread_ending(self): + from PySide6.QtCore import QThread + + from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import RequestThread + + # QThread.finished is what tells the panel the thread is over, however + # run() ended; the answer travels on a signal of its own. + assert RequestThread.finished is QThread.finished + assert hasattr(RequestThread, "answered") def test_no_thread_just_accepts(self): gui = SkillsSendGUI.__new__(SkillsSendGUI) From 62f5505f3d9143683f7fa40c7deff5ebc0ad5e04 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 05:55:25 +0800 Subject: [PATCH 048/312] Bring architecture.md up to today's layout It did not list thread_keeper.py or the log's new place under ~/.pybreeze, and its last-verified line was a day old. --- architecture.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/architecture.md b/architecture.md index 800e7815..62dc5b80 100644 --- a/architecture.md +++ b/architecture.md @@ -1,7 +1,7 @@ # PyBreeze Architecture > Short overview for people and agents. Per-module detail lives in [`architecture_explore.md`](architecture_explore.md). -> Last verified: 2026-09-22 against `d0068d2` on `dev`. +> Last verified: 2026-09-23 against `dcd35c6` on `dev`. ## 1. Purpose @@ -32,6 +32,7 @@ their output reaches the UI through Queue + QTimer. | `pybreeze/pybreeze_ui/extend_ai_gui/`, `dialog/` | LLM code-review chain and prompt editors; prthinker settings dialog | | `pybreeze/pybreeze_ui/connect_gui/` | `ssh/` terminal + SFTP tree; `url/` HTTP code-review client | | `pybreeze/pybreeze_ui/jupyter_lab_gui/`, `show_code_window/`, `syntax/` | JupyterLab tab; `CodeWindow` subprocess output window; automation keyword highlighting | +| `pybreeze/pybreeze_ui/thread_keeper.py` | `let_run_out()`: a worker `QThread` whose widget closed is kept until it ends instead of being waited for | | `pybreeze/extend/process_executor/` | Subprocess isolation layer: `TaskProcessManager`, `process_executor_utils.py`, `FileRunnerProcess`, `queue_pump.py`, one sub-package per automation package, plus `test_pioneer/` and `prthinker/` | | `pybreeze/extend/mail_thunder_extend/`, `prthinker_extend/` | Post-test email hook; prthinker settings and argument assembly (pure logic) | | `pybreeze/extend_multi_language/` | PyBreeze's English and Traditional Chinese strings, merged into JEditor's dictionaries | @@ -57,7 +58,8 @@ The layers are presentation (`pybreeze_ui/`), then execution (`extend/`), then f - **Plugin API (re-exported)**: `load_external_plugins`, `register_programming_language`, `register_natural_language`. - **Persisted state**: `~/.pybreeze/` via `utils/app_dirs.pybreeze_data_dir()` (SSH known hosts, - prthinker settings, edited prompts, review history). The editor settings inherited from JEditor + prthinker settings, edited prompts, review history, and `logs/PyBreeze.log`, which + `$PYBREEZE_LOG_FILE` can move). The editor settings inherited from JEditor stay in `.jeditor/` under the working directory. ## 4. Main flows From 2f79f851b3c71ee2c2b14fa67f3a0920eeca537a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 06:07:23 +0800 Subject: [PATCH 049/312] Read a prompt in any encoding safely and never lose an edit to it A prompt saved as ANSI kept the skills tab and the prompt editor from opening, the editor reloaded over unsaved edits when the file changed on disk, and a save truncated the file before writing. The editor's file watcher also outlived the editor and fired into it after it closed. --- architecture_explore.md | 8 +- docs/updates/2026-09.md | 12 ++ docs/updates/README.md | 3 +- progress.md | 1 - .../extend_multi_language/extend_english.py | 4 + .../extend_traditional_chinese.py | 4 + .../prompt_edit_gui/prompt_editor_widget.py | 72 +++++++++- .../prompt_edit_gui/prompt_file_io.py | 15 +- .../pybreeze_ui/extend_ai_gui/prompt_store.py | 24 +++- test/test_utils/test_prompt_store.py | 136 +++++++++++++++++- 10 files changed, 256 insertions(+), 23 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 258a28f0..1a1c996e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -257,16 +257,16 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ``` extend_ai_gui/ ├── ai_gui_global_variable.py 模板檔名清單 + 檔名→內建模板內容對照表 -├── prompt_store.py 編輯過的 prompt 檔案解析(~/.pybreeze/prompts/) +├── prompt_store.py 編輯過的 prompt 檔案解析(~/.pybreeze/prompts/;`read_prompt_file()` 讀 utf-8-sig,非 UTF-8 視同讀不到、退回內建) ├── code_review/ │ ├── cot_chain.py 接線表(純邏輯,無 Qt):哪步引用哪步 │ ├── code_review_thread.py SenderThread(QThread):跑八步審查鏈 │ └── cot_code_review_gui.py UI ├── prompt_edit_gui/ -│ ├── prompt_editor_widget.py 共用編輯器(QFileSystemWatcher 熱更新) +│ ├── prompt_editor_widget.py 共用編輯器(QFileSystemWatcher 熱更新,watcher 以編輯器為 parent、關閉時停止監看;有未存編輯時先問再重新載入) │ ├── cot_prompt_editor_widget.py 8 個 CoT 模板的檔案清單+語言鍵 │ ├── skills_prompt_editor_widget.py 2 個 Skill 模板的檔案清單+語言鍵 -│ ├── prompt_file_io.py 共用存檔(失敗跳警告對話框) +│ ├── prompt_file_io.py 共用存檔(先寫 `.saving` 再 `os.replace()`;失敗跳警告對話框,不顯示路徑) │ ├── cot_code_review_prompt_templates/ 8 個模板常數+global_rule │ └── skills_prompt_templates/ 2 個模板常數 └── skills/skills_send_gui.py 單次 prompt 發送(RequestThread:回答走 `answered`,不再蓋掉 QThread 自己的 `finished`;關閉時交給 `thread_keeper.let_run_out()`) @@ -431,7 +431,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1210 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1218 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index de9bd988..c3aa3aa3 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -413,3 +413,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1194 → 1196 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/thread_keeper.py` (new), `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `test/test_utils/{test_ai_gui_lifecycle,test_ai_code_review_client}.py`, `CLAUDE.md` (architecture tree), `architecture.md` §7, `architecture_explore.md` §8, §9, §18, `progress.md` (#23 removed). - **Open items**: none. + +## U-20260923-30 · 2026-09-23 · Prompt files: other encodings, unsaved edits, half-written saves · #done #ai + +- **What**: `progress.md` #22 — the prompt files under `~/.pybreeze/prompts/`, which the editors advertise as editable by hand — and one crash found while testing it. + - **Another encoding.** `load_prompt()` and the editor read with `encoding="utf-8"` inside `except OSError`, so a prompt saved as "ANSI" (cp950 on a Traditional Chinese Windows) raised `UnicodeDecodeError` out of the skills panel's constructor and the prompt editor's: the tab or dock did not open. `read_prompt_file()` reads `utf-8-sig` (a byte-order mark no longer ends up in the prompt) and treats an undecodable file as unreadable, so a review falls back to the built-in rather than send mojibake. The editor still opens such a file, with the unreadable characters replaced and a note that saving writes it back as UTF-8. + - **Unsaved edits.** The editor's file watcher reloaded the file over whatever had been typed whenever it changed on disk. It now asks first when there are unsaved edits, and reloads silently only when there are none; a save clears the flag. + - **Half-written saves.** `save_prompt_text()` opened the file for writing, truncating it first, so a failure part-way left an empty prompt — which a review then quietly replaced with the built-in. It writes beside the file and `os.replace()`s it in, and its error box shows the OS's reason without the path. + - **A crash.** The editor's `QFileSystemWatcher` had no parent and the editor no `closeEvent`, so a change on disk after the editor closed was delivered to a widget on its way out — with unsaved edits, a modal question over it, which took the test process down with a heap corruption (`0xC0000374`). The watcher is parented to the editor, and closing the editor stops watching. +- **Tests**: `test_prompt_store.py` gains nine: a cp950 file falling back, a BOM stripped, the editor opening a cp950 file and saying so, unsaved edits kept when the user declines a reload and a reload without asking when nothing is unsaved, a failed save leaving the last good file and no leftovers, the watcher parented, and closing stopping it. Five fail against the old code. An older test in the same file replaced `QMessageBox.information` for the rest of the session instead of through `monkeypatch`; it no longer does. +- **Result / numbers**: unit tests 1196 → 1204 passed, 14 skipped, in two consecutive full runs. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py`, `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/{prompt_editor_widget,prompt_file_io}.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/test_prompt_store.py`, `architecture_explore.md` §8, §18, `progress.md` (#22 removed). +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index be0c822f..0ca6e619 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-30 | 2026-09-23 | Prompt files: other encodings, unsaved edits, half-written saves | #done #ai | [2026-09](2026-09.md) | | U-20260923-29 | 2026-09-23 | Closing a request panel no longer waits for its request | #done #ai | [2026-09](2026-09.md) | | U-20260923-28 | 2026-09-23 | One broken extend tab no longer stops the IDE from starting | #done #editor | [2026-09](2026-09.md) | | U-20260923-27 | 2026-09-23 | PyBreeze.log leaves the working directory and becomes UTF-8 | #done #logging | [2026-09](2026-09.md) | @@ -110,4 +111,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 47 | +| [2026-09.md](2026-09.md) | 2026-09 | 48 | diff --git a/progress.md b/progress.md index 54f206f8..37889d77 100644 --- a/progress.md +++ b/progress.md @@ -9,5 +9,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#10** SSH connecting and directory listing still run on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s) and `listdir_attr()` by nothing at all, so an unresponsive host or a directory with tens of thousands of entries freezes the IDE. Transfers moved off it in U-20260923-19; moving `connect()` has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. - **#15** The regex tool runs the user's pattern on the UI thread with nothing to bound it (`tools_gui/regex_gui.py` -> `utils/regex_tools/regex_tester.py:find_matches`): `(a+)+$` against 26 `a`s and a `b` takes about 5 s, and each further character doubles it. `_MAX_MATCHES` caps how many matches are reported, not how long one attempt takes. Only a separate process can be stopped mid-match. -- **#22** Prompt files: a prompt saved as ANSI raises `UnicodeDecodeError` out of `prompt_store.load_prompt` and `PromptEditorWidget.load_file_content`, which catch only `OSError` (so the skills tab or the prompt editor fails to open); the editor's file watcher reloads over unsaved edits without asking (`prompt_edit_gui/prompt_editor_widget.py:on_file_changed`); and `prompt_file_io.save_prompt_text` truncates before it writes. diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 4d6e5eed..35ee4d40 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -195,6 +195,10 @@ "ssh_file_viewer_dialog_title_invalid_selection": "Invalid selection", "ssh_file_viewer_dialog_message_select_file_to_download": "Select a file to download.", "ssh_file_viewer_dialog_title_save_as": "Save as", + "prompt_editor_not_utf8": + "{filename} is not UTF-8 text. It is shown with the characters that could not be read replaced; saving writes it back as UTF-8.", + "prompt_editor_reload_over_edits": + "{filename} changed on disk. Reload it and lose the edits made here?", "ssh_state_shell_and_files": "Connected: shell and files", "ssh_state_shell_only": "Connected: shell only (the file tree did not connect)", "ssh_state_files_only": "Connected: files only (the shell did not connect)", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index aedc8666..1f86a626 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -195,6 +195,10 @@ "ssh_file_viewer_dialog_title_invalid_selection": "選取無效", "ssh_file_viewer_dialog_message_select_file_to_download": "請選擇要下載的檔案。", "ssh_file_viewer_dialog_title_save_as": "另存新檔", + "prompt_editor_not_utf8": + "{filename} 不是 UTF-8 文字檔。讀不出來的字元已經換掉後顯示;存檔時會改存成 UTF-8。", + "prompt_editor_reload_over_edits": + "{filename} 在磁碟上被改過了。要重新載入並捨棄這裡還沒存的編輯嗎?", "ssh_state_shell_and_files": "已連線:終端與檔案", "ssh_state_shell_only": "已連線:只有終端(檔案樹沒連上)", "ssh_state_files_only": "已連線:只有檔案(終端沒連上)", diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py index bb400547..18d38c0a 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py @@ -22,7 +22,10 @@ from je_editor import language_wrapper from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import save_prompt_text -from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import prompt_dir, prompt_path +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import ( + prompt_dir, prompt_path, read_prompt_file +) +from pybreeze.utils.logging.logger import pybreeze_logger @dataclass(frozen=True) @@ -98,8 +101,10 @@ def __init__(self, files: list[str], templates: dict[str, str], main_layout.addLayout(bottom_layout) # 檔案監控器:外部改動即時反映 / Pick up edits made outside the editor + # Parented to the editor so it goes with it: an orphan watcher outlived a + # closed editor and delivered the next change on disk to a deleted widget. self.watcher = QFileSystemWatcher( - [str(prompt_path(name)) for name in self.prompt_files]) + [str(prompt_path(name)) for name in self.prompt_files], self) self.watcher.fileChanged.connect(self.on_file_changed) if self.prompt_files: @@ -111,12 +116,36 @@ def load_file_content(self, index: int) -> None: self.current_file = str(prompt_path(name)) path = Path(self.current_file) if path.is_file(): - self.middle_editor.setPlainText(path.read_text(encoding="utf-8")) + self._show_file(path) return - self.middle_editor.setPlainText( + self._show_text( language_wrapper.language_word_dict.get( self._labels.file_not_exist).format(filename=name)) + def _show_file(self, path: Path) -> None: + """Put the prompt file in the edit area, however it was encoded.""" + text = read_prompt_file(path) + if text is None: + try: + # Not UTF-8: shown with what cannot be read replaced, so that a + # save writes it back as UTF-8 and the review can use it again. + text = path.read_bytes().decode("utf-8", errors="replace") + except OSError as error: + pybreeze_logger.error("Prompt file %s could not be opened: %r", path.name, error) + # Nothing was shown, so there is nothing a save may write back. + self.current_file = None + return + QMessageBox.information( + self, language_wrapper.language_word_dict.get(self._labels.info_title), + language_wrapper.language_word_dict.get("prompt_editor_not_utf8").format( + filename=path.name)) + self._show_text(text) + + def _show_text(self, text: str) -> None: + """Replace the edit area's text; what is shown now counts as unedited.""" + self.middle_editor.setPlainText(text) + self.middle_editor.document().setModified(False) + def create_file(self) -> None: """用內建模板建立目前選擇的檔案 / Create the selected file from its built-in template.""" word = language_wrapper.language_word_dict @@ -141,10 +170,38 @@ def create_file(self) -> None: word.get(self._labels.file_created).format(filename=self.current_file)) self.load_file_content(self.file_selector.currentIndex()) + def closeEvent(self, event) -> None: + """Stop watching the prompt files once the editor closes. + + A change on disk after that -- the next save from another window, or + the directory being cleaned up -- would otherwise reach a widget that is + on its way out, and with unsaved edits open a question box over it. + """ + self.watcher.blockSignals(True) + watched = self.watcher.files() + if watched: + self.watcher.removePaths(watched) + super().closeEvent(event) + def on_file_changed(self, path: str) -> None: - """外部改動時重新載入 / Reload when the file changes underneath us.""" - if path == self.current_file: - self.load_file_content(self.file_selector.currentIndex()) + """外部改動時重新載入 / Reload when the file changes underneath us. + + Unsaved edits are not thrown away without asking: another window, an + external editor or a sync client touching the file used to replace + whatever had been typed here. + """ + if path != self.current_file: + return + if self.middle_editor.document().isModified(): + word = language_wrapper.language_word_dict + reply = QMessageBox.question( + self, word.get(self._labels.info_title), + word.get("prompt_editor_reload_over_edits").format(filename=Path(path).name), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) + if reply != QMessageBox.StandardButton.Yes: + return + self.load_file_content(self.file_selector.currentIndex()) def save_file(self) -> None: """把編輯區內容存回檔案 / Write the edit area back to the file.""" @@ -160,6 +217,7 @@ def save_file(self) -> None: word.get(self._labels.error_title)): return self.watcher.addPath(self.current_file) + self.middle_editor.document().setModified(False) QMessageBox.information( self, word.get(self._labels.success_title), word.get(self._labels.file_saved).format(filename=self.current_file)) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py index 9627a377..12d8111f 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import os from pathlib import Path from PySide6.QtWidgets import QMessageBox, QWidget @@ -22,11 +23,17 @@ def save_prompt_text(parent: QWidget, path: str, content: str, error_title: str) try: # The directory is made here rather than on every read, so a session that # only looks at the built-in prompts leaves nothing behind. - Path(path).parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as file_handle: - file_handle.write(content) + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + # Written beside the file and moved into place in one step: opening the + # file itself for writing truncates it first, so a failure part-way left + # the prompt empty, and the review silently fell back to the built-in. + beside = target.with_name(target.name + ".saving") + beside.write_text(content, encoding="utf-8") + os.replace(beside, target) return True except OSError as error: pybreeze_logger.error("Prompt file write failed for %s: %r", path, error) - QMessageBox.warning(parent, error_title, str(error)) + Path(path).with_name(Path(path).name + ".saving").unlink(missing_ok=True) + QMessageBox.warning(parent, error_title, error.strerror or type(error).__name__) return False diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py index ed28cb19..32653491 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py @@ -48,9 +48,25 @@ def load_prompt(name: str, default: str) -> str: path = prompt_path(name) if not path.is_file(): return default - try: - edited = path.read_text(encoding="utf-8") - except OSError as error: - pybreeze_logger.error("Prompt %s could not be read: %r", name, error) + edited = read_prompt_file(path) + if edited is None: return default return edited if edited.strip() else default + + +def read_prompt_file(path: Path) -> str | None: + """Return the text of the prompt file *path*, or ``None`` when it cannot be read. + + Read as ``utf-8-sig``, so the byte-order mark some editors write does not end + up at the front of a prompt. A file in another encoding -- a prompt saved as + "ANSI" on Windows -- counts as unreadable, the same as one that is locked: + decoding it anyway would send mojibake to the model. + + :param path: the prompt file + :return: its text, or ``None`` + """ + try: + return path.read_text(encoding="utf-8-sig") + except (OSError, UnicodeDecodeError) as error: + pybreeze_logger.error("Prompt %s could not be read: %r", path.name, error) + return None diff --git a/test/test_utils/test_prompt_store.py b/test/test_utils/test_prompt_store.py index 71ea936e..11e71faa 100644 --- a/test/test_utils/test_prompt_store.py +++ b/test/test_utils/test_prompt_store.py @@ -119,7 +119,7 @@ def test_every_template_can_be_overridden(self, prompts): class TestTheEditorAndTheChainAgree: """The point of the editor: what is saved there is what a review sends.""" - def test_saving_in_the_editor_changes_what_the_chain_sends(self, prompts): + def test_saving_in_the_editor_changes_what_the_chain_sends(self, prompts, monkeypatch): from PySide6.QtWidgets import QApplication, QMessageBox from pybreeze.extend_multi_language.update_language_dict import update_language_dict @@ -131,7 +131,7 @@ def test_saving_in_the_editor_changes_what_the_chain_sends(self, prompts): update_language_dict() editor = CoTPromptEditor() # The confirmation dialogs would block; the save itself is what matters. - QMessageBox.information = staticmethod(lambda *a, **k: None) + monkeypatch.setattr(QMessageBox, "information", staticmethod(lambda *a, **k: None)) editor.file_selector.setCurrentIndex( editor.prompt_files.index("linter.md")) @@ -201,3 +201,135 @@ def test_an_edited_skill_is_what_the_selector_loads(self, prompts): widget.prompt_select.setCurrentText(name) assert widget.prompt_input.toPlainText() == "my own skill prompt" widget.deleteLater() + + +def _cot_editor(monkeypatch): + """A CoT prompt editor whose message boxes answer without blocking.""" + from PySide6.QtWidgets import QApplication, QMessageBox + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import ( + CoTPromptEditor + ) + + QApplication.instance() or QApplication([]) + update_language_dict() + shown: list = [] + monkeypatch.setattr(QMessageBox, "information", staticmethod(lambda *a, **k: shown.append(a))) + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: shown.append(a))) + editor = CoTPromptEditor() + editor.shown = shown + return editor + + +class TestAPromptFileInAnotherEncoding: + """A prompt saved as "ANSI" on Windows is not UTF-8; it must not break anything.""" + + def test_the_review_falls_back_to_the_built_in(self, prompts): + prompts.mkdir(parents=True, exist_ok=True) + (prompts / "linter.md").write_bytes("檢查這段程式碼".encode("cp950")) + + assert load_prompt("linter.md", "built-in") == "built-in" + + def test_a_byte_order_mark_does_not_reach_the_prompt(self, prompts): + prompts.mkdir(parents=True, exist_ok=True) + (prompts / "linter.md").write_text("my prompt", encoding="utf-8-sig") + + assert load_prompt("linter.md", "built-in") == "my prompt" + + def test_the_editor_opens_it_and_says_so(self, prompts, monkeypatch): + prompts.mkdir(parents=True, exist_ok=True) + (prompts / "linter.md").write_bytes("檢查這段程式碼".encode("cp950")) + editor = _cot_editor(monkeypatch) + + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + + assert editor.shown, "the user was not told the file is not UTF-8" + assert editor.current_file == str(prompt_path("linter.md")) + editor.close() + editor.deleteLater() + + +class TestAChangeOnDiskWhileEditing: + def test_unsaved_edits_are_kept_unless_the_user_says_otherwise(self, prompts, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + write(prompts, "linter.md", "on disk") + editor = _cot_editor(monkeypatch) + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + # Typed, the way a keystroke arrives: setPlainText() would reset the + # document's modified flag, which is what a real edit sets. + editor.middle_editor.selectAll() + editor.middle_editor.textCursor().insertText("typed here, not saved") + monkeypatch.setattr( + QMessageBox, "question", + staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)) + + write(prompts, "linter.md", "changed elsewhere") + editor.on_file_changed(editor.current_file) + + assert editor.middle_editor.toPlainText() == "typed here, not saved" + editor.close() + editor.deleteLater() + + def test_with_nothing_unsaved_it_reloads_without_asking(self, prompts, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + write(prompts, "linter.md", "on disk") + editor = _cot_editor(monkeypatch) + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + asked: list = [] + monkeypatch.setattr( + QMessageBox, "question", staticmethod(lambda *a, **k: asked.append(a))) + + write(prompts, "linter.md", "changed elsewhere") + editor.on_file_changed(editor.current_file) + + assert editor.middle_editor.toPlainText() == "changed elsewhere" + assert asked == [] + editor.close() + editor.deleteLater() + + +class TestSavingAPrompt: + def test_a_save_that_fails_leaves_the_last_good_file(self, prompts, monkeypatch): + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui import prompt_file_io + + write(prompts, "linter.md", "the last good prompt") + editor = _cot_editor(monkeypatch) + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + editor.middle_editor.setPlainText("a new prompt") + + def refuse(*_args): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(prompt_file_io.os, "replace", refuse) + editor.save_file() + + assert (prompts / "linter.md").read_text(encoding="utf-8") == "the last good prompt" + assert [path.name for path in prompts.iterdir()] == ["linter.md"] + editor.close() + editor.deleteLater() + + +class TestClosingThePromptEditor: + def test_its_file_watcher_goes_with_it(self, prompts, monkeypatch): + # An orphan watcher outlived the editor and delivered the next change on + # disk to a deleted widget -- a crash, once the slot touched the editor. + from PySide6.QtCore import QObject + + editor = _cot_editor(monkeypatch) + + assert editor.watcher.parent() is editor + assert isinstance(editor.watcher, QObject) + editor.close() + editor.deleteLater() + + def test_closing_it_stops_watching(self, prompts, monkeypatch): + write(prompts, "linter.md", "on disk") + editor = _cot_editor(monkeypatch) + + editor.close() + + assert editor.watcher.files() == [] + editor.deleteLater() From d8078cd65a1f723195c29dd09aa1375413b0708d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 06:12:18 +0800 Subject: [PATCH 050/312] Resolve the package facade's names on first use Refactor only: importing any pybreeze.utils module first ran the facade, which imported the whole IDE and took about four seconds. The six public names are the same objects, looked up when first used. --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 9 ++++ docs/updates/README.md | 3 +- pybreeze/__init__.py | 57 ++++++++++++++++---- test/test_utils/test_package_facade.py | 72 ++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 test/test_utils/test_package_facade.py diff --git a/architecture_explore.md b/architecture_explore.md index 1a1c996e..fb1a3dbc 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -377,7 +377,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | 模式 | 落點 | |---|---| -| **Facade** | `pybreeze/__init__.py` — 對外只暴露 `start_editor`、`PyBreezeMainWindow`、`EDITOR_EXTEND_TAB` 與轉出的插件 API | +| **Facade** | `pybreeze/__init__.py` — 對外只暴露 `start_editor`、`PyBreezeMainWindow`、`EDITOR_EXTEND_TAB` 與轉出的插件 API;第一次用到才 import(PEP 562 `__getattr__`),所以 `import pybreeze.utils.*` 不會連帶載入 PySide6 與 JEditor | | **Strategy** | 六個自動化模組共用 `build_process()`,差別只在 `_PACKAGE` | | **Template Method** | `TaskProcessManager` 固定 spawn → read threads → QTimer poll → drain → exit 的骨架 | | **Observer** | Queue + QTimer 把子行程輸出橋接到 UI 執行緒;Qt Signal/Slot(`SenderThread.update_response`、`JupyterLauncherThread.server_ready`) | @@ -431,7 +431,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 80 個 `test_*.py`、1218 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 81 個 `test_*.py`、1221 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index c3aa3aa3..4e769270 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -425,3 +425,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1196 → 1204 passed, 14 skipped, in two consecutive full runs. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py`, `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/{prompt_editor_widget,prompt_file_io}.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/test_prompt_store.py`, `architecture_explore.md` §8, §18, `progress.md` (#22 removed). - **Open items**: none. + +## U-20260923-31 · 2026-09-23 · Importing a utility no longer imports the whole IDE · #done #refactor #performance + +- **What**: a refactor of the package facade, no change to its names. `pybreeze/__init__.py` imported `main_ui` and JEditor eagerly, and Python runs a package's `__init__` before any of its submodules, so `import pybreeze.utils.regex_tools.regex_tester` — or any other utility — first imported PySide6, JEditor and every menu builder. Measured with `-X importtime`: 4.10 s for that one import. It surfaced while moving the regex tester into a worker process, which would have paid it on every run (see the next entry), but every test process and every tool that only needs a utility paid it too. +- **Change**: the facade resolves its six public names on first use through a module `__getattr__` (PEP 562), with the same `__all__`, and the imports repeated under `TYPE_CHECKING` for type checkers. `from pybreeze import start_editor`, `pybreeze.EDITOR_EXTEND_TAB` and `from pybreeze import *` behave as before and return the very same objects. +- **Tests**: `test_package_facade.py` (new, 3, each in a fresh interpreter): importing a utility loads neither PySide6, JEditor nor `main_ui`; every public name resolves to the IDE's own object (`EDITOR_EXTEND_TAB is main_ui.EDITOR_EXTEND_TAB`, and so on); an unknown name is an `AttributeError`. +- **Result / numbers**: the same import takes 0.07 s. Unit tests 1204 → 1207 passed, 14 skipped; both startup tests exit 0 (the extend-tab one registers through `from pybreeze import EDITOR_EXTEND_TAB`). `ruff check` clean. +- **Files**: `pybreeze/__init__.py`, `test/test_utils/test_package_facade.py` (new), `architecture_explore.md` §15, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 0ca6e619..9c31630f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-31 | 2026-09-23 | Importing a utility no longer imports the whole IDE | #done #refactor #performance | [2026-09](2026-09.md) | | U-20260923-30 | 2026-09-23 | Prompt files: other encodings, unsaved edits, half-written saves | #done #ai | [2026-09](2026-09.md) | | U-20260923-29 | 2026-09-23 | Closing a request panel no longer waits for its request | #done #ai | [2026-09](2026-09.md) | | U-20260923-28 | 2026-09-23 | One broken extend tab no longer stops the IDE from starting | #done #editor | [2026-09](2026-09.md) | @@ -111,4 +112,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 48 | +| [2026-09.md](2026-09.md) | 2026-09 | 49 | diff --git a/pybreeze/__init__.py b/pybreeze/__init__.py index f1ca2460..8e682279 100644 --- a/pybreeze/__init__.py +++ b/pybreeze/__init__.py @@ -1,13 +1,38 @@ -from pybreeze.pybreeze_ui.editor_main.main_ui import EDITOR_EXTEND_TAB -from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -from pybreeze.pybreeze_ui.editor_main.main_ui import start_editor +"""PyBreeze's public names, imported the first time one is used. -# Re-export jeditor plugin API for convenience -from je_editor import ( - load_external_plugins, - register_natural_language, - register_programming_language, -) +Importing ``pybreeze`` used to import the whole IDE up front -- PySide6, JEditor, +every menu builder -- so ``import pybreeze.utils.`` took about four +seconds, and so did every process that only needed a utility: a test run, or the +regex tester's worker process, which paid it on each run. The names are the same +and resolve to the same objects; they are just looked up on first use. +""" +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from je_editor import ( + load_external_plugins, + register_natural_language, + register_programming_language, + ) + + from pybreeze.pybreeze_ui.editor_main.main_ui import ( + EDITOR_EXTEND_TAB, + PyBreezeMainWindow, + start_editor, + ) + +# Where each public name lives; JEditor's plugin API is re-exported for convenience +_HOMES = { + "EDITOR_EXTEND_TAB": "pybreeze.pybreeze_ui.editor_main.main_ui", + "PyBreezeMainWindow": "pybreeze.pybreeze_ui.editor_main.main_ui", + "start_editor": "pybreeze.pybreeze_ui.editor_main.main_ui", + "load_external_plugins": "je_editor", + "register_natural_language": "je_editor", + "register_programming_language": "je_editor", +} __all__ = [ "EDITOR_EXTEND_TAB", @@ -17,3 +42,17 @@ "register_programming_language", "start_editor", ] + + +def __getattr__(name: str) -> Any: + """Import a public name from its home the first time it is asked for.""" + home = _HOMES.get(name) + if home is None: + raise AttributeError(f"module 'pybreeze' has no attribute {name!r}") + value = getattr(importlib.import_module(home), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/test/test_utils/test_package_facade.py b/test/test_utils/test_package_facade.py new file mode 100644 index 00000000..226cc9bb --- /dev/null +++ b/test/test_utils/test_package_facade.py @@ -0,0 +1,72 @@ +"""The package facade: the public names, and what importing a utility costs. + +Importing ``pybreeze`` used to import the whole IDE, so ``pybreeze.utils`` came +with PySide6 and JEditor attached. The facade looks its names up on first use. +Each check runs in a fresh interpreter, since this one has long imported the IDE. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +_TIMEOUT_SECONDS = 120 + + +def _run(code: str) -> dict: + environment = { + **os.environ, + "QT_QPA_PLATFORM": "offscreen", + "PYTHONPATH": os.pathsep.join( + filter(None, [str(_REPOSITORY_ROOT), os.environ.get("PYTHONPATH")])), + } + completed = subprocess.run( # noqa: S603 — fixed argv: this interpreter and a literal script + [sys.executable, "-c", code], env=environment, capture_output=True, + timeout=_TIMEOUT_SECONDS, check=False, shell=False) + assert completed.returncode == 0, completed.stderr.decode("utf-8", "replace")[-2000:] + return json.loads(completed.stdout.decode("utf-8").splitlines()[-1]) + + +def test_a_utility_imports_without_the_ide(): + seen = _run( + "import json, sys\n" + "import pybreeze.utils.regex_tools.regex_tester\n" + "print(json.dumps({'loaded': [m for m in ('PySide6', 'je_editor', " + "'pybreeze.pybreeze_ui.editor_main.main_ui') if m in sys.modules]}))\n") + + assert seen["loaded"] == [] + + +def test_the_public_names_are_the_ide_s_own_objects(): + seen = _run( + "import json\n" + "import pybreeze\n" + "from pybreeze import EDITOR_EXTEND_TAB, PyBreezeMainWindow, start_editor\n" + "from pybreeze import load_external_plugins, register_natural_language, " + "register_programming_language\n" + "from pybreeze.pybreeze_ui.editor_main import main_ui\n" + "import je_editor\n" + "print(json.dumps({\n" + " 'same_registry': EDITOR_EXTEND_TAB is main_ui.EDITOR_EXTEND_TAB,\n" + " 'same_window': PyBreezeMainWindow is main_ui.PyBreezeMainWindow,\n" + " 'same_start': start_editor is main_ui.start_editor,\n" + " 'same_plugins': load_external_plugins is je_editor.load_external_plugins,\n" + " 'all_resolve': all(hasattr(pybreeze, name) for name in pybreeze.__all__),\n" + "}))\n") + + assert all(seen.values()), seen + + +def test_an_unknown_name_is_an_attribute_error(): + seen = _run( + "import json\n" + "import pybreeze\n" + "try:\n" + " pybreeze.no_such_name\n" + "except AttributeError:\n" + " print(json.dumps({'raised': True}))\n") + + assert seen["raised"] From 845c989928d395b81f9b9af5c73246da023bd8d8 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 06:17:26 +0800 Subject: [PATCH 051/312] Run a regex in a process that can be stopped, off the UI thread A pattern with nested repetition backtracked on the UI thread for as long as it liked, freezing the IDE. It runs in a spawned process stopped after five seconds, waited for on a thread; the entry points are guarded so the spawned child does not start a second IDE. --- architecture.md | 5 +- architecture_explore.md | 4 +- docs/updates/2026-09.md | 10 +++ docs/updates/README.md | 3 +- exe/start_pybreeze.py | 11 ++- progress.md | 1 - pybreeze/__main__.py | 9 ++- .../extend_multi_language/extend_english.py | 1 + .../extend_traditional_chinese.py | 1 + pybreeze/pybreeze_ui/tools_gui/regex_gui.py | 68 +++++++++++++++--- pybreeze/utils/exception/exception_tags.py | 4 ++ pybreeze/utils/regex_tools/regex_tester.py | 70 ++++++++++++++++++- test/test_utils/test_regex_gui.py | 58 +++++++++++++-- test/test_utils/test_regex_tester.py | 32 +++++++++ 14 files changed, 253 insertions(+), 24 deletions(-) diff --git a/architecture.md b/architecture.md index 62dc5b80..467ac49d 100644 --- a/architecture.md +++ b/architecture.md @@ -47,7 +47,10 @@ The layers are presentation (`pybreeze_ui/`), then execution (`extend/`), then f ## 3. Entry points and public interfaces -- **CLI**: `python -m pybreeze` (`pybreeze/__main__.py`). No console script is declared. +- **CLI**: `python -m pybreeze` (`pybreeze/__main__.py`). No console script is declared. It and + `exe/start_pybreeze.py` start the IDE only under `if __name__ == "__main__":` with + `multiprocessing.freeze_support()`: the regex tester runs patterns in a spawned process, which + re-runs the main script (and, in the packaged executable, the executable itself). - **Programmatic**: `pybreeze.start_editor(debug_mode=False, theme="dark_amber.xml", **kwargs)`. `debug_mode=True` adds an auto-close timer, which CI uses. - **Main window**: `PyBreezeMainWindow` exposes `tab_widget`, `current_run_code_window` and diff --git a/architecture_explore.md b/architecture_explore.md index fb1a3dbc..ea9b30e5 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -221,7 +221,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `HashGUI` | `utils/hash_tools/` | 多演算法摘要 | | `QueryJsonGUI` | `utils/query_tools/` | query string ↔ JSON 雙向 | | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | -| `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組 | +| `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組。pattern 在另一個行程裡跑(`find_matches_bounded()`,spawn,5 秒後結束掉),分頁用 `RegexMatchThread` 等它:`re` 開始比對後就停不下來,災難性回溯只有整個行程能停 | | `HttpStatusGUI` | `utils/http_reference/` | 狀態碼參考,可依碼前綴或描述搜尋 | | `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計 | | `JsonFormatGUI` | `utils/json_format/` | 美化 / 壓縮 / 驗證 | @@ -431,7 +431,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 81 個 `test_*.py`、1221 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 81 個 `test_*.py`、1226 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 4e769270..142e9f75 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -434,3 +434,13 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: the same import takes 0.07 s. Unit tests 1204 → 1207 passed, 14 skipped; both startup tests exit 0 (the extend-tab one registers through `from pybreeze import EDITOR_EXTEND_TAB`). `ruff check` clean. - **Files**: `pybreeze/__init__.py`, `test/test_utils/test_package_facade.py` (new), `architecture_explore.md` §15, §18. - **Open items**: none. + +## U-20260923-32 · 2026-09-23 · A runaway regex no longer freezes the IDE · #done #tools + +- **What**: `progress.md` #15. The regex tool ran the user's pattern in the button's slot with nothing to bound it. `(a+)+$` against 26 `a`s and a `b` takes about 5 s and each further character doubles it, so a nested quantifier over a pasted log line could hold the IDE for minutes, "Not Responding", with killing the process the only way out. `_MAX_MATCHES` capped how many matches were reported, not how long one attempt took. Python's `re` cannot be interrupted once a match starts; only a process can be stopped mid-match. +- **Fix**: `find_matches_bounded()` compiles the pattern here first (a malformed one is reported without starting anything), then runs `find_matches()` in a `spawn` process and stops it if it has not answered within `MATCH_TIMEOUT_SECONDS` (5 s), reporting why. The tab waits for it on `RegexMatchThread`, so even those 5 s leave the IDE usable; the Test button comes back when the thread ends, and closing the tab hands a run still going to `thread_keeper.let_run_out()`. A normal run takes about 0.1 s — made possible by the lazy facade (U-20260923-31); with the old one, the worker spent about 5 s importing the IDE before matching anything. +- **Entry points**: a spawned child re-runs the main script, so `pybreeze/__main__.py` and `exe/start_pybreeze.py` start the IDE only under `if __name__ == "__main__":`, after `multiprocessing.freeze_support()` for the packaged executable; `exe/start_pybreeze.py` ran `start_editor()` at module level and would have opened a second IDE in the worker. Checked by running it with `run_name="__mp_main__"`, as the child does: no IDE. +- **Tests**: `test_regex_tester.py` gains three (same result as `find_matches`, a malformed pattern reported before any process, `(a+)+$` on 40 `a`s stopped); `test_regex_gui.py` waits for the thread in its existing tests and gains two (the tab does not wait for a slow pattern and gives the button back, a runaway pattern reported in the output). +- **Result / numbers**: unit tests 1207 → 1212 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/utils/regex_tools/regex_tester.py`, `pybreeze/utils/exception/exception_tags.py`, `pybreeze/pybreeze_ui/tools_gui/regex_gui.py`, `pybreeze/__main__.py`, `exe/start_pybreeze.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/{test_regex_tester,test_regex_gui}.py`, `architecture.md` §3, `architecture_explore.md` §6, §18, `progress.md` (#15 removed). +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9c31630f..4dad491a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-32 | 2026-09-23 | A runaway regex no longer freezes the IDE | #done #tools | [2026-09](2026-09.md) | | U-20260923-31 | 2026-09-23 | Importing a utility no longer imports the whole IDE | #done #refactor #performance | [2026-09](2026-09.md) | | U-20260923-30 | 2026-09-23 | Prompt files: other encodings, unsaved edits, half-written saves | #done #ai | [2026-09](2026-09.md) | | U-20260923-29 | 2026-09-23 | Closing a request panel no longer waits for its request | #done #ai | [2026-09](2026-09.md) | @@ -112,4 +113,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 49 | +| [2026-09.md](2026-09.md) | 2026-09 | 50 | diff --git a/exe/start_pybreeze.py b/exe/start_pybreeze.py index a4655909..2a7e227a 100644 --- a/exe/start_pybreeze.py +++ b/exe/start_pybreeze.py @@ -1,3 +1,12 @@ +from __future__ import annotations + +import multiprocessing + from pybreeze import start_editor -start_editor() +# Guarded: a tool that runs work in a spawned process (the regex tester) re-runs +# the main script in the child, and an unguarded start_editor() there would open +# a second IDE. freeze_support() does the same job for the packaged executable. +if __name__ == "__main__": + multiprocessing.freeze_support() + start_editor() diff --git a/progress.md b/progress.md index 37889d77..c3df4975 100644 --- a/progress.md +++ b/progress.md @@ -8,5 +8,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#10** SSH connecting and directory listing still run on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s) and `listdir_attr()` by nothing at all, so an unresponsive host or a directory with tens of thousands of entries freezes the IDE. Transfers moved off it in U-20260923-19; moving `connect()` has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. -- **#15** The regex tool runs the user's pattern on the UI thread with nothing to bound it (`tools_gui/regex_gui.py` -> `utils/regex_tools/regex_tester.py:find_matches`): `(a+)+$` against 26 `a`s and a `b` takes about 5 s, and each further character doubles it. `_MAX_MATCHES` caps how many matches are reported, not how long one attempt takes. Only a separate process can be stopped mid-match. diff --git a/pybreeze/__main__.py b/pybreeze/__main__.py index 408a8146..2a7e227a 100644 --- a/pybreeze/__main__.py +++ b/pybreeze/__main__.py @@ -1,5 +1,12 @@ from __future__ import annotations +import multiprocessing + from pybreeze import start_editor -start_editor() +# Guarded: a tool that runs work in a spawned process (the regex tester) re-runs +# the main script in the child, and an unguarded start_editor() there would open +# a second IDE. freeze_support() does the same job for the packaged executable. +if __name__ == "__main__": + multiprocessing.freeze_support() + start_editor() diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 35ee4d40..60be6e51 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -492,6 +492,7 @@ "regex_test_button": "Find matches", "regex_output_label": "Matches:", "regex_match_count": "{count} match(es):", + "regex_running": "Running the pattern…", "regex_no_match": "No matches.", "regex_error": "Regex error: {error}", # HTTP Status Reference — Menu diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 1f86a626..c6fa1bc7 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -470,6 +470,7 @@ "regex_test_button": "尋找符合項", "regex_output_label": "符合項:", "regex_match_count": "{count} 個符合:", + "regex_running": "正在執行這個 pattern…", "regex_no_match": "沒有符合項。", "regex_error": "正規表示式錯誤:{error}", # HTTP 狀態碼參考 — 選單 diff --git a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py index e0975c05..1759b972 100644 --- a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py @@ -1,20 +1,41 @@ """A tool tab that tests a regular expression against sample text.""" from __future__ import annotations +from PySide6.QtCore import QThread, Signal from PySide6.QtWidgets import ( QCheckBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextEdit, QVBoxLayout, QWidget ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import RegexTesterException from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.regex_tools.regex_tester import ( - MatchResult, available_flags, find_matches + MatchResult, available_flags, find_matches_bounded ) +class RegexMatchThread(QThread): + """One pattern run, waited for off the UI thread.""" + + matched = Signal(list) + failed = Signal(str) + + def __init__(self, pattern: str, text: str, flag_names: list[str]) -> None: + super().__init__() + self._pattern = pattern + self._text = text + self._flag_names = flag_names + + def run(self) -> None: + try: + self.matched.emit(find_matches_bounded(self._pattern, self._text, self._flag_names)) + except RegexTesterException as error: + self.failed.emit(str(error)) + + def build_matches_text(matches: list[MatchResult], no_match_message: str) -> str: """Render matches into a readable, numbered report. @@ -44,6 +65,7 @@ def __init__(self, main_window=None) -> None: """ super().__init__() self._valid_output = False + self._match_thread: RegexMatchThread | None = None word = language_wrapper.language_word_dict self.pattern_label = QLabel(word.get("regex_pattern_label")) @@ -91,16 +113,40 @@ def selected_flags(self) -> list[str]: return [name for name, box in self.flag_checkboxes.items() if box.isChecked()] def test(self) -> None: - """Run the pattern against the sample text and show the matches.""" - word = language_wrapper.language_word_dict - pattern = self.pattern_edit.text() - try: - matches = find_matches(pattern, self.text_edit.toPlainText(), self.selected_flags()) - except RegexTesterException as error: - pybreeze_logger.info("regex_gui.py test failed: %r", error) - self._valid_output = False - self.output_edit.setPlainText(word.get("regex_error").format(error=str(error))) + """Run the pattern against the sample text; the matches arrive when it is done. + + The pattern runs in a separate process that is stopped after a few + seconds (``find_matches_bounded``), and this tab waits for it on a + thread of its own: a pattern that backtracks catastrophically used to + run on the UI thread and freeze the IDE for minutes. + """ + if self._match_thread is not None and self._match_thread.isRunning(): return + word = language_wrapper.language_word_dict + self.test_button.setEnabled(False) + self.output_edit.setPlainText(word.get("regex_running")) + thread = RegexMatchThread( + self.pattern_edit.text(), self.text_edit.toPlainText(), self.selected_flags()) + thread.matched.connect(self._show_matches) + thread.failed.connect(self._show_error) + thread.finished.connect(lambda: self.test_button.setEnabled(True)) + self._match_thread = thread + thread.start() + + def _show_matches(self, matches: list) -> None: self._valid_output = True self.output_edit.setPlainText( - build_matches_text(matches, word.get("regex_no_match"))) + build_matches_text(matches, language_wrapper.language_word_dict.get("regex_no_match"))) + + def _show_error(self, message: str) -> None: + pybreeze_logger.info("regex_gui.py test failed: %s", message) + self._valid_output = False + self.output_edit.setPlainText( + language_wrapper.language_word_dict.get("regex_error").format(error=message)) + + def closeEvent(self, event) -> None: + """Let a pattern still running run out; its process is stopped on its own deadline.""" + thread = self._match_thread + if thread is not None and thread.isRunning(): + let_run_out(thread, thread.matched, thread.failed) + super().closeEvent(event) diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index 82e7d9ab..e052dd4b 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -70,3 +70,7 @@ invalid_json_for_url_error: str = "can't parse the input as JSON" invalid_url_components_error: str = "the input must be a JSON object of URL parts" unreadable_url_error: str = "the input is not a URL that can be read" +regex_timeout_error: str = ( + "the pattern was still running after {seconds} s and was stopped; nested " + "repetition such as (a+)+ can take exponentially long on text that almost matches") +regex_worker_error: str = "the pattern could not be run: {detail}" diff --git a/pybreeze/utils/regex_tools/regex_tester.py b/pybreeze/utils/regex_tools/regex_tester.py index 3720c5d4..74a906fb 100644 --- a/pybreeze/utils/regex_tools/regex_tester.py +++ b/pybreeze/utils/regex_tools/regex_tester.py @@ -9,19 +9,26 @@ """ from __future__ import annotations +import multiprocessing import re from dataclasses import dataclass, field from pybreeze.utils.exception.exception_tags import ( empty_regex_pattern_error, invalid_regex_pattern_error, + regex_timeout_error, + regex_worker_error, ) from pybreeze.utils.exception.exceptions import RegexTesterException from pybreeze.utils.logging.logger import pybreeze_logger -# Cap on reported matches so a pathological pattern cannot flood the UI. +# Cap on reported matches, so a pattern that matches everywhere cannot flood +# the output. It bounds how many matches are reported, not how long one takes. _MAX_MATCHES = 1000 +# How long a pattern may run, in its own process, before it is stopped. +MATCH_TIMEOUT_SECONDS = 5.0 + # Human-facing flag names mapped to their ``re`` values. _FLAG_NAMES: dict[str, int] = { "IGNORECASE": re.IGNORECASE, @@ -111,3 +118,64 @@ def find_matches( if len(results) >= _MAX_MATCHES: break return results + + +def find_matches_bounded( + pattern: str, text: str, flag_names: list[str] | set[str] | None = None, + timeout_seconds: float = MATCH_TIMEOUT_SECONDS) -> list[MatchResult]: + """Like :func:`find_matches`, in a separate process stopped after *timeout_seconds*. + + Python's ``re`` cannot be interrupted once a match attempt starts, and a + pattern with nested repetition backtracks exponentially: ``(a+)+$`` against + 26 ``a``'s and a ``b`` takes seconds, and each further character doubles it. + Only a process can be stopped mid-match. The pattern is compiled here first, + so a malformed one is reported without starting anything. + + :param pattern: the regular expression + :param text: the sample text to search + :param flag_names: optional flag names to apply + :param timeout_seconds: how long the pattern may run, process start included + :return: the matches, up to an internal cap + :raises RegexTesterException: when the pattern is empty or invalid, or ran too long + """ + compile_pattern(pattern, flag_names) + context = multiprocessing.get_context("spawn") + receiving, sending = context.Pipe(duplex=False) + process = context.Process( + target=_matches_into_pipe, args=(sending, pattern, text, list(flag_names or [])), + daemon=True) + process.start() + sending.close() + try: + if not receiving.poll(timeout_seconds): + message = regex_timeout_error.format(seconds=timeout_seconds) + pybreeze_logger.error(message) + raise RegexTesterException(message) + kind, value = receiving.recv() + except (EOFError, OSError) as error: + message = regex_worker_error.format(detail=repr(error)) + pybreeze_logger.error(message) + raise RegexTesterException(message) from error + finally: + if process.is_alive(): + process.terminate() + process.join(timeout=_JOIN_SECONDS) + receiving.close() + if kind == "error": + raise RegexTesterException(value) + return value + + +# How long to wait for the worker process to go once it is done or stopped +_JOIN_SECONDS = 2.0 + + +def _matches_into_pipe(sending, pattern: str, text: str, flag_names: list[str]) -> None: + """Run :func:`find_matches` in the worker process and send back what happened.""" + try: + sending.send(("matches", find_matches(pattern, text, flag_names))) + except RegexTesterException as error: + sending.send(("error", str(error))) + finally: + sending.close() + diff --git a/test/test_utils/test_regex_gui.py b/test/test_utils/test_regex_gui.py index f638cfa7..8323c42e 100644 --- a/test/test_utils/test_regex_gui.py +++ b/test/test_utils/test_regex_gui.py @@ -18,6 +18,14 @@ def app(): return instance +def run(widget) -> None: + """Start the pattern and wait for its answer, as the tab would on the event loop.""" + widget.test() + thread = widget._match_thread + assert thread.wait(30_000), "the pattern did not come back" + QApplication.processEvents() + + @pytest.fixture() def widget(app): from pybreeze.pybreeze_ui.tools_gui.regex_gui import RegexGUI @@ -31,7 +39,7 @@ class TestRegexGUI: def test_finds_matches(self, widget): widget.pattern_edit.setText(r"\d+") widget.text_edit.setPlainText("a1 b22") - widget.test() + run(widget) output = widget.output_edit.toPlainText() assert "'1'" in output assert "'22'" in output @@ -39,21 +47,21 @@ def test_finds_matches(self, widget): def test_no_match_message(self, widget): widget.pattern_edit.setText(r"z") widget.text_edit.setPlainText("abc") - widget.test() + run(widget) assert widget.output_edit.toPlainText() != "" assert "'z'" not in widget.output_edit.toPlainText() def test_invalid_pattern_shows_error(self, widget): widget.pattern_edit.setText("(") widget.text_edit.setPlainText("abc") - widget.test() + run(widget) assert widget.output_edit.toPlainText() != "" def test_ignorecase_flag_used(self, widget): widget.pattern_edit.setText("abc") widget.text_edit.setPlainText("ABC") widget.flag_checkboxes["IGNORECASE"].setChecked(True) - widget.test() + run(widget) assert "'ABC'" in widget.output_edit.toPlainText() widget.flag_checkboxes["IGNORECASE"].setChecked(False) @@ -65,10 +73,50 @@ def test_selected_flags(self, widget): def test_copy_output(self, app, widget): widget.pattern_edit.setText(r"\d+") widget.text_edit.setPlainText("a1") - widget.test() + run(widget) widget.actions.copy() assert "'1'" in QApplication.clipboard().text() def test_build_matches_text_no_matches(self, app): from pybreeze.pybreeze_ui.tools_gui.regex_gui import build_matches_text assert build_matches_text([], "none") == "none" + + +class TestAPatternThatNeverFinishes: + def test_the_tab_does_not_wait_for_it(self, widget, monkeypatch): + import threading + + from pybreeze.pybreeze_ui.tools_gui import regex_gui + + answering = threading.Event() + + def slow(*_args): + answering.wait(10) + return [] + + monkeypatch.setattr(regex_gui, "find_matches_bounded", slow) + widget.pattern_edit.setText("a") + widget.text_edit.setPlainText("a") + + widget.test() + + assert widget._match_thread.isRunning(), "test() waited for the pattern" + assert not widget.test_button.isEnabled() + answering.set() + assert widget._match_thread.wait(10_000) + QApplication.processEvents() + assert widget.test_button.isEnabled() + + def test_catastrophic_backtracking_is_stopped_and_reported(self, widget, monkeypatch): + from pybreeze.utils.regex_tools import regex_tester + + # A 2 s deadline instead of the tab's 5 s, to keep the test short. The + # default is bound at definition, so it is the defaults that are patched. + monkeypatch.setattr( + regex_tester.find_matches_bounded, "__defaults__", (None, 2.0)) + widget.pattern_edit.setText("(a+)+$") + widget.text_edit.setPlainText("a" * 40 + "b") + + run(widget) + + assert "still running" in widget.output_edit.toPlainText() diff --git a/test/test_utils/test_regex_tester.py b/test/test_utils/test_regex_tester.py index 875a2654..13f23d7f 100644 --- a/test/test_utils/test_regex_tester.py +++ b/test/test_utils/test_regex_tester.py @@ -92,3 +92,35 @@ def test_match_cap(self): # More potential matches than the cap; result is capped, not unbounded. text = "a" * (regex_tester._MAX_MATCHES + 50) assert len(find_matches("a", text)) == regex_tester._MAX_MATCHES + + +class TestABoundedRun: + """find_matches_bounded runs the pattern in a process it can stop.""" + + def test_it_finds_what_find_matches_finds(self): + from pybreeze.utils.regex_tools.regex_tester import find_matches, find_matches_bounded + + assert find_matches_bounded(r"\d+", "a1 b22") == find_matches(r"\d+", "a1 b22") + + def test_a_malformed_pattern_is_reported_before_any_process(self, monkeypatch): + from pybreeze.utils.exception.exceptions import RegexTesterException + from pybreeze.utils.regex_tools import regex_tester + + monkeypatch.setattr( + regex_tester.multiprocessing, "get_context", + lambda *_a: pytest.fail("a process was started for a pattern that cannot compile")) + + with pytest.raises(RegexTesterException): + regex_tester.find_matches_bounded("(", "abc") + + def test_catastrophic_backtracking_is_stopped(self): + import time + + from pybreeze.utils.exception.exceptions import RegexTesterException + from pybreeze.utils.regex_tools.regex_tester import find_matches_bounded + + started = time.monotonic() + with pytest.raises(RegexTesterException, match="still running"): + find_matches_bounded("(a+)+$", "a" * 40 + "b", timeout_seconds=2.0) + + assert time.monotonic() - started < 10 From f3a176af7c92b729111c204ef05aa617b735183c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 06:26:59 +0800 Subject: [PATCH 052/312] Connect an SSH tab off the UI thread, asking about host keys on it --- architecture.md | 3 +- architecture_explore.md | 14 +- docs/updates/2026-09.md | 23 ++ docs/updates/README.md | 3 +- progress.md | 2 +- .../connect_gui/ssh/ssh_command_widget.py | 113 +++++--- .../connect_gui/ssh/ssh_connect_thread.py | 42 +++ .../connect_gui/ssh/ssh_file_viewer_widget.py | 53 +++- .../connect_gui/ssh/ssh_host_key_policy.py | 63 ++++- .../connect_gui/ssh/ssh_main_widget.py | 9 +- test/test_utils/test_ssh_reentrancy.py | 247 ++++++++++++++++-- 11 files changed, 489 insertions(+), 83 deletions(-) create mode 100644 pybreeze/pybreeze_ui/connect_gui/ssh/ssh_connect_thread.py diff --git a/architecture.md b/architecture.md index 467ac49d..0a44d254 100644 --- a/architecture.md +++ b/architecture.md @@ -195,7 +195,8 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() - Every outbound request to a user URL passes SSRF validation (`utils/network/url_validation.py`) with timeouts and size caps (§ Security › Network). - SSH uses the interactive host-key policy, never auto-add (§ Security › SSH). -- Work that waits on a network — an AI review request, a diagram's image downloads — runs on a +- Work that waits on a network — an AI review request, a diagram's image downloads, an SSH + connect or SFTP transfer — runs on a `QThread` and reaches the UI only through signals. A request panel that closes mid-request hands its thread to `thread_keeper.let_run_out()` (cut off from the panel, kept until it ends) rather than waiting for it on the UI thread; a running `QThread` must never be destroyed. diff --git a/architecture_explore.md b/architecture_explore.md index ea9b30e5..037c0144 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -290,15 +290,16 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,237 行) +### `ssh/`(1,433 行) | 檔案 | 職責 | |---|---| -| `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | +| `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | -| `ssh_command_widget.py` | 互動式 shell。`SSHReaderThread(QThread)` 輪詢 channel,ANSI escape 用 regex 剝除,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (672) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個,`closeEvent` 等它結束再關掉 SFTP 連線 | -| `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU) | +| `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel,ANSI escape 用 regex 剝除,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | +| `ssh_file_viewer_widget.py` (711) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;連線交給 `SshConnectThread`,成功後才列出根目錄;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個,`closeEvent` 等它結束再關掉 SFTP 連線 | +| `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等 | +| `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | ### `url/ai_code_review_gui.py` @@ -402,6 +403,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ├── daemon Thread: stderr readline ┘ │ ├── QThread: SSHReaderThread ──Signal──► terminal widget + ├── QThread: SshConnectThread ──Signal──► shell / file tree(host key 問題 BlockingQueued 回 UI) ├── QThread: SenderThread (CoT) ──Signal──► review UI ├── QThread: RequestThread(Skills)──Signal──► result UI └── QThread: JupyterLauncherThread──Signal──► QWebEngineView @@ -431,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 81 個 `test_*.py`、1226 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 81 個 `test_*.py`、1235 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 142e9f75..1c2ff770 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -444,3 +444,26 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1207 → 1212 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/utils/regex_tools/regex_tester.py`, `pybreeze/utils/exception/exception_tags.py`, `pybreeze/pybreeze_ui/tools_gui/regex_gui.py`, `pybreeze/__main__.py`, `exe/start_pybreeze.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/{test_regex_tester,test_regex_gui}.py`, `architecture.md` §3, `architecture_explore.md` §6, §18, `progress.md` (#15 removed). - **Open items**: none. + +## U-20260923-33 · 2026-09-23 · Connecting an SSH tab no longer freezes the IDE · #done #ssh + +- **What**: `progress.md` #10, its connect half. Both halves of the SSH tab called `SSHClient.connect()` in the Connect button's slot. Against a host that does not answer, that is the 10 s TCP timeout, then paramiko's banner (15 s) and auth (30 s) timeouts, and the tab connects twice (shell, then file tree), so the IDE could sit "Not Responding" for over a minute. The connect could not simply move to a thread: the host-key policy shows a modal box from inside `connect()`, on whatever thread is connecting, and a widget built off the UI thread aborts Qt. +- **Fix**: `HostKeyAsker` in `ssh_host_key_policy.py` is a `QObject` on the UI thread that shows the question. Asked from the UI thread it shows it directly; asked from another thread it goes over a `BlockingQueuedConnection`, so the connecting thread waits for the answer and the UI does not. `host_key_asker()` creates it on first use, and both SSH widgets call it when they are built so it exists on the UI thread before any connect. New `ssh_connect_thread.SshConnectThread` runs one connect and emits `connected` or `failed(message)`. The shell prepares on the UI thread (validation, key-file check, cleanup of the prior session, new client, policy) and connects on the thread. The shell opens on `connected` only if that client is still the widget's; a connect that finishes after a disconnect or a newer connect is closed. The file tree lists the root on `connected`. A second Connect while one is in flight is ignored. Closing a widget mid-connect hands the thread to `thread_keeper.let_run_out()` and closes whatever session the late connect leaves. This is hooked to `finished` *after* `let_run_out`, which disconnects everything on `finished`. +- **Status label**: `SSHMainWidget` reported both sessions when the button was clicked, which is now before either has connected. Each half now emits `state_changed` when it comes up, fails or disconnects, and the label is reported from that. +- **Tests**: `test_ssh_reentrancy.py` rewritten on real widgets (it built one with `__new__` and asserted a synchronous order). It has ten tests: + - the prior session is torn down before the new one; + - the connect returns before the host answers and runs on another thread, and the shell opens on the UI thread; + - a second click is ignored; + - a failure is reported and the client dropped; + - closing mid-connect closes the late session, for the shell and for the file tree; + - the file tree lists the root on the UI thread after its connect, and reports a failure; + - the asker answers directly on the UI thread, and a question from a plain worker thread is shown on the UI thread. +- **Also**: `_cleanup()`'s three debug logs use lazy `%r` instead of f-strings. +- **Result / numbers**: unit tests 1212 → 1221 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/{ssh_connect_thread (new),ssh_host_key_policy,ssh_command_widget,ssh_file_viewer_widget,ssh_main_widget}.py` + - `test/test_utils/test_ssh_reentrancy.py` + - `architecture.md` §7 + - `architecture_explore.md` §9, §16, §18 + - `progress.md` (#10 narrowed to listing) +- **Open items**: directory listing (`listdir_attr`) still runs on the UI thread, and nothing bounds it (#10). diff --git a/docs/updates/README.md b/docs/updates/README.md index 4dad491a..fabee3dc 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-33 | 2026-09-23 | Connecting an SSH tab no longer freezes the IDE | #done #ssh | [2026-09](2026-09.md) | | U-20260923-32 | 2026-09-23 | A runaway regex no longer freezes the IDE | #done #tools | [2026-09](2026-09.md) | | U-20260923-31 | 2026-09-23 | Importing a utility no longer imports the whole IDE | #done #refactor #performance | [2026-09](2026-09.md) | | U-20260923-30 | 2026-09-23 | Prompt files: other encodings, unsaved edits, half-written saves | #done #ai | [2026-09](2026-09.md) | @@ -113,4 +114,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 50 | +| [2026-09.md](2026-09.md) | 2026-09 | 51 | diff --git a/progress.md b/progress.md index c3df4975..431410d1 100644 --- a/progress.md +++ b/progress.md @@ -7,5 +7,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? -- **#10** SSH connecting and directory listing still run on the UI thread (`ssh_command_widget.py`, `ssh_file_viewer_widget.py`): `connect()` is bounded only by paramiko's own timeouts (banner 15 s, auth 30 s) and `listdir_attr()` by nothing at all, so an unresponsive host or a directory with tens of thousands of entries freezes the IDE. Transfers moved off it in U-20260923-19; moving `connect()` has to route the host-key prompt (`ssh_host_key_policy.apply_host_key_policy`, a modal box inside `SSHClient.connect`) back through a signal. +- **#10** SSH directory listing still runs on the UI thread (`ssh_file_viewer_widget.py` `populate_children`, from `load_root`, `on_item_expanded` and `action_refresh`): `listdir_attr()` has no timeout, so a directory with tens of thousands of entries or a stalled server freezes the IDE. Connecting moved off it in U-20260923-33, transfers in U-20260923-19. A listing that runs on a thread has to find its tree item again when it returns, because `load_root`, a refresh or a disconnect may have cleared the tree in the meantime. 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 35a41027..259c40a9 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -12,9 +12,13 @@ ) from je_editor import language_wrapper -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import apply_host_key_policy +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import CONNECT_ERRORS, SshConnectThread +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import ( + apply_host_key_policy, host_key_asker +) from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget +from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.logging.logger import pybreeze_logger ANSI_ESCAPE_PATTERN = re.compile( @@ -76,6 +80,9 @@ def stop(self): class SSHCommandWidget(QWidget): + # Emitted when the session comes up or goes down, for the tab's status label + state_changed = Signal() + def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: bool = True): super().__init__() self.word_dict = language_wrapper.language_word_dict @@ -88,6 +95,9 @@ def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: self.ssh_client: paramiko.SSHClient | None = None self.shell_channel: paramiko.Channel | None = None self.reader_thread: SSHReaderThread | None = None + # The connect in progress, if any / 正在進行的連線 + self._connecting: SshConnectThread | None = None + host_key_asker() # built here, on the UI thread, for a connect to ask through if self.add_login_widget: # 使用獨立的登入介面 @@ -157,38 +167,41 @@ def connect_ssh(self): "ssh_command_widget_dialog_message_input_error_host_user_required")) return - try: - # Tear down any prior session first: re-clicking Connect while already - # connected would otherwise leak the old SSH client and orphan its - # reader thread (which keeps appending to the terminal). - self._cleanup() - self.ssh_client = paramiko.SSHClient() - apply_host_key_policy(self.ssh_client, self) - pybreeze_logger.info("SSH connecting to %s:%s", host, port) - - if use_key: - if not self._authenticate_with_key(host, port, user, key_path, password): - return - else: - self.ssh_client.connect( - hostname=host, port=port, username=user, password=password, timeout=10 - ) - - self._start_shell(host, port, user) - except Exception as e: - self.login_widget.status_label.setText( - self.word_dict.get('ssh_command_widget_status_label_disconnected')) - self.append_text(f"{self.word_dict.get('ssh_command_widget_log_message_error')} {e}\n") - self._cleanup() - - def _authenticate_with_key(self, host: str, port: int, user: str, key_path: str, password: str) -> bool: - """Perform key-based auth. Returns True on success; False if the key file is missing.""" - if not os.path.exists(key_path): + if self._connecting is not None and self._connecting.isRunning(): + return + if use_key and not os.path.exists(key_path): QMessageBox.warning( self, self.word_dict.get("ssh_command_widget_dialog_title_key_error"), self.word_dict.get("ssh_command_widget_dialog_message_key_file_not_exist")) - return False + return + + # Tear down any prior session first: re-clicking Connect while already + # connected would otherwise leak the old SSH client and orphan its + # reader thread (which keeps appending to the terminal). + self._cleanup() + client = paramiko.SSHClient() + self.ssh_client = client + apply_host_key_policy(client, self) + pybreeze_logger.info("SSH connecting to %s:%s", host, port) + if use_key: + def connect() -> None: + self._connect_with_key(client, host, port, user, key_path, password) + else: + def connect() -> None: + client.connect( + hostname=host, port=port, username=user, password=password, timeout=10) + # The connect itself runs off the UI thread: an unreachable host used to + # hold the IDE for the connect, banner and auth timeouts together. + thread = SshConnectThread(connect) + thread.connected.connect(lambda: self._on_connected(client, host, port, user)) + thread.failed.connect(lambda message: self._on_connect_failed(client, message)) + self._connecting = thread + thread.start() + + def _connect_with_key(self, client: paramiko.SSHClient, host: str, port: int, user: str, + key_path: str, password: str) -> None: + """Key-based auth, on the connecting thread. Raises what the connect raises.""" try: pkey = load_private_key(key_path, password, context="SSH") if pkey is None: @@ -196,11 +209,32 @@ def _authenticate_with_key(self, host: str, port: int, user: str, key_path: str, self.word_dict.get( "ssh_command_widget_error_message_unsupported_private_key" )) - self.ssh_client.connect(hostname=host, port=port, username=user, pkey=pkey, timeout=10) - except Exception as e: + client.connect(hostname=host, port=port, username=user, pkey=pkey, timeout=10) + except CONNECT_ERRORS as e: raise RuntimeError( f"{self.word_dict.get('ssh_command_widget_error_message_key_auth_failed')} {e}") from e - return True + + def _on_connected(self, client: paramiko.SSHClient, host: str, port: int, user: str) -> None: + """Open the shell once the connect has succeeded. UI thread.""" + if client is not self.ssh_client: + client.close() # disconnected, or reconnected, while it was connecting + return + try: + self._start_shell(host, port, user) + except CONNECT_ERRORS as error: + self._on_connect_failed(client, str(error)) + return + self.state_changed.emit() + + def _on_connect_failed(self, client: paramiko.SSHClient, message: str) -> None: + """Say why the connect failed and drop the half-made session. UI thread.""" + if client is not self.ssh_client: + return + self.login_widget.status_label.setText( + self.word_dict.get('ssh_command_widget_status_label_disconnected')) + self.append_text(f"{self.word_dict.get('ssh_command_widget_log_message_error')} {message}\n") + self._cleanup() + self.state_changed.emit() def _start_shell(self, host: str, port: int, user: str) -> None: transport = self.ssh_client.get_transport() @@ -254,6 +288,7 @@ def disconnect_ssh(self): self._cleanup() self.login_widget.status_label.setText( self.word_dict.get('ssh_command_widget_status_label_disconnected')) + self.state_changed.emit() def is_connected(self) -> bool: """Whether a shell session is open here.""" @@ -266,6 +301,14 @@ def closeEvent(self, event) -> None: running QThread is destroyed, and a queued signal from one lands in a widget that is already gone. """ + if self._connecting is not None and self._connecting.isRunning(): + let_run_out(self._connecting, self._connecting.connected, self._connecting.failed) + # A connect that still succeeds after the tab has gone would leave its + # session open: close it whatever way the thread ends. (After + # let_run_out, which cuts off everything connected to ``finished``.) + connecting_client = self.ssh_client + if connecting_client is not None: + self._connecting.finished.connect(connecting_client.close) self._cleanup() super().closeEvent(event) @@ -278,19 +321,19 @@ def _cleanup(self): self.reader_thread.stop() self.reader_thread.wait(1000) except Exception as error: - pybreeze_logger.debug(f"SSH reader thread cleanup: {error}") + pybreeze_logger.debug("SSH reader thread cleanup: %r", error) self.reader_thread = None try: if self.shell_channel and not self.shell_channel.closed: self.shell_channel.close() except Exception as error: - pybreeze_logger.debug(f"SSH channel cleanup: {error}") + pybreeze_logger.debug("SSH channel cleanup: %r", error) self.shell_channel = None try: if self.ssh_client: self.ssh_client.close() except Exception as error: - pybreeze_logger.debug(f"SSH client cleanup: {error}") + pybreeze_logger.debug("SSH client cleanup: %r", error) self.ssh_client = None diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_connect_thread.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_connect_thread.py new file mode 100644 index 00000000..b896580b --- /dev/null +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_connect_thread.py @@ -0,0 +1,42 @@ +"""Run one blocking SSH connect off the UI thread. + +``SSHClient.connect()`` blocks for as long as the host takes: the TCP connect +(10 s here), then paramiko's banner (15 s) and auth (30 s) timeouts, and an +unreachable host used to freeze the IDE for all of it. The shell and the file +tree hand their connect to this thread and pick up on its signals. An unknown +host key is still asked about on the UI thread, through +``ssh_host_key_policy.HostKeyAsker``. +""" +from __future__ import annotations + +from collections.abc import Callable + +import paramiko +from PySide6.QtCore import QThread, Signal + +from pybreeze.utils.logging.logger import pybreeze_logger + +# What a connect can raise: paramiko's own errors (a rejected host key or +# failed auth included), the socket's, a key file that cannot be read, and an +# EOF from a server that hangs up during the handshake. +CONNECT_ERRORS = (paramiko.SSHException, OSError, ValueError, RuntimeError, EOFError) + + +class SshConnectThread(QThread): + """Call *connect* on this thread; report ``connected`` or ``failed(message)``.""" + + connected = Signal() + failed = Signal(str) + + def __init__(self, connect: Callable[[], None]) -> None: + super().__init__() + self._connect = connect + + def run(self) -> None: + try: + self._connect() + except CONNECT_ERRORS as error: + pybreeze_logger.info("SSH connect failed: %r", error) + self.failed.emit(str(error)) + else: + self.connected.emit() diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 52e1b426..c60df637 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -13,7 +13,11 @@ ) from je_editor import language_wrapper -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import apply_host_key_policy +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import CONNECT_ERRORS, SshConnectThread +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import ( + apply_host_key_policy, host_key_asker +) +from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget from pybreeze.utils.logging.logger import pybreeze_logger @@ -252,6 +256,9 @@ class SSHFileTreeManager(QWidget): QWidget:連線表單 + 樹狀檔案管理 + 右鍵選單。 """ + # Emitted when the session comes up or goes down, for the tab's status label + state_changed = Signal() + def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: bool = True): super().__init__() self.word_dict = language_wrapper.language_word_dict @@ -261,6 +268,9 @@ def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: self.add_login_widget = add_login_widget self.client = SFTPClientWrapper() + # The connect in progress, if any / 正在進行的連線 + self._connecting: SshConnectThread | None = None + host_key_asker() # built here, on the UI thread, for a connect to ask through # The transfer in flight, if any / 正在進行的傳輸 self._transfer: SftpTransferThread | None = None @@ -312,14 +322,36 @@ def _connect(self): self.word_dict.get("ssh_file_viewer_dialog_title_missing_input"), self.word_dict.get("ssh_file_viewer_dialog_message_missing_input")) return - try: + if self._connecting is not None and self._connecting.isRunning(): + return + + def connect() -> None: self.client.connect(host, port, user, pwd, use_key, key_path, parent_widget=self) + + # Off the UI thread, as for the shell: an unreachable host used to hold + # the IDE for every timeout the connect has. + thread = SshConnectThread(connect) + thread.connected.connect(self._on_connected) + thread.failed.connect(self._on_connect_failed) + self._connecting = thread + thread.start() + + def _on_connected(self) -> None: + """List the root once the session is up. UI thread.""" + try: self.load_root("/") - except Exception as e: - QMessageBox.critical( - self, - self.word_dict.get("ssh_file_viewer_dialog_title_connection_failed"), - f"{self.word_dict.get('ssh_file_viewer_dialog_message_connection_failed')}: {e}") + except CONNECT_ERRORS as error: + self._on_connect_failed(str(error)) + return + self.state_changed.emit() + + def _on_connect_failed(self, message: str) -> None: + """Say why the connect failed. UI thread.""" + QMessageBox.critical( + self, + self.word_dict.get("ssh_file_viewer_dialog_title_connection_failed"), + f"{self.word_dict.get('ssh_file_viewer_dialog_message_connection_failed')}: {message}") + self.state_changed.emit() def closeEvent(self, event) -> None: """Close the SFTP session with the widget. @@ -334,6 +366,12 @@ def closeEvent(self, event) -> None: if transfer is not None and transfer.isRunning(): transfer.blockSignals(True) transfer.wait() + if self._connecting is not None and self._connecting.isRunning(): + let_run_out(self._connecting, self._connecting.connected, self._connecting.failed) + # A connect that still succeeds after the widget has gone would leave + # its session open: close it whatever way the thread ends. (After + # let_run_out, which cuts off everything connected to ``finished``.) + self._connecting.finished.connect(self.client.close) self.client.close() super().closeEvent(event) @@ -344,6 +382,7 @@ def _disconnect(self): """ self.client.close() self.tree.clear() + self.state_changed.emit() def load_root(self, path: str = "/"): """ diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py index 84936c2c..f7b4e070 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py @@ -15,6 +15,7 @@ import paramiko from je_editor import language_wrapper +from PySide6.QtCore import QObject, Qt, QThread, Signal, Slot from PySide6.QtWidgets import QMessageBox from pybreeze.utils.app_dirs import pybreeze_data_dir @@ -70,15 +71,7 @@ def missing_host_key( host=hostname, key_type=key_type, fingerprint=fingerprint ) - box = QMessageBox(self._parent) - box.setIcon(QMessageBox.Icon.Warning) - box.setWindowTitle(title) - box.setText(message) - box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) - box.setDefaultButton(QMessageBox.StandardButton.No) - response = box.exec() - - if response != QMessageBox.StandardButton.Yes: + if not host_key_asker().ask(self._parent, title, message): pybreeze_logger.warning( "SSH host key for %s rejected by user (%s)", hostname, fingerprint ) @@ -98,6 +91,58 @@ def missing_host_key( ) +class HostKeyAsker(QObject): + """Asks the user about an unknown host key, on the UI thread, from any thread. + + ``SSHClient.connect()`` calls the host-key policy from whichever thread is + connecting. To connect off the UI thread and still ask, the question travels + to this object -- which lives on the UI thread -- over a blocking queued + signal: the connecting thread waits for the answer, the UI does not. + """ + + _asked = Signal(object, str, str) + + def __init__(self) -> None: + super().__init__() + self._answer = False + self._asked.connect(self._show, Qt.ConnectionType.BlockingQueuedConnection) + + def ask(self, parent: QWidget | None, title: str, message: str) -> bool: + """Show the question and return whether the user trusts the key.""" + if QThread.currentThread() is self.thread(): + # Already on the UI thread: a blocking queued signal to ourselves + # would wait for itself forever. + self._show(parent, title, message) + else: + self._asked.emit(parent, title, message) + return self._answer + + @Slot(object, str, str) + def _show(self, parent: QWidget | None, title: str, message: str) -> None: + box = QMessageBox(parent) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle(title) + box.setText(message) + box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + box.setDefaultButton(QMessageBox.StandardButton.No) + self._answer = box.exec() == QMessageBox.StandardButton.Yes + + +_ASKER: HostKeyAsker | None = None + + +def host_key_asker() -> HostKeyAsker: + """The asker, created on first use -- which has to be on the UI thread. + + The SSH widgets call this when they are built, so a connection started later + on a worker thread finds it already living where dialogs can be shown. + """ + global _ASKER + if _ASKER is None: + _ASKER = HostKeyAsker() + return _ASKER + + def apply_host_key_policy(client: paramiko.SSHClient, parent: QWidget | None) -> None: """Load known hosts and attach the interactive TOFU policy to *client*.""" client.load_system_host_keys() diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py index c249a835..4a9f776b 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py @@ -45,10 +45,11 @@ def __init__(self, parent=None): self.setLayout(main_layout) # One Connect button, two sessions: the shell and the file tree each open - # their own, and each used to set the shared label, so whichever finished - # last decided what it said. This runs after both and reports the pair. - self.login_widget.connect_btn.clicked.connect(self.report_connection_state) - self.login_widget.disconnect_btn.clicked.connect(self.report_connection_state) + # their own, on a thread of their own. Setting the shared label from each + # would let whichever finished last decide what it says, so the pair is + # reported each time either one comes up or goes down. + self.command_widget.state_changed.connect(self.report_connection_state) + self.file_tree.state_changed.connect(self.report_connection_state) def report_connection_state(self) -> None: """Put what actually happened to both sessions in the shared status label.""" diff --git a/test/test_utils/test_ssh_reentrancy.py b/test/test_utils/test_ssh_reentrancy.py index e8d5bf08..f68cce3c 100644 --- a/test/test_utils/test_ssh_reentrancy.py +++ b/test/test_utils/test_ssh_reentrancy.py @@ -1,40 +1,249 @@ -"""Re-clicking Connect must tear down the prior SSH session, not leak it.""" +"""Connecting an SSH tab: off the UI thread, and never leaking the prior session.""" from __future__ import annotations import os os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -from unittest.mock import MagicMock +import threading +import time -from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_command_widget as mod +import pytest +from PySide6.QtCore import QThread +from PySide6.QtWidgets import QApplication +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_command_widget as shell_mod +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as tree_mod +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_host_key_policy as host_key_mod +from pybreeze.pybreeze_ui.thread_keeper import is_kept -def _widget_with_valid_inputs(): - widget = mod.SSHCommandWidget.__new__(mod.SSHCommandWidget) - widget.word_dict = {} - login = MagicMock() - login.host_edit.text.return_value = "host" - login.user_edit.text.return_value = "user" - login.port_spin.value.return_value = 22 - login.use_key_check.isChecked.return_value = False - login.key_edit.text.return_value = "" - login.pass_edit.text.return_value = "pw" - widget.login_widget = login +WAIT_SECONDS = 5 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _wait_for(condition) -> None: + deadline = time.monotonic() + WAIT_SECONDS + while not condition(): + assert time.monotonic() < deadline, "timed out" + QApplication.processEvents() + time.sleep(0.01) + + +class FakeClient: + """A paramiko client whose connect waits for *release*, then succeeds or raises *error*.""" + + def __init__(self, release: threading.Event | None = None, error: Exception | None = None) -> None: + self.release = release + self.error = error + self.closed = False + self.connect_thread: QThread | None = None + + def connect(self, **_kwargs) -> None: + self.connect_thread = QThread.currentThread() + if self.release is not None: + self.release.wait(WAIT_SECONDS) + if self.error is not None: + raise self.error + + def close(self) -> None: + self.closed = True + + +def _shell(monkeypatch, client: FakeClient): + widget = shell_mod.SSHCommandWidget() + widget.login_widget.host_edit.setText("host") + widget.login_widget.user_edit.setText("user") + widget.login_widget.pass_edit.setText("pw") + monkeypatch.setattr(shell_mod.paramiko, "SSHClient", lambda: client) + monkeypatch.setattr(shell_mod, "apply_host_key_policy", lambda _client, _parent: None) return widget -class TestConnectReentrancy: - def test_cleanup_runs_before_new_session(self, monkeypatch): - widget = _widget_with_valid_inputs() +class TestShellConnect: + def test_cleanup_runs_before_the_new_session(self, app, monkeypatch): + widget = _shell(monkeypatch, FakeClient()) order = [] widget._cleanup = lambda: order.append("cleanup") widget._start_shell = lambda *args: order.append("start_shell") - monkeypatch.setattr(mod.paramiko, "SSHClient", lambda: MagicMock()) - monkeypatch.setattr(mod, "apply_host_key_policy", lambda client, parent: None) widget.connect_ssh() + _wait_for(lambda: "start_shell" in order) # The prior session is torn down before a new client/shell is created, # so re-connecting cannot leak the old client or orphan its reader. assert order == ["cleanup", "start_shell"] + + def test_the_connect_does_not_hold_the_ui_thread(self, app, monkeypatch): + release = threading.Event() + client = FakeClient(release) + widget = _shell(monkeypatch, client) + started = [] + widget._start_shell = lambda *args: started.append(QThread.currentThread()) + states = [] + widget.state_changed.connect(lambda: states.append("changed")) + + widget.connect_ssh() + assert not started # returned while the host is still answering + release.set() + _wait_for(lambda: started and states) + + assert client.connect_thread is not app.thread() + assert started == [app.thread()] # the shell is opened on the UI thread + + def test_a_second_click_while_connecting_is_ignored(self, app, monkeypatch): + release = threading.Event() + widget = _shell(monkeypatch, FakeClient(release)) + started = [] + widget._start_shell = lambda *args: started.append(True) + + widget.connect_ssh() + first = widget._connecting + widget.connect_ssh() + release.set() + _wait_for(lambda: started) + + assert widget._connecting is first + assert started == [True] + + def test_a_failed_connect_is_reported_and_dropped(self, app, monkeypatch): + client = FakeClient(error=OSError("unreachable")) + widget = _shell(monkeypatch, client) + states = [] + widget.state_changed.connect(lambda: states.append("changed")) + + widget.connect_ssh() + _wait_for(lambda: states) + + assert "unreachable" in widget.terminal.toPlainText() + assert client.closed + assert widget.ssh_client is None + + def test_closing_while_connecting_closes_the_late_session(self, app, monkeypatch): + release = threading.Event() + client = FakeClient(release) + widget = _shell(monkeypatch, client) + widget._start_shell = lambda *args: pytest.fail("shell opened after close") + + widget.connect_ssh() + thread = widget._connecting + widget.close() + assert is_kept(thread) + client.closed = False # _cleanup closed it once; the late connect must close it again + release.set() + _wait_for(lambda: client.closed and not is_kept(thread)) + + +class FakeManager: + """The SFTP wrapper, with a connect that waits for *release*.""" + + def __init__(self, release: threading.Event, error: Exception | None = None) -> None: + self.release = release + self.error = error + self.connect_thread: QThread | None = None + self.closes = 0 + + def connect(self, *_args, **_kwargs) -> None: + self.connect_thread = QThread.currentThread() + self.release.wait(WAIT_SECONDS) + if self.error is not None: + raise self.error + + def close(self) -> None: + self.closes += 1 + + @property + def connected(self) -> bool: + return False + + +def _tree(manager: FakeManager): + widget = tree_mod.SSHFileTreeManager() + widget.login_widget.host_edit.setText("host") + widget.login_widget.user_edit.setText("user") + widget.client = manager + return widget + + +class TestFileTreeConnect: + def test_the_root_is_listed_on_the_ui_thread_after_the_connect(self, app): + release = threading.Event() + manager = FakeManager(release) + widget = _tree(manager) + listed = [] + widget.load_root = lambda path: listed.append((path, QThread.currentThread())) + + widget._connect() + assert not listed + release.set() + _wait_for(lambda: listed) + + assert manager.connect_thread is not app.thread() + assert listed == [("/", app.thread())] + widget.close() + + def test_a_failed_connect_is_reported(self, app, monkeypatch): + release = threading.Event() + release.set() + widget = _tree(FakeManager(release, OSError("refused"))) + shown = [] + monkeypatch.setattr(tree_mod.QMessageBox, "critical", lambda *args: shown.append(args[2])) + + widget._connect() + _wait_for(lambda: shown) + + assert "refused" in shown[0] + widget.close() + + def test_closing_while_connecting_closes_the_late_session(self, app): + release = threading.Event() + manager = FakeManager(release) + widget = _tree(manager) + widget.load_root = lambda path: pytest.fail("listed after close") + + widget._connect() + thread = widget._connecting + widget.close() + closes_at_close = manager.closes + release.set() + _wait_for(lambda: not is_kept(thread)) + + assert manager.closes > closes_at_close + + +class TestHostKeyAsker: + """The unknown-host question is shown on the UI thread whoever asks it.""" + + def _asker(self, monkeypatch, answer): + shown_on = [] + + def exec_(_box): + shown_on.append(QThread.currentThread()) + return answer + + monkeypatch.setattr(host_key_mod.QMessageBox, "exec", exec_) + return host_key_mod.HostKeyAsker(), shown_on + + def test_asked_on_the_ui_thread_it_answers_directly(self, app, monkeypatch): + asker, shown_on = self._asker(monkeypatch, host_key_mod.QMessageBox.StandardButton.Yes) + + assert asker.ask(None, "title", "message") is True + assert shown_on == [app.thread()] + + def test_asked_from_a_connecting_thread_it_waits_for_the_ui(self, app, monkeypatch): + asker, shown_on = self._asker(monkeypatch, host_key_mod.QMessageBox.StandardButton.No) + answers = [] + worker = threading.Thread(target=lambda: answers.append(asker.ask(None, "title", "message"))) + + worker.start() + _wait_for(lambda: answers) + worker.join(WAIT_SECONDS) + + assert answers == [False] + assert shown_on == [app.thread()] From a99669803f7b7fb13614063c1c74525055066562 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 06:47:19 +0800 Subject: [PATCH 053/312] List remote directories off the UI thread, once per load --- architecture.md | 2 +- architecture_explore.md | 7 +- docs/updates/2026-09.md | 22 +++ docs/updates/README.md | 3 +- progress.md | 1 - .../extend_multi_language/extend_english.py | 1 + .../extend_traditional_chinese.py | 1 + .../connect_gui/ssh/ssh_file_viewer_widget.py | 128 ++++++++++++--- test/test_utils/test_sftp_listing.py | 150 ++++++++++++++++++ test/test_utils/test_sftp_remote_path.py | 4 +- 10 files changed, 286 insertions(+), 33 deletions(-) create mode 100644 test/test_utils/test_sftp_listing.py diff --git a/architecture.md b/architecture.md index 0a44d254..f56d4bcf 100644 --- a/architecture.md +++ b/architecture.md @@ -196,7 +196,7 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() with timeouts and size caps (§ Security › Network). - SSH uses the interactive host-key policy, never auto-add (§ Security › SSH). - Work that waits on a network — an AI review request, a diagram's image downloads, an SSH - connect or SFTP transfer — runs on a + connect, an SFTP listing or transfer — runs on a `QThread` and reaches the UI only through signals. A request panel that closes mid-request hands its thread to `thread_keeper.let_run_out()` (cut off from the panel, kept until it ends) rather than waiting for it on the UI thread; a running `QThread` must never be destroyed. diff --git a/architecture_explore.md b/architecture_explore.md index 037c0144..7153e74f 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -290,14 +290,14 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,433 行) +### `ssh/`(1,510 行) | 檔案 | 職責 | |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel,ANSI escape 用 regex 剝除,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (711) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;連線交給 `SshConnectThread`,成功後才列出根目錄;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個,`closeEvent` 等它結束再關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (789) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;連線交給 `SshConnectThread`,成功後才列出根目錄;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個,`closeEvent` 等它結束再關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | @@ -404,6 +404,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 │ ├── QThread: SSHReaderThread ──Signal──► terminal widget ├── QThread: SshConnectThread ──Signal──► shell / file tree(host key 問題 BlockingQueued 回 UI) + ├── QThread: SftpListThread / SftpTransferThread ──Signal──► file tree ├── QThread: SenderThread (CoT) ──Signal──► review UI ├── QThread: RequestThread(Skills)──Signal──► result UI └── QThread: JupyterLauncherThread──Signal──► QWebEngineView @@ -433,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 81 個 `test_*.py`、1235 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 82 個 `test_*.py`、1240 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 1c2ff770..f3f1e4fd 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -467,3 +467,25 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §9, §16, §18 - `progress.md` (#10 narrowed to listing) - **Open items**: directory listing (`listdir_attr`) still runs on the UI thread, and nothing bounds it (#10). + +## U-20260923-34 · 2026-09-23 · Remote directories are listed without freezing the IDE · #done #ssh + +- **What**: `progress.md` #10, its listing half (the connect moved in U-20260923-33). `populate_children()` called `listdir_attr()` in the UI thread's slot. It has no timeout and returns every entry at once, so expanding a directory of tens of thousands of files, or any directory on a server that had stalled, held the IDE until the answer came. +- **Fix**: each listing, whether of the root, an expanded directory or a refresh, runs on a new `SftpListThread`. The thread sorts the entries there with `sort_entries()`, now a module function. The item shows "Loading…" until the rows arrive. They are filled in only if the tree has not been cleared since (`_tree_generation`, bumped by `_clear_tree()` on load and disconnect, because cleared items are deleted) and the item is still waiting for this listing: the serial under `LISTING_ROLE` lets a refresh started meantime supersede the older listing. A failure is reported on the item it was for, and a stale one is dropped. Closing the widget hands the listings still running to `thread_keeper.let_run_out()`. `list_dir()` takes the SFTP client into a local variable, so a close from the UI thread between the check and the call raises "not connected" instead of `AttributeError`. +- **Also fixed**: `load_root()` listed the root twice. `setExpanded(True)` on the root, which still had its placeholder, fired `itemExpanded`, and `on_item_expanded` listed it, then `load_root` listed it again. Neither cleared the other's rows, so every root entry appeared twice. This was found because the new code started two listings. The root is now listed once, before it is expanded. +- **Tests**: new `test_sftp_listing.py` has five tests: + - the root fills from another thread and shows "Loading…" first; + - a listing that returns after a disconnect is dropped; + - a refresh supersedes the listing before it, even when the older one returns last; + - a failed listing is reported; + - closing mid-listing lets it run out. + `test_sftp_remote_path.py` imports `sort_entries` directly. +- **Result / numbers**: unit tests 1221 → 1226 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py` + - `test/test_utils/{test_sftp_listing (new),test_sftp_remote_path}.py` + - `architecture.md` §7 + - `architecture_explore.md` §9, §16, §18 + - `progress.md` (#10 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index fabee3dc..96bbdee9 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-34 | 2026-09-23 | Remote directories are listed without freezing the IDE | #done #ssh | [2026-09](2026-09.md) | | U-20260923-33 | 2026-09-23 | Connecting an SSH tab no longer freezes the IDE | #done #ssh | [2026-09](2026-09.md) | | U-20260923-32 | 2026-09-23 | A runaway regex no longer freezes the IDE | #done #tools | [2026-09](2026-09.md) | | U-20260923-31 | 2026-09-23 | Importing a utility no longer imports the whole IDE | #done #refactor #performance | [2026-09](2026-09.md) | @@ -114,4 +115,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 51 | +| [2026-09.md](2026-09.md) | 2026-09 | 52 | diff --git a/progress.md b/progress.md index 431410d1..2b163d8e 100644 --- a/progress.md +++ b/progress.md @@ -7,5 +7,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? -- **#10** SSH directory listing still runs on the UI thread (`ssh_file_viewer_widget.py` `populate_children`, from `load_root`, `on_item_expanded` and `action_refresh`): `listdir_attr()` has no timeout, so a directory with tens of thousands of entries or a stalled server freezes the IDE. Connecting moved off it in U-20260923-33, transfers in U-20260923-19. A listing that runs on a thread has to find its tree item again when it returns, because `load_root`, a refresh or a disconnect may have cleared the tree in the meantime. diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 60be6e51..00689a4b 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -175,6 +175,7 @@ # SSH File Viewer GUI "ssh_file_viewer_dialog_title_list_error": "List error", "ssh_file_viewer_dialog_message_list_failed": "Failed to list", + "ssh_file_viewer_loading": "Loading…", "ssh_file_viewer_dialog_title_operation_failed": "Operation failed", "ssh_file_viewer_dialog_message_operation_failed": "Error", "ssh_file_viewer_context_menu_action_refresh": "Refresh", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index c6fa1bc7..08a02389 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -175,6 +175,7 @@ # SSH File Viewer GUI "ssh_file_viewer_dialog_title_list_error": "列出錯誤", "ssh_file_viewer_dialog_message_list_failed": "無法列出", + "ssh_file_viewer_loading": "載入中…", "ssh_file_viewer_dialog_title_operation_failed": "操作失敗", "ssh_file_viewer_dialog_message_operation_failed": "錯誤", "ssh_file_viewer_context_menu_action_refresh": "重新整理", diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index c60df637..f7ea72c7 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -57,6 +57,22 @@ def natural_key(name: str) -> list: ] +# Item data: the serial of the listing a directory item is waiting for +LISTING_ROLE = Qt.ItemDataRole.UserRole + + +def sort_entries(entries) -> list: + """Return ``[(name, entry)]`` with directories first, each in natural order.""" + dirs: list = [] + files: list = [] + for entry in entries: + bucket = dirs if stat.S_ISDIR(entry.st_mode) else files + bucket.append((entry.filename, entry)) + dirs.sort(key=lambda item: natural_key(item[0])) + files.sort(key=lambda item: natural_key(item[0])) + return dirs + files + + def remote_join(directory: str, name: str) -> str: """Join *name* under a remote POSIX *directory* into an absolute path. @@ -154,7 +170,10 @@ def list_dir(self, path: str): 列出目錄項目(含屬性)。 """ self._require_connection() - return self._sftp.listdir_attr(path) + sftp = self._sftp + if sftp is None: # closed from the UI thread since the check + self._require_connection() + return sftp.listdir_attr(path) def is_dir(self, path: str) -> bool: """ @@ -250,6 +269,31 @@ def run(self) -> None: self.done.emit(self._local_path if self._downloading else self._remote_path) +class SftpListThread(QThread): + """List one remote directory off the UI thread, sorted for the tree. + + ``listdir_attr()`` has no timeout of its own and returns every entry at + once, so a stalled server or a directory of tens of thousands of files + used to hold the IDE until it answered. + """ + + listed = Signal(object) # [(name, SFTPAttributes)], directories first + failed = Signal(str) + + def __init__(self, client: "SFTPClientWrapper", path: str) -> None: + super().__init__() + self._client = client + self._path = path + + def run(self) -> None: + try: + entries = self._client.list_dir(self._path) + except (OSError, RuntimeError, EOFError, paramiko.SSHException) as error: + self.failed.emit(str(error)) + else: + self.listed.emit(sort_entries(entries)) + + class SSHFileTreeManager(QWidget): """ QWidget: connection form + tree + context menu. @@ -273,6 +317,12 @@ def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: host_key_asker() # built here, on the UI thread, for a connect to ask through # The transfer in flight, if any / 正在進行的傳輸 self._transfer: SftpTransferThread | None = None + # Listings in flight / 正在進行的目錄列出 + self._listings: set[SftpListThread] = set() + self._listing_serial = 0 + # Bumped whenever the tree is cleared: a listing started before then + # returns to items that no longer exist. + self._tree_generation = 0 if self.add_login_widget: # 使用獨立的登入介面 @@ -366,6 +416,10 @@ def closeEvent(self, event) -> None: if transfer is not None and transfer.isRunning(): transfer.blockSignals(True) transfer.wait() + for listing in list(self._listings): + if listing.isRunning(): + let_run_out(listing, listing.listed, listing.failed) + self._listings.clear() if self._connecting is not None and self._connecting.isRunning(): let_run_out(self._connecting, self._connecting.connected, self._connecting.failed) # A connect that still succeeds after the widget has gone would leave @@ -381,7 +435,7 @@ def _disconnect(self): 斷線 SSH。 """ self.client.close() - self.tree.clear() + self._clear_tree() self.state_changed.emit() def load_root(self, path: str = "/"): @@ -389,13 +443,18 @@ def load_root(self, path: str = "/"): Clear and load root items. 清空並載入根項目。 """ - self.tree.clear() + self._clear_tree() root_item = self.make_item("/", "dir", 0, "/") - # Add a placeholder child to show expandable icon - self.add_placeholder(root_item) self.tree.addTopLevelItem(root_item) - root_item.setExpanded(True) + # Listed once, here. Expanding an item that still had its placeholder + # would list it a second time through on_item_expanded. self.populate_children(root_item) + root_item.setExpanded(True) + + def _clear_tree(self) -> None: + """Empty the tree; listings still running will find nothing to fill.""" + self._tree_generation += 1 + self.tree.clear() def make_item(self, name: str, typ: str, size: int, full_path: str) -> QTreeWidgetItem: """ @@ -450,28 +509,47 @@ def populate_children(self, parent_item: QTreeWidgetItem): if not self.client.connected: return path = parent_item.text(3) - try: - entries = self.client.list_dir(path) - except Exception as ex: - QMessageBox.critical( - self, - self.word_dict.get("ssh_file_viewer_dialog_title_list_error"), - f"{self.word_dict.get('ssh_file_viewer_dialog_message_list_failed')} '{path}': {ex}") + # The rows arrive from a thread. Until then the item shows it is loading, + # and it remembers which listing it is waiting for: a refresh started + # meanwhile supersedes this one. + self._listing_serial += 1 + serial = self._listing_serial + parent_item.setData(0, LISTING_ROLE, serial) + parent_item.addChild(QTreeWidgetItem([self.word_dict.get("ssh_file_viewer_loading"), "", "", ""])) + generation = self._tree_generation + listing = SftpListThread(self.client, path) + listing.listed.connect( + lambda rows: self._listing_done(parent_item, generation, serial, rows)) + listing.failed.connect( + lambda message: self._listing_failed(parent_item, generation, serial, message)) + listing.finished.connect(lambda: self._listings.discard(listing)) + self._listings.add(listing) + listing.start() + + def _is_waiting_for(self, item: QTreeWidgetItem, generation: int, serial: int) -> bool: + """Whether *item* still exists and is waiting for listing *serial*. UI thread.""" + return generation == self._tree_generation and item.data(0, LISTING_ROLE) == serial + + def _listing_done(self, parent_item: QTreeWidgetItem, generation: int, serial: int, rows) -> None: + """Fill *parent_item* with its listing, unless it has moved on. UI thread.""" + if not self._is_waiting_for(parent_item, generation, serial): return - for name, entry in self._sort_entries(entries): + parent_item.takeChildren() + path = parent_item.text(3) + for name, entry in rows: self._add_entry_row(parent_item, path, name, entry) - @staticmethod - def _sort_entries(entries): - """Return ``[(name, entry)]`` with directories first, each in natural order.""" - dirs: list = [] - files: list = [] - for entry in entries: - bucket = dirs if stat.S_ISDIR(entry.st_mode) else files - bucket.append((entry.filename, entry)) - dirs.sort(key=lambda item: natural_key(item[0])) - files.sort(key=lambda item: natural_key(item[0])) - return dirs + files + def _listing_failed(self, parent_item: QTreeWidgetItem, generation: int, serial: int, + message: str) -> None: + """Say why *parent_item* could not be listed, unless it has moved on. UI thread.""" + if not self._is_waiting_for(parent_item, generation, serial): + return + parent_item.takeChildren() + QMessageBox.critical( + self, + self.word_dict.get("ssh_file_viewer_dialog_title_list_error"), + f"{self.word_dict.get('ssh_file_viewer_dialog_message_list_failed')} " + f"'{parent_item.text(3)}': {message}") def _add_entry_row(self, parent_item: QTreeWidgetItem, path: str, name: str, entry) -> None: full_path = remote_join(path, name) diff --git a/test/test_utils/test_sftp_listing.py b/test/test_utils/test_sftp_listing.py new file mode 100644 index 00000000..7a14339b --- /dev/null +++ b/test/test_utils/test_sftp_listing.py @@ -0,0 +1,150 @@ +"""Listing a remote directory runs off the UI thread and lands only where it is still wanted.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import stat +import threading +import time +from types import SimpleNamespace + +import pytest +from PySide6.QtCore import QThread +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as tree_mod +from pybreeze.pybreeze_ui.thread_keeper import is_kept + +WAIT_SECONDS = 5 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _wait_for(condition) -> None: + deadline = time.monotonic() + WAIT_SECONDS + while not condition(): + assert time.monotonic() < deadline, "timed out" + QApplication.processEvents() + time.sleep(0.01) + + +def _entry(name: str, directory: bool = False): + mode = stat.S_IFDIR if directory else stat.S_IFREG + return SimpleNamespace(filename=name, st_mode=mode, st_size=10) + + +class FakeClient: + """An open session whose listings wait for their path's gate to open.""" + + connected = True + + def __init__(self, listings: dict[str, list]) -> None: + self.listings = listings + self.gates: dict[str, threading.Event] = {} + self.listed_on: list[QThread] = [] + + def gate(self, path: str) -> threading.Event: + return self.gates.setdefault(path, threading.Event()) + + def list_dir(self, path: str): + self.listed_on.append(QThread.currentThread()) + self.gate(path).wait(WAIT_SECONDS) + result = self.listings[path] + if isinstance(result, Exception): + raise result + return result + + def close(self) -> None: + """Nothing to close.""" + + +def _tree(client: FakeClient): + widget = tree_mod.SSHFileTreeManager() + widget.client = client + return widget + + +def _names(item) -> list[str]: + return [item.child(i).text(0) for i in range(item.childCount())] + + +def _idle(widget) -> bool: + return not widget._listings + + +class TestListing: + def test_the_root_fills_from_a_thread(self, app): + client = FakeClient({"/": [_entry("b.txt"), _entry("etc", directory=True), _entry("a.txt")]}) + widget = _tree(client) + + widget.load_root("/") + root = widget.tree.topLevelItem(0) + assert _names(root) == [widget.word_dict.get("ssh_file_viewer_loading")] + client.gate("/").set() + _wait_for(lambda: _idle(widget) and _names(root) == ["etc", "a.txt", "b.txt"]) + + assert client.listed_on and client.listed_on[0] is not app.thread() + widget.close() + + def test_a_listing_that_returns_after_the_tree_is_cleared_is_dropped(self, app): + client = FakeClient({"/": [_entry("old.txt")]}) + widget = _tree(client) + + widget.load_root("/") + widget._disconnect() + client.gate("/").set() + _wait_for(lambda: _idle(widget)) + + assert widget.tree.topLevelItemCount() == 0 + widget.close() + + def test_a_refresh_supersedes_the_listing_before_it(self, app): + client = FakeClient({"/": [_entry("stale.txt")]}) + widget = _tree(client) + widget.load_root("/") + root = widget.tree.topLevelItem(0) + first_gate = client.gate("/") + + client.gates["/"] = threading.Event() # the refresh waits on a gate of its own + client.listings["/"] = [_entry("fresh.txt")] + widget.action_refresh(root) + client.gates["/"].set() + _wait_for(lambda: _names(root) == ["fresh.txt"]) + first_gate.set() + _wait_for(lambda: _idle(widget)) + + assert _names(root) == ["fresh.txt"] + widget.close() + + def test_a_failed_listing_is_reported(self, app, monkeypatch): + client = FakeClient({"/": OSError("permission denied")}) + client.gate("/").set() + widget = _tree(client) + shown = [] + monkeypatch.setattr(tree_mod.QMessageBox, "critical", lambda *args: shown.append(args[2])) + + widget.load_root("/") + _wait_for(lambda: shown and _idle(widget)) + + assert "permission denied" in shown[0] + assert _names(widget.tree.topLevelItem(0)) == [] + widget.close() + + def test_closing_mid_listing_lets_it_run_out(self, app): + client = FakeClient({"/": [_entry("a.txt")]}) + widget = _tree(client) + + widget.load_root("/") + (listing,) = widget._listings + widget.close() + assert is_kept(listing) + client.gate("/").set() + _wait_for(lambda: not is_kept(listing)) diff --git a/test/test_utils/test_sftp_remote_path.py b/test/test_utils/test_sftp_remote_path.py index ee34d681..9079528b 100644 --- a/test/test_utils/test_sftp_remote_path.py +++ b/test/test_utils/test_sftp_remote_path.py @@ -7,7 +7,7 @@ from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as sftp_mod from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import ( - SSHFileTreeManager, + sort_entries, SSH_KEEPALIVE_SECONDS, SFTPClientWrapper, format_size, @@ -82,7 +82,7 @@ def test_directories_first_then_natural_order(self): _Entry("file2.txt", is_dir=False), _Entry("alpha_dir", is_dir=True), ] - ordered = [name for name, _ in SSHFileTreeManager._sort_entries(entries)] + ordered = [name for name, _ in sort_entries(entries)] # directories first (natural/case-insensitive), then files (natural) assert ordered == ["alpha_dir", "zeta_dir", "file2.txt", "file10.txt"] From 1b3834807ab7a23aa61095a52aa27fd24281e22c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 06:52:38 +0800 Subject: [PATCH 054/312] Fetch an image added by URL off the UI thread --- architecture_explore.md | 8 +- docs/updates/2026-09.md | 11 ++ docs/updates/README.md | 3 +- .../diagram_editor/diagram_editor_widget.py | 47 ++++--- .../diagram_editor/diagram_scene.py | 14 ++- test/test_utils/test_diagram_images.py | 119 +++++++++++++++--- 6 files changed, 163 insertions(+), 39 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 7153e74f..ff62a589 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -235,12 +235,12 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,553 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,574 行,最大子系統) | 檔案 | 職責 | |---|---| -| `diagram_editor_widget.py` (599) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | -| `diagram_scene.py` (810) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路 | +| `diagram_editor_widget.py` (616) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | +| `diagram_scene.py` (814) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | | `diagram_items.py` (884) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | | `diagram_mermaid_parser.py` (532) | Mermaid flowchart → diagram dict。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (421) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 82 個 `test_*.py`、1240 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 82 個 `test_*.py`、1244 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index f3f1e4fd..5309b484 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -489,3 +489,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §9, §16, §18 - `progress.md` (#10 removed) - **Open items**: none. + +## U-20260923-35 · 2026-09-23 · Adding an image from a URL no longer freezes the IDE · #done #diagram + +- **What**: the diagram editor's Add Image from URL called `safe_download_image()` in the menu action's slot. Everything ran on the UI thread and held the IDE until the host answered: the URL check's DNS lookup, the 15 s timeout, which applies to each read and not to the whole download, and a body of up to 20 MB. Images in a loaded diagram had already moved to `ImageDownloadThread` (U-20260923-16); this path had not. The slot also caught `Exception`, which the project rules forbid. +- **Fix**: the action starts an `ImageDownloadThread` and returns. When the image arrives it goes on the canvas. A failure, or data that is not an image, is shown in the same warning as before. `ImageDownloadThread` also catches `ValueError`, from a URL urllib cannot parse; before, that ended the thread without either signal. When the editor closes, its fetches and the scene's are handed to `thread_keeper.let_run_out()` instead of being waited for on the UI thread. For that, `DiagramScene.stop_image_downloads()` becomes `let_image_downloads_run_out()`. +- **Tests**: `test_diagram_images.py`: + - the closing test now checks that the fetch is kept until it ends, not waited for; + - four new tests: the image arrives without holding the UI; a failed fetch is reported; data that is not an image is reported; closing mid-fetch lets the fetch run out. +- **Result / numbers**: unit tests 1226 → 1230 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/{diagram_editor_widget,diagram_scene}.py`, `test/test_utils/test_diagram_images.py`, `architecture_explore.md` §7, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 96bbdee9..f052780a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-35 | 2026-09-23 | Adding an image from a URL no longer freezes the IDE | #done #diagram | [2026-09](2026-09.md) | | U-20260923-34 | 2026-09-23 | Remote directories are listed without freezing the IDE | #done #ssh | [2026-09](2026-09.md) | | U-20260923-33 | 2026-09-23 | Connecting an SSH tab no longer freezes the IDE | #done #ssh | [2026-09](2026-09.md) | | U-20260923-32 | 2026-09-23 | A runaway regex no longer freezes the IDE | #done #tools | [2026-09](2026-09.md) | @@ -115,4 +116,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 52 | +| [2026-09.md](2026-09.md) | 2026-09 | 53 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 52aed218..955b0dc8 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -27,10 +27,10 @@ from je_editor import language_wrapper from pybreeze.pybreeze_ui.diagram_editor.diagram_mermaid_parser import parse_mermaid -from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import safe_download_image from pybreeze.pybreeze_ui.diagram_editor.diagram_property_panel import DiagramPropertyPanel -from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene, ToolMode +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene, ImageDownloadThread, ToolMode from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView +from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.logging.logger import pybreeze_logger @@ -126,6 +126,8 @@ class DiagramEditorWidget(QWidget): def __init__(self, parent=None): super().__init__(parent) self._current_path: Path | None = None + # Images being fetched for Add Image from URL + self._url_fetches: set[ImageDownloadThread] = set() # --- MVC core --- self._scene = DiagramScene(self) @@ -447,8 +449,12 @@ def _import_mermaid(self) -> None: ) def closeEvent(self, event) -> None: - """Wait for the scene's image fetches before the editor goes.""" - self._scene.stop_image_downloads() + """Let the image fetches still going run out, cut off from the editor.""" + for fetch in tuple(self._url_fetches): + if fetch.isRunning(): + let_run_out(fetch, fetch.fetched, fetch.failed) + self._url_fetches.clear() + self._scene.let_image_downloads_run_out() super().closeEvent(event) def _write_json(self, path: Path) -> None: @@ -575,17 +581,28 @@ def _add_image_from_url(self) -> None: ) if not ok or not url.strip(): return - url = url.strip() - try: - data = safe_download_image(url) - pix = QPixmap() - pix.loadFromData(data) - if pix.isNull(): - raise ValueError("Invalid image data") - self._scene.add_image(pix, url) - except Exception as e: - pybreeze_logger.error(f"URL image load failed: {e}") - QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), str(e)) + # Fetched on its own thread: the download (and the DNS lookup its URL + # check makes) used to hold the IDE until the host answered. + fetch = ImageDownloadThread(url.strip()) + fetch.fetched.connect(self._on_url_image_fetched) + fetch.failed.connect(self._on_url_image_failed) + fetch.finished.connect(lambda: self._url_fetches.discard(fetch)) + self._url_fetches.add(fetch) + fetch.start() + + def _on_url_image_fetched(self, url: str, data: bytes) -> None: + """Put a fetched image on the canvas, or say it was not one. UI thread.""" + pix = QPixmap() + pix.loadFromData(data) + if pix.isNull(): + self._on_url_image_failed(url, _lang("diagram_editor_image_load_failed", "Failed to load image.")) + return + self._scene.add_image(pix, url) + + def _on_url_image_failed(self, url: str, message: str) -> None: + """Say why the image at *url* could not be added. UI thread.""" + pybreeze_logger.error("URL image load failed: %s", message) + QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), message) # ------------------------------------------------------------------ # View helpers diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index b21722a3..30fbb04e 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -23,6 +23,7 @@ ImageDownloadError, safe_download_image, ) +from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.logging.logger import pybreeze_logger # Allowlist of image extensions that a saved diagram may reference on disk. @@ -85,7 +86,8 @@ def __init__(self, source: str) -> None: def run(self) -> None: try: data = safe_download_image(self._source) - except (ImageDownloadError, OSError, HTTPException) as err: + # ValueError: a URL urllib cannot parse at all + except (ImageDownloadError, OSError, HTTPException, ValueError) as err: self.failed.emit(self._source, str(err)) else: self.fetched.emit(self._source, data) @@ -781,15 +783,17 @@ def _on_image_fetched(self, source: str, data: bytes) -> None: def _on_image_download_failed(source: str, message: str) -> None: pybreeze_logger.debug("safe_download_image failed for %s: %s", source, message) - def stop_image_downloads(self) -> None: - """Wait for every fetch still going, with its signals blocked. + def let_image_downloads_run_out(self) -> None: + """Cut every fetch still going off from the scene, and keep it until it ends. Called when the editor closes: a running QThread destroyed with the scene aborts the process, and a late signal would reach a dead scene. + Waiting for them instead would hold the UI for up to a download's + timeout. """ for thread in tuple(self._image_downloads.values()): - thread.blockSignals(True) - thread.wait() + if thread.isRunning(): + let_run_out(thread, thread.fetched, thread.failed) self._image_downloads.clear() def load_from_dict(self, data: dict) -> None: diff --git a/test/test_utils/test_diagram_images.py b/test/test_utils/test_diagram_images.py index 49842bbc..cb111496 100644 --- a/test/test_utils/test_diagram_images.py +++ b/test/test_utils/test_diagram_images.py @@ -10,12 +10,17 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +import threading +import time + import pytest from PySide6.QtGui import QColor, QImage from PySide6.QtWidgets import QApplication +from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget as editor_module from pybreeze.pybreeze_ui.diagram_editor import diagram_scene as scene_module from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene +from pybreeze.pybreeze_ui.thread_keeper import is_kept _A_URL = "https://pictures.example/logo.png" @@ -66,8 +71,6 @@ def wait_for_downloads(scene: DiagramScene, app) -> None: class TestFetchingAnImage: def test_the_load_does_not_wait_for_the_network(self, app, monkeypatch): - import threading - answering = threading.Event() started = threading.Event() @@ -86,7 +89,7 @@ def fetch(_source: str) -> bytes: assert scene._image_downloads, "the load waited for the fetch" answering.set() wait_for_downloads(scene, app) - scene.stop_image_downloads() + scene.let_image_downloads_run_out() def test_what_comes_back_reaches_the_image(self, app, downloads): scene = DiagramScene() @@ -96,7 +99,7 @@ def test_what_comes_back_reaches_the_image(self, app, downloads): assert downloads == [_A_URL] assert scene._pixmap_cache[_A_URL].isNull() is False - scene.stop_image_downloads() + scene.let_image_downloads_run_out() def test_an_undo_does_not_fetch_it_again(self, app, downloads): scene = DiagramScene() @@ -110,7 +113,7 @@ def test_an_undo_does_not_fetch_it_again(self, app, downloads): assert downloads == [_A_URL], "the image was fetched again" images = scene.get_all_images() assert images and not images[0]._pix_item.pixmap().isNull() - scene.stop_image_downloads() + scene.let_image_downloads_run_out() def test_two_images_from_one_source_share_a_single_fetch(self, app, downloads): scene = DiagramScene() @@ -125,7 +128,7 @@ def test_two_images_from_one_source_share_a_single_fetch(self, app, downloads): wait_for_downloads(scene, app) assert downloads == [_A_URL] - scene.stop_image_downloads() + scene.let_image_downloads_run_out() def test_a_fetch_that_fails_is_only_logged(self, app, monkeypatch): def refuse(source: str) -> bytes: @@ -139,21 +142,109 @@ def refuse(source: str) -> bytes: assert _A_URL not in scene._pixmap_cache assert scene.get_all_images() - scene.stop_image_downloads() + scene.let_image_downloads_run_out() -class TestClosingTheEditor: - def test_it_waits_for_a_fetch_still_going(self, app, downloads): - from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget - from pybreeze.extend_multi_language.update_language_dict import update_language_dict +def _editor(): + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + + update_language_dict() + return DiagramEditorWidget() + - update_language_dict() - editor = DiagramEditorWidget() +def _wait_until(app, condition) -> None: + deadline = time.monotonic() + 5 + while not condition(): + assert time.monotonic() < deadline, "timed out" + app.processEvents() + time.sleep(0.01) + + +@pytest.fixture() +def slow_host(monkeypatch) -> threading.Event: + """A host that answers with an image once the returned event is set.""" + answering = threading.Event() + + def fetch(_source: str) -> bytes: + answering.wait(5) + return an_image() + + monkeypatch.setattr(scene_module, "safe_download_image", fetch) + return answering + + +class TestClosingTheEditor: + def test_it_lets_a_fetch_still_going_run_out(self, app, slow_host): + editor = _editor() editor._scene.load_from_dict(_A_DIAGRAM) + (fetch,) = editor._scene._image_downloads.values() - editor.close() + editor.close() # returns while the host is still answering assert editor._scene._image_downloads == {} + assert is_kept(fetch) + slow_host.set() + _wait_until(app, lambda: not is_kept(fetch)) + + +class TestAddingAnImageFromAUrl: + def _ask_for(self, monkeypatch, url: str) -> None: + monkeypatch.setattr(editor_module.QInputDialog, "getText", lambda *args: (url, True)) + + def test_the_image_arrives_without_holding_the_ui(self, app, monkeypatch, slow_host): + editor = _editor() + self._ask_for(monkeypatch, _A_URL) + + editor._add_image_from_url() + assert editor._scene.get_all_images() == [] # back before the host answered + slow_host.set() + _wait_until(app, lambda: editor._scene.get_all_images() and not editor._url_fetches) + + assert editor._scene.get_all_images()[0].source() == _A_URL + editor.close() + + def test_a_failed_fetch_is_reported(self, app, monkeypatch): + def refuse(_source: str) -> bytes: + raise scene_module.ImageDownloadError("nothing answered") + + monkeypatch.setattr(scene_module, "safe_download_image", refuse) + warned = [] + monkeypatch.setattr(editor_module.QMessageBox, "warning", lambda *args: warned.append(args[2])) + editor = _editor() + self._ask_for(monkeypatch, _A_URL) + + editor._add_image_from_url() + _wait_until(app, lambda: warned) + + assert warned == ["nothing answered"] + assert editor._scene.get_all_images() == [] + editor.close() + + def test_something_that_is_not_an_image_is_reported(self, app, monkeypatch): + monkeypatch.setattr(scene_module, "safe_download_image", lambda _source: b"") + warned = [] + monkeypatch.setattr(editor_module.QMessageBox, "warning", lambda *args: warned.append(args[2])) + editor = _editor() + self._ask_for(monkeypatch, _A_URL) + + editor._add_image_from_url() + _wait_until(app, lambda: warned) + + assert editor._scene.get_all_images() == [] + editor.close() + + def test_closing_mid_fetch_lets_it_run_out(self, app, monkeypatch, slow_host): + editor = _editor() + self._ask_for(monkeypatch, _A_URL) + editor._add_image_from_url() + (fetch,) = editor._url_fetches + + editor.close() + + assert is_kept(fetch) + slow_host.set() + _wait_until(app, lambda: not is_kept(fetch)) class TestCopyingAnImage: From 2874602368ea55171a9bd215d96d5876db6e2283 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 06:56:00 +0800 Subject: [PATCH 055/312] Mail a run's report off the UI thread --- architecture.md | 2 +- architecture_explore.md | 7 +- docs/updates/2026-09.md | 22 +++ docs/updates/README.md | 3 +- .../mail_thunder_setting.py | 102 ++++++---- test/test_utils/test_mail_report.py | 179 ++++++++++++++++++ 6 files changed, 271 insertions(+), 44 deletions(-) create mode 100644 test/test_utils/test_mail_report.py diff --git a/architecture.md b/architecture.md index f56d4bcf..9e4e2741 100644 --- a/architecture.md +++ b/architecture.md @@ -88,7 +88,7 @@ Automation menu (automation_menu_factory.build_automation_menu) → call_() extend/process_executor// → build_process() (process_executor_utils.py) → CodeWindow + TaskProcessManager → python -m --execute_str | --execute_file → stdout/stderr reader threads → Queue → QTimer → pump_message_queue() → CodeWindow.append_output() - → optional send_after_test() (mail_thunder_extend) + → optional send_after_test() (mail_thunder_extend; mails the report on a thread of its own) ``` **Run a non-Python file through a plugin** diff --git a/architecture_explore.md b/architecture_explore.md index ff62a589..8c159f0e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -401,6 +401,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 │ ▲ ├── daemon Thread: stdout readline ┤ ├── daemon Thread: stderr readline ┘ + ├── daemon Thread: pybreeze-report-mail(send_after_test → send_report,只寫 log) │ ├── QThread: SSHReaderThread ──Signal──► terminal widget ├── QThread: SshConnectThread ──Signal──► shell / file tree(host key 問題 BlockingQueued 回 UI) @@ -434,7 +435,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 82 個 `test_*.py`、1244 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 83 個 `test_*.py`、1255 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) @@ -453,9 +454,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 3. **`start_editor()` 以 `os._exit(ret)` 收場** — 繞過 atexit 與 Qt 拆解。對 GUI 主程式常見(避免殘留執行緒卡住),但 `closeEvent` 之後的清理路徑等於不存在。 -4. **`mail_thunder_setting.send_after_test()` 捕捉裸 `Exception`** — 最後一個 `except Exception` 只記 log,符合「不讓寄信失敗炸掉測試」的意圖,但與 CLAUDE.md「避免捕捉 Exception 除非立即重拋」有張力。 - -5. **`PackageManager` 名實不符** — 類別名暗示 pip 管理,實際只承載 `syntax_check_list` 六個項目;pip 安裝落在 `install_utils.install_package()`。 +4. **`PackageManager` 名實不符** — 類別名暗示 pip 管理,實際只承載 `syntax_check_list` 六個項目;pip 安裝落在 `install_utils.install_package()`。 --- diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 5309b484..c1e28aab 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -500,3 +500,25 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1226 → 1230 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/{diagram_editor_widget,diagram_scene}.py`, `test/test_utils/test_diagram_images.py`, `architecture_explore.md` §7, §18. - **Open items**: none. + +## U-20260923-36 · 2026-09-23 · Mailing a run's report no longer freezes the IDE · #done #executor + +- **What**: `send_after_test()` is a run's done-hook. `TaskProcessManager` calls it on the UI thread as the run ends, and it mailed the report right there. `SMTPWrapper` extends `SMTP_SSL`, which connects to smtp.gmail.com:465 as it is built, so the connect, the login and the send each held the IDE for as long as the mail server took. It also connected and logged in before checking that there was a report and a user to send it to. It never quit the connection when a step failed. It caught `Exception` (observation 4 in `architecture_explore.md` §19). +- **Fix**: `send_after_test()` starts a daemon thread named `pybreeze-report-mail` and returns. The work is now `send_report()`: + - it checks the report file and the mail user first, and connects only when both are there; + - the client is used as a context manager, so it quits however the block ends; + - it catches the import failure of `je_mail_thunder` on its own, then only `OSError` (the socket and every `smtplib` error), `ValueError` (a report that is not UTF-8) and `MailThunderException`; + - it logs with lazy formatting. + Finding the user moved into `_mail_user()`. +- **Tests**: new `test_mail_report.py` uses a stand-in `je_mail_thunder`. It has 11 tests: + - the hook returns before the mail is sent; + - the report goes to the content file's user, or else the environment's; + - no user, or no report, means no connection; + - a failed login, a failed send (socket error or `MailThunderException`), an unreachable server, a report that is not UTF-8, and a missing `je_mail_thunder` are each logged, never raised, and the connection is closed. +- **Result / numbers**: unit tests 1230 → 1241 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py` + - `test/test_utils/test_mail_report.py` (new) + - `architecture.md` §4 + - `architecture_explore.md` §16, §18, §19 (observation 4 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index f052780a..49231ff2 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-36 | 2026-09-23 | Mailing a run's report no longer freezes the IDE | #done #executor | [2026-09](2026-09.md) | | U-20260923-35 | 2026-09-23 | Adding an image from a URL no longer freezes the IDE | #done #diagram | [2026-09](2026-09.md) | | U-20260923-34 | 2026-09-23 | Remote directories are listed without freezing the IDE | #done #ssh | [2026-09](2026-09.md) | | U-20260923-33 | 2026-09-23 | Connecting an SSH tab no longer freezes the IDE | #done #ssh | [2026-09](2026-09.md) | @@ -116,4 +117,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 53 | +| [2026-09.md](2026-09.md) | 2026-09 | 54 | diff --git a/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py b/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py index 134facac..529ebc17 100644 --- a/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py +++ b/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py @@ -1,52 +1,78 @@ from __future__ import annotations import os -from email.mime.multipart import MIMEMultipart +import threading 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 +DEFAULT_REPORT_PATH = "default_name.html" + def send_after_test(html_report_path: str | None = None) -> None: + """Mail a finished run's HTML report, on a thread of its own. + + This is a run's done-hook, called on the UI thread as the run ends. The + SMTP connect (made when the client is built), the login and the send take + as long as the mail server does, and the IDE used to be frozen for all of + it. Whatever goes wrong is logged by that thread, never raised. + + :param html_report_path: the report to send; ``default_name.html`` when None + """ + threading.Thread( + target=send_report, args=(html_report_path,), name="pybreeze-report-mail", daemon=True + ).start() + + +def send_report(html_report_path: str | None = None) -> None: + """Mail the HTML report to the configured mail user, and log what went wrong. + + Blocks for as long as the mail server takes; ``send_after_test`` runs it + off the UI thread. + + :param html_report_path: the report to send; ``default_name.html`` when None + """ try: - 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 - - # 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 - - # 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 + from je_mail_thunder import SMTPWrapper, get_mail_thunder_os_environ, read_output_content + from je_mail_thunder.utils.exception.exceptions import MailThunderException + except ImportError as error: + pybreeze_logger.error("Cannot send the report without je_mail_thunder: %r", error) + return + report_path = html_report_path if html_report_path is not None else DEFAULT_REPORT_PATH + if not os.path.isfile(report_path): + pybreeze_logger.error("Report file not found: %s", report_path) + return + user = _mail_user(read_output_content, get_mail_thunder_os_environ) + if user is None: + pybreeze_logger.error("Cannot determine mail user for sending report") + return + try: 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() + html_string = file.read() + # The client connects when it is built, and quits when the block ends + # however it ends. + with SMTPWrapper() as mail_thunder_smtp: + mail_thunder_smtp.later_init() + if not mail_thunder_smtp.login_state: + raise ITESendHtmlReportException + message = 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) except ITESendHtmlReportException as error: - 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}") + pybreeze_logger.error("%r %s", error, send_html_exception_tag) + # OSError covers the socket and every smtplib error; ValueError a report + # that is not UTF-8 + except (OSError, ValueError, MailThunderException) as error: + pybreeze_logger.error("Failed to send report: %r", error) + + +def _mail_user(read_output_content, get_mail_thunder_os_environ) -> str | None: + """The address to send to: the saved content file's user, else the environment's.""" + user_info = read_output_content() + if isinstance(user_info, dict) and user_info.get("user") is not None: + return user_info["user"] + return get_mail_thunder_os_environ().get("mail_thunder_user") diff --git a/test/test_utils/test_mail_report.py b/test/test_utils/test_mail_report.py new file mode 100644 index 00000000..b8dd0923 --- /dev/null +++ b/test/test_utils/test_mail_report.py @@ -0,0 +1,179 @@ +"""Mailing a run's report: off the UI thread, and every failure logged rather than raised.""" +from __future__ import annotations + +import sys +import threading +import types +from unittest.mock import MagicMock + +import pytest + +from pybreeze.extend.mail_thunder_extend import mail_thunder_setting as mail + + +class FakeSmtp: + """SMTPWrapper's surface, recording what happens to it.""" + + instances: list[FakeSmtp] = [] + connect_error: Exception | None = None + logs_in = True + send_error: Exception | None = None + + def __init__(self) -> None: + if FakeSmtp.connect_error is not None: + raise FakeSmtp.connect_error + self.login_state = False + self.sent: list = [] + self.quit_called = False + FakeSmtp.instances.append(self) + + def __enter__(self): + return self + + def __exit__(self, *_exc) -> None: + self.quit_called = True + + def later_init(self) -> None: + self.login_state = FakeSmtp.logs_in + + def create_message_with_attach(self, html, settings, path, use_html) -> dict: + return {"html": html, **settings, "path": path, "use_html": use_html} + + def send_message(self, message) -> None: + if FakeSmtp.send_error is not None: + raise FakeSmtp.send_error + self.sent.append(message) + + +class MailThunderException(Exception): + pass + + +@pytest.fixture() +def mail_thunder(monkeypatch): + """A stand-in je_mail_thunder with a user in its content file.""" + FakeSmtp.instances = [] + FakeSmtp.connect_error = None + FakeSmtp.logs_in = True + FakeSmtp.send_error = None + package = types.ModuleType("je_mail_thunder") + package.SMTPWrapper = FakeSmtp + package.read_output_content = lambda: {"user": "tester@example.com"} + package.get_mail_thunder_os_environ = lambda: {} + exceptions = types.ModuleType("je_mail_thunder.utils.exception.exceptions") + exceptions.MailThunderException = MailThunderException + monkeypatch.setitem(sys.modules, "je_mail_thunder", package) + monkeypatch.setitem(sys.modules, "je_mail_thunder.utils.exception.exceptions", exceptions) + return package + + +@pytest.fixture() +def logger(monkeypatch) -> MagicMock: + fake = MagicMock() + monkeypatch.setattr(mail, "pybreeze_logger", fake) + return fake + + +@pytest.fixture() +def report(tmp_path) -> str: + path = tmp_path / "report.html" + path.write_text("ok", encoding="utf-8") + return str(path) + + +def _logged(logger: MagicMock) -> str: + return " ".join(str(arg) for call in logger.error.call_args_list for arg in call.args) + + +class TestSendAfterTest: + def test_it_returns_before_the_mail_is_sent(self, monkeypatch): + sending = threading.Event() + release = threading.Event() + + def slow_send(_path) -> None: + sending.set() + release.wait(5) + + monkeypatch.setattr(mail, "send_report", slow_send) + + mail.send_after_test("report.html") # the mail server has not answered + + assert sending.wait(5) + release.set() + + +class TestSendReport: + def test_the_report_goes_to_the_user(self, mail_thunder, logger, report): + mail.send_report(report) + + (smtp,) = FakeSmtp.instances + assert smtp.sent == [{ + "html": "ok", "Subject": "Test Report", + "To": "tester@example.com", "From": "tester@example.com", + "path": report, "use_html": True, + }] + assert smtp.quit_called + logger.error.assert_not_called() + + def test_the_user_can_come_from_the_environment(self, mail_thunder, logger, report): + mail_thunder.read_output_content = lambda: None + mail_thunder.get_mail_thunder_os_environ = lambda: {"mail_thunder_user": "env@example.com"} + + mail.send_report(report) + + assert FakeSmtp.instances[0].sent[0]["To"] == "env@example.com" + + def test_no_user_means_no_connection(self, mail_thunder, logger, report): + mail_thunder.read_output_content = lambda: {} + + mail.send_report(report) + + assert FakeSmtp.instances == [] + assert "mail user" in _logged(logger) + + def test_a_missing_report_means_no_connection(self, mail_thunder, logger, tmp_path): + mail.send_report(str(tmp_path / "absent.html")) + + assert FakeSmtp.instances == [] + assert "not found" in _logged(logger) + + def test_a_failed_login_is_logged_and_the_connection_closed(self, mail_thunder, logger, report): + FakeSmtp.logs_in = False + + mail.send_report(report) + + (smtp,) = FakeSmtp.instances + assert smtp.sent == [] and smtp.quit_called + assert mail.send_html_exception_tag in _logged(logger) + + @pytest.mark.parametrize("error", [ConnectionRefusedError("refused"), MailThunderException("bad")]) + def test_a_failed_send_is_logged_and_the_connection_closed(self, mail_thunder, logger, report, error): + FakeSmtp.send_error = error + + mail.send_report(report) + + assert FakeSmtp.instances[0].quit_called + assert "Failed to send report" in _logged(logger) + + def test_an_unreachable_server_is_logged(self, mail_thunder, logger, report): + FakeSmtp.connect_error = TimeoutError("timed out") + + mail.send_report(report) + + assert "timed out" in _logged(logger) + + def test_a_report_that_is_not_utf8_is_logged(self, mail_thunder, logger, tmp_path): + path = tmp_path / "report.html" + path.write_bytes(b"\xff\xfe\x00bad") + + mail.send_report(str(path)) + + assert FakeSmtp.instances == [] + assert "Failed to send report" in _logged(logger) + + def test_without_je_mail_thunder_it_is_logged(self, monkeypatch, logger, report): + monkeypatch.setitem(sys.modules, "je_mail_thunder", None) # makes the import fail + + mail.send_report(report) + + assert "je_mail_thunder" in _logged(logger) From 0daabb07b1358b242e929ff6ef24cf134994a172 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 06:59:47 +0800 Subject: [PATCH 056/312] Catch only what each call can raise --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 29 +++++++++++++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 1 - .../extend_traditional_chinese.py | 1 - .../connect_gui/ssh/ssh_command_widget.py | 21 +++++---- .../connect_gui/ssh/ssh_file_viewer_widget.py | 3 +- .../connect_gui/url/ai_code_review_gui.py | 2 +- .../diagram_editor/diagram_editor_widget.py | 10 +++-- pybreeze/pybreeze_ui/editor_main/main_ui.py | 6 ++- .../jupyter_lab_gui/jupyter_lab_thread.py | 3 +- test/test_utils/test_no_blind_except.py | 43 +++++++++++++++++++ 12 files changed, 100 insertions(+), 24 deletions(-) create mode 100644 test/test_utils/test_no_blind_except.py diff --git a/architecture_explore.md b/architecture_explore.md index 8c159f0e..19c129b7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -435,7 +435,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 83 個 `test_*.py`、1255 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 84 個 `test_*.py`、1256 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index c1e28aab..6f14578b 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -522,3 +522,32 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture.md` §4 - `architecture_explore.md` §16, §18, §19 (observation 4 removed) - **Open items**: none. + +## U-20260923-37 · 2026-09-23 · Blind catches narrowed to what each call raises · #done #quality + +- **What**: ten handlers caught `Exception` without re-raising and without a stated reason. CLAUDE.md's quality gates allow that only to log and re-raise. Each one hid whatever error it was not written for: a programming error in a diagram load, for example, reached the user as a "could not open" warning. +- **Fix**: each handler now catches what its call can raise: + - SSH shell: + - sending a command: `OSError`, `SSHException`; + - stopping the reader: `RuntimeError`, from a deleted C++ object; + - closing the channel or the client: new `CLOSE_ERRORS`. + - `_on_data` lost its try entirely: `decode(errors="replace")` cannot fail. The message key it used, `ssh_command_widget_error_message_decode_failed`, is removed from both languages. + - SFTP context menu: `CONNECT_ERRORS`. + - AI review statistics save: `OSError`. + - Opening a diagram: `OSError`, `ValueError`, `TypeError`, `KeyError`. + - Mermaid import: `ValueError`, `TypeError`, `KeyError`. + - JEditor's `startup_setting`: `OSError`, `ValueError`, `TypeError`, `KeyError`, `RuntimeError`. A bad saved setting still does not stop the IDE starting. + - JupyterLab launch: `OSError` (which includes the startup `TimeoutError`), `ValueError`, `RuntimeError`, `SubprocessError`. + The logging in the touched handlers is lazy. +- **Guard**: new `test_no_blind_except.py` walks every module's syntax tree and fails on an `except Exception`/`BaseException`/bare `except` that neither re-raises nor carries `# noqa: BLE001` followed by a reason. Run against the previous `main_ui.py`, it flags line 152. The handlers still allowed are the four with a stated reason (a third-party tab, a run's done-hook, a batch action, the PNG/SVG exports), the SSH reader's, and the SFTP connect's log-and-re-raise. +- **Result / numbers**: unit tests 1241 → 1242 passed, 14 skipped. `ruff check` clean, and `ruff --select BLE001` finds nothing. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/{ssh_command_widget,ssh_file_viewer_widget}.py` + - `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py` + - `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py` + - `pybreeze/pybreeze_ui/editor_main/main_ui.py` + - `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py` + - `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py` + - `test/test_utils/test_no_blind_except.py` (new) + - `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 49231ff2..828355c4 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-37 | 2026-09-23 | Blind catches narrowed to what each call raises | #done #quality | [2026-09](2026-09.md) | | U-20260923-36 | 2026-09-23 | Mailing a run's report no longer freezes the IDE | #done #executor | [2026-09](2026-09.md) | | U-20260923-35 | 2026-09-23 | Adding an image from a URL no longer freezes the IDE | #done #diagram | [2026-09](2026-09.md) | | U-20260923-34 | 2026-09-23 | Remote directories are listed without freezing the IDE | #done #ssh | [2026-09](2026-09.md) | @@ -117,4 +118,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 54 | +| [2026-09.md](2026-09.md) | 2026-09 | 55 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 00689a4b..3477655d 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -164,7 +164,6 @@ "ssh_command_widget_status_label_disconnected": "Disconnected", "ssh_command_widget_log_message_connected": "Connected to", "ssh_command_widget_log_message_error": "[Error] ", - "ssh_command_widget_error_message_decode_failed": " ", "ssh_command_widget_log_message_channel_closed": "[Channel closed]", "ssh_command_widget_error_message_reader_failed": "Reader error", "ssh_command_widget_log_message_reader_closed": "Reader closed", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 08a02389..3b891ef5 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -164,7 +164,6 @@ "ssh_command_widget_status_label_disconnected": "已斷線", "ssh_command_widget_log_message_connected": "已連線至", "ssh_command_widget_log_message_error": "[錯誤]", - "ssh_command_widget_error_message_decode_failed": "<解碼錯誤> ", "ssh_command_widget_log_message_channel_closed": "[通道已關閉]", "ssh_command_widget_error_message_reader_failed": "讀取錯誤", "ssh_command_widget_log_message_reader_closed": "讀取已關閉", 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 259c40a9..a4d5966c 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -21,6 +21,9 @@ from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.logging.logger import pybreeze_logger +# What closing a channel or a client can raise on a connection already broken +CLOSE_ERRORS = (OSError, EOFError, paramiko.SSHException) + ANSI_ESCAPE_PATTERN = re.compile( r'\x1B(?:' r'\][^\x07\x1B]*(?:\x07|\x1B\\)?' # OSC; BEL/ST-terminated or implicitly ended by the next ESC / EOF @@ -252,13 +255,9 @@ def _start_shell(self, host: str, port: int, user: str) -> None: f" {host}:{port} as {user}\n") def _on_data(self, data: bytes): - try: - text = data.decode("utf-8", errors="replace") - clean_text = ANSI_ESCAPE_PATTERN.sub('', text) - self.append_text(clean_text) - except Exception as error: - self.append_text(f"{self.word_dict.get('ssh_command_widget_error_message_decode_failed')}" - f" {error}\n") + # errors="replace" cannot fail, so there is nothing to catch here + text = data.decode("utf-8", errors="replace") + self.append_text(ANSI_ESCAPE_PATTERN.sub('', text)) def _on_closed(self, msg: str): self.append_text(f"\n{self.word_dict.get('ssh_command_widget_log_message_channel_closed')}" @@ -275,7 +274,7 @@ def send_command(self): try: self.shell_channel.send(cmd + "\n") self.command_input_edit.clear() - except Exception as e: + except (OSError, paramiko.SSHException) as e: self.append_text(f"{self.word_dict.get('ssh_command_widget_error_message_send_failed')} {e}\n") else: QMessageBox.information( @@ -320,20 +319,20 @@ def _cleanup(self): self.reader_thread.blockSignals(True) self.reader_thread.stop() self.reader_thread.wait(1000) - except Exception as error: + except RuntimeError as error: # its C++ object already deleted pybreeze_logger.debug("SSH reader thread cleanup: %r", error) self.reader_thread = None try: if self.shell_channel and not self.shell_channel.closed: self.shell_channel.close() - except Exception as error: + except CLOSE_ERRORS as error: pybreeze_logger.debug("SSH channel cleanup: %r", error) self.shell_channel = None try: if self.ssh_client: self.ssh_client.close() - except Exception as error: + except CLOSE_ERRORS as error: pybreeze_logger.debug("SSH client cleanup: %r", error) self.ssh_client = None diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index f7ea72c7..4180de79 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -592,7 +592,8 @@ def on_context_menu(self, pos): self.action_download(item) elif action == upload_act: self.action_upload(item) - except Exception as e: + # what an SFTP operation raises, a closed session's RuntimeError included + except CONNECT_ERRORS as e: QMessageBox.critical( self, self.word_dict.get("ssh_file_viewer_dialog_title_operation_failed"), 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 fe5f4753..071c7335 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 @@ -283,7 +283,7 @@ def save_stats(self): with open(self.stats_file, "w", encoding="utf-8") as f: f.write(f"Accepted: {self.accept_count}\n") f.write(f"Rejected: {self.reject_count}\n") - except Exception as e: + except OSError as e: self.response_panel.append(f"\n[{self.word_dict.get('ai_code_review_gui_status_save_failed')}: {e}]") diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 955b0dc8..c49e6ea1 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -397,8 +397,10 @@ def _open_diagram(self) -> None: data = json.loads(Path(path).read_text(encoding="utf-8")) self._scene.load_from_dict(data) self._current_path = Path(path) - except Exception as e: - pybreeze_logger.error(f"Open diagram failed: {e}") + # ValueError covers bad JSON, a file that is not UTF-8 and one that is + # not a diagram; TypeError and KeyError an item with the wrong fields + except (OSError, ValueError, TypeError, KeyError) as e: + pybreeze_logger.error("Open diagram failed: %r", e) QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), str(e)) def _save_diagram(self) -> None: @@ -440,8 +442,8 @@ def _import_mermaid(self) -> None: self._scene._load_items(data) self._scene.item_count_changed.emit() self._zoom_fit() - except Exception as e: - pybreeze_logger.error(f"Mermaid import failed: {e}") + except (ValueError, TypeError, KeyError) as e: + pybreeze_logger.error("Mermaid import failed: %r", e) QMessageBox.warning( self, _lang("diagram_editor_import_error", "Parse Error"), diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index ebd544af..395a3db4 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -149,7 +149,9 @@ def start_editor(debug_mode: bool = False, theme: str = "dark_amber.xml", **kwar window.showMaximized() try: window.startup_setting() - except Exception as error: - pybreeze_logger.error(f"Startup setting error: {error}") + # The user's saved settings, and the files they reopen, can be anything: + # a bad one is logged and the IDE starts without it. + except (OSError, ValueError, TypeError, KeyError, RuntimeError) as error: + pybreeze_logger.error("Startup setting error: %r", error) ret = new_ide.exec() os._exit(ret) 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 8a57adc9..a8c2f58a 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py @@ -126,7 +126,8 @@ def run(self): self._wait_until_ready(port) self.server_ready.emit(f"http://localhost:{port}/lab") - except Exception: + # OSError includes the TimeoutError of a server that never came up + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError): err = traceback.format_exc() # Tear down a half-started server so a startup timeout doesn't leave an # orphaned JupyterLab process running and holding the port. diff --git a/test/test_utils/test_no_blind_except.py b/test/test_utils/test_no_blind_except.py new file mode 100644 index 00000000..231dadb6 --- /dev/null +++ b/test/test_utils/test_no_blind_except.py @@ -0,0 +1,43 @@ +"""``except Exception`` only to log and re-raise, or with a reason on the line. + +CLAUDE.md, Code quality gates: a blind catch hides the error it was not written +for. The few places that must survive anything (a third-party tab, a run's +done-hook, an export) say why with ``# noqa: BLE001 — ``. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +PACKAGE = Path(__file__).resolve().parents[2] / "pybreeze" + + +def _catches_everything(handler: ast.ExceptHandler) -> bool: + caught = handler.type + if caught is None: + return True + names = caught.elts if isinstance(caught, ast.Tuple) else [caught] + return any(isinstance(name, ast.Name) and name.id in ("Exception", "BaseException") for name in names) + + +def _re_raises(handler: ast.ExceptHandler) -> bool: + return any(isinstance(node, ast.Raise) for node in ast.walk(handler)) + + +def _has_reason(line: str) -> bool: + _, _, comment = line.partition("# noqa: BLE001") + return bool(comment.strip(" —-:")) + + +def test_every_blind_catch_re_raises_or_says_why(): + offenders = [] + for path in sorted(PACKAGE.rglob("*.py")): + source = path.read_text(encoding="utf-8") + lines = source.splitlines() + for node in ast.walk(ast.parse(source, filename=str(path))): + if not isinstance(node, ast.ExceptHandler) or not _catches_everything(node): + continue + if _re_raises(node) or _has_reason(lines[node.lineno - 1]): + continue + offenders.append(f"{path.relative_to(PACKAGE.parent)}:{node.lineno}") + assert offenders == [] From 26d5ad17089fe92edea7326c059fa3c81267d297 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 07:18:10 +0800 Subject: [PATCH 057/312] Let a closing tab's worker run out, and start no server for a closed lab --- architecture_explore.md | 12 +-- docs/updates/2026-09.md | 25 ++++++ docs/updates/README.md | 3 +- progress.md | 2 +- .../connect_gui/ssh/ssh_file_viewer_widget.py | 15 ++-- .../code_review/cot_code_review_gui.py | 13 ++-- .../jupyter_lab_gui/jupyter_lab_thread.py | 77 ++++++++++++------- .../jupyter_lab_gui/jupyter_lab_widget.py | 17 ++-- test/test_utils/test_ai_gui_lifecycle.py | 18 +++-- test/test_utils/test_cot_session_reuse.py | 28 +++++++ test/test_utils/test_jupyter_lifecycle.py | 43 ++++++++++- test/test_utils/test_ssh_teardown.py | 46 ++++++----- 12 files changed, 217 insertions(+), 82 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 19c129b7..69eea0e9 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -261,7 +261,7 @@ extend_ai_gui/ ├── code_review/ │ ├── cot_chain.py 接線表(純邏輯,無 Qt):哪步引用哪步 │ ├── code_review_thread.py SenderThread(QThread):跑八步審查鏈 -│ └── cot_code_review_gui.py UI +│ └── cot_code_review_gui.py UI;關閉時請審查停在目前這一步,交給 let_run_out(),不等 ├── prompt_edit_gui/ │ ├── prompt_editor_widget.py 共用編輯器(QFileSystemWatcher 熱更新,watcher 以編輯器為 parent、關閉時停止監看;有未存編輯時先問再重新載入) │ ├── cot_prompt_editor_widget.py 8 個 CoT 模板的檔案清單+語言鍵 @@ -290,14 +290,14 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,510 行) +### `ssh/`(1,513 行) | 檔案 | 職責 | |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel,ANSI escape 用 regex 剝除,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (789) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;連線交給 `SshConnectThread`,成功後才列出根目錄;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個,`closeEvent` 等它結束再關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (793) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;連線交給 `SshConnectThread`,成功後才列出根目錄;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | @@ -314,8 +314,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 10. `pybreeze_ui/jupyter_lab_gui/` -- `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `get_venv_python()` → `is_jupyter_installed()`(缺就自動裝)→ 啟動 server → `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)`。server 的輸出寫進暫存檔而不是管線(server 起來後沒人讀管線,緩衝區滿了它會卡在 `write()`);提早結束時錯誤訊息取這個檔案的尾巴 -- `jupyter_lab_widget.py` — 收到 URL 後用 `QWebEngineView.setUrl()` 載入;`closeEvent` 一律關掉 server(launcher 執行緒在 lab 載入完就結束了,只停「還在跑的執行緒」等於從不停 server)。IDE 關閉時 `PyBreezeMainWindow._close_tool_tabs_and_docks()` 會關掉所有非編輯器分頁與 `DestroyDock`,這個 `closeEvent` 才會被呼叫到 +- `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `get_venv_python()` → `is_jupyter_installed()`(缺就自動裝)→ 啟動 server(`_start_server()`,在 `_process_lock` 裡先看 `_stopped`:`stop()` 之後就不再啟動,即使還在安裝)→ `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)`。server 的輸出寫進暫存檔而不是管線(server 起來後沒人讀管線,緩衝區滿了它會卡在 `write()`);提早結束時錯誤訊息取這個檔案的尾巴 +- `jupyter_lab_widget.py` — 收到 URL 後用 `QWebEngineView.setUrl()` 載入;`closeEvent` 一律關掉 server(launcher 執行緒在 lab 載入完就結束了,只停「還在跑的執行緒」等於從不停 server);還在安裝或啟動的 launcher 交給 `let_run_out()`,不在 UI 執行緒等(安裝可能要好幾分鐘),也不用 `blockSignals`(那會連 `finished` 一起擋掉,keeper 永遠放不掉它)。IDE 關閉時 `PyBreezeMainWindow._close_tool_tabs_and_docks()` 會關掉所有非編輯器分頁與 `DestroyDock`,這個 `closeEvent` 才會被呼叫到 安全前提(CLAUDE.md 已明列):server 只綁 localhost,因此 token/password 刻意留空、`disable_check_xsrf=True` 才能內嵌。**不設 `allow_origin`**:loopback 擋不住瀏覽器,開放來源的話使用者逛到的任何網頁都能操作這個沒有 token 的 server。 @@ -435,7 +435,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 84 個 `test_*.py`、1256 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 84 個 `test_*.py`、1259 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 6f14578b..47a1c5c0 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -551,3 +551,28 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_no_blind_except.py` (new) - `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-38 · 2026-09-23 · Closing a tab no longer waits for its worker, and a closed JupyterLab tab starts no server · #done #jupyter #ssh #ai + +- **What**: three widgets still waited for their worker on the UI thread when they closed. The IDE was frozen for as long as the worker took: + - **JupyterLab**: its tab waited for the launcher, which may be running `pip install jupyterlab`, which is allowed 300 s. Worse, `stop()` found no server during the install, so after the wait the launcher went on and started one, token-less on localhost, with its tab gone and nothing left to stop it. The same happened, in a smaller window, to a close between the install check and the start. + - **CoT review**: closing waited for the current request, up to its read timeout. + - **SFTP tree**: closing waited for a transfer still going, however large the file. +- **Fix**: + - The launcher has a `_stopped` event and a `_process_lock`. `stop()` sets the event and takes the server under the lock. The launcher checks the event under the same lock before `_start_server()`, so once `stop()` has run no server is started. `stop()` can now be called from any thread. + - The tab lets a running launcher run out and then stops it. It no longer blocks the launcher's signals: `blockSignals` also blocks `finished`, so thread_keeper would never have let the launcher go. This was found by the new test. + - The CoT panel asks for an interruption, which stops after the current request, and lets the thread run out. + - The SFTP tree lets a transfer run out and closes the session when it ends. Closing the session under the transfer would have left half a file behind. +- **Tests**: + - `test_jupyter_lifecycle.py`: a launcher stopped during the install check starts nothing; closing the tab returns with the launcher kept, stopped, and released once it ends. + - `test_cot_session_reuse.py`: closing mid-review returns, and the interrupted thread is released when it ends. + - `test_ai_gui_lifecycle.py`: the CoT close test now asserts interrupted, not waited for, and kept. + - `test_ssh_teardown.py`: the wait-on-close test is replaced by a real transfer whose session stays open until it ends and then closes. The earlier transfer test now drains its own `done` signal: left pending, it opened a real modal box in the next test. +- **Result / numbers**: unit tests 1242 → 1245 passed, 14 skipped. Startup tests exit 0. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/jupyter_lab_gui/{jupyter_lab_thread,jupyter_lab_widget}.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py` + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `test/test_utils/{test_jupyter_lifecycle,test_cot_session_reuse,test_ai_gui_lifecycle,test_ssh_teardown}.py` + - `architecture_explore.md` §8, §9, §10, §18 +- **Open items**: the plugin compile step (`file_runner_process.py` `_compile_and_run`) still runs the compiler on the UI thread for up to 60 s (`progress.md` #26). diff --git a/docs/updates/README.md b/docs/updates/README.md index 828355c4..0936735d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-38 | 2026-09-23 | Closing a tab no longer waits for its worker, and a closed JupyterLab tab starts no server | #done #jupyter #ssh #ai | [2026-09](2026-09.md) | | U-20260923-37 | 2026-09-23 | Blind catches narrowed to what each call raises | #done #quality | [2026-09](2026-09.md) | | U-20260923-36 | 2026-09-23 | Mailing a run's report no longer freezes the IDE | #done #executor | [2026-09](2026-09.md) | | U-20260923-35 | 2026-09-23 | Adding an image from a URL no longer freezes the IDE | #done #diagram | [2026-09](2026-09.md) | @@ -118,4 +119,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 55 | +| [2026-09.md](2026-09.md) | 2026-09 | 56 | diff --git a/progress.md b/progress.md index 2b163d8e..5ed56e01 100644 --- a/progress.md +++ b/progress.md @@ -7,4 +7,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - +- **#26** A plugin run that compiles first runs the compiler on the UI thread (`pybreeze/extend/process_executor/file_runner_process.py` `_compile_and_run`, `subprocess.run(..., timeout=60)`): the IDE is frozen for the whole compile, up to 60 s, and the compiler's output only appears once it has finished. The compile should go through the same streamed process path as the run, with the run started when the compile exits 0. diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 4180de79..26ac971d 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -409,13 +409,15 @@ def closeEvent(self, event) -> None: Qt delivers a close event only to the widget being closed, so a tab or a dock closing takes this route; without it the paramiko transport stays open, sending keepalives, for the rest of the session. A transfer still - going is waited for first, with its signals blocked: a QThread destroyed - while running aborts the process. + going is not waited for -- that froze the IDE for the rest of the + transfer -- nor cut short, which would leave half a file: it is kept + until it ends, and the session closes then. """ transfer = self._transfer - if transfer is not None and transfer.isRunning(): - transfer.blockSignals(True) - transfer.wait() + transfer_running = transfer is not None and transfer.isRunning() + if transfer_running: + let_run_out(transfer, transfer.done, transfer.failed) + transfer.finished.connect(self.client.close) for listing in list(self._listings): if listing.isRunning(): let_run_out(listing, listing.listed, listing.failed) @@ -426,7 +428,8 @@ def closeEvent(self, event) -> None: # its session open: close it whatever way the thread ends. (After # let_run_out, which cuts off everything connected to ``finished``.) self._connecting.finished.connect(self.client.close) - self.client.close() + if not transfer_running: + self.client.close() super().closeEvent(event) def _disconnect(self): 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 57d86b56..86790159 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 @@ -6,6 +6,7 @@ from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread +from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -98,12 +99,14 @@ def handle_response(self, filename, response): self.show_response(filename) def closeEvent(self, event): + """Ask a review still going to stop after its current request, without waiting. + + A request can take its whole read timeout, so waiting here froze the IDE + that long. The thread is cut off from this widget and kept until it + ends: a QThread destroyed while running aborts the process. + """ thread = self.thread if thread is not None and thread.isRunning(): - # Block slots so a late signal can't hit the dying widget, ask the - # worker to stop after its current request, then wait so the QThread - # is never destroyed while still running ("Destroyed while running"). - thread.blockSignals(True) thread.requestInterruption() - thread.wait() + let_run_out(thread, thread.update_response) event.accept() 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 a8c2f58a..e83dfe4b 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py @@ -5,6 +5,7 @@ import subprocess import sys import tempfile +import threading import time import traceback @@ -70,6 +71,11 @@ class JupyterLauncherThread(QThread): def __init__(self, parent=None, startup_timeout: int = JUPYTER_STARTUP_TIMEOUT): super().__init__(parent) self.process = None + # Set by stop(). Checked, under the lock, before the server is started: + # a tab closed during the install would otherwise get a server started + # after it had gone, with nothing left to stop it. + self._stopped = threading.Event() + self._process_lock = threading.Lock() # The server's output, kept in a file rather than a pipe self._output = None self.startup_timeout = startup_timeout @@ -100,28 +106,11 @@ def run(self): port = find_free_port() - # Launch embedded JupyterLab. Server binds to localhost only (see - # CLAUDE.md JupyterLab integration notes); shell=False. nosec B603. - # The bind address is pinned explicitly: with token and password - # empty, the loopback-only binding is the sole barrier, so we never - # rely on the jupyter default staying localhost. No wildcard origin: - # a loopback bind does not stop a browser, and with the origin open - # any page the user visits could drive this server's API and kernel - # sockets. The view this serves loads from the same origin, so it - # needs nothing relaxed. - self._output = tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") - self.process = subprocess.Popen([ # nosec B603 # nosemgrep # noqa: S603 - python_exe, - "-m", - "jupyterlab", - "--no-browser", - "--ServerApp.ip=localhost", - f"--ServerApp.port={port}", - "--ServerApp.token=", - "--ServerApp.password=", - "--ServerApp.disable_check_xsrf=True", - ], stdout=self._output, stderr=subprocess.STDOUT, text=True, - creationflags=no_window_creationflags()) + with self._process_lock: + if self._stopped.is_set(): + return + self._output = tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") + self.process = self._start_server(python_exe, port) self._wait_until_ready(port) self.server_ready.emit(f"http://localhost:{port}/lab") @@ -135,6 +124,30 @@ def run(self): self.error_occurred.emit(err) pybreeze_logger.error(f"JupyterLab launch failed: {err}") + def _start_server(self, python_exe: str, port: int) -> subprocess.Popen: + """Start the server on *port*, its output going to ``self._output``. + + It binds to localhost only (CLAUDE.md, Security > JupyterLab); shell=False. + The bind address is pinned explicitly: with token and password empty, + the loopback-only binding is the sole barrier, so this never relies on + jupyter's default staying localhost. No wildcard origin: a loopback bind + does not stop a browser, and with the origin open any page the user + visits could drive this server's API and kernel sockets. The view this + serves loads from the same origin, so it needs nothing relaxed. + """ + return subprocess.Popen([ # nosec B603 # nosemgrep # noqa: S603 + python_exe, + "-m", + "jupyterlab", + "--no-browser", + "--ServerApp.ip=localhost", + f"--ServerApp.port={port}", + "--ServerApp.token=", + "--ServerApp.password=", + "--ServerApp.disable_check_xsrf=True", + ], stdout=self._output, stderr=subprocess.STDOUT, text=True, + creationflags=no_window_creationflags()) + @staticmethod def _port_open(port: int) -> bool: try: @@ -188,13 +201,19 @@ def _output_tail(self, characters: int = 500) -> str: return "" def stop(self): - """Stop the server and let go of its output file. Safe to call twice.""" - if self.process is not None: + """Stop the server, and any server not started yet, and let go of its output. + + Safe to call twice, and from any thread. Once it has been called the + launcher starts no server, even one it is still installing. + """ + with self._process_lock: + self._stopped.set() + process, self.process = self.process, None + output, self._output = self._output, None + if process is not None: try: - self.process.terminate() + process.terminate() except OSError as error: pybreeze_logger.debug("JupyterLab terminate failed: %r", error) - self.process = None - if self._output is not None: - self._output.close() - self._output = None + if output is not None: + output.close() 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 004ec58b..47b0b991 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py @@ -8,6 +8,7 @@ from je_editor import language_wrapper from pybreeze.pybreeze_ui.jupyter_lab_gui.jupyter_lab_thread import JupyterLauncherThread +from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.logging.logger import pybreeze_logger @@ -59,13 +60,17 @@ def closeEvent(self, event): JupyterLab process behind for every tab that had finished loading -- holding its port, and reachable for as long as the machine was up. """ - # Block signals first so a late status/error emit can't reach a slot - # on the widget being torn down. - self.thread.blockSignals(True) - self.thread.stop() if self.thread.isRunning(): - self.thread.quit() - self.thread.wait() + # Still installing or starting: cut off from this tab first, so a + # late status or error cannot reach it, and kept until it ends + # rather than waited for -- an install can take minutes, and a + # QThread destroyed while running aborts the process. (Not + # blockSignals: that would also block the ``finished`` that lets + # the keeper release it.) + let_run_out(self.thread, self.thread.status_update, + self.thread.server_ready, self.thread.error_occurred) + # After this the launcher starts no server, even one still installing. + self.thread.stop() event.accept() diff --git a/test/test_utils/test_ai_gui_lifecycle.py b/test/test_utils/test_ai_gui_lifecycle.py index 0632bd84..e5c5bdee 100644 --- a/test/test_utils/test_ai_gui_lifecycle.py +++ b/test/test_utils/test_ai_gui_lifecycle.py @@ -27,17 +27,25 @@ def _running_thread(): class TestCoTCloseEvent: - def test_running_thread_is_blocked_interrupted_and_awaited(self): + def test_a_running_review_is_interrupted_and_let_go_of_without_waiting(self): + # Waiting froze the IDE for up to a request's read timeout. The thread + # is asked to stop after its current request, cut off from the panel, + # and kept referenced until it ends. + from pybreeze.pybreeze_ui import thread_keeper + gui = CoTCodeReviewGUI.__new__(CoTCodeReviewGUI) - gui.thread = _running_thread() + thread = _running_thread() + gui.thread = thread event = MagicMock() CoTCodeReviewGUI.closeEvent(gui, event) - gui.thread.blockSignals.assert_called_once_with(True) - gui.thread.requestInterruption.assert_called_once() - gui.thread.wait.assert_called_once() + thread.requestInterruption.assert_called_once() + thread.wait.assert_not_called() + thread.update_response.disconnect.assert_called_once() + assert thread_keeper.is_kept(thread) event.accept.assert_called_once() + thread_keeper._OUTLIVING_THEIR_WIDGET.discard(thread) def test_no_thread_just_accepts(self): gui = CoTCodeReviewGUI.__new__(CoTCodeReviewGUI) diff --git a/test/test_utils/test_cot_session_reuse.py b/test/test_utils/test_cot_session_reuse.py index b331bf32..be8ca44e 100644 --- a/test/test_utils/test_cot_session_reuse.py +++ b/test/test_utils/test_cot_session_reuse.py @@ -164,3 +164,31 @@ def __init__(self): except RuntimeError: pass assert created["session"].closed is True + + +def test_closing_mid_review_does_not_wait(monkeypatch): + import threading + import time + + from pybreeze.pybreeze_ui.extend_ai_gui.code_review import code_review_thread + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI + from pybreeze.pybreeze_ui.thread_keeper import is_kept + + app = _qt_app() + answering = threading.Event() + monkeypatch.setattr(code_review_thread.SenderThread, "run", lambda self: answering.wait(5)) + gui = CoTCodeReviewGUI() + gui.thread = code_review_thread.SenderThread(files=[], code="", url="https://review.example") + gui.thread.start() + thread = gui.thread + + gui.close() # returns while the request is still out + + assert is_kept(thread) + assert thread.isInterruptionRequested() + answering.set() + deadline = time.monotonic() + 5 + while is_kept(thread): + assert time.monotonic() < deadline, "the review thread was never let go" + app.processEvents() + time.sleep(0.01) diff --git a/test/test_utils/test_jupyter_lifecycle.py b/test/test_utils/test_jupyter_lifecycle.py index c4839b0b..09360fbb 100644 --- a/test/test_utils/test_jupyter_lifecycle.py +++ b/test/test_utils/test_jupyter_lifecycle.py @@ -133,9 +133,6 @@ def start(self) -> None: def isRunning(self) -> bool: return False - def blockSignals(self, _blocked: bool) -> None: - """Nothing to block in a stand-in.""" - def stop(self) -> None: self.stopped = True @@ -178,3 +175,43 @@ def test_closing_the_ide_closes_its_own_tabs_and_docks(tmp_path): assert "tab" in seen["closed"] assert "dock" in seen["closed"] + + +class TestStoppingBeforeTheServerStarts: + """A tab closed while the launcher checks or installs gets no server afterwards.""" + + def test_a_stopped_launcher_starts_nothing(self, app, launched, monkeypatch): + thread = jupyter_lab_thread.JupyterLauncherThread() + + def installed_while_the_tab_closes(_python: str) -> bool: + thread.stop() # the tab closes during the check (or the install) + return True + + monkeypatch.setattr(jupyter_lab_thread, "is_jupyter_installed", installed_while_the_tab_closes) + + thread.run() + + assert launched == [] + + def test_closing_the_tab_does_not_wait_for_the_launcher(self, app, monkeypatch): + import threading + import time + + from pybreeze.pybreeze_ui.thread_keeper import is_kept + + installing = threading.Event() + monkeypatch.setattr( + jupyter_lab_thread.JupyterLauncherThread, "run", lambda self: installing.wait(5)) + tab = jupyter_lab_widget.JupyterLabWidget() + launcher = tab.thread + + tab.close() # returns with the install still going + + assert is_kept(launcher) + assert launcher._stopped.is_set() + installing.set() + deadline = time.monotonic() + 5 + while is_kept(launcher): + assert time.monotonic() < deadline, "the launcher was never let go" + app.processEvents() + time.sleep(0.01) diff --git a/test/test_utils/test_ssh_teardown.py b/test/test_utils/test_ssh_teardown.py index df660510..20fa2224 100644 --- a/test/test_utils/test_ssh_teardown.py +++ b/test/test_utils/test_ssh_teardown.py @@ -167,6 +167,7 @@ def slow_download(_remote, _local): assert tree._transfer.isRunning(), "the caller waited for the transfer" answering.set() tree._transfer.wait(5000) + app.processEvents() # deliver its "done" while the message box is still stubbed def test_a_second_transfer_is_refused_while_one_runs(self, app, monkeypatch): from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer @@ -203,31 +204,36 @@ def test_a_transfer_that_fails_is_reported(self, app, monkeypatch): assert problems and "link went away" in problems[0][2] - def test_closing_waits_for_a_transfer(self, app): - from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SSHFileTreeManager - - class Waited: - def __init__(self) -> None: - self.waited = False - self.blocked = False - - def isRunning(self) -> bool: - return True - - def blockSignals(self, blocked: bool) -> None: - self.blocked = blocked + def test_closing_leaves_a_transfer_to_finish_then_closes_the_session(self, app, monkeypatch): + import threading + import time - def wait(self) -> None: - self.waited = True + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + from pybreeze.pybreeze_ui.thread_keeper import is_kept - tree = SSHFileTreeManager() + # Nothing may be shown, but a stray signal must not open a modal box. + monkeypatch.setattr(viewer.QMessageBox, "information", staticmethod(lambda *args: None)) + monkeypatch.setattr(viewer.QMessageBox, "critical", staticmethod(lambda *args: None)) + answering = threading.Event() + tree = self._tree(app) tree.client = FakeClient() - tree._transfer = Waited() + tree.client.download = lambda _remote, _local: answering.wait(5) + tree._start_transfer( + downloading=True, remote_path="/tmp/big.bin", local_path="big.bin", + title="Downloaded", message="Saved to") + transfer = tree._transfer - tree.close() + tree.close() # returns with the transfer still going - assert tree._transfer.waited and tree._transfer.blocked - assert tree.client.closed + assert is_kept(transfer) + # Closing the session under the transfer would leave half a file. + assert not tree.client.closed + answering.set() + deadline = time.monotonic() + 5 + while not tree.client.closed or is_kept(transfer): + assert time.monotonic() < deadline, "the session was never closed" + app.processEvents() + time.sleep(0.01) class TestWhatTheStatusLabelSays: From a5fadc9ca4b5337df136511682eadbc189055bd7 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 07:22:09 +0800 Subject: [PATCH 058/312] Stream a plugin's compile step instead of waiting for it --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 22 ++++ docs/updates/README.md | 3 +- progress.md | 1 - .../process_executor/file_runner_process.py | 96 ++++++++------ test/test_utils/test_compile_then_run.py | 118 ++++++++++++++++++ 6 files changed, 201 insertions(+), 45 deletions(-) create mode 100644 test/test_utils/test_compile_then_run.py diff --git a/architecture_explore.md b/architecture_explore.md index 69eea0e9..ce6cb511 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -141,8 +141,8 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - **`file_runner_process.py`** — `FileRunnerProcess`。**唯一不跑 Python 的執行器**,服務插件註冊的 run config: - 直譯式:`compiler [args...] file`(如 `go run main.go`) - - 編譯式:`compiler file -o out` → 執行 `out` → 執行完 `os.remove()` 清掉產物 - - 編譯有 60 秒 timeout,QTimer 間隔 50 ms(比 Python 執行器更快) + - 編譯式:`compiler file -o out` → 執行 `out` → 執行完 `os.remove()` 清掉產物。編譯器跟執行一樣走 `_start_process()`(輸出即時串流、不佔 UI 執行緒),結束碼交給 `after_exit`:0 才接著跑產物,否則印 `[Compile failed]` + - 編譯最多 `COMPILE_TIME_LIMIT_SECONDS`(60 秒),由 pump 檢查、超過就 `terminate()`;編譯中也能 `stop()`。QTimer 間隔 50 ms(比 Python 執行器更快),編譯與執行共用同一個 - 讀取、pump、drain 與寫進視窗都用 §4.5 的共用函式 - `run_current_file_with()` 建好後同樣掛在執行視窗的 `runner` 上 - 有和 `TaskProcessManager` 相同語意的 `stop()` @@ -435,7 +435,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 84 個 `test_*.py`、1259 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 85 個 `test_*.py`、1263 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 47a1c5c0..fd84ca52 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -576,3 +576,25 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/{test_jupyter_lifecycle,test_cot_session_reuse,test_ai_gui_lifecycle,test_ssh_teardown}.py` - `architecture_explore.md` §8, §9, §10, §18 - **Open items**: the plugin compile step (`file_runner_process.py` `_compile_and_run`) still runs the compiler on the UI thread for up to 60 s (`progress.md` #26). + +## U-20260923-39 · 2026-09-23 · A plugin's compile step streams instead of freezing the IDE · #done #executor #plugin + +- **What**: `progress.md` #26. A plugin run config with `compile_then_run` ran its compiler with `subprocess.run(..., timeout=60)` in the Run with… slot. The IDE was frozen for the whole compile, up to a minute. The compiler's output appeared only once it had finished. The run window's Stop could not reach the compile, because there was no process to stop yet. +- **Fix**: + - The compiler now goes through `_start_process()`, the same streamed path as the run, with two new parameters. `after_exit` receives the exit code on the UI thread in place of the exit line; the compile step uses it to start the binary on 0 and to print `[Compile failed] exit code N` otherwise. `time_limit` is checked by the pump, which terminates the child past `COMPILE_TIME_LIMIT_SECONDS` (60) and says so. + - Both, and the binary to clean up, are set only once the child has started, so a compiler that cannot be found leaves nothing behind. + - The compile and the run share one `QTimer` instead of creating a new one per child. + - Stop now reaches a compile in progress. +- **Tests**: new `test_compile_then_run.py`. The compiler is Python running a small script; the run step is recorded rather than executed. It has four tests: + - `run_file` returns while the compiler is still working, its output streams in before it ends, and the binary runs after exit 0; + - a failed compile shows the compiler's error and `[Compile failed] exit code 3`, and nothing runs; + - a compile past its time limit is stopped and reported; + - a missing compiler is reported. + The first test fails on the old code, where `run_file` returned only after the compile. +- **Result / numbers**: unit tests 1245 → 1249 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/extend/process_executor/file_runner_process.py` + - `test/test_utils/test_compile_then_run.py` (new) + - `architecture_explore.md` §4, §18 + - `progress.md` (#26 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 0936735d..98c51d35 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-39 | 2026-09-23 | A plugin's compile step streams instead of freezing the IDE | #done #executor #plugin | [2026-09](2026-09.md) | | U-20260923-38 | 2026-09-23 | Closing a tab no longer waits for its worker, and a closed JupyterLab tab starts no server | #done #jupyter #ssh #ai | [2026-09](2026-09.md) | | U-20260923-37 | 2026-09-23 | Blind catches narrowed to what each call raises | #done #quality | [2026-09](2026-09.md) | | U-20260923-36 | 2026-09-23 | Mailing a run's report no longer freezes the IDE | #done #executor | [2026-09](2026-09.md) | @@ -119,4 +120,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 56 | +| [2026-09.md](2026-09.md) | 2026-09 | 57 | diff --git a/progress.md b/progress.md index 5ed56e01..fc863091 100644 --- a/progress.md +++ b/progress.md @@ -7,4 +7,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? -- **#26** A plugin run that compiles first runs the compiler on the UI thread (`pybreeze/extend/process_executor/file_runner_process.py` `_compile_and_run`, `subprocess.run(..., timeout=60)`): the IDE is frozen for the whole compile, up to 60 s, and the compiler's output only appears once it has finished. The compile should go through the same streamed process path as the run, with the run started when the compile exits 0. diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index 2cd2f982..d1ef6800 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -10,6 +10,8 @@ import os import subprocess import sys +import time +from collections.abc import Callable from pathlib import Path from queue import Queue from threading import Thread @@ -21,6 +23,8 @@ from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.subprocess_util import no_window_creationflags, utf8_subprocess_env +COMPILE_TIME_LIMIT_SECONDS = 60 + class FileRunnerProcess: """Manages subprocess execution for any language file.""" @@ -41,6 +45,11 @@ def __init__( self.timer: QTimer | None = None self._stdout_thread: Thread | None = None self._stderr_thread: Thread | None = None + self._cleanup_binary: str | None = None + # What to do with the exit code instead of reporting it (the compile + # step starts the run from here), and when to give up on the child + self._after_exit: Callable[[int], None] | None = None + self._deadline: float | None = None def run_file(self, run_config: dict, file_path: str) -> None: """ @@ -64,7 +73,12 @@ def run_file(self, run_config: dict, file_path: str) -> None: self._start_process(command) def _compile_and_run(self, compiler: str, args: list, output_flag: str, file_path: str) -> None: - """Compile, then run the output binary.""" + """Compile, then run the output binary. + + The compiler is a child like the run: its output streams into the window + as it comes, and the IDE stays usable while it works. It used to run to + completion on the UI thread, which froze the IDE for up to a minute. + """ path = Path(file_path) output_name = str(path.with_suffix("")) if sys.platform in ("win32", "cygwin", "msys"): @@ -73,39 +87,27 @@ def _compile_and_run(self, compiler: str, args: list, output_flag: str, file_pat compile_cmd = [compiler] + args + [file_path, output_flag, output_name] self.main_window.append_output(f"[Compile] {' '.join(compile_cmd)}\n", is_error=False) - try: - # Runs the plugin-configured compiler against a file the user opened. - # shell=False, bounded timeout. nosec B603. - result = subprocess.run( # nosec B603 # nosemgrep # noqa: S603 - compile_cmd, - capture_output=True, - timeout=60, - check=False, - creationflags=no_window_creationflags(), - ) - except FileNotFoundError: - self.main_window.append_output(f"[Error] Compiler not found: {compiler}\n", is_error=True) - return - except subprocess.TimeoutExpired: - self.main_window.append_output("[Error] Compilation timed out (60s)\n", is_error=True) - return - - if result.stdout: - self.main_window.append_output(result.stdout.decode(self.program_encoding, "replace"), is_error=False) - if result.stderr: - self.main_window.append_output(result.stderr.decode(self.program_encoding, "replace"), is_error=True) - - if result.returncode != 0: - self.main_window.append_output(f"[Compile failed] exit code {result.returncode}\n", is_error=True) - return - - self.main_window.append_output(f"[Run] {output_name}\n", is_error=False) - self._start_process([output_name], cleanup_binary=output_name) - - def _start_process(self, command: list[str], cleanup_binary: str | None = None) -> None: - """Launch subprocess and start output reading.""" - self._cleanup_binary = cleanup_binary - + def run_if_compiled(exit_code: int) -> None: + if exit_code != 0: + self.main_window.append_output(f"[Compile failed] exit code {exit_code}\n", is_error=True) + return + self.main_window.append_output(f"[Run] {output_name}\n", is_error=False) + self._start_process([output_name], cleanup_binary=output_name) + + self._start_process( + compile_cmd, after_exit=run_if_compiled, time_limit=COMPILE_TIME_LIMIT_SECONDS) + + def _start_process(self, command: list[str], cleanup_binary: str | None = None, + after_exit: Callable[[int], None] | None = None, + time_limit: float | None = None) -> None: + """Launch subprocess and start output reading. + + :param command: the argv to run + :param cleanup_binary: a file to delete once the child has exited + :param after_exit: called with the exit code, on the UI thread, in place + of the exit line + :param time_limit: seconds after which the child is stopped + """ cmd_display = " ".join(command) self.main_window.append_output(f"> {cmd_display}\n", is_error=False) @@ -127,6 +129,9 @@ def _start_process(self, command: list[str], cleanup_binary: str | None = None) self.main_window.append_output(f"[Error] Command not found: {command[0]}\n", is_error=True) return + self._cleanup_binary = cleanup_binary + self._after_exit = after_exit + self._deadline = None if time_limit is None else time.monotonic() + time_limit self.still_running = True self._stdout_thread = Thread(target=self._read_stdout, daemon=True) @@ -136,9 +141,10 @@ def _start_process(self, command: list[str], cleanup_binary: str | None = None) self._stderr_thread.start() self.main_window.show() - self.timer = QTimer(self.main_window) - self.timer.setInterval(50) - self.timer.timeout.connect(self._pull_text) + if self.timer is None: + self.timer = QTimer(self.main_window) + self.timer.setInterval(50) + self.timer.timeout.connect(self._pull_text) self.timer.start() def stop(self) -> None: @@ -159,6 +165,11 @@ def _pull_text(self) -> None: self.process.poll() if self.process.returncode is not None: self._finish() + elif self._deadline is not None and time.monotonic() > self._deadline: + self._deadline = None + self.main_window.append_output( + f"[Error] Timed out after {COMPILE_TIME_LIMIT_SECONDS}s\n", is_error=True) + self.process.terminate() def _finish(self) -> None: """Clean up after process exits.""" @@ -177,12 +188,17 @@ def _finish(self) -> None: # Drain remaining output directly (not via _pull_text to avoid recursion) self._drain_queues() + after_exit, self._after_exit = self._after_exit, None if self.process is not None: + exit_code = self.process.returncode + self.process = None + if after_exit is not None: + after_exit(exit_code) + return self.main_window.append_output( - f"\n[Process exited with code {self.process.returncode}]\n", - is_error=self.process.returncode != 0, + f"\n[Process exited with code {exit_code}]\n", + is_error=exit_code != 0, ) - self.process = None # Clean up compiled binary if self._cleanup_binary: diff --git a/test/test_utils/test_compile_then_run.py b/test/test_utils/test_compile_then_run.py new file mode 100644 index 00000000..58cb21ca --- /dev/null +++ b/test/test_utils/test_compile_then_run.py @@ -0,0 +1,118 @@ +"""A plugin run that compiles first: the compiler is a streamed child, not a UI-thread wait.""" +from __future__ import annotations + +import os +import sys +import time + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +from pybreeze.extend.process_executor import file_runner_process +from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + +_WAIT_SECONDS = 30 + + +@pytest.fixture(scope="module") +def qt_app(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +def _run_events_until(qt_app, condition) -> None: + deadline = time.monotonic() + _WAIT_SECONDS + while not condition(): + assert time.monotonic() < deadline, "timed out" + qt_app.processEvents() + time.sleep(0.01) + + +def _compiler(tmp_path, body: str) -> dict: + """A run config whose "compiler" is Python running *body* as a script.""" + script = tmp_path / "compiler.py" + script.write_text("import sys, time, pathlib\n" + body, encoding="utf-8") + return {"name": "Fake C", "compiler": sys.executable, "args": (str(script),), + "compile_then_run": True, "output_flag": "-o"} + + +def _runner(monkeypatch): + """A runner whose compile runs for real and whose run step is only recorded.""" + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + runner = FileRunnerProcess(window) + runs: list = [] + real_start = runner._start_process + + def start(command, cleanup_binary=None, after_exit=None, time_limit=None): + if after_exit is None: # the run of the compiled binary + runs.append((command, cleanup_binary)) + return + real_start(command, cleanup_binary, after_exit, time_limit) + + monkeypatch.setattr(runner, "_start_process", start) + return window, runner, runs + + +class TestCompileThenRun: + def test_the_compile_does_not_hold_the_ui_and_the_run_follows(self, qt_app, tmp_path, monkeypatch): + go = tmp_path / "go" + config = _compiler(tmp_path, ( + f"flag = pathlib.Path({str(go)!r})\n" + "print('compiling', flush=True)\n" + "deadline = time.monotonic() + 20\n" + "while not flag.exists() and time.monotonic() < deadline:\n" + " time.sleep(0.02)\n" + )) + window, runner, runs = _runner(monkeypatch) + source = tmp_path / "main.c" + + runner.run_file(config, str(source)) + assert runner.process is not None # back while the compiler still works + _run_events_until(qt_app, lambda: "compiling" in window.code_result.toPlainText()) + assert runs == [] # its output streamed in before it finished + go.touch() + _run_events_until(qt_app, lambda: runs) + + expected = str(source.with_suffix("")) + (".exe" if sys.platform == "win32" else "") + assert runs == [([expected], expected)] + assert f"[Run] {expected}" in window.code_result.toPlainText() + + def test_a_failed_compile_is_reported_and_nothing_runs(self, qt_app, tmp_path, monkeypatch): + config = _compiler(tmp_path, "print('main.c:1: error', file=sys.stderr)\nsys.exit(3)\n") + window, runner, runs = _runner(monkeypatch) + + runner.run_file(config, str(tmp_path / "main.c")) + _run_events_until(qt_app, lambda: runner.process is None) + + text = window.code_result.toPlainText() + assert "main.c:1: error" in text + assert text.endswith("[Compile failed] exit code 3\n") + assert "[Process exited" not in text + assert runs == [] + + def test_a_compile_that_runs_too_long_is_stopped(self, qt_app, tmp_path, monkeypatch): + monkeypatch.setattr(file_runner_process, "COMPILE_TIME_LIMIT_SECONDS", 0.5) + config = _compiler(tmp_path, "time.sleep(30)\n") + window, runner, runs = _runner(monkeypatch) + + runner.run_file(config, str(tmp_path / "main.c")) + _run_events_until(qt_app, lambda: runner.process is None) + + text = window.code_result.toPlainText() + assert "Timed out after 0.5s" in text + assert "[Compile failed]" in text + assert runs == [] + + def test_a_missing_compiler_is_reported(self, qt_app, tmp_path, monkeypatch): + window, runner, runs = _runner(monkeypatch) + config = {"name": "Fake C", "compiler": str(tmp_path / "no-such-compiler"), + "compile_then_run": True} + + runner.run_file(config, str(tmp_path / "main.c")) + + assert "[Error] Command not found" in window.code_result.toPlainText() + assert runner.process is None and runs == [] From 3a6ef6de9eec43dd622d1a991d1c543d1ae54805 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 07:28:18 +0800 Subject: [PATCH 059/312] Refuse SHA-1 on every SSH connect --- CLAUDE.md | 2 +- architecture_explore.md | 6 +-- docs/updates/2026-09.md | 17 ++++++++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_command_widget.py | 10 +++-- .../connect_gui/ssh/ssh_connect_thread.py | 16 +++++++ .../connect_gui/ssh/ssh_file_viewer_widget.py | 10 +++-- test/test_utils/test_ssh_reentrancy.py | 43 ++++++++++++++++++- test/test_utils/test_ssh_security.py | 18 ++++++++ 9 files changed, 113 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 687c420e..51e215cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ ruff check pybreeze/ # before committing non-trivia Reference implementations: `utils/network/url_validation.py` (`validate_url`), `utils/network/http_client.py` (`read_capped_text`), `diagram_editor/diagram_net_utils.py` (`safe_download_image`). Never pass a user URL to `urlopen()` / `requests.*` unvalidated, and never set `verify=False`. -**SSH** — never `paramiko.AutoAddPolicy()` or `WarningPolicy()`. Use `apply_host_key_policy(client, parent_widget)` from `connect_gui/ssh/ssh_host_key_policy.py`: it shows the SHA256 fingerprint for confirmation on first connect and persists to `~/.pybreeze/ssh_known_hosts`. +**SSH** — never `paramiko.AutoAddPolicy()` or `WarningPolicy()`. Use `apply_host_key_policy(client, parent_widget)` from `connect_gui/ssh/ssh_host_key_policy.py`: it shows the SHA256 fingerprint for confirmation on first connect and persists to `~/.pybreeze/ssh_known_hosts`. Every `connect()` passes `disabled_algorithms=SHA1_ALGORITHMS` (`connect_gui/ssh/ssh_connect_thread.py`): `requirements.txt` does not pin paramiko, and paramiko 4 still offers SHA-1 signatures and key exchanges (CVE-2026-44405). **Subprocess** — always argument lists, explicit `shell=False`, `timeout` on every `subprocess.run()`. Never interpolate user input into a command string. Secrets travel as `env`, never argv (see `prthinker_setting.environment_for`). The IDE intentionally runs user-authored scripts — this hardening guards against accidental shell injection, not against malicious local files. diff --git a/architecture_explore.md b/architecture_explore.md index ce6cb511..17f9bcfe 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -290,7 +290,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,513 行) +### `ssh/`(1,537 行) | 檔案 | 職責 | |---|---| @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel,ANSI escape 用 regex 剝除,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | | `ssh_file_viewer_widget.py` (793) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;連線交給 `SshConnectThread`,成功後才列出根目錄;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等 | -| `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | +| `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | ### `url/ai_code_review_gui.py` @@ -435,7 +435,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 85 個 `test_*.py`、1263 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 85 個 `test_*.py`、1266 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index fd84ca52..8edbdd26 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -598,3 +598,20 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §4, §18 - `progress.md` (#26 removed) - **Open items**: none. + +## U-20260923-40 · 2026-09-23 · SSH connects refuse SHA-1 on any paramiko (CVE-2026-44405) · #done #security #ssh + +- **What**: CVE-2026-44405 (CVSS 3.4): paramiko through 4.0.0 accepts RSA signatures made with SHA-1. paramiko 5.0.0 (2026-05-09) removed SHA-1 entirely: `ssh-rsa` signatures and the three SHA-1 Diffie-Hellman key exchanges. `requirements.txt` and `pyproject.toml` do not pin paramiko, so what a user gets depends on when they installed. This machine's `.venv` has 4.0.0, and a real `Transport` there offers six SHA-1 entries by default: `ssh-rsa` and `ssh-rsa-cert-v01@openssh.com` host keys, `ssh-rsa` auth, and `diffie-hellman-group1/14/group-exchange-sha1`. +- **Fix**: new `SHA1_ALGORITHMS` in `ssh_connect_thread.py` is passed as `disabled_algorithms` to all four `connect()` calls: the shell's password and key paths, and the SFTP wrapper's. With it, paramiko 4.0.0 offers no SHA-1, and RSA keys still work through `rsa-sha2-256/512`. paramiko 5.0.0 accepts the same list. This matches OpenSSH, which has refused `ssh-rsa` signatures by default since 8.8. CLAUDE.md § Security › SSH now requires it. +- **Checked against paramiko 5.0.0**, which CI installs because nothing pins it. The 5.0 breaking changes are SHA-1, GSSAPI, `demos/`, `PKey.write_private_key*` moved to the base class, and `from_path(passphrase=)` renamed to `password=`. None of them touch this code: the key loader uses only `RSAKey`/`Ed25519Key`/`ECDSAKey.from_private_key_file`. DSA went in 4.0 and was already not used. All 67 SSH/SFTP tests pass with 5.0.0 installed to a throwaway `--target` directory, without touching the shared `.venv`. +- **Tests**: + - `test_ssh_security.py`: a real `Transport` given `SHA1_ALGORITHMS` offers no SHA-1 and still offers `rsa-sha2-256`; + - `test_ssh_reentrancy.py`: the shell's connect and the SFTP wrapper's connect both pass it. + All pass on 4.0.0 and 5.0.0. +- **Result / numbers**: unit tests 1249 → 1252 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/{ssh_connect_thread,ssh_command_widget,ssh_file_viewer_widget}.py` + - `test/test_utils/{test_ssh_security,test_ssh_reentrancy}.py` + - `CLAUDE.md` § Security › SSH + - `architecture_explore.md` §9, §18 +- **Open items**: whether to require `paramiko>=5.0.0` is a dependency-policy decision for the owner, like every pin here. diff --git a/docs/updates/README.md b/docs/updates/README.md index 98c51d35..234102e8 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-40 | 2026-09-23 | SSH connects refuse SHA-1 on any paramiko (CVE-2026-44405) | #done #security #ssh | [2026-09](2026-09.md) | | U-20260923-39 | 2026-09-23 | A plugin's compile step streams instead of freezing the IDE | #done #executor #plugin | [2026-09](2026-09.md) | | U-20260923-38 | 2026-09-23 | Closing a tab no longer waits for its worker, and a closed JupyterLab tab starts no server | #done #jupyter #ssh #ai | [2026-09](2026-09.md) | | U-20260923-37 | 2026-09-23 | Blind catches narrowed to what each call raises | #done #quality | [2026-09](2026-09.md) | @@ -120,4 +121,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 57 | +| [2026-09.md](2026-09.md) | 2026-09 | 58 | 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 a4d5966c..ebd8737c 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -12,7 +12,9 @@ ) from je_editor import language_wrapper -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import CONNECT_ERRORS, SshConnectThread +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import ( + CONNECT_ERRORS, SHA1_ALGORITHMS, SshConnectThread +) from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import ( apply_host_key_policy, host_key_asker ) @@ -193,7 +195,8 @@ def connect() -> None: else: def connect() -> None: client.connect( - hostname=host, port=port, username=user, password=password, timeout=10) + hostname=host, port=port, username=user, password=password, timeout=10, + disabled_algorithms=SHA1_ALGORITHMS) # The connect itself runs off the UI thread: an unreachable host used to # hold the IDE for the connect, banner and auth timeouts together. thread = SshConnectThread(connect) @@ -212,7 +215,8 @@ def _connect_with_key(self, client: paramiko.SSHClient, host: str, port: int, us self.word_dict.get( "ssh_command_widget_error_message_unsupported_private_key" )) - client.connect(hostname=host, port=port, username=user, pkey=pkey, timeout=10) + client.connect(hostname=host, port=port, username=user, pkey=pkey, timeout=10, + disabled_algorithms=SHA1_ALGORITHMS) except CONNECT_ERRORS as e: raise RuntimeError( f"{self.word_dict.get('ssh_command_widget_error_message_key_auth_failed')} {e}") from e diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_connect_thread.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_connect_thread.py index b896580b..3b98229b 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_connect_thread.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_connect_thread.py @@ -21,6 +21,22 @@ # EOF from a server that hangs up during the handshake. CONNECT_ERRORS = (paramiko.SSHException, OSError, ValueError, RuntimeError, EOFError) +# SHA-1 in SSH, refused on every connect: RSA signatures made with SHA-1 +# ("ssh-rsa" and its certificate) and the SHA-1 key exchanges. paramiko 5 +# removed them (CVE-2026-44405); a paramiko 4 install still offers all of +# them, and refusing them here keeps it from falling back to one. OpenSSH has +# refused "ssh-rsa" signatures by default since 8.8; RSA keys still work +# through rsa-sha2-256/512. +SHA1_ALGORITHMS = { + "pubkeys": ("ssh-rsa", "ssh-rsa-cert-v01@openssh.com"), + "keys": ("ssh-rsa", "ssh-rsa-cert-v01@openssh.com"), + "kex": ( + "diffie-hellman-group1-sha1", + "diffie-hellman-group14-sha1", + "diffie-hellman-group-exchange-sha1", + ), +} + class SshConnectThread(QThread): """Call *connect* on this thread; report ``connected`` or ``failed(message)``.""" diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 26ac971d..bba33c25 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -13,7 +13,9 @@ ) from je_editor import language_wrapper -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import CONNECT_ERRORS, SshConnectThread +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import ( + CONNECT_ERRORS, SHA1_ALGORITHMS, SshConnectThread +) from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import ( apply_host_key_policy, host_key_asker ) @@ -115,9 +117,11 @@ def connect(self, host: str, port: int, username: str, password: str, raise ValueError( self.word_dict.get("ssh_command_widget_error_message_unsupported_private_key") ) - self._ssh.connect(hostname=host, port=port, username=username, pkey=pkey, timeout=10) + self._ssh.connect(hostname=host, port=port, username=username, pkey=pkey, timeout=10, + disabled_algorithms=SHA1_ALGORITHMS) else: - self._ssh.connect(hostname=host, port=port, username=username, password=password, timeout=10) + self._ssh.connect(hostname=host, port=port, username=username, password=password, + timeout=10, disabled_algorithms=SHA1_ALGORITHMS) transport = self._ssh.get_transport() if transport is not None: transport.set_keepalive(SSH_KEEPALIVE_SECONDS) diff --git a/test/test_utils/test_ssh_reentrancy.py b/test/test_utils/test_ssh_reentrancy.py index f68cce3c..91ff49b7 100644 --- a/test/test_utils/test_ssh_reentrancy.py +++ b/test/test_utils/test_ssh_reentrancy.py @@ -44,9 +44,11 @@ def __init__(self, release: threading.Event | None = None, error: Exception | No self.error = error self.closed = False self.connect_thread: QThread | None = None + self.connect_options: dict = {} - def connect(self, **_kwargs) -> None: + def connect(self, **options) -> None: self.connect_thread = QThread.currentThread() + self.connect_options = options if self.release is not None: self.release.wait(WAIT_SECONDS) if self.error is not None: @@ -247,3 +249,42 @@ def test_asked_from_a_connecting_thread_it_waits_for_the_ui(self, app, monkeypat assert answers == [False] assert shown_on == [app.thread()] + + +class TestSha1IsRefused: + """Every connect refuses SHA-1, whichever paramiko is installed (CVE-2026-44405).""" + + def test_the_shell_refuses_it(self, app, monkeypatch): + client = FakeClient() + widget = _shell(monkeypatch, client) + widget._start_shell = lambda *args: None + + widget.connect_ssh() + _wait_for(lambda: client.connect_options and not widget._connecting.isRunning()) + + assert client.connect_options["disabled_algorithms"] is shell_mod.SHA1_ALGORITHMS + + def test_the_file_tree_refuses_it(self, app, monkeypatch): + connects = [] + + class Client: + def set_missing_host_key_policy(self, _policy) -> None: + """Nothing to set in a stand-in.""" + + def load_host_keys(self, _path) -> None: + """Nothing to load in a stand-in.""" + + def connect(self, **options) -> None: + connects.append(options) + raise OSError("stop here") + + def close(self) -> None: + """Nothing to close.""" + + monkeypatch.setattr(tree_mod.paramiko, "SSHClient", Client) + monkeypatch.setattr(tree_mod, "apply_host_key_policy", lambda _client, _parent: None) + + with pytest.raises(OSError): + tree_mod.SFTPClientWrapper().connect("host", 22, "user", "pw") + + assert connects[0]["disabled_algorithms"] is tree_mod.SHA1_ALGORITHMS diff --git a/test/test_utils/test_ssh_security.py b/test/test_utils/test_ssh_security.py index f87e249c..e666dce7 100644 --- a/test/test_utils/test_ssh_security.py +++ b/test/test_utils/test_ssh_security.py @@ -5,6 +5,7 @@ import paramiko +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import SHA1_ALGORITHMS from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import _fingerprint_sha256 from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key @@ -48,3 +49,20 @@ def test_encrypted_key_requires_correct_passphrase(self, tmp_path): paramiko.RSAKey.generate(2048).write_private_key_file(str(path), password="secret") assert load_private_key(str(path), "wrong") is None assert isinstance(load_private_key(str(path), "secret"), paramiko.RSAKey) + + +class TestSha1Algorithms: + def test_a_transport_given_them_offers_no_sha1(self): + import socket + + ours, theirs = socket.socketpair() + transport = paramiko.Transport(ours, disabled_algorithms=SHA1_ALGORITHMS) + try: + offered = (*transport.preferred_keys, *transport.preferred_kex, *transport.preferred_pubkeys) + finally: + transport.close() + theirs.close() + + assert [name for name in offered if "sha1" in name or name.startswith("ssh-rsa")] == [] + # RSA keys still work, signed with SHA-2 + assert "rsa-sha2-256" in transport.preferred_pubkeys From 4ad4f589109a6d0cacfaaae52661cc62d4d3022e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 07:28:25 +0800 Subject: [PATCH 060/312] Ask whether paramiko should require 5.0 --- progress.md | 1 + 1 file changed, 1 insertion(+) diff --git a/progress.md b/progress.md index fc863091..7d94b9ad 100644 --- a/progress.md +++ b/progress.md @@ -7,3 +7,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- ## Open - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? +- **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? From 20ce9bc41ac14aca300f41df4428b0c261026309 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 07:33:23 +0800 Subject: [PATCH 061/312] Count only a 2xx as an AI answer --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 26 +++++ docs/updates/README.md | 3 +- .../connect_gui/url/ai_code_review_gui.py | 4 +- .../code_review/code_review_thread.py | 12 ++- .../extend_ai_gui/skills/skills_send_gui.py | 4 +- pybreeze/utils/network/http_client.py | 10 ++ test/test_utils/test_ai_code_review_client.py | 12 +++ test/test_utils/test_cot_session_reuse.py | 38 +++++++- test/test_utils/test_http_client.py | 14 +++ test/test_utils/test_skills_request.py | 95 +++++++++++++++++++ 11 files changed, 213 insertions(+), 11 deletions(-) create mode 100644 test/test_utils/test_skills_request.py diff --git a/architecture_explore.md b/architecture_explore.md index 17f9bcfe..07316c7c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -284,7 +284,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 `cot_chain.py` 用兩張表描述接線:`STEP_RESULT_KEY`(每步答案存在哪個 key)與 `STEP_ARGUMENTS`(每步的 placeholder 由哪個 key 填)。**順序即相依順序** —— 每步只能引用它上面的步驟,`test_cot_chain.py` 有結構性測試守住這件事。步驟失敗時錯誤訊息只顯示給使用者、不會被存進 results,避免後續步驟把「傳送失敗」當成審查內容引用。每步都套 `build_global_rule_template()` 包一層全域規則。 -安全處理:送出前 `validate_url()`、`allow_redirects=False`、`stream=True` 搭配 `read_capped_text()` 限制回應大小、單一 `requests.Session` 重用 TCP/TLS 連線、`isInterruptionRequested()` 讓 widget 關閉時能中止。 +安全處理:送出前 `validate_url()`、`allow_redirects=False`、`stream=True` 搭配 `read_capped_text()` 限制回應大小、非 2xx(`succeeded()`)算這一步失敗、不會被後面的步驟引用、單一 `requests.Session` 重用 TCP/TLS 連線、`isInterruptionRequested()` 讓 widget 關閉時能中止。 --- @@ -340,7 +340,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | -| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`truncate_for_display()`、`CONNECT_TIMEOUT` | +| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | | `curl_import/` | `curl_parser.py`(401) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`-b`、`-G`);`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式;`script_templates.py`(271) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(304) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重 | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | @@ -435,7 +435,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 85 個 `test_*.py`、1266 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 86 個 `test_*.py`、1286 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 8edbdd26..57087a2f 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -615,3 +615,29 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `CLAUDE.md` § Security › SSH - `architecture_explore.md` §9, §18 - **Open items**: whether to require `paramiko>=5.0.0` is a dependency-policy decision for the owner, like every pin here. + +## U-20260923-41 · 2026-09-23 · A redirect or an HTTP error no longer passes for an AI answer · #done #ai + +- **What**: the three AI request threads send with `allow_redirects=False` and judged the answer by `response.ok`. In `requests`, `ok` is true for every status below 400, checked here: a 302 has `ok == True`. So: + - **Skills**: a redirect took the `ok` branch and showed an empty answer. The branch meant for redirects, which reports the status and where it pointed, could never run. + - **AI code review panel**: same. Its comment said a 302 would not look like a success, but it did. + - **CoT review chain**: `_ask()` never looked at the status at all. An HTTP 500 error page counted as the step's answer and was quoted into every later step as if it were the model's findings. Failed steps are meant to be kept out of later prompts (`test_a_failed_step_is_shown_but_never_quoted_back`). +- **Found by**: a characterization test written before a planned refactor of the Skills thread's status branches. The redirect case failed against the unchanged code. +- **Fix**: + - New `http_client.succeeded(response)`: 2xx only. + - Skills and the AI review panel use it in place of `.ok`. + - The CoT step counts a non-2xx as a failed step. It is shown as `HTTP ` with the start of the body, logged, and not quoted onward. +- **Tests**: + - new `test_skills_request.py` pins every branch of the Skills thread: 2xx, redirect, 401/403, 5xx, another 4xx, connection failure; + - `test_http_client.py`: `succeeded()` on 2xx and on 1xx/3xx/4xx/5xx; + - `test_ai_code_review_client.py`: a 302 is reported as `HTTP 302 Found`; + - `test_cot_session_reuse.py`: an error page is a failed step and is not quoted onward. Its fake response now has a status. +- **Result / numbers**: unit tests 1252 → 1272 passed, 14 skipped. `ruff check` clean. Dependency audit, a throwaway venv resolving `requirements.txt` today with `pip-audit`: no known vulnerabilities. +- **Files**: + - `pybreeze/utils/network/http_client.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py` + - `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py` + - `test/test_utils/{test_skills_request (new),test_http_client,test_ai_code_review_client,test_cot_session_reuse}.py` + - `architecture_explore.md` §8, §12, §18 +- **Open items**: the Skills status branches and `AICodeReviewClient.__init__` (97 lines, over the 75-line cap) are still to be restructured. That is a refactor, so it is its own stage, now that the tests above pin the behaviour. diff --git a/docs/updates/README.md b/docs/updates/README.md index 234102e8..7e6c820d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-41 | 2026-09-23 | A redirect or an HTTP error no longer passes for an AI answer | #done #ai | [2026-09](2026-09.md) | | U-20260923-40 | 2026-09-23 | SSH connects refuse SHA-1 on any paramiko (CVE-2026-44405) | #done #security #ssh | [2026-09](2026-09.md) | | U-20260923-39 | 2026-09-23 | A plugin's compile step streams instead of freezing the IDE | #done #executor #plugin | [2026-09](2026-09.md) | | U-20260923-38 | 2026-09-23 | Closing a tab no longer waits for its worker, and a closed JupyterLab tab starts no server | #done #jupyter #ssh #ai | [2026-09](2026-09.md) | @@ -121,4 +122,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 58 | +| [2026-09.md](2026-09.md) | 2026-09 | 59 | 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 071c7335..c65fa494 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 @@ -16,7 +16,7 @@ from pybreeze.utils.hash_tools.hash_text import hash_text from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( - ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, + ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, succeeded, ) from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -66,7 +66,7 @@ def run(self) -> None: validate_url(self._url) response = self._send() body = read_capped_text(response) - if response.ok: + if succeeded(response): self.answered.emit(body) else: # Without this a 302 (redirects are not followed) or a 500 with 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 280df72f..b505f48d 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 @@ -10,7 +10,7 @@ ) from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( - ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, + ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, succeeded, truncate_for_display, ) from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -68,8 +68,16 @@ def _ask(self, session: requests.Session, file: str, prompt: str) -> tuple[str, self.url, json={"prompt": prompt}, timeout=(CONNECT_TIMEOUT, 60), allow_redirects=False, stream=True, ) - return read_capped_text(resp), True + body = read_capped_text(resp) except (requests.RequestException, ResponseTooLargeError) as error: pybreeze_logger.error("CoT code review send failed for %s: %r", file, error) word = language_wrapper.language_word_dict return f"{word.get('cot_gui_error_sending')} {file} {error}", False + if not succeeded(resp): + # An error page or an unfollowed redirect is not an answer: counted + # as one, it was quoted into every later step of the chain. + pybreeze_logger.error("CoT code review step %s got HTTP %s", file, resp.status_code) + word = language_wrapper.language_word_dict + return (f"{word.get('cot_gui_error_sending')} {file} " + f"HTTP {resp.status_code}\n{truncate_for_display(body)}"), False + return body, True 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 ed792ddd..699d7510 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 @@ -15,7 +15,7 @@ from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( - ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, truncate_for_display, + ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, succeeded, truncate_for_display, ) from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -39,7 +39,7 @@ def run(self): timeout=(CONNECT_TIMEOUT, 30), allow_redirects=False, stream=True, ) body = read_capped_text(response) - if response.ok: + if succeeded(response): self.answered.emit(body) elif response.is_redirect: self.answered.emit( diff --git a/pybreeze/utils/network/http_client.py b/pybreeze/utils/network/http_client.py index e1443a9c..803e05c2 100644 --- a/pybreeze/utils/network/http_client.py +++ b/pybreeze/utils/network/http_client.py @@ -61,6 +61,16 @@ def read_capped_text( return body.decode(default_encoding, "replace") +def succeeded(response) -> bool: + """Whether *response* answered with a 2xx status. + + Not ``response.ok``: that is true for every status below 400, so a redirect + -- which these requests do not follow -- would pass for an answer, usually + an empty one. + """ + return 200 <= response.status_code < 300 + + def truncate_for_display(text: str, limit: int = DISPLAY_TRUNCATE_CHARS) -> str: """Shorten *text* for safe embedding in an error message / dialog.""" if len(text) <= limit: diff --git a/test/test_utils/test_ai_code_review_client.py b/test/test_utils/test_ai_code_review_client.py index b92b25b7..2cf986bf 100644 --- a/test/test_utils/test_ai_code_review_client.py +++ b/test/test_utils/test_ai_code_review_client.py @@ -123,6 +123,18 @@ class Response: assert answered and "500" in answered[0] + def test_a_redirect_is_reported_not_shown_as_an_empty_answer(self, app, monkeypatch): + # requests calls every status below 400 "ok", a 302 included. + class Response: + ok = True + status_code = 302 + reason = "Found" + text = "" + + answered, failed = self._run(monkeypatch, Response()) + + assert answered and answered[0].startswith("HTTP 302 Found") + def test_a_request_that_fails_does_not_log_the_url(self, app, monkeypatch): logged: list = [] monkeypatch.setattr( diff --git a/test/test_utils/test_cot_session_reuse.py b/test/test_utils/test_cot_session_reuse.py index be8ca44e..924267ef 100644 --- a/test/test_utils/test_cot_session_reuse.py +++ b/test/test_utils/test_cot_session_reuse.py @@ -6,8 +6,9 @@ class _FakeResponse: - def __init__(self, body: bytes): + def __init__(self, body: bytes, status_code: int = 200): self._body = body + self.status_code = status_code self.encoding = "utf-8" self.closed = False @@ -192,3 +193,38 @@ def test_closing_mid_review_does_not_wait(monkeypatch): assert time.monotonic() < deadline, "the review thread was never let go" app.processEvents() time.sleep(0.01) + + +class _ErrorPageSession(_FakeSession): + """Answers every step but the *fail_on*-th, which gets an HTTP 500 error page.""" + + def __init__(self, fail_on: int): + super().__init__() + self.fail_on = fail_on + + def post(self, url, **kwargs): + self.post_calls.append((url, kwargs)) + if len(self.post_calls) == self.fail_on: + return _FakeResponse(b"

Internal Server Error

", status_code=500) + return _FakeResponse(f"answer {len(self.post_calls)}".encode()) + + +def test_an_error_status_is_a_failed_step_not_an_answer(): + # An HTTP error page used to count as the step's answer and was quoted into + # the steps after it as if it were the model's findings. + _qt_app() + from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread + + linter_step = COT_TEMPLATE_FILES.index("linter.md") + thread = SenderThread(files=list(COT_TEMPLATE_FILES), code="print('x')", + url="https://example.com/api") + received = {} + thread.update_response.connect(lambda name, resp: received.__setitem__(name, resp)) + + session = _ErrorPageSession(fail_on=linter_step + 1) + thread._run_templates(session, "print('x')") + + assert "HTTP 500" in received["linter.md"] + later = _prompts(session)[linter_step + 1:] + assert not any("Internal Server Error" in prompt for prompt in later) diff --git a/test/test_utils/test_http_client.py b/test/test_utils/test_http_client.py index a956693f..0a9c15b2 100644 --- a/test/test_utils/test_http_client.py +++ b/test/test_utils/test_http_client.py @@ -1,11 +1,14 @@ from __future__ import annotations +from types import SimpleNamespace + import pytest from pybreeze.utils.network.http_client import ( CONNECT_TIMEOUT, ResponseTooLargeError, read_capped_text, + succeeded, truncate_for_display, ) @@ -83,3 +86,14 @@ def test_an_encoding_python_does_not_know_falls_back(self): resp = FakeResponse("héllo".encode("utf-8"), encoding="totally-made-up") assert read_capped_text(resp, default_encoding="utf-8") == "héllo" + + +class TestSucceeded: + @pytest.mark.parametrize("status", [200, 201, 204, 299]) + def test_a_2xx_is_an_answer(self, status): + assert succeeded(SimpleNamespace(status_code=status)) + + @pytest.mark.parametrize("status", [199, 301, 302, 304, 400, 404, 500]) + def test_anything_else_is_not(self, status): + # 3xx included, although requests calls it "ok" + assert not succeeded(SimpleNamespace(status_code=status)) diff --git a/test/test_utils/test_skills_request.py b/test/test_utils/test_skills_request.py new file mode 100644 index 00000000..2b132a52 --- /dev/null +++ b/test/test_utils/test_skills_request.py @@ -0,0 +1,95 @@ +"""What a Skills request reports for each kind of answer the endpoint gives.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +import requests + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.extend_ai_gui.skills import skills_send_gui +from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import RequestThread + +_URL = "https://skills.example/api" + + +@pytest.fixture(scope="module", autouse=True) +def app(): + from PySide6.QtWidgets import QApplication + + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeResponse: + def __init__(self, status_code: int, body: str = "", location: str | None = None) -> None: + self.status_code = status_code + self.ok = status_code < 400 + self.is_redirect = location is not None + self.headers = {"Location": location} if location else {} + self.encoding = "utf-8" + self._body = body.encode("utf-8") + + def iter_content(self, chunk_size: int = 65536): + yield self._body + + def close(self) -> None: + """Nothing to close.""" + + +def _run(monkeypatch, answer) -> tuple[list[str], list[str]]: + """Run one request against *answer* (a response, or an exception to raise).""" + monkeypatch.setattr(skills_send_gui, "validate_url", lambda url: url) + + def post(*_args, **_kwargs): + if isinstance(answer, Exception): + raise answer + return answer + + monkeypatch.setattr(skills_send_gui.requests, "post", post) + answered: list[str] = [] + errors: list[str] = [] + thread = RequestThread(_URL, "print(1)") + thread.answered.connect(answered.append) + thread.error.connect(errors.append) + thread.run() # on this thread: direct connections, no event loop needed + return answered, errors + + +class TestWhatARequestReports: + def test_a_good_answer_is_shown_as_is(self, monkeypatch): + assert _run(monkeypatch, FakeResponse(200, "looks fine")) == (["looks fine"], []) + + def test_a_redirect_is_shown_with_where_it_went(self, monkeypatch): + answered, errors = _run(monkeypatch, FakeResponse(302, location="https://elsewhere.example")) + + assert errors == [] + assert "302" in answered[0] and "Redirect to https://elsewhere.example" in answered[0] + + @pytest.mark.parametrize("status", [401, 403]) + def test_a_refusal_is_an_error(self, monkeypatch, status): + answered, errors = _run(monkeypatch, FakeResponse(status, "no")) + + assert answered == [] + assert str(status) in errors[0] and "Authentication/Authorization failed" in errors[0] + + def test_a_server_error_is_an_error_with_its_body(self, monkeypatch): + answered, errors = _run(monkeypatch, FakeResponse(503, "overloaded")) + + assert answered == [] + assert "503" in errors[0] and "Server error: overloaded" in errors[0] + + def test_another_client_error_is_shown_with_its_body(self, monkeypatch): + answered, errors = _run(monkeypatch, FakeResponse(422, "bad field")) + + assert errors == [] + assert "422" in answered[0] and "bad field" in answered[0] + + def test_a_failed_request_is_an_error(self, monkeypatch): + answered, errors = _run(monkeypatch, requests.ConnectionError("refused")) + + assert answered == [] + assert "refused" in errors[0] From d14ac8b0727c3f5d436cf5e3af30c3fd1cab453a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 07:36:08 +0800 Subject: [PATCH 062/312] Refactor: split the Skills status wording and the review panel layout --- docs/updates/2026-09.md | 13 ++++++ docs/updates/README.md | 3 +- .../connect_gui/url/ai_code_review_gui.py | 42 ++++++++---------- .../extend_ai_gui/skills/skills_send_gui.py | 43 +++++++++---------- 4 files changed, 52 insertions(+), 49 deletions(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 57087a2f..3f147367 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -641,3 +641,16 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/{test_skills_request (new),test_http_client,test_ai_code_review_client,test_cot_session_reuse}.py` - `architecture_explore.md` §8, §12, §18 - **Open items**: the Skills status branches and `AICodeReviewClient.__init__` (97 lines, over the 75-line cap) are still to be restructured. That is a refactor, so it is its own stage, now that the tests above pin the behaviour. + +## U-20260923-42 · 2026-09-23 · Skills status wording and the review panel's layout split out · #done #refactor #ai + +- **What**: two pieces of code over the quality gates, both finished as a refactor with no change in behaviour: + - `skills_send_gui.RequestThread.run` repeated the same `skills_error_status` formatting in four branches. + - `AICodeReviewClient.__init__` was 97 lines; the cap is 75. +- **Change**: + - New pure `describe_failed_status(response, body) -> (is_error, text)` decides what a non-2xx answer means. `run` formats it once and sends it to `error` or `answered`. + - `__init__` builds its layout from `_build_request_row()`, `_build_code_and_response()` and `_build_verdict_buttons()`; the longest function in either file is now 44 lines. +- **Guarded by**: `test_skills_request.py` (every status branch, written first, U-20260923-41), `test_ai_code_review_client.py` and `test_ai_gui_lifecycle.py`, unchanged and passing before and after. +- **Result / numbers**: unit tests 1272 passed, 14 skipped (unchanged). `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 7e6c820d..36759ddf 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-42 | 2026-09-23 | Skills status wording and the review panel's layout split out | #done #refactor #ai | [2026-09](2026-09.md) | | U-20260923-41 | 2026-09-23 | A redirect or an HTTP error no longer passes for an AI answer | #done #ai | [2026-09](2026-09.md) | | U-20260923-40 | 2026-09-23 | SSH connects refuse SHA-1 on any paramiko (CVE-2026-44405) | #done #security #ssh | [2026-09](2026-09.md) | | U-20260923-39 | 2026-09-23 | A plugin's compile step streams instead of freezing the IDE | #done #executor #plugin | [2026-09](2026-09.md) | @@ -122,4 +123,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 59 | +| [2026-09.md](2026-09.md) | 2026-09 | 60 | 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 c65fa494..4e76c074 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 @@ -107,10 +107,17 @@ def __init__(self): # 主佈局 (垂直) main_layout = QVBoxLayout() + main_layout.addLayout(self._build_request_row()) + main_layout.addLayout(self._build_code_and_response()) + self.send_button = QPushButton( + self.word_dict.get("ai_code_review_gui_button_send_request")) + self.send_button.clicked.connect(self.send_request) + main_layout.addWidget(self.send_button) + main_layout.addLayout(self._build_verdict_buttons()) + self.setLayout(main_layout) - # ------------------------------- - # 上方:URL 與 Method - # ------------------------------- + def _build_request_row(self) -> QHBoxLayout: + """上方:URL 與 Method / The URL and method row.""" top_layout = QHBoxLayout() # URL @@ -127,12 +134,10 @@ def __init__(self): self.method_box.addItems(["GET", "POST", "PUT", "DELETE"]) method_layout.addWidget(self.method_box) top_layout.addLayout(method_layout) + return top_layout - main_layout.addLayout(top_layout) - - # ------------------------------- - # 中間:左右顯示框 (同樣高) - # ------------------------------- + def _build_code_and_response(self) -> QHBoxLayout: + """中間:左右顯示框 (同樣高) / The code and the response, side by side.""" middle_layout = QHBoxLayout() # 左邊:程式碼輸入 @@ -154,20 +159,10 @@ def __init__(self): # 放入中間佈局 middle_layout.addLayout(left_layout, 1) middle_layout.addLayout(right_layout, 1) + return middle_layout - main_layout.addLayout(middle_layout) - - # ------------------------------- - # 最下面:發送按鈕 - # ------------------------------- - self.send_button = QPushButton( - self.word_dict.get("ai_code_review_gui_button_send_request")) - self.send_button.clicked.connect(self.send_request) - main_layout.addWidget(self.send_button) - - # ------------------------------- - # 最下面:接受/不接受按鈕 - # ------------------------------- + def _build_verdict_buttons(self) -> QHBoxLayout: + """最下面:接受/不接受按鈕 / Accept and reject.""" bottom_layout = QHBoxLayout() self.accept_button = QPushButton( self.word_dict.get("ai_code_review_gui_button_accept_response")) @@ -180,10 +175,7 @@ def __init__(self): bottom_layout.addWidget(self.accept_button) bottom_layout.addWidget(self.reject_button) - - main_layout.addLayout(bottom_layout) - - self.setLayout(main_layout) + return bottom_layout def send_request(self): """Start a review request; the answer reaches the panel when it arrives. 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 699d7510..d76d88a0 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 @@ -41,34 +41,31 @@ def run(self): body = read_capped_text(response) if succeeded(response): self.answered.emit(body) - elif response.is_redirect: - self.answered.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 in (401, 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: {truncate_for_display(body)}")) - else: - self.answered.emit( - language_wrapper.language_word_dict.get( - "skills_error_status").format( - status_code=response.status_code, text=truncate_for_display(body))) + return + is_error, text = describe_failed_status(response, body) + message = language_wrapper.language_word_dict.get("skills_error_status").format( + status_code=response.status_code, text=text) + (self.error if is_error else self.answered).emit(message) except (requests.RequestException, ResponseTooLargeError, UnsafeURLError) as e: pybreeze_logger.error("Skills send request failed: %r", e) self.error.emit(language_wrapper.language_word_dict.get("skills_exception").format(error=str(e))) +def describe_failed_status(response, body: str) -> tuple[bool, str]: + """Say what a non-2xx answer means: whether it is an error, and in what words. + + A redirect (not followed) and an ordinary client error are shown as the + answer; a refused request and a server error are errors. + """ + if response.is_redirect: + return False, f"Redirect to {response.headers.get('Location', 'unknown')}" + if response.status_code in (401, 403): + return True, "Authentication/Authorization failed" + if response.status_code >= 500: + return True, f"Server error: {truncate_for_display(body)}" + return False, truncate_for_display(body) + + class SkillsSendGUI(QWidget): def __init__(self): super().__init__() From ac05f3343499f81a4899d1de1ca50d2c322ee97b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 07:40:34 +0800 Subject: [PATCH 063/312] Show SSH output as sent, whatever a read cuts --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 25 ++++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_command_widget.py | 75 ++++++++++- test/test_utils/test_ssh_terminal_output.py | 126 ++++++++++++++++++ 5 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 test/test_utils/test_ssh_terminal_output.py diff --git a/architecture_explore.md b/architecture_explore.md index 07316c7c..deaa29d2 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -290,13 +290,13 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,537 行) +### `ssh/`(1,604 行) | 檔案 | 職責 | |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | -| `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel,ANSI escape 用 regex 剝除,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | +| `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,ANSI escape 用 regex 剝除;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | | `ssh_file_viewer_widget.py` (793) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;連線交給 `SshConnectThread`,成功後才列出根目錄;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | @@ -435,7 +435,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 86 個 `test_*.py`、1286 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1302 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 3f147367..5cf0184a 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -654,3 +654,28 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1272 passed, 14 skipped (unchanged). `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`. - **Open items**: none. + +## U-20260923-43 · 2026-09-23 · The SSH terminal no longer breaks lines, characters or escapes where a read ends · #done #ssh + +- **What**: the SSH shell showed its output wherever the 4096-byte channel reads happened to end, and it showed it wrong there in three ways: + - **Line breaks**: every read went through `QPlainTextEdit.appendPlainText`, which always starts a new paragraph. A line split across two reads showed as two lines. Checked: `ab` then `cd` gave `ab\ncd`. + - **Characters**: each read was decoded on its own, so a multi-byte UTF-8 character cut by a read became replacement marks. Checked: `中文` cut after its fourth byte showed as `中���`, which hits any Chinese output. + - **Escapes**: an ANSI escape cut by a read left its tail (`1m`) in the text. + - **Lost output**: separately, the reader stopped as soon as the exit status was ready. Output that arrived together with the exit and did not fit in the last read was dropped. +- **Fix**: + - New `TerminalDecoder` in `ssh_command_widget.py`, with no Qt. An incremental UTF-8 decoder carries a cut character over. An unfinished escape at the end of a read (a lone ESC, a CSI without its final byte, an OSC without its terminator, up to 256 characters) is held for the next read. Escapes are then removed as before. It is reset for each new shell. + - Output is inserted at the end of the document with a `QTextCursor` and continues the line it belongs to. The view follows only if it was already at the bottom. + - `append_text` now carries only our own notices, and starts them on a line of their own. + - `SSHReaderThread._drain()` reads what is still buffered after the shell exits. +- **Tests**: new `test_ssh_terminal_output.py`: + - `中文` cut at each of its five inner byte positions comes back whole; + - `ESC[31m` cut at each position is still removed; + - text before an unfinished escape is shown at once; + - an overlong unterminated OSC is not held; + - `reset()` forgets what it carried; + - three reads continue one line; + - a notice after a partial line starts a new line, and after a finished line adds no blank line; + - output buffered at exit is not lost. +- **Result / numbers**: unit tests 1272 → 1288 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_ssh_terminal_output.py` (new), `architecture_explore.md` §9, §18. +- **Open items**: none. A lone carriage return, as in a progress bar redrawing its line, is still shown as a line break, as before. diff --git a/docs/updates/README.md b/docs/updates/README.md index 36759ddf..607d637a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-43 | 2026-09-23 | The SSH terminal no longer breaks lines, characters or escapes where a read ends | #done #ssh | [2026-09](2026-09.md) | | U-20260923-42 | 2026-09-23 | Skills status wording and the review panel's layout split out | #done #refactor #ai | [2026-09](2026-09.md) | | U-20260923-41 | 2026-09-23 | A redirect or an HTTP error no longer passes for an AI answer | #done #ai | [2026-09](2026-09.md) | | U-20260923-40 | 2026-09-23 | SSH connects refuse SHA-1 on any paramiko (CVE-2026-44405) | #done #security #ssh | [2026-09](2026-09.md) | @@ -123,4 +124,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 60 | +| [2026-09.md](2026-09.md) | 2026-09 | 61 | 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 ebd8737c..f83e8134 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -1,10 +1,12 @@ from __future__ import annotations +import codecs import os import re import paramiko from PySide6.QtCore import QThread, Signal +from PySide6.QtGui import QTextCursor from PySide6.QtWidgets import ( QWidget, QLineEdit, QPushButton, QPlainTextEdit, QHBoxLayout, QVBoxLayout, @@ -34,6 +36,42 @@ r')' ) +# The end of a read that stops inside an escape sequence: a lone ESC, a CSI +# still waiting for its final byte, or an OSC still waiting for its terminator +_INCOMPLETE_ESCAPE = re.compile(r'\x1B(?:\[[0-?]*[ -/]*|\][^\x07\x1B]*)?\Z') +# Longest such tail held back for the next read; anything longer is shown as is +_MAX_PENDING_ESCAPE = 256 + + +class TerminalDecoder: + """Turn what the shell sends into text to show, one read at a time. + + A read ends wherever the channel's buffer did, so it can stop inside a + multi-byte UTF-8 character or inside an escape sequence. Decoding each read + on its own showed the character as replacement marks and the escape's tail + as text; this carries the unfinished part over to the next read. + """ + + def __init__(self) -> None: + self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + self._pending = "" + + def reset(self) -> None: + """Forget anything carried over, for a new session.""" + self._decoder.reset() + self._pending = "" + + def feed(self, data: bytes) -> str: + """Return the text *data* completes, escape sequences removed.""" + text = self._pending + self._decoder.decode(data) + self._pending = "" + tail = _INCOMPLETE_ESCAPE.search(text) + if tail is not None and len(text) - tail.start() <= _MAX_PENDING_ESCAPE: + self._pending = text[tail.start():] + text = text[:tail.start()] + return ANSI_ESCAPE_PATTERN.sub('', text) + + # Bound the terminal scrollback so an endless stream (``tail -f``, ``yes``) # cannot grow the document without limit; oldest lines drop once exceeded. TERMINAL_MAX_BLOCKS = 10000 @@ -66,11 +104,21 @@ def _pump_once(self) -> bool: self.data_received.emit(err) return not (self.chan.closed or self.chan.exit_status_ready()) + def _drain(self) -> None: + """Forward what is still buffered once the shell has exited. + + The last output and the exit status can arrive together; stopping at + the exit status dropped whatever did not fit in the final read. + """ + while self._running and (self.chan.recv_ready() or self.chan.recv_stderr_ready()): + self._pump_once() + def run(self): error_msg = None try: while self._running and self._pump_once(): self.msleep(10) + self._drain() except Exception as e: # noqa: BLE001 — any reader failure must surface to the UI pybreeze_logger.debug("SSH reader thread error: %r", e) error_msg = f"{self.word_dict.get('ssh_command_widget_error_message_reader_failed')} {e}" @@ -100,6 +148,8 @@ def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: self.ssh_client: paramiko.SSHClient | None = None self.shell_channel: paramiko.Channel | None = None self.reader_thread: SSHReaderThread | None = None + # What the shell sends, joined across reads / 跨次讀取的解碼狀態 + self._decoder = TerminalDecoder() # The connect in progress, if any / 正在進行的連線 self._connecting: SshConnectThread | None = None host_key_asker() # built here, on the UI thread, for a connect to ask through @@ -154,7 +204,25 @@ def _bind_events(self): self.command_input_edit.returnPressed.connect(self.send_command) def append_text(self, text: str): - self.terminal.appendPlainText(text) + """Add a notice of our own, starting on a line of its own.""" + end = QTextCursor(self.terminal.document()) + end.movePosition(QTextCursor.MoveOperation.End) + self._insert_output(text if end.atBlockStart() else "\n" + text) + + def _insert_output(self, text: str) -> None: + """Add *text* where the output ends, without starting a new line. + + ``appendPlainText`` starts a new paragraph on every call, so each read + from the shell began on a line of its own, wherever the read happened + to stop. The view follows the output only if it was already at the end. + """ + scroll_bar = self.terminal.verticalScrollBar() + following = scroll_bar.value() == scroll_bar.maximum() + end = QTextCursor(self.terminal.document()) + end.movePosition(QTextCursor.MoveOperation.End) + end.insertText(text) + if following: + scroll_bar.setValue(scroll_bar.maximum()) def connect_ssh(self): host = self.login_widget.host_edit.text().strip() @@ -249,6 +317,7 @@ def _start_shell(self, host: str, port: int, user: str) -> None: transport.set_keepalive(SSH_KEEPALIVE_SECONDS) self.shell_channel = self.ssh_client.invoke_shell(term='xterm', width=120, height=32) self.shell_channel.settimeout(0.0) + self._decoder.reset() self.reader_thread = SSHReaderThread(self.shell_channel) self.reader_thread.data_received.connect(self._on_data) self.reader_thread.closed.connect(self._on_closed) @@ -259,9 +328,7 @@ def _start_shell(self, host: str, port: int, user: str) -> None: f" {host}:{port} as {user}\n") def _on_data(self, data: bytes): - # errors="replace" cannot fail, so there is nothing to catch here - text = data.decode("utf-8", errors="replace") - self.append_text(ANSI_ESCAPE_PATTERN.sub('', text)) + self._insert_output(self._decoder.feed(data)) def _on_closed(self, msg: str): self.append_text(f"\n{self.word_dict.get('ssh_command_widget_log_message_channel_closed')}" diff --git a/test/test_utils/test_ssh_terminal_output.py b/test/test_utils/test_ssh_terminal_output.py new file mode 100644 index 00000000..fd75534c --- /dev/null +++ b/test/test_utils/test_ssh_terminal_output.py @@ -0,0 +1,126 @@ +"""What the shell sends, shown as it was sent: reads may stop anywhere.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import ( + SSHCommandWidget, + SSHReaderThread, + TerminalDecoder, +) + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class TestTerminalDecoder: + @pytest.mark.parametrize("cut", range(1, 6)) + def test_a_character_cut_between_reads_is_joined(self, cut): + data = "中文".encode("utf-8") # six bytes, two characters + decoder = TerminalDecoder() + + assert decoder.feed(data[:cut]) + decoder.feed(data[cut:]) == "中文" + + @pytest.mark.parametrize("cut", range(1, 5)) + def test_an_escape_cut_between_reads_is_still_removed(self, cut): + data = b"\x1b[31mred" + decoder = TerminalDecoder() + + assert decoder.feed(data[:cut]) + decoder.feed(data[cut:]) == "red" + + def test_text_before_an_unfinished_escape_is_shown_now(self): + decoder = TerminalDecoder() + + assert decoder.feed(b"ready \x1b[") == "ready " + assert decoder.feed(b"0mgo") == "go" + + def test_a_very_long_unterminated_sequence_is_not_held_forever(self): + decoder = TerminalDecoder() + title = b"\x1b]0;" + b"t" * 400 + + decoder.feed(title) + + assert decoder._pending == "" + + def test_reset_forgets_what_was_carried_over(self): + decoder = TerminalDecoder() + decoder.feed("中".encode("utf-8")[:2]) + decoder.feed(b"\x1b[") + + decoder.reset() + + assert decoder.feed(b"plain") == "plain" + + +class TestTheTerminal: + def _widget(self): + return SSHCommandWidget() + + def test_reads_continue_the_line_they_belong_to(self, app): + widget = self._widget() + + for chunk in (b"ab", b"cd\r\n", b"ef"): + widget._on_data(chunk) + + assert widget.terminal.toPlainText() == "abcd\nef" + widget.close() + + def test_a_notice_starts_on_a_line_of_its_own(self, app): + widget = self._widget() + widget._on_data(b"$ ") + + widget.append_text("Disconnecting\n") + + assert widget.terminal.toPlainText() == "$ \nDisconnecting\n" + widget.close() + + def test_a_notice_after_a_finished_line_adds_no_blank_line(self, app): + widget = self._widget() + widget._on_data(b"done\r\n") + + widget.append_text("Disconnecting\n") + + assert widget.terminal.toPlainText() == "done\nDisconnecting\n" + widget.close() + + +class FakeChannel: + """A channel whose shell has exited with output still buffered.""" + + closed = False + + def __init__(self, chunks: list[bytes]) -> None: + self.chunks = chunks + + def recv_ready(self) -> bool: + return bool(self.chunks) + + def recv(self, _size: int) -> bytes: + return self.chunks.pop(0) + + def recv_stderr_ready(self) -> bool: + return False + + def exit_status_ready(self) -> bool: + return True + + +class TestTheReader: + def test_output_buffered_when_the_shell_exits_is_not_lost(self, app): + reader = SSHReaderThread(FakeChannel([b"last ", b"words"])) + received: list[bytes] = [] + reader.data_received.connect(received.append) + + reader.run() # on this thread: direct connections + + assert b"".join(received) == b"last words" From e577cbcb4a6983d4fa4d3dc8c08ba664bf35afb5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 07:43:40 +0800 Subject: [PATCH 064/312] Decode a long output line across its pieces --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 12 ++++++++ docs/updates/README.md | 3 +- .../extend/process_executor/queue_pump.py | 30 +++++++++++++++++-- test/test_utils/test_queue_pump.py | 30 +++++++++++++++++++ 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index deaa29d2..6730b5bf 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -152,7 +152,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) 子行程輸出到執行視窗的整條管線,兩個執行器(`TaskProcessManager`、`FileRunnerProcess`)共用: -- `read_stream_into_queue(stream, queue, buffer_size, encoding, keep_reading)` — reader 執行緒用。行**原樣**進 queue(縮排、行尾、空行都留著);空讀 = EOF 即停,管線被關掉的 `OSError` / `ValueError` 記 debug 後停 +- `read_stream_into_queue(stream, queue, buffer_size, encoding, keep_reading)` — reader 執行緒用。行**原樣**進 queue(縮排、行尾、空行都留著);空讀 = EOF 即停,管線被關掉的 `OSError` / `ValueError` 記 debug 後停。超過 `buffer_size` 的長行分段讀進來:用 incremental decoder 解碼(被切斷的多位元組字元接到下一段),段尾的 `\r` 留到下一段(`\r\n` 被切開時不會變成兩個換行);不認得的 encoding 退回 UTF-8 並記 warning - `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 - `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。`\r\n` 與單獨的 `\r` 轉成換行;換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 @@ -435,7 +435,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1302 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1306 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 5cf0184a..393c3d23 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -679,3 +679,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1272 → 1288 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_ssh_terminal_output.py` (new), `architecture_explore.md` §9, §18. - **Open items**: none. A lone carriage return, as in a progress bar redrawing its line, is still shown as a line break, as before. + +## U-20260923-44 · 2026-09-23 · A long output line no longer garbles its characters or doubles its line ending · #done #executor + +- **What**: the same read-boundary fault as the SSH terminal (U-20260923-43), in the run window. Both executors read a child's pipes with `readline(1024)`, so a line longer than 1024 bytes arrives in pieces. Each piece was decoded on its own. Reproduced on the previous code: + - a multi-byte character cut by the buffer was shown as replacement marks: `中文輸出` read in 4-byte pieces came out `中�����出`; + - a `\r\n` cut between its two bytes arrived as a piece ending in `\r` and a piece `\n`, which `normalize_line_endings` turns into two line breaks, a stray blank line; + - an encoding name the codecs do not know raised `LookupError` in the reader thread on the first line and ended it, so the run showed no output at all. +- **Fix**: `read_stream_into_queue()` decodes with an incremental decoder, which carries a cut character over to the next piece. It holds a trailing `\r` until the next piece arrives, and flushes both at EOF, so a final progress-bar `\r` is not lost. `_decoder_for()` falls back to UTF-8, with a warning, for an unknown encoding. +- **Tests**: `test_queue_pump.py` adds four: three-byte characters cut by a four-byte buffer are joined with no replacement marks; `\r\n` cut by the buffer never leaves a piece ending in `\r`; a trailing `\r` at EOF is kept; an unknown encoding decodes as UTF-8. The first, second and fourth fail on the previous reader. +- **Result / numbers**: unit tests 1288 → 1292 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/queue_pump.py`, `test/test_utils/test_queue_pump.py`, `architecture_explore.md` §4.5, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 607d637a..9d49e1f4 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-44 | 2026-09-23 | A long output line no longer garbles its characters or doubles its line ending | #done #executor | [2026-09](2026-09.md) | | U-20260923-43 | 2026-09-23 | The SSH terminal no longer breaks lines, characters or escapes where a read ends | #done #ssh | [2026-09](2026-09.md) | | U-20260923-42 | 2026-09-23 | Skills status wording and the review panel's layout split out | #done #refactor #ai | [2026-09](2026-09.md) | | U-20260923-41 | 2026-09-23 | A redirect or an HTTP error no longer passes for an AI answer | #done #ai | [2026-09](2026-09.md) | @@ -124,4 +125,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 61 | +| [2026-09.md](2026-09.md) | 2026-09 | 62 | diff --git a/pybreeze/extend/process_executor/queue_pump.py b/pybreeze/extend/process_executor/queue_pump.py index 220c6bd4..0e70dcca 100644 --- a/pybreeze/extend/process_executor/queue_pump.py +++ b/pybreeze/extend/process_executor/queue_pump.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import codecs import itertools import queue from collections.abc import Callable @@ -37,7 +38,16 @@ def read_stream_into_queue( loop spins at 100% CPU re-reading a closed pipe until the QTimer notices the process exited. Lines keep their leading whitespace and line ending, and blank lines are kept, so the run window shows the output as it was written. + + A line longer than *buffer_size* arrives in pieces, and a piece can end + inside a multi-byte character or between the ``\\r`` and ``\\n`` of a line + ending. The character is carried over by an incremental decoder, and a + trailing ``\\r`` is held for the next piece: decoded on its own, the + character showed as replacement marks, and the split line ending as a + blank line. """ + decoder = _decoder_for(encoding) + held = "" while keep_reading(): try: line = stream.readline(buffer_size) @@ -48,8 +58,24 @@ def read_stream_into_queue( if not line: break if isinstance(line, bytes): - line = line.decode(encoding, "replace") - target_queue.put(line) + line = decoder.decode(line) + line, held = held + line, "" + if line.endswith("\r"): + line, held = line[:-1], "\r" + if line: + target_queue.put(line) + rest = held + decoder.decode(b"", final=True) + if rest: + target_queue.put(rest) + + +def _decoder_for(encoding: str) -> codecs.IncrementalDecoder: + """An incremental decoder for *encoding*, or UTF-8's when there is no such codec.""" + try: + return codecs.getincrementaldecoder(encoding)(errors="replace") + except LookupError: + pybreeze_logger.warning("Unknown output encoding %r; decoding as UTF-8", encoding) + return codecs.getincrementaldecoder("utf-8")(errors="replace") def pump_message_queue( diff --git a/test/test_utils/test_queue_pump.py b/test/test_utils/test_queue_pump.py index dc2a6b88..e52edae3 100644 --- a/test/test_utils/test_queue_pump.py +++ b/test/test_utils/test_queue_pump.py @@ -105,3 +105,33 @@ def test_a_line_longer_than_the_buffer_arrives_in_pieces(self): while not q.empty(): pieces.append(q.get()) assert pieces == ["abcd", "efgh", "ij\n"] + + def _pieces(self, data: bytes, buffer_size: int, encoding: str = "utf-8") -> list[str]: + q: Queue = Queue() + read_stream_into_queue( + io.BytesIO(data), q, buffer_size=buffer_size, encoding=encoding, + keep_reading=lambda: True) + items = [] + while not q.empty(): + items.append(q.get()) + return items + + def test_a_character_cut_by_the_buffer_is_joined(self): + # Each of these characters is three bytes; a four-byte buffer cuts them. + text = "中文輸出\n" + pieces = self._pieces(text.encode("utf-8"), buffer_size=4) + assert "".join(pieces) == text + assert "�" not in "".join(pieces) + + def test_a_line_ending_cut_by_the_buffer_stays_one_line_ending(self): + pieces = self._pieces(b"abc\r\ndef\r\n", buffer_size=4) + # Normalised for display piece by piece, a lone "\r" then "\n" would be + # two line breaks. + assert not any(piece.endswith("\r") for piece in pieces) + assert "".join(pieces) == "abc\r\ndef\r\n" + + def test_a_carriage_return_at_the_very_end_is_not_lost(self): + assert "".join(self._pieces(b"50%\r", buffer_size=1024)) == "50%\r" + + def test_an_unknown_encoding_falls_back_to_utf8(self): + assert self._pieces("ok 中\n".encode("utf-8"), 1024, encoding="no-such-codec") == ["ok 中\n"] From d1811d2f4f6f732acf27497f148930a3fe6072fa Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 07:46:59 +0800 Subject: [PATCH 065/312] Close every tab inside a deleted folder first --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 11 ++++++ docs/updates/README.md | 3 +- .../editor_main/file_tree_context_menu.py | 26 +++++--------- .../test_utils/test_file_tree_context_menu.py | 35 ++++++++++++++++--- 5 files changed, 54 insertions(+), 25 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 6730b5bf..a70f2c1c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -73,7 +73,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE - `add_menu_to_menubar()` — 建構全部選單(見 §5) - `syntax_extend_package()` — 註冊 `.json` / `.yml` 自動化關鍵字高亮 - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁(`_add_extend_tabs()`:每一個分頁各自建,建不起來的只記 log,不會讓整個 IDE 起不來) - - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向 + - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁都先關掉(關閉會停掉它的自動存檔),再刪 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) 3. `apply_stylesheet()` 套 qt_material 主題(預設 `dark_amber.xml`) 4. `showMaximized()` → `startup_setting()` → `app.exec()` @@ -435,7 +435,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1306 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1307 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 393c3d23..98c6bb73 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -691,3 +691,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1288 → 1292 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/queue_pump.py`, `test/test_utils/test_queue_pump.py`, `architecture_explore.md` §4.5, §18. - **Open items**: none. + +## U-20260923-45 · 2026-09-23 · Deleting a folder closes the tabs open inside it · #done #editor + +- **What**: deleting from the file tree closed only a tab open on exactly the deleted path. Deleting a folder left every tab open on a file inside it open, with JEditor's auto-save still running every 5 s. That save could write a file back while `shutil.rmtree` was removing the folder, which fails the delete part-way ("directory not empty") or leaves a file behind. And the tabs went on showing files that no longer existed, where a later save would bring them back. Rename had already been fixed to cover everything under a folder (`_editors_under`, U-20260923-22); delete had not. +- **Fix**: `_action_delete` closes every tab `_editors_under()` finds, before removing anything. `EditorWidget.close()` stops the tab's auto-save. The single-file lookup `_find_editor_for_file`, now unused, is removed. +- **Tests**: `test_file_tree_context_menu.py`: + - new: deleting a folder closes both tabs inside it, leaves a tab outside it open, and removes the folder; + - the open-tab test and the finding test now go through `_editors_under`. +- **Result / numbers**: unit tests 1292 → 1293 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_file_tree_context_menu.py`, `architecture_explore.md` §3, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9d49e1f4..5dc9f7d4 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-45 | 2026-09-23 | Deleting a folder closes the tabs open inside it | #done #editor | [2026-09](2026-09.md) | | U-20260923-44 | 2026-09-23 | A long output line no longer garbles its characters or doubles its line ending | #done #executor | [2026-09](2026-09.md) | | U-20260923-43 | 2026-09-23 | The SSH terminal no longer breaks lines, characters or escapes where a read ends | #done #ssh | [2026-09](2026-09.md) | | U-20260923-42 | 2026-09-23 | Skills status wording and the review panel's layout split out | #done #refactor #ai | [2026-09](2026-09.md) | @@ -125,4 +126,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 62 | +| [2026-09.md](2026-09.md) | 2026-09 | 63 | diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index 711306e0..0824ca06 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -193,17 +193,6 @@ def _action_new_folder(tree_view: QTreeView, path: Path | None) -> None: _perform_file_op(tree_view, lambda: new_path.mkdir(parents=True)) -def _find_editor_for_file(main_window, file_path: Path) -> EditorWidget | None: - """Find the EditorWidget that has the given file open.""" - path_str = str(file_path) - for i in range(main_window.tab_widget.count()): - widget = main_window.tab_widget.widget(i) - if isinstance(widget, EditorWidget) and widget.current_file is not None: - if str(Path(widget.current_file)) == path_str: - return widget - return None - - def _editors_under(main_window, path: Path) -> list[tuple[EditorWidget, Path]]: """The editor tabs open on *path*, or on any file under it, with their files.""" found = [] @@ -288,13 +277,14 @@ def _action_delete(tree_view: QTreeView, main_window, path: Path | None) -> None if reply != QMessageBox.StandardButton.Yes: return - # Close editor tab if this file is open - editor = _find_editor_for_file(main_window, path) - if editor is not None: - idx = main_window.tab_widget.indexOf(editor) - if idx >= 0: - editor.close() - main_window.tab_widget.removeTab(idx) + # Every tab open on the file, or on a file under the folder, closes first: + # closing stops its auto-save, which would otherwise keep writing the + # buffer back while the files are being removed. + for editor, _file in _editors_under(main_window, path): + index = main_window.tab_widget.indexOf(editor) + editor.close() + if index >= 0: + main_window.tab_widget.removeTab(index) def _delete() -> None: if path.is_dir(): diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py index 6fa2f5c8..d008ebf1 100644 --- a/test/test_utils/test_file_tree_context_menu.py +++ b/test/test_utils/test_file_tree_context_menu.py @@ -20,7 +20,7 @@ from pybreeze.pybreeze_ui.editor_main import file_tree_context_menu as ctx from pybreeze.pybreeze_ui.editor_main.file_tree_context_menu import ( _action_copy_path, _action_delete, _action_new_file, _action_new_folder, - _action_rename, _attach_context_menu, _find_editor_for_file, _get_tree_root_path, + _action_rename, _attach_context_menu, _editors_under, _get_tree_root_path, _perform_file_op, _resolve_parent_dir, setup_file_tree_context_menu ) @@ -265,13 +265,40 @@ def test_an_open_tab_is_closed_with_the_file(self, tree, tmp_path, monkeypatch): window = FakeWindow() editor = FakeEditor(str(target)) window.tab_widget.addTab(editor, "open.py") - monkeypatch.setattr(ctx, "_find_editor_for_file", lambda _w, _p: editor) + monkeypatch.setattr(ctx, "_editors_under", lambda _w, _p: [(editor, target)]) confirm(monkeypatch, yes=True) _action_delete(tree, window, target) assert editor.closed assert window.tab_widget.count() == 0 assert not target.exists() + def test_every_tab_inside_a_deleted_folder_is_closed(self, tree, tmp_path, monkeypatch): + folder = tmp_path / "pkg" + folder.mkdir() + inside = [folder / "a.py", folder / "b.py"] + for file in inside: + file.touch() + window = FakeWindow() + editors = [FakeEditor(str(file)) for file in inside] + outside = FakeEditor(str(tmp_path / "other.py")) + for editor in (*editors, outside): + window.tab_widget.addTab(editor, "tab") + asked = [] + + def under(_window, path): + asked.append(path) + return list(zip(editors, inside)) + + monkeypatch.setattr(ctx, "_editors_under", under) + confirm(monkeypatch, yes=True) + _action_delete(tree, window, folder) + + assert asked == [folder] + assert all(editor.closed for editor in editors) + assert not outside.closed + assert window.tab_widget.count() == 1 + assert not folder.exists() + class TestCopyingThePath: def test_the_absolute_path_reaches_the_clipboard(self, tree, tmp_path): @@ -299,9 +326,9 @@ def test_nothing_selected_leaves_the_clipboard_alone(self, tree): assert QApplication.clipboard().text() == "untouched" -class TestFindingTheOpenEditor: +class TestFindingTheOpenEditors: def test_a_window_with_no_editor_tabs_finds_nothing(self, app, tmp_path): - assert _find_editor_for_file(FakeWindow(), tmp_path / "any.py") is None + assert _editors_under(FakeWindow(), tmp_path / "any.py") == [] class TestAttachingTheMenu: From ef7973a4eda4eab7fae7e423abbe41f081e1cf5f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 07:52:50 +0800 Subject: [PATCH 066/312] Install packages with pip as a module, not through cmd.exe --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 20 +++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 2 - .../extend_traditional_chinese.py | 2 - .../build_automation_install_menu.py | 8 +- .../menu/install_menu/install_utils.py | 67 ++----- .../tools_menu/build_tool_install_menu.py | 12 +- test/test_utils/test_install_menu.py | 165 +++++++----------- 9 files changed, 108 insertions(+), 177 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index a70f2c1c..6c53eb92 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -190,8 +190,6 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `_TAB_ACTIONS: tuple[...]` — (widget key, 主視窗屬性, 選單屬性, action 語言鍵, 分頁標籤鍵) - `_DOCK_ACTIONS` / `_DOCK_TITLES` — 同一組 widget 也能開成右側 dock -安裝選單的 `install_is_possible()` 也是先問再做:沒有編輯器分頁就說一聲並回傳 `False`,安裝三個建置工具或先問 prthinker 原始碼資料夾的路徑都靠它提早停下。 - `_register_action()` 有一段關鍵註解:QAction 必須 `setattr` 掛回主視窗,否則 Qt 不持有它、被 GC 後選單項就失效。另一種做法是建構時把選單當 parent(自動化選單工廠、插件選單用這種)。`test_started_menus.py` 在子行程啟動真的 IDE、GC 後走訪整條選單列,任何子選單變空就失敗(JEditor 的兩個字型選單除外:offscreen 平台沒有字型)。 ### 5.4 插件選單 @@ -201,7 +199,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.5 安裝選單 -`install_utils.install_package()` 借用 JEditor 的 `ShellManager` 跑 `pip install -U`,輸出進目前編輯器分頁的 shell 面板,pip 用 IDE 選定的直譯器(沒選才用 shell 自己找到的)。目前分頁不是編輯器分頁時不會安裝,並跳訊息說明(回傳 `False`),不是靜靜地什麼都不做。 +`install_utils.install_packages()` 用 `build_task_process()` 開一個執行視窗,`start_module_process("pip", ["install", "-U", *packages])`:參數清單、不經 shell(以前借 JEditor 的 `ShellManager`,它用 `shell=True` 交給 `cmd.exe`,使用者選的資料夾名稱裡有 `&` 就會把指令切開)。多個套件一次 pip(建置工具以前是三個 pip 同時對同一個環境跑)。pip 用 IDE 選定的直譯器,沒選時照一般執行的退路。`install_package()` 是單一套件的寫法 - `automation_menu/` — 七個自動化套件的一鍵安裝。**prthinker 例外**:不在 PyPI 上,第一次會問來源資料夾、記進設定,之後裝 `[runner]` - `tools_menu/` — 安裝 setuptools / build / wheel @@ -435,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1307 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1305 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 98c6bb73..da3d1755 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -702,3 +702,23 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1292 → 1293 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_file_tree_context_menu.py`, `architecture_explore.md` §3, §18. - **Open items**: none. + +## U-20260923-46 · 2026-09-23 · Package installs no longer go through cmd.exe · #done #security #install + +- **What**: the Install menu ran `pip install -U ` through JEditor's `ShellManager`, which starts its command with `shell=True`. On Windows the argument list is joined by `list2cmdline` and handed to `cmd.exe /c`. A target without spaces is not quoted, so `&`, `|`, `^` and `%` in it are cmd's. The prthinker install passes a source folder the user chose, remembered in settings, and a folder named like `R&D` is ordinary. Reproduced with the same call shape: the argument `D:\R&echo.SECOND-COMMAND-RAN` reached the program as `D:\R`, and `echo.SECOND-COMMAND-RAN` ran as a second command. This is the accidental shell injection that CLAUDE.md § Security › Subprocess requires argument lists with `shell=False` to prevent. +- **Also**: Install Build Tools started three pip processes one after another without waiting, so they ran at the same time against the same environment. Each started by clearing the panel the previous one was writing to. +- **Fix**: `install_packages(packages, main_window)` opens a run window with `build_task_process()` and calls `start_module_process("pip", ["install", "-U", *packages])`: PyBreeze's own executor, argv with no shell, output streamed like any run, and the interpreter chosen in the IDE with the usual fallbacks. Build Tools is one pip for all three. `install_package()` wraps one package. The editor-tab requirement existed only because the output went to that tab's shell panel, so it is gone with it: `install_is_possible()`, its message key `install_need_editor_tab_message` in both languages, and the prthinker install's pre-check. +- **Tests**: `test_install_menu.py` is rewritten: + - a package runs as `pip install -U `; + - a source folder holding `&` is one argument; + - Build Tools is a single `pip install -U setuptools build wheel`; + - a remembered prthinker folder is installed; + - end to end through the real `TaskProcessManager`, a module standing in for pip receives `D:\R&D|x^y%PATH%\prthinker[runner]` exactly. + The four tests about the editor-tab requirement went with it. +- **Result / numbers**: unit tests 1293 → 1291 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/install_menu/{install_utils,automation_menu/build_automation_install_menu,tools_menu/build_tool_install_menu}.py` + - `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py` + - `test/test_utils/test_install_menu.py` + - `architecture_explore.md` §5.3, §5.5, §18 +- **Open items**: JEditor's `ShellManager` still uses `shell=True` for its own Run Shell feature. There it runs what the user typed as a shell command on purpose, and it is JEditor's to decide. diff --git a/docs/updates/README.md b/docs/updates/README.md index 5dc9f7d4..0b105470 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-46 | 2026-09-23 | Package installs no longer go through cmd.exe | #done #security #install | [2026-09](2026-09.md) | | U-20260923-45 | 2026-09-23 | Deleting a folder closes the tabs open inside it | #done #editor | [2026-09](2026-09.md) | | U-20260923-44 | 2026-09-23 | A long output line no longer garbles its characters or doubles its line ending | #done #executor | [2026-09](2026-09.md) | | U-20260923-43 | 2026-09-23 | The SSH terminal no longer breaks lines, characters or escapes where a read ends | #done #ssh | [2026-09](2026-09.md) | @@ -126,4 +127,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 63 | +| [2026-09.md](2026-09.md) | 2026-09 | 64 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 3477655d..1b8e2954 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -92,8 +92,6 @@ "install_menu_automation_file": "Install Automation File", "install_menu_mail_thunder": "Install MailThunder", "install_menu_prthinker": "Install prthinker (code review)", - "install_need_editor_tab_message": - "Open a file tab first: the install runs in that tab's shell panel.", "install_menu_tools_install_menu_label": "Tools", "install_menu_tools_install_build_tools": "Install Build Tools", # Tools Menu diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 3b891ef5..414f8a9c 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -92,8 +92,6 @@ "install_menu_automation_file": "安裝 Automation File", "install_menu_mail_thunder": "安裝 MailThunder", "install_menu_prthinker": "安裝 prthinker(程式碼審查)", - "install_need_editor_tab_message": - "請先開啟一個檔案分頁:安裝會在那個分頁的 shell 面板裡執行。", "install_menu_tools_install_menu_label": "工具", "install_menu_tools_install_build_tools": "安裝 Build Tools", # Tools Menu diff --git a/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py b/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py index 37d2d4f1..1f0e53cb 100644 --- a/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py +++ b/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py @@ -9,9 +9,7 @@ from pybreeze.extend.prthinker_extend.prthinker_setting import ( install_target, load_setting, save_setting ) -from pybreeze.pybreeze_ui.menu.install_menu.install_utils import ( - install_is_possible, install_package -) +from pybreeze.pybreeze_ui.menu.install_menu.install_utils import install_package if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -101,10 +99,6 @@ def install_prthinker(ui_we_want_to_set: PyBreezeMainWindow) -> None: prthinker is installed from source rather than from PyPI, so the folder is asked for once and then remembered in the prthinker settings. """ - if not install_is_possible(ui_we_want_to_set): - # Asked before the folder dialog: choosing and storing a source folder - # for an install that cannot start is work the user would repeat. - return setting = load_setting() target = install_target(setting.get("source_path", "")) if not target: diff --git a/pybreeze/pybreeze_ui/menu/install_menu/install_utils.py b/pybreeze/pybreeze_ui/menu/install_menu/install_utils.py index 40d2af08..85d54508 100644 --- a/pybreeze/pybreeze_ui/menu/install_menu/install_utils.py +++ b/pybreeze/pybreeze_ui/menu/install_menu/install_utils.py @@ -1,61 +1,32 @@ from __future__ import annotations +from collections.abc import Sequence from typing import TYPE_CHECKING -from PySide6.QtWidgets import QMessageBox -from je_editor import EditorWidget, ShellManager, language_wrapper - -from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.extend.process_executor.process_executor_utils import build_task_process if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -def install_is_possible(ui_we_want_to_set: PyBreezeMainWindow) -> bool: - """Whether an install can start, telling the user once when it cannot. +def install_packages(packages: Sequence[str], ui_we_want_to_set: PyBreezeMainWindow) -> None: + """Install *packages* with one ``pip install -U``, in a run window of its own. - An editor tab has to be in front: the shell panel it writes to is that - tab's. A caller with work to do first -- asking for a source folder, - installing several packages in a row -- asks here before starting it. + pip runs as ``python -m pip`` with the interpreter chosen in the IDE (and + the same fallbacks as any run), from an argument list with no shell. The + install used to go through JEditor's shell runner, which hands the command + to ``cmd.exe``: a target can be a folder the user chose, and a folder name + holding ``&`` or ``|`` split the command there. Several packages go to one + pip, not one pip each -- those ran at the same time against the same + environment. + :param packages: what pip is asked to install (names, or a path with extras) :param ui_we_want_to_set: the main window - :return: whether an install would start - """ - widget = ui_we_want_to_set.tab_widget.currentWidget() - if isinstance(widget, EditorWidget): - return True - pybreeze_logger.error( - "install needs an editor tab in front, not %r", type(widget).__name__) - messagebox = QMessageBox(ui_we_want_to_set) - messagebox.setWindowTitle( - language_wrapper.language_word_dict.get("install_menu_label")) - messagebox.setText( - language_wrapper.language_word_dict.get("install_need_editor_tab_message")) - messagebox.exec() - return False - - -def install_package(package_text: str, ui_we_want_to_set: PyBreezeMainWindow) -> bool: - """Install *package_text* with pip, in the current editor tab's shell panel. - - pip runs with the interpreter chosen in the IDE, falling back to the one the - shell finds. An editor tab has to be in front: its panel is where the output - goes, so without one the install is not started and the user is told why - rather than left with a menu entry that does nothing. - - :param package_text: what pip is asked to install (a name, or a path with extras) - :param ui_we_want_to_set: the main window - :return: whether the install started """ - if not install_is_possible(ui_we_want_to_set): - return False - widget = ui_we_want_to_set.tab_widget.currentWidget() - widget.python_compiler = ui_we_want_to_set.python_compiler - shell_manager = ShellManager(main_window=widget) - shell_manager.later_init() - if widget.python_compiler is not None: - compiler_path = widget.python_compiler - else: - compiler_path = shell_manager.compiler_path - shell_manager.exec_shell([f"{compiler_path}", "-m", "pip", "install", f"{package_text}", "-U"]) - return True + process = build_task_process(ui_we_want_to_set) + process.start_module_process("pip", ["install", "-U", *packages]) + + +def install_package(package_text: str, ui_we_want_to_set: PyBreezeMainWindow) -> None: + """Install one package; see :func:`install_packages`.""" + install_packages([package_text], ui_we_want_to_set) diff --git a/pybreeze/pybreeze_ui/menu/install_menu/tools_menu/build_tool_install_menu.py b/pybreeze/pybreeze_ui/menu/install_menu/tools_menu/build_tool_install_menu.py index 4eec8ea7..a1163ad9 100644 --- a/pybreeze/pybreeze_ui/menu/install_menu/tools_menu/build_tool_install_menu.py +++ b/pybreeze/pybreeze_ui/menu/install_menu/tools_menu/build_tool_install_menu.py @@ -5,7 +5,7 @@ from PySide6.QtGui import QAction from je_editor import language_wrapper -from pybreeze.pybreeze_ui.menu.install_menu.install_utils import install_package +from pybreeze.pybreeze_ui.menu.install_menu.install_utils import install_packages if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -24,11 +24,5 @@ def build_tool_install_menu(ui_we_want_to_set: PyBreezeMainWindow): def install_build_tools(ui_we_want_to_set: PyBreezeMainWindow) -> None: - """Install the packaging tools, stopping at the first one that cannot start. - - Without an editor tab in front none of them can, and carrying on would - put the same message box in front of the user once per package. - """ - for package in ("setuptools", "build", "wheel"): - if not install_package(package, ui_we_want_to_set): - return + """Install the packaging tools with one pip.""" + install_packages(["setuptools", "build", "wheel"], ui_we_want_to_set) diff --git a/test/test_utils/test_install_menu.py b/test/test_utils/test_install_menu.py index 6295a09f..be2337d6 100644 --- a/test/test_utils/test_install_menu.py +++ b/test/test_utils/test_install_menu.py @@ -1,12 +1,13 @@ -"""Installing an automation package: which interpreter pip runs with, and where it runs.""" +"""Installing packages from the Install menu: one pip, no shell, in a run window.""" from __future__ import annotations import os +import sys os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget, QWidget +from PySide6.QtWidgets import QApplication from pybreeze.extend_multi_language.update_language_dict import update_language_dict from pybreeze.pybreeze_ui.menu.install_menu import install_utils @@ -20,146 +21,102 @@ def app(): return instance -class EditorTab(QWidget): - """Stands in for JEditor's editor tab, which is where the install's output goes.""" +class Process: + """Records the module run instead of starting it.""" def __init__(self) -> None: - super().__init__() - self.python_compiler = None + self.runs: list = [] - -class Window(QMainWindow): - def __init__(self, tab: QWidget, python_compiler=None) -> None: - super().__init__() - self.tab_widget = QTabWidget() - self.tab_widget.addTab(tab, "tab") - self.python_compiler = python_compiler - - -class Shell: - """Records the command instead of starting a shell.""" - - last: "Shell | None" = None - every: list = [] - - def __init__(self, main_window) -> None: - self.main_window = main_window - self.compiler_path = "shell/python" - self.commands: list = [] - Shell.last = self - Shell.every.append(self) - - def later_init(self) -> None: - self.initialised = True - - def exec_shell(self, command) -> None: - self.commands.append(command) - - -@pytest.fixture() -def shell(monkeypatch): - Shell.last = None - Shell.every = [] - monkeypatch.setattr(install_utils, "EditorWidget", EditorTab) - monkeypatch.setattr(install_utils, "ShellManager", Shell) - return Shell + def start_module_process(self, package, arguments, environment=None) -> None: + self.runs.append((package, list(arguments))) @pytest.fixture() -def told(monkeypatch) -> list: - """Collect what the user was told instead of opening a message box.""" - said: list = [] - - class Message: - def __init__(self, _parent=None) -> None: - pass +def processes(monkeypatch) -> list: + started: list = [] - def setWindowTitle(self, title) -> None: - self.title = title + def build(_window) -> Process: + process = Process() + started.append(process) + return process - def setText(self, text) -> None: - said.append(text) + monkeypatch.setattr(install_utils, "build_task_process", build) + return started - def exec(self) -> None: - pass - monkeypatch.setattr(install_utils, "QMessageBox", Message, raising=False) - return said +def _runs(processes) -> list: + return [run for process in processes for run in process.runs] class TestInstallingAPackage: - def test_it_pips_with_the_interpreter_chosen_in_the_ide(self, app, shell, told): - window = Window(EditorTab(), python_compiler="C:/envs/py313/python.exe") + def test_it_runs_pip_as_a_module(self, app, processes): + install_package("je_web_runner", object()) - assert install_package("je_web_runner", window) is True + assert _runs(processes) == [("pip", ["install", "-U", "je_web_runner"])] - assert shell.last.commands == [ - ["C:/envs/py313/python.exe", "-m", "pip", "install", "je_web_runner", "-U"]] - assert told == [] + def test_a_source_folder_target_is_one_argument(self, app, processes): + install_package(r"D:\R&D\prthinker[runner]", object()) - def test_without_a_choice_the_shells_own_interpreter_is_used(self, app, shell, told): - window = Window(EditorTab()) + assert _runs(processes) == [("pip", ["install", "-U", r"D:\R&D\prthinker[runner]"])] - assert install_package("je_web_runner", window) is True - assert shell.last.commands == [ - ["shell/python", "-m", "pip", "install", "je_web_runner", "-U"]] +class TestNoShellIsInvolved: + def test_a_folder_name_with_shell_characters_reaches_the_module_unchanged( + self, app, tmp_path): + # Through cmd.exe, "&" ended the command. The executor pip now runs in + # passes argv to the child as it is: run it for real, with a module + # standing in for pip that prints what it was given. + import time - def test_a_source_folder_target_is_passed_through(self, app, shell, told): - window = Window(EditorTab(), python_compiler="python") + from pybreeze.extend.process_executor.process_executor_utils import build_task_process - install_package(r"D:\src\prthinker[runner]", window) + (tmp_path / "echo_argv.py").write_text( + "import sys\nprint(repr(sys.argv[1:]))\n", encoding="utf-8") + target = r"D:\R&D|x^y%PATH%\prthinker[runner]" - assert shell.last.commands[0][4] == r"D:\src\prthinker[runner]" + class MainWindow: + python_compiler = sys.executable + encoding = "utf-8" - def test_a_tab_that_is_not_an_editor_is_reported_not_ignored(self, app, shell, told): - window = Window(QWidget()) + def __init__(self) -> None: + self.current_run_code_window: list = [] - assert install_package("je_web_runner", window) is False + def clear_code_result(self) -> None: + """Nothing to clear in a stand-in.""" - assert shell.last is None - assert told, "the user was told nothing" + window = MainWindow() + build_task_process(window).start_module_process( + "echo_argv", ["install", "-U", target], environment={"PYTHONPATH": str(tmp_path)}) + run_window = window.current_run_code_window[0] + deadline = time.monotonic() + 30 + while "Task exit with code" not in run_window.code_result.toPlainText(): + assert time.monotonic() < deadline, "the child did not finish" + app.processEvents() + time.sleep(0.01) + assert repr(["install", "-U", target]) in run_window.code_result.toPlainText() -class TestInstallingTheBuildTools: - def test_it_stops_at_the_first_one_that_cannot_start(self, app, shell, told): - from pybreeze.pybreeze_ui.menu.install_menu.tools_menu.build_tool_install_menu import ( - install_build_tools, - ) - - install_build_tools(Window(QWidget())) - # One message, not one per package. - assert len(told) == 1 - assert shell.last is None - - def test_it_installs_all_three_when_it_can(self, app, shell, told): +class TestInstallingTheBuildTools: + def test_one_pip_installs_all_three(self, app, processes): from pybreeze.pybreeze_ui.menu.install_menu.tools_menu.build_tool_install_menu import ( install_build_tools, ) - install_build_tools(Window(EditorTab(), python_compiler="python")) + install_build_tools(object()) - assert [one.commands[0][4] for one in shell.every] == ["setuptools", "build", "wheel"] - assert told == [] + assert _runs(processes) == [("pip", ["install", "-U", "setuptools", "build", "wheel"])] class TestInstallingPrthinker: - def test_the_source_folder_is_not_asked_for_when_nothing_can_be_installed( - self, app, shell, told, monkeypatch): + def test_a_remembered_source_folder_is_installed(self, app, processes, monkeypatch, tmp_path): from pybreeze.pybreeze_ui.menu.install_menu.automation_menu import ( build_automation_install_menu as install_menu, ) - asked: list = [] - monkeypatch.setattr( - install_menu.QFileDialog, "getExistingDirectory", - lambda *args, **kwargs: asked.append(kwargs) or "") - monkeypatch.setattr(install_menu, "load_setting", lambda: {"source_path": ""}) - monkeypatch.setattr( - install_menu, "save_setting", lambda setting: asked.append(setting)) + monkeypatch.setattr(install_menu, "load_setting", lambda: {"source_path": str(tmp_path)}) - install_menu.install_prthinker(Window(QWidget())) + install_menu.install_prthinker(object()) - assert asked == [], "the user was asked to choose a folder for an install that cannot start" - assert told + (run,) = _runs(processes) + assert run[0] == "pip" and run[1][2].startswith(str(tmp_path)) From 8e4412fbb4dd9f38f621971f4064f0258a2b29da Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 07:53:20 +0800 Subject: [PATCH 067/312] Record the tool defects found in review --- progress.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/progress.md b/progress.md index 7d94b9ad..859ed499 100644 --- a/progress.md +++ b/progress.md @@ -8,3 +8,10 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? +- **#28** Generated scripts take the request's method and URL into code unescaped: `script_templates.test_function_name` puts `request.method.lower()` into `def ...():` (a method holding a newline injects statements; `M-SEARCH` is a SyntaxError), and `har_codegen._numbered_comment` writes the raw method and URL into a `#` comment (a URL holding a newline injects a line). Neither parser checks that a method is an HTTP token. +- **#29** A JSON body is emitted as a JSON literal in generated Python (`request_codegen.payload_python_parts`, `script_templates._apitestka_payload_lines`): `true`/`false`/`null` raise `NameError` in the requests, pytest and apitestka scripts. +- **#30** The diff tab computes its summary with `difflib.ndiff` on the UI thread (`utils/diff_tools/text_diff.py` `diff_summary`, from `DiffGUI.compare`): two 3000-line texts with every line changed took 73 s. The counts can come from the opcodes already computed. +- **#31** Exceptions a tool slot does not catch: `timestamp_converter._from_iso` `astimezone()` raises `OverflowError` for `0001-01-01T00:00:00+01:00`; `json_process` raises `ValueError` for an integer over 4300 digits; `jwt_decoder._decode_segment` and `response_analyzer._pretty_json` raise `RecursionError` on deeply nested JSON; `header_analyzer._check_hsts` raises `ValueError` from `int()` on a 5000-digit `max-age`. +- **#32** curl/HAR import fidelity: repeated query keys keep only one value (`curl_parser._split_url_query`, `_finalise_method`, `har_parser._apply_query` store a dict); bash `$'...'` quoting from browsers' Copy as cURL is not understood (`curl_parser._tokenize`); a HAR multipart text value starting with `@` becomes a file upload (`har_parser._multipart_fields`); a HAR file with a UTF-8 BOM is rejected (`har_import_gui.open_file`); `--max-time nan` generates `timeout=nan` (`curl_parser._apply_timeout`). +- **#33** The timestamp tool drops sub-second precision (`timestamp_converter.py:99-103`): `1700000000123` gives `epoch_millis` `...000`, and `-1.5` gives `epoch_seconds` `-1` (truncates toward zero). +- **#34** Minor tool output errors: `query_convert.json_to_query` turns `null` into `None` and a nested object into its Python repr; the response inspector keeps only the last `Set-Cookie` header (`response_analyzer.py:80`); `url_to_json` drops an out-of-range port silently. From 273e7390080d7361235ac5ce85158964e56952eb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 07:56:49 +0800 Subject: [PATCH 068/312] Keep a request's method and URL out of generated code --- architecture_explore.md | 6 ++--- docs/updates/2026-09.md | 25 +++++++++++++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - pybreeze/utils/curl_import/curl_parser.py | 23 ++++++++++++++++- .../utils/curl_import/script_templates.py | 3 ++- pybreeze/utils/exception/exception_tags.py | 2 ++ pybreeze/utils/har_import/har_codegen.py | 14 +++++++++-- pybreeze/utils/har_import/har_parser.py | 9 ++++--- test/test_utils/test_har_import.py | 20 +++++++++++++++ test/test_utils/test_script_templates.py | 20 +++++++++++++++ 11 files changed, 114 insertions(+), 12 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 6c53eb92..7993d9b8 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -339,8 +339,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(401) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`-b`、`-G`);`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式;`script_templates.py`(271) 產 APITestka/LoadDensity/pytest 模板 | -| `har_import/` | `har_parser.py`(304) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重 | +| `curl_import/` | `curl_parser.py`(412) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`-b`、`-G`);`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式;`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | +| `har_import/` | `har_parser.py`(310) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感)+ 資料夾選擇對話框包裝 | @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1305 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1309 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index da3d1755..1b0d3274 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -722,3 +722,28 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_install_menu.py` - `architecture_explore.md` §5.3, §5.5, §18 - **Open items**: JEditor's `ShellManager` still uses `shell=True` for its own Run Shell feature. There it runs what the user typed as a shell command on purpose, and it is JEditor's to decide. + +## U-20260923-47 · 2026-09-23 · A request's method or URL can no longer write code into a generated script · #done #security #tools + +- **What**: `progress.md` #28. The curl and HAR importers write the request's method and URL into the scripts they generate, and two places did so as code rather than as data: + - **Test function name**: `script_templates.test_function_name` built `def test__...():` from the raw method. `curl -X 'GET():\n import os\ndef t' https://x/a` produced a pytest file whose syntax tree holds an `import os` and a second function. The same method in a HAR entry does the same. A legitimate `M-SEARCH` produced a SyntaxError. + - **Batch comment**: `har_codegen._numbered_comment` wrote `# N. ` raw. A recorded URL holding a newline ended the comment and put its rest on a line of code: `https://x/a\nimport os; os.system('calc') #` emitted a real `import os` in the batch script. + Everything else in those scripts goes through `json.dumps`, which was checked: URLs, headers and values are safe literals. + Found by a review of `tools_gui/` and `utils/`, each case reproduced with a concrete input. +- **Fix**: + - New `curl_parser.http_method()` upper-cases the method and accepts only an RFC 9110 token. Both parsers use it and refuse anything else with a parse error that names the value: `CurlParseException` for curl, `HarParseException` for HAR. + - The function name slugs the method (`M-SEARCH` becomes `test_m_search_...`). + - The batch comment writes control characters as `\xNN` escapes. + - New message `invalid_http_method_error`. +- **Tests**: + - `test_script_templates.py`: the injecting method is refused; `M-SEARCH` gives `def test_m_search_a():` and a file that parses. + - `test_har_import.py`: the injecting HAR method is refused; a URL holding a newline leaves the batch script with no import but `requests`, and the newline shows as `\x0a`. +- **Result / numbers**: unit tests 1291 → 1295 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/{curl_parser,script_templates}.py` + - `pybreeze/utils/har_import/{har_parser,har_codegen}.py` + - `pybreeze/utils/exception/exception_tags.py` + - `test/test_utils/{test_script_templates,test_har_import}.py` + - `architecture_explore.md` §12, §18 + - `progress.md` (#28 removed) +- **Open items**: none for this item. The review's other findings are #29–#34. diff --git a/docs/updates/README.md b/docs/updates/README.md index 0b105470..a0bde0f1 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-47 | 2026-09-23 | A request's method or URL can no longer write code into a generated script | #done #security #tools | [2026-09](2026-09.md) | | U-20260923-46 | 2026-09-23 | Package installs no longer go through cmd.exe | #done #security #install | [2026-09](2026-09.md) | | U-20260923-45 | 2026-09-23 | Deleting a folder closes the tabs open inside it | #done #editor | [2026-09](2026-09.md) | | U-20260923-44 | 2026-09-23 | A long output line no longer garbles its characters or doubles its line ending | #done #executor | [2026-09](2026-09.md) | @@ -127,4 +128,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 64 | +| [2026-09.md](2026-09.md) | 2026-09 | 65 | diff --git a/progress.md b/progress.md index 859ed499..46309dd0 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#28** Generated scripts take the request's method and URL into code unescaped: `script_templates.test_function_name` puts `request.method.lower()` into `def ...():` (a method holding a newline injects statements; `M-SEARCH` is a SyntaxError), and `har_codegen._numbered_comment` writes the raw method and URL into a `#` comment (a URL holding a newline injects a line). Neither parser checks that a method is an HTTP token. - **#29** A JSON body is emitted as a JSON literal in generated Python (`request_codegen.payload_python_parts`, `script_templates._apitestka_payload_lines`): `true`/`false`/`null` raise `NameError` in the requests, pytest and apitestka scripts. - **#30** The diff tab computes its summary with `difflib.ndiff` on the UI thread (`utils/diff_tools/text_diff.py` `diff_summary`, from `DiffGUI.compare`): two 3000-line texts with every line changed took 73 s. The counts can come from the opcodes already computed. - **#31** Exceptions a tool slot does not catch: `timestamp_converter._from_iso` `astimezone()` raises `OverflowError` for `0001-01-01T00:00:00+01:00`; `json_process` raises `ValueError` for an integer over 4300 digits; `jwt_decoder._decode_segment` and `response_analyzer._pretty_json` raise `RecursionError` on deeply nested JSON; `header_analyzer._check_hsts` raises `ValueError` from `int()` on a 5000-digit `max-age`. diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index 6ac76986..0769d133 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -16,6 +16,7 @@ from pybreeze.utils.exception.exception_tags import ( empty_curl_command_error, + invalid_http_method_error, malformed_curl_command_error, not_a_curl_command_error, ) @@ -31,6 +32,23 @@ _METHOD_WITH_BODY = "POST" # Matches a backslash or caret line continuation before a newline _LINE_CONTINUATION_RE = re.compile(r"[\\^]\r?\n") +# An HTTP method is a token (RFC 9110, section 5.6.2) +_METHOD_TOKEN_RE = re.compile(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+") + + +def http_method(value: str) -> str: + """Return *value* as an upper-case HTTP method. + + Generated scripts put the method into code -- a function name, a comment -- + so anything that is not a token (a space, a newline, a parenthesis) is + refused here rather than written into a script. + + :raises ValueError: when *value* is not an HTTP method + """ + method = value.strip().upper() + if not _METHOD_TOKEN_RE.fullmatch(method): + raise ValueError(f"{invalid_http_method_error}: {value!r}") + return method @dataclass @@ -281,7 +299,10 @@ def _apply_cookie(request: CurlRequest, value: str) -> None: def _apply_value_flag(request: CurlRequest, kind: str, value: str) -> None: """Apply one value-taking flag to *request* according to its *kind*.""" if kind == "method": - request.method = value.upper() + try: + request.method = http_method(value) + except ValueError as error: + raise CurlParseException(str(error)) from None elif kind == "header": _apply_header(request, value) elif kind == "data": diff --git a/pybreeze/utils/curl_import/script_templates.py b/pybreeze/utils/curl_import/script_templates.py index fa43a0db..c897cfa7 100644 --- a/pybreeze/utils/curl_import/script_templates.py +++ b/pybreeze/utils/curl_import/script_templates.py @@ -204,7 +204,8 @@ def test_function_name(request: CurlRequest) -> str: segments = [segment for segment in parsed.path.split("/") if segment] slug_source = "_".join(segments) if segments else (parsed.hostname or "request") slug = re.sub(r"[^0-9A-Za-z]+", "_", slug_source).strip("_").lower() - name = f"test_{request.method.lower()}_{slug}".rstrip("_") + method = re.sub(r"[^0-9A-Za-z]+", "_", request.method).strip("_").lower() + name = re.sub(r"_+", "_", f"test_{method}_{slug}").rstrip("_") return name or "test_request" diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index e052dd4b..385618a8 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -42,6 +42,8 @@ not_a_curl_command_error: str = "the command does not look like a curl command" malformed_curl_command_error: str = "can't parse the curl command: check quoting" no_url_in_curl_error: str = "no URL found in the curl command" +invalid_http_method_error: str = ( + "not an HTTP method: a method is one word of letters, digits and !#$%&'*+.^_`|~-") # HAR import empty_har_error: str = "no HAR content provided" diff --git a/pybreeze/utils/har_import/har_codegen.py b/pybreeze/utils/har_import/har_codegen.py index 8ba54986..fd973dba 100644 --- a/pybreeze/utils/har_import/har_codegen.py +++ b/pybreeze/utils/har_import/har_codegen.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import re from collections.abc import Callable from pybreeze.utils.curl_import.curl_parser import CurlRequest @@ -48,9 +49,18 @@ def unique_test_names(requests: list[CurlRequest]) -> list[str]: return names +# Characters that must not reach a comment as they are: they would end it +_CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]") + + def _numbered_comment(index: int, request: CurlRequest) -> str: - """Return the comment introducing one request's block.""" - return f"# {index}. {request.method} {request.full_url}" + """Return the comment introducing one request's block. + + A recorded URL can hold anything, a line break included, and a line break + in a comment ends it: control characters are written as escapes. + """ + text = f"{request.method} {request.full_url}" + return f"# {index}. " + _CONTROL_CHARACTERS.sub(lambda match: f"\\x{ord(match.group()):02x}", text) def _requests_script(requests: list[CurlRequest]) -> str: diff --git a/pybreeze/utils/har_import/har_parser.py b/pybreeze/utils/har_import/har_parser.py index 121a0026..1c5c957c 100644 --- a/pybreeze/utils/har_import/har_parser.py +++ b/pybreeze/utils/har_import/har_parser.py @@ -14,7 +14,7 @@ from dataclasses import dataclass, field from urllib.parse import parse_qsl, urlparse -from pybreeze.utils.curl_import.curl_parser import CurlRequest +from pybreeze.utils.curl_import.curl_parser import CurlRequest, http_method from pybreeze.utils.exception.exception_tags import ( empty_har_error, invalid_har_json_error, @@ -184,8 +184,11 @@ def _entry_request(raw_request: dict) -> CurlRequest: """Build a :class:`CurlRequest` from a HAR entry's ``request`` object.""" url = str(raw_request.get("url", "")) base, _separator, query = url.partition("?") - request = CurlRequest( - method=str(raw_request.get("method", "GET")).upper(), url=base) + try: + method = http_method(str(raw_request.get("method", "GET"))) + except ValueError as error: + raise HarParseException(str(error)) from None + request = CurlRequest(method=method, url=base) _apply_headers(request, raw_request) _apply_cookies(request, raw_request) _apply_query(request, raw_request, query) diff --git a/test/test_utils/test_har_import.py b/test/test_utils/test_har_import.py index 4a84baa9..ac65012d 100644 --- a/test/test_utils/test_har_import.py +++ b/test/test_utils/test_har_import.py @@ -304,3 +304,23 @@ def test_the_rebuilt_url_is_encoded_once(self): assert "%2520" not in entry.request.full_url assert entry.request.full_url == "https://x/api?q=hello+world" + + +class TestWhatReachesTheGeneratedCode: + """A recording's method and URL end up in code; nothing in them may become code.""" + + def test_a_method_that_is_not_a_token_is_refused(self): + with pytest.raises(HarParseException, match="not an HTTP method"): + parse_har(_har(_entry(method="GET():\n __import__('os').system('calc')\ndef t"))) + + def test_a_line_break_in_a_url_stays_inside_the_comment(self): + import ast + + url = "https://x/a\nimport os; os.system('calc') #" + requests = [e.request for e in parse_har(_har(_entry(url=url), _entry()))] + + code = generate_har_script("requests", requests) + + imports = [node for node in ast.parse(code).body if isinstance(node, ast.Import)] + assert [alias.name for node in imports for alias in node.names] == ["requests"] + assert "\x0a" in code diff --git a/test/test_utils/test_script_templates.py b/test/test_utils/test_script_templates.py index 239d1367..c1cc8680 100644 --- a/test/test_utils/test_script_templates.py +++ b/test/test_utils/test_script_templates.py @@ -288,3 +288,23 @@ def test_apitestka_action_target(self): def test_unknown_target_falls_back_to_requests(self): code = generate_template("nope", parse_curl("curl https://x")) assert "import requests" in code + + +class TestMethodsInGeneratedCode: + def test_a_method_that_is_not_a_token_is_refused(self): + import pytest + + from pybreeze.utils.exception.exceptions import CurlParseException + + with pytest.raises(CurlParseException, match="not an HTTP method"): + parse_curl("curl -X 'GET():\n import os\ndef t' https://x/a") + + def test_a_method_with_a_hyphen_makes_a_valid_test_name(self): + import ast + + from pybreeze.utils.curl_import.script_templates import to_pytest_test + + code = to_pytest_test(parse_curl("curl -X M-SEARCH https://x/a")) + + assert "def test_m_search_a():" in code + ast.parse(code) From 97b3c70b530cee1479f2fcf99383679c18fe7d53 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:01:56 +0800 Subject: [PATCH 069/312] Write JSON bodies and every string as Python in generated scripts --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 22 ++++++ docs/updates/README.md | 3 +- progress.md | 1 - pybreeze/utils/curl_import/request_codegen.py | 74 +++++++++++++++--- .../utils/curl_import/script_templates.py | 7 +- test/test_utils/test_script_templates.py | 75 +++++++++++++++++++ 7 files changed, 171 insertions(+), 15 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 7993d9b8..ef829bec 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -339,7 +339,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(412) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`-b`、`-G`);`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式;`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | +| `curl_import/` | `curl_parser.py`(412) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`-b`、`-G`);`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(310) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1309 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1314 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 1b0d3274..6128bd3f 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -747,3 +747,25 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §12, §18 - `progress.md` (#28 removed) - **Open items**: none for this item. The review's other findings are #29–#34. + +## U-20260923-48 · 2026-09-23 · Generated scripts write JSON bodies and non-BMP characters as Python · #done #tools + +- **What**: `progress.md` #29, and a second fault the new property test found while fixing it. + - **JSON body**: it was written into the generated Python with `json.dumps`. `-d '{"a": true, "b": null}'` became `json_body = {"a": true, "b": null}`, a `NameError` in the requests, pytest and APITestka Python scripts: `true`, `false` and `null` are not Python names. + - **Non-BMP characters**: every string the requests and pytest scripts carry (URL, headers, form fields, body, auth) was written with `json.dumps`'s ASCII-only default. That writes an emoji or a rare CJK character as a `\ud83d\ude00` surrogate pair: one character to JSON, but two lone surrogates to Python, which `requests` then cannot encode. The APITestka and LoadDensity templates already wrote such characters as themselves. +- **Fix**: + - `request_codegen.python_literal(value, inline=False)` writes a decoded JSON value as Python in the same `indent=4` layout: `True`, `False` and `None`, and `float("nan")`/`float("inf")` for the non-finite values Python's `json` accepts. It has a one-line form for the APITestka call. + - `python_string(text)` is `json.dumps(text, ensure_ascii=False)`, falling back to `repr` for a lone surrogate, which a UTF-8 file cannot hold. Every string in `request_codegen` goes through it. +- **Tests**: `test_script_templates.py`: + - no Python target contains `true`/`false`/`null` names; + - the requests script's `json_body` evaluates to exactly what was sent; + - a Hypothesis property: `literal_eval(python_literal(v)) == v` for any JSON value, one-line or not. It is what found the surrogate fault; + - non-finite floats are spelled out; + - `é`, `😀` and `𠀀` stay one character each, and a lone surrogate survives. +- **Result / numbers**: unit tests 1295 → 1300 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/{request_codegen,script_templates}.py` + - `test/test_utils/test_script_templates.py` + - `architecture_explore.md` §12, §18 + - `progress.md` (#29 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index a0bde0f1..be0c130b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-48 | 2026-09-23 | Generated scripts write JSON bodies and non-BMP characters as Python | #done #tools | [2026-09](2026-09.md) | | U-20260923-47 | 2026-09-23 | A request's method or URL can no longer write code into a generated script | #done #security #tools | [2026-09](2026-09.md) | | U-20260923-46 | 2026-09-23 | Package installs no longer go through cmd.exe | #done #security #install | [2026-09](2026-09.md) | | U-20260923-45 | 2026-09-23 | Deleting a folder closes the tabs open inside it | #done #editor | [2026-09](2026-09.md) | @@ -128,4 +129,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 65 | +| [2026-09.md](2026-09.md) | 2026-09 | 66 | diff --git a/progress.md b/progress.md index 46309dd0..3f161bae 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#29** A JSON body is emitted as a JSON literal in generated Python (`request_codegen.payload_python_parts`, `script_templates._apitestka_payload_lines`): `true`/`false`/`null` raise `NameError` in the requests, pytest and apitestka scripts. - **#30** The diff tab computes its summary with `difflib.ndiff` on the UI thread (`utils/diff_tools/text_diff.py` `diff_summary`, from `DiffGUI.compare`): two 3000-line texts with every line changed took 73 s. The counts can come from the opcodes already computed. - **#31** Exceptions a tool slot does not catch: `timestamp_converter._from_iso` `astimezone()` raises `OverflowError` for `0001-01-01T00:00:00+01:00`; `json_process` raises `ValueError` for an integer over 4300 digits; `jwt_decoder._decode_segment` and `response_analyzer._pretty_json` raise `RecursionError` on deeply nested JSON; `header_analyzer._check_hsts` raises `ValueError` from `int()` on a 5000-digit `max-age`. - **#32** curl/HAR import fidelity: repeated query keys keep only one value (`curl_parser._split_url_query`, `_finalise_method`, `har_parser._apply_query` store a dict); bash `$'...'` quoting from browsers' Copy as cURL is not understood (`curl_parser._tokenize`); a HAR multipart text value starting with `@` becomes a file upload (`har_parser._multipart_fields`); a HAR file with a UTF-8 BOM is rejected (`har_import_gui.open_file`); `--max-time nan` generates `timeout=nan` (`curl_parser._apply_timeout`). diff --git a/pybreeze/utils/curl_import/request_codegen.py b/pybreeze/utils/curl_import/request_codegen.py index eeb92e01..e75ab983 100644 --- a/pybreeze/utils/curl_import/request_codegen.py +++ b/pybreeze/utils/curl_import/request_codegen.py @@ -7,6 +7,8 @@ from __future__ import annotations import json +import math +import re from pybreeze.utils.curl_import.curl_parser import CurlRequest from pybreeze.utils.curl_import.request_body import body_kind, form_parts @@ -15,6 +17,10 @@ _DATA_KWARG = "data=data" # Import line every generated ``requests`` script starts with REQUESTS_IMPORT = "import requests" +# One level of indentation in generated code +_INDENT = " " +# A UTF-16 surrogate: only ever half of a character +_SURROGATE = re.compile("[\ud800-\udfff]") def _format_dict(name: str, mapping: dict[str, str]) -> str | None: @@ -22,7 +28,7 @@ def _format_dict(name: str, mapping: dict[str, str]) -> str | None: if not mapping: return None lines = [f"{name} = {{"] - lines.extend(f" {json.dumps(key)}: {json.dumps(value)}," for key, value in mapping.items()) + lines.extend(f" {python_string(key)}: {python_string(value)}," for key, value in mapping.items()) lines.append("}") return "\n".join(lines) @@ -31,7 +37,7 @@ def _format_files(file_fields: dict[str, str]) -> str: """Render a ``files = { ... }`` block that opens each upload.""" lines = ["files = {"] lines.extend( - f' {json.dumps(field)}: open({json.dumps(filename)}, "rb"),' + f' {python_string(field)}: open({python_string(filename)}, "rb"),' for field, filename in file_fields.items() ) lines.append("}") @@ -49,14 +55,64 @@ def data_from_file_expr(request: CurlRequest) -> str: """ pieces: list[str] = [] if request.data_parts: - pieces.append(json.dumps(request.body)) + pieces.append(python_string(request.body)) pieces.extend( - f'open({json.dumps(name)}, encoding="utf-8").read()' + f'open({python_string(name)}, encoding="utf-8").read()' for name in request.data_file_refs ) return ' + "&" + '.join(pieces) +def python_string(text: str) -> str: + """Write *text* as a Python string literal, in double quotes. + + ``json.dumps`` by default writes a character outside the Basic Multilingual + Plane -- an emoji, a rare CJK character -- as a pair of ``\\uXXXX`` + surrogates: one character to JSON, two to Python, which then cannot send + them. Written as itself it is one character to both. A lone surrogate, + which cannot be written to a UTF-8 file, falls back to ``repr``. + """ + if _SURROGATE.search(text): + return repr(text) + return json.dumps(text, ensure_ascii=False) + + +def python_literal(value: object, *, inline: bool = False, level: int = 0) -> str: + """Write a decoded JSON value as Python source that evaluates back to it. + + ``json.dumps`` is not Python: ``true``, ``false`` and ``null`` are names that + do not exist there, so a JSON body written with it made the generated script + fail with ``NameError``. Strings go through :func:`python_string`. + + :param value: what ``json.loads`` returned + :param inline: all on one line, for a keyword argument inside a call + :param level: how deep *value* is, for the indentation of a nested block + """ + if isinstance(value, dict): + items = [f"{python_string(key)}: {python_literal(item, inline=inline, level=level + 1)}" + for key, item in value.items()] + return _python_container("{", items, "}", inline, level) + if isinstance(value, list): + items = [python_literal(item, inline=inline, level=level + 1) for item in value] + return _python_container("[", items, "]", inline, level) + if isinstance(value, str): + return python_string(value) + if isinstance(value, float) and not math.isfinite(value): + return f'float("{value}")' + return repr(value) # bool, None, int, finite float: repr is their literal + + +def _python_container(opening: str, items: list[str], closing: str, inline: bool, level: int) -> str: + """Lay out *items* between *opening* and *closing*, as ``json.dumps(indent=4)`` would.""" + if not items: + return opening + closing + if inline: + return opening + ", ".join(items) + closing + inner = _INDENT * (level + 1) + body = ",\n".join(inner + item for item in items) + return f"{opening}\n{body}\n{_INDENT * level}{closing}" + + def payload_python_parts(request: CurlRequest) -> tuple[list[str], list[str]]: """Return the assignment lines and call kwargs for the request payload. @@ -87,8 +143,8 @@ def payload_python_parts(request: CurlRequest) -> tuple[list[str], list[str]]: if kind is None: return [], [] if kind[0] == "json": - return [f"json_body = {json.dumps(kind[1], indent=4)}"], ["json=json_body"] - return [f"data = {json.dumps(kind[1])}"], [_DATA_KWARG] + return [f"json_body = {python_literal(kind[1])}"], ["json=json_body"] + return [f"data = {python_string(kind[1])}"], [_DATA_KWARG] def _call_keyword_arguments(request: CurlRequest, payload_kwargs: list[str]) -> list[str]: @@ -119,7 +175,7 @@ def request_statements(request: CurlRequest) -> list[str]: :return: the statement blocks in order """ payload_sections, payload_kwargs = payload_python_parts(request) - statements: list[str] = [f"url = {json.dumps(request.url)}"] + statements: list[str] = [f"url = {python_string(request.url)}"] headers_block = _format_dict("headers", request.headers) if headers_block is not None: @@ -133,11 +189,11 @@ def request_statements(request: CurlRequest) -> list[str]: statements.extend(payload_sections) if request.username is not None: statements.append( - f"auth = ({json.dumps(request.username)}, {json.dumps(request.password or '')})") + f"auth = ({python_string(request.username)}, {python_string(request.password or '')})") arguments = ", ".join(_call_keyword_arguments(request, payload_kwargs)) statements.append( - f'response = requests.request({json.dumps(request.method)}, {arguments})') + f'response = requests.request({python_string(request.method)}, {arguments})') return statements diff --git a/pybreeze/utils/curl_import/script_templates.py b/pybreeze/utils/curl_import/script_templates.py index c897cfa7..42513a6b 100644 --- a/pybreeze/utils/curl_import/script_templates.py +++ b/pybreeze/utils/curl_import/script_templates.py @@ -19,7 +19,7 @@ from pybreeze.utils.curl_import.curl_parser import CurlRequest from pybreeze.utils.curl_import.request_body import body_kind, form_parts from pybreeze.utils.curl_import.request_codegen import ( - REQUESTS_IMPORT, data_from_file_expr, request_statements, to_requests_code + REQUESTS_IMPORT, data_from_file_expr, python_literal, request_statements, to_requests_code ) # APITestka action command that performs an HTTP request @@ -54,7 +54,10 @@ def _apitestka_payload_lines(request: CurlRequest) -> list[str]: if request.data_file_refs: return [f" data={data_from_file_expr(request)},"] kind = body_kind(request) - return [f" {kind[0]}={_inline_json(kind[1])},"] if kind is not None else [] + if kind is None: + return [] + value = python_literal(kind[1], inline=True) if kind[0] == "json" else _inline_json(kind[1]) + return [f" {kind[0]}={value},"] def apitestka_call_block(request: CurlRequest) -> str: diff --git a/test/test_utils/test_script_templates.py b/test/test_utils/test_script_templates.py index c1cc8680..6f30edd1 100644 --- a/test/test_utils/test_script_templates.py +++ b/test/test_utils/test_script_templates.py @@ -308,3 +308,78 @@ def test_a_method_with_a_hyphen_makes_a_valid_test_name(self): assert "def test_m_search_a():" in code ast.parse(code) + + +class TestJsonBodiesInGeneratedPython: + """A JSON body is written as Python: true/false/null would be undefined names.""" + + _COMMAND = ( + "curl https://x/api -H 'Content-Type: application/json' " + "-d '{\"a\": true, \"b\": null, \"c\": [1.5, {\"d\": false}], \"e\": \"x\"}'" + ) + + def test_every_python_target_is_free_of_json_names(self): + import ast + + from pybreeze.utils.curl_import.script_templates import generate_template + + request = parse_curl(self._COMMAND) + for target in ("requests", "pytest", "apitestka_python"): + tree = ast.parse(generate_template(target, request)) + names = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)} + assert not names & {"true", "false", "null"}, target + + def test_the_body_evaluates_to_what_was_sent(self): + import ast + + code = to_requests_code(parse_curl(self._COMMAND)) + assignment = next( + node for node in ast.parse(code).body + if isinstance(node, ast.Assign) and node.targets[0].id == "json_body") + + assert ast.literal_eval(assignment.value) == { + "a": True, "b": None, "c": [1.5, {"d": False}], "e": "x"} + + +def test_python_literal_round_trips_any_json_value(): + import ast + + from hypothesis import given, settings + from hypothesis import strategies as st + + from pybreeze.utils.curl_import.request_codegen import python_literal + + scalars = st.none() | st.booleans() | st.integers() | st.floats(allow_nan=False, allow_infinity=False) | st.text() + values = st.recursive( + scalars, + lambda children: st.lists(children, max_size=4) + | st.dictionaries(st.text(), children, max_size=4), + max_leaves=20) + + @settings(max_examples=200, deadline=None) + @given(values, st.booleans()) + def round_trip(value, inline): + assert ast.literal_eval(python_literal(value, inline=inline)) == value + + round_trip() + + +def test_a_float_json_allows_but_python_cannot_write_is_spelled_out(): + from pybreeze.utils.curl_import.request_codegen import python_literal + + assert python_literal(float("inf")) == 'float("inf")' + assert python_literal([float("-inf")], inline=True) == '[float("-inf")]' + assert python_literal(float("nan")) == 'float("nan")' + + +def test_a_character_outside_the_bmp_stays_one_character(): + import ast + + from pybreeze.utils.curl_import.request_codegen import python_string + + text = "h\u00e9llo \U0001F600 \U00020000" + assert ast.literal_eval(python_string(text)) == text + assert "\\ud83d" not in python_string("\U0001F600") + lone = "lone \ud800" + assert ast.literal_eval(python_string(lone)) == lone + From f46d246617d0e5051196201311a2b8f3bf5f965f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:04:55 +0800 Subject: [PATCH 070/312] Count the diff summary from the diff's own opcodes --- architecture_explore.md | 4 ++-- docs/updates/2026-09.md | 16 +++++++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - pybreeze/utils/diff_tools/text_diff.py | 19 +++++++++------ test/test_utils/test_text_diff.py | 32 ++++++++++++++++++++++++++ 6 files changed, 64 insertions(+), 11 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index ef829bec..046e44f4 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -221,7 +221,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | | `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組。pattern 在另一個行程裡跑(`find_matches_bounded()`,spawn,5 秒後結束掉),分頁用 `RegexMatchThread` 等它:`re` 開始比對後就停不下來,災難性回溯只有整個行程能停 | | `HttpStatusGUI` | `utils/http_reference/` | 狀態碼參考,可依碼前綴或描述搜尋 | -| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計 | +| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計(統計取自 unified diff 同一套 `SequenceMatcher` 的 opcodes,和畫面上的 diff 一致;以前用 `ndiff`,逐行再比字元,3000 行全改要一分多鐘) | | `JsonFormatGUI` | `utils/json_format/` | 美化 / 壓縮 / 驗證 | | `HeaderAnalyzerGUI` | `utils/header_tools/` | HTTP header 安全稽核(HSTS、CSP、CORS、Set-Cookie、banner…)| | `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT | @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1314 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1316 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 6128bd3f..06c2f506 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -769,3 +769,19 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §12, §18 - `progress.md` (#29 removed) - **Open items**: none. + +## U-20260923-49 · 2026-09-23 · The diff tab's summary no longer takes a minute on a large text · #done #tools #performance + +- **What**: `progress.md` #30. The diff tab's Compare computed its "N added, M removed" line with `difflib.ndiff`, synchronously in the slot. `ndiff` also matches the characters inside every changed line, which the counts never use. Two 3000-line texts with every line slightly changed took 73 s in the review, with the IDE frozen. The unified diff shown under it took 0.01 s on the same input. +- **Fix**: `diff_summary()` counts from the opcodes of a `SequenceMatcher` over the lines, the same matching `unified_diff()` uses. `replace` and `delete` add to removed, `replace` and `insert` to added. The same input now takes milliseconds, and the counts now agree with the diff beside them by construction. +- **Tests**: `test_text_diff.py`: + - a Hypothesis property: the counts equal the `+` and `-` lines of the shown diff; + - 3000 changed lines are summarised in under 5 s, as `(3000, 3000)`. + The existing 20 diff tests pass unchanged. +- **Result / numbers**: unit tests 1300 → 1302 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/diff_tools/text_diff.py` + - `test/test_utils/test_text_diff.py` + - `architecture_explore.md` §6, §18 + - `progress.md` (#30 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index be0c130b..6b64e5f8 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-49 | 2026-09-23 | The diff tab's summary no longer takes a minute on a large text | #done #tools #performance | [2026-09](2026-09.md) | | U-20260923-48 | 2026-09-23 | Generated scripts write JSON bodies and non-BMP characters as Python | #done #tools | [2026-09](2026-09.md) | | U-20260923-47 | 2026-09-23 | A request's method or URL can no longer write code into a generated script | #done #security #tools | [2026-09](2026-09.md) | | U-20260923-46 | 2026-09-23 | Package installs no longer go through cmd.exe | #done #security #install | [2026-09](2026-09.md) | @@ -129,4 +130,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 66 | +| [2026-09.md](2026-09.md) | 2026-09 | 67 | diff --git a/progress.md b/progress.md index 3f161bae..72f50b86 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#30** The diff tab computes its summary with `difflib.ndiff` on the UI thread (`utils/diff_tools/text_diff.py` `diff_summary`, from `DiffGUI.compare`): two 3000-line texts with every line changed took 73 s. The counts can come from the opcodes already computed. - **#31** Exceptions a tool slot does not catch: `timestamp_converter._from_iso` `astimezone()` raises `OverflowError` for `0001-01-01T00:00:00+01:00`; `json_process` raises `ValueError` for an integer over 4300 digits; `jwt_decoder._decode_segment` and `response_analyzer._pretty_json` raise `RecursionError` on deeply nested JSON; `header_analyzer._check_hsts` raises `ValueError` from `int()` on a 5000-digit `max-age`. - **#32** curl/HAR import fidelity: repeated query keys keep only one value (`curl_parser._split_url_query`, `_finalise_method`, `har_parser._apply_query` store a dict); bash `$'...'` quoting from browsers' Copy as cURL is not understood (`curl_parser._tokenize`); a HAR multipart text value starting with `@` becomes a file upload (`har_parser._multipart_fields`); a HAR file with a UTF-8 BOM is rejected (`har_import_gui.open_file`); `--max-time nan` generates `timeout=nan` (`curl_parser._apply_timeout`). - **#33** The timestamp tool drops sub-second precision (`timestamp_converter.py:99-103`): `1700000000123` gives `epoch_millis` `...000`, and `-1.5` gives `epoch_seconds` `-1` (truncates toward zero). diff --git a/pybreeze/utils/diff_tools/text_diff.py b/pybreeze/utils/diff_tools/text_diff.py index dfac84f3..9211643c 100644 --- a/pybreeze/utils/diff_tools/text_diff.py +++ b/pybreeze/utils/diff_tools/text_diff.py @@ -60,17 +60,22 @@ def unified_diff( def diff_summary(left: str, right: str) -> DiffSummary: """Summarise how *left* and *right* differ, line by line. + The counts come from the same line matching ``unified_diff`` uses, so they + agree with the diff shown beside them. ``difflib.ndiff`` also compared the + characters inside every changed line, which the counts never needed: two + 3000-line texts with every line changed took over a minute, on the UI + thread. + :param left: the first (expected) text :param right: the second (actual) text :return: the counts of added and removed lines and whether they are equal """ - left_lines = _split_lines(left) - right_lines = _split_lines(right) + matcher = difflib.SequenceMatcher(None, _split_lines(left), _split_lines(right)) added = 0 removed = 0 - for line in difflib.ndiff(left_lines, right_lines): - if line.startswith("+ "): - added += 1 - elif line.startswith("- "): - removed += 1 + for tag, left_start, left_end, right_start, right_end in matcher.get_opcodes(): + if tag in ("replace", "delete"): + removed += left_end - left_start + if tag in ("replace", "insert"): + added += right_end - right_start return DiffSummary(added=added, removed=removed, is_equal=left == right) diff --git a/test/test_utils/test_text_diff.py b/test/test_utils/test_text_diff.py index 112de0c2..c0e43155 100644 --- a/test/test_utils/test_text_diff.py +++ b/test/test_utils/test_text_diff.py @@ -56,3 +56,35 @@ def test_changed_line_counts_as_add_and_remove(self): def test_whitespace_only_difference(self): summary = diff_summary("a", "a ") assert not summary.is_equal + + +class TestSummaryMatchesTheShownDiff: + def test_the_counts_are_the_diffs_own_plus_and_minus_lines(self): + from hypothesis import given, settings + from hypothesis import strategies as st + + lines = st.lists(st.sampled_from(["a", "b", "c", "d", ""]), max_size=12) + + @settings(max_examples=200, deadline=None) + @given(lines, lines) + def agree(left, right): + left_text, right_text = "\n".join(left), "\n".join(right) + shown = unified_diff(left_text, right_text).splitlines()[2:] # skip the ---/+++ header + summary = diff_summary(left_text, right_text) + assert summary.added == sum(line.startswith("+") for line in shown) + assert summary.removed == sum(line.startswith("-") for line in shown) + + agree() + + def test_every_line_changed_in_a_large_text_is_quick(self): + import time + + left = "\n".join(f"line {number} alpha" for number in range(3000)) + right = "\n".join(f"line {number} beta" for number in range(3000)) + + started = time.perf_counter() + summary = diff_summary(left, right) + + # ndiff took over a minute here; the opcodes take milliseconds. + assert time.perf_counter() - started < 5 + assert (summary.added, summary.removed) == (3000, 3000) From c3c825e99168661ad0c5f5aa4821338d5ce7865d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:08:31 +0800 Subject: [PATCH 071/312] Report five tool inputs that used to raise out of the tab --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 28 +++++++++++++++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../utils/header_tools/header_analyzer.py | 5 ++++ pybreeze/utils/json_format/json_process.py | 11 ++++---- pybreeze/utils/jwt_tools/jwt_decoder.py | 5 ++-- .../response_inspector/response_analyzer.py | 6 ++-- .../timestamp_tools/timestamp_converter.py | 10 ++++--- test/test_utils/test_header_analyzer.py | 5 ++++ test/test_utils/test_json_process.py | 10 +++++++ test/test_utils/test_jwt_decoder.py | 9 ++++++ test/test_utils/test_response_analyzer.py | 6 ++++ test/test_utils/test_timestamp_converter.py | 8 ++++++ 14 files changed, 92 insertions(+), 17 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 046e44f4..6a9095dd 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1316 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1322 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 06c2f506..929e8cc9 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -785,3 +785,31 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §6, §18 - `progress.md` (#30 removed) - **Open items**: none. + +## U-20260923-50 · 2026-09-23 · Five inputs that made a tool tab raise are reported instead · #done #tools + +- **What**: `progress.md` #31. Five inputs raised an exception the tool's handler did not name, so it left the tab's slot as a traceback instead of the tool's error message: + - **Timestamp**: `0001-01-01T00:00:00+01:00` or `9999-12-31T23:30:00-01:00` falls outside `datetime`'s range once moved to UTC. `astimezone()` raised `OverflowError`, and it was outside the `try`. + - **JSON format and minify**: an integer longer than Python's 4300-digit conversion limit raises a plain `ValueError`, not `JSONDecodeError`. + - **JWT decoder**: a payload nested past the recursion limit raised `RecursionError`. + - **Response inspector**: the same happened when pretty-printing such a body. + - **Header analyzer**: `int()` on a 5000-digit HSTS `max-age` raised `ValueError`. + Found by the review of `tools_gui/` and `utils/`, each reproduced there. +- **Fix**: + - The timestamp conversion to UTC is inside the `try`, which catches `(ValueError, OverflowError)`. + - The JSON tools catch `ValueError`, which covers `JSONDecodeError` and the digit limit. The now-unused `import json` is gone. + - The JWT decoder and the response inspector catch `RecursionError`. The inspector's serialisation is inside the `try` too. + - A `max-age` longer than 18 digits (`_MAX_AGE_DIGITS`) is centuries, so it counts as long enough without `int()`. +- **Tests**: one per fault, in each tool's test file: + - `test_timestamp_converter.py`: both edge dates; + - `test_json_process.py`: reformat and minify; + - `test_jwt_decoder.py`; + - `test_response_analyzer.py`; + - `test_header_analyzer.py`. +- **Result / numbers**: unit tests 1302 → 1308 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/{timestamp_tools/timestamp_converter,json_format/json_process,jwt_tools/jwt_decoder,response_inspector/response_analyzer,header_tools/header_analyzer}.py` + - the five matching `test/test_utils/test_*.py` + - `architecture_explore.md` §18 + - `progress.md` (#31 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 6b64e5f8..c7ecc403 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-50 | 2026-09-23 | Five inputs that made a tool tab raise are reported instead | #done #tools | [2026-09](2026-09.md) | | U-20260923-49 | 2026-09-23 | The diff tab's summary no longer takes a minute on a large text | #done #tools #performance | [2026-09](2026-09.md) | | U-20260923-48 | 2026-09-23 | Generated scripts write JSON bodies and non-BMP characters as Python | #done #tools | [2026-09](2026-09.md) | | U-20260923-47 | 2026-09-23 | A request's method or URL can no longer write code into a generated script | #done #security #tools | [2026-09](2026-09.md) | @@ -130,4 +131,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 67 | +| [2026-09.md](2026-09.md) | 2026-09 | 68 | diff --git a/progress.md b/progress.md index 72f50b86..dded3836 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#31** Exceptions a tool slot does not catch: `timestamp_converter._from_iso` `astimezone()` raises `OverflowError` for `0001-01-01T00:00:00+01:00`; `json_process` raises `ValueError` for an integer over 4300 digits; `jwt_decoder._decode_segment` and `response_analyzer._pretty_json` raise `RecursionError` on deeply nested JSON; `header_analyzer._check_hsts` raises `ValueError` from `int()` on a 5000-digit `max-age`. - **#32** curl/HAR import fidelity: repeated query keys keep only one value (`curl_parser._split_url_query`, `_finalise_method`, `har_parser._apply_query` store a dict); bash `$'...'` quoting from browsers' Copy as cURL is not understood (`curl_parser._tokenize`); a HAR multipart text value starting with `@` becomes a file upload (`har_parser._multipart_fields`); a HAR file with a UTF-8 BOM is rejected (`har_import_gui.open_file`); `--max-time nan` generates `timeout=nan` (`curl_parser._apply_timeout`). - **#33** The timestamp tool drops sub-second precision (`timestamp_converter.py:99-103`): `1700000000123` gives `epoch_millis` `...000`, and `-1.5` gives `epoch_seconds` `-1` (truncates toward zero). - **#34** Minor tool output errors: `query_convert.json_to_query` turns `null` into `None` and a nested object into its Python repr; the response inspector keeps only the last `Set-Cookie` header (`response_analyzer.py:80`); `url_to_json` drops an out-of-range port silently. diff --git a/pybreeze/utils/header_tools/header_analyzer.py b/pybreeze/utils/header_tools/header_analyzer.py index e133d605..cb1d8f22 100644 --- a/pybreeze/utils/header_tools/header_analyzer.py +++ b/pybreeze/utils/header_tools/header_analyzer.py @@ -39,6 +39,8 @@ # An HSTS policy shorter than 180 days is too short to survive a browser restart # cycle and is below what the preload list accepts. _MIN_HSTS_MAX_AGE = 15_552_000 +# More digits than this is centuries of max-age: long enough, without int() +_MAX_AGE_DIGITS = 18 # Headers that are legitimately sent more than once, so a repeat is not a finding _REPEATABLE_HEADERS = frozenset({ @@ -177,6 +179,9 @@ def _check_content_type_options(header: HeaderField) -> list[HeaderFinding]: def _check_hsts(header: HeaderField) -> list[HeaderFinding]: """Flag an HSTS policy whose ``max-age`` is missing or too short to matter.""" match = _MAX_AGE_RE.search(header.value) + # A value too long for int() to convert is far past any minimum anyway. + if match is not None and len(match.group(1)) > _MAX_AGE_DIGITS: + return [] max_age = int(match.group(1)) if match is not None else 0 if max_age >= _MIN_HSTS_MAX_AGE: return [] diff --git a/pybreeze/utils/json_format/json_process.py b/pybreeze/utils/json_format/json_process.py index 6b522ecd..be912aaf 100644 --- a/pybreeze/utils/json_format/json_process.py +++ b/pybreeze/utils/json_format/json_process.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json from json import dumps from json import loads @@ -13,11 +12,13 @@ def _process_json(json_string: str, **kwargs) -> str: try: return dumps(loads(json_string), indent=4, sort_keys=True, **kwargs) - except (json.JSONDecodeError, RecursionError) as error: + except (ValueError, RecursionError) as error: # Wrap in the project exception so reformat_json's caller sees a single, # documented ITEJsonException type rather than a raw JSONDecodeError -- - # or a RecursionError, which the json module raises on input nested - # deeper than the interpreter's recursion limit. + # a ValueError, as is the one for an integer longer than Python's + # 4300-digit conversion limit -- or a RecursionError, which the json + # module raises on input nested deeper than the interpreter's + # recursion limit. pybreeze_logger.error(wrong_json_data_error) raise ITEJsonException(wrong_json_data_error) from error except TypeError: @@ -47,6 +48,6 @@ def minify_json(json_string: str) -> str: """ try: return dumps(loads(json_string), separators=_MINIFY_SEPARATORS) - except (json.JSONDecodeError, RecursionError) as error: + except (ValueError, RecursionError) as error: pybreeze_logger.error(wrong_json_data_error) raise ITEJsonException(wrong_json_data_error) from error diff --git a/pybreeze/utils/jwt_tools/jwt_decoder.py b/pybreeze/utils/jwt_tools/jwt_decoder.py index 42188827..9c5c7a70 100644 --- a/pybreeze/utils/jwt_tools/jwt_decoder.py +++ b/pybreeze/utils/jwt_tools/jwt_decoder.py @@ -60,8 +60,9 @@ def _decode_segment(segment: str) -> dict: try: raw = base64.urlsafe_b64decode(segment + padding) decoded = json.loads(raw.decode("utf-8")) - # binascii.Error and UnicodeDecodeError both derive from ValueError. - except ValueError as error: + # binascii.Error and UnicodeDecodeError both derive from ValueError; a + # payload nested past the recursion limit raises RecursionError. + except (ValueError, RecursionError) as error: pybreeze_logger.error(jwt_segment_decode_error) raise JwtDecodeException(jwt_segment_decode_error) from error if not isinstance(decoded, dict): diff --git a/pybreeze/utils/response_inspector/response_analyzer.py b/pybreeze/utils/response_inspector/response_analyzer.py index 30bd08a2..f6b90d21 100644 --- a/pybreeze/utils/response_inspector/response_analyzer.py +++ b/pybreeze/utils/response_inspector/response_analyzer.py @@ -92,10 +92,10 @@ def _parse_head_and_body(text: str) -> tuple[int | None, dict[str, str], str]: def _pretty_json(body: str) -> str | None: """Return *body* pretty-printed if it is JSON, else ``None`` (key order kept).""" try: - parsed = json.loads(body) - except (ValueError, TypeError): + return json.dumps(json.loads(body), indent=4, ensure_ascii=False) + # RecursionError: a body nested past the recursion limit + except (ValueError, TypeError, RecursionError): return None - return json.dumps(parsed, indent=4, ensure_ascii=False) def _find_jwts(text: str) -> list[JwtFinding]: diff --git a/pybreeze/utils/timestamp_tools/timestamp_converter.py b/pybreeze/utils/timestamp_tools/timestamp_converter.py index 3b8dd5b9..b135079c 100644 --- a/pybreeze/utils/timestamp_tools/timestamp_converter.py +++ b/pybreeze/utils/timestamp_tools/timestamp_converter.py @@ -67,12 +67,14 @@ def _from_iso(text: str) -> datetime: normalised = text[:-1] + "+00:00" if text.endswith("Z") else text try: parsed = datetime.fromisoformat(normalised) - except ValueError as error: + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + # Moving 0001-01-01 +01:00 (or 9999-12-31 -01:00) to UTC leaves the + # range datetime can hold: OverflowError, not ValueError. + return parsed.astimezone(timezone.utc) + except (ValueError, OverflowError) as error: pybreeze_logger.error(unrecognized_timestamp_error) raise TimestampParseException(unrecognized_timestamp_error) from error - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) def _parse(text: str) -> datetime: diff --git a/test/test_utils/test_header_analyzer.py b/test/test_utils/test_header_analyzer.py index ddb29534..e78e2839 100644 --- a/test/test_utils/test_header_analyzer.py +++ b/test/test_utils/test_header_analyzer.py @@ -188,3 +188,8 @@ def test_empty_input_yields_empty_analysis(self): def test_text_without_headers_yields_no_fields(self): assert analyze_headers("just some prose").fields == [] + + +def test_a_max_age_too_long_for_int_is_long_enough(): + assert "hsts_weak_max_age" not in _codes( + "Strict-Transport-Security: max-age=" + "9" * 5000) diff --git a/test/test_utils/test_json_process.py b/test/test_utils/test_json_process.py index a132c5cc..bbfa9a80 100644 --- a/test/test_utils/test_json_process.py +++ b/test/test_utils/test_json_process.py @@ -78,3 +78,13 @@ def test_minify_reports_it_as_bad_json(self): with pytest.raises(ITEJsonException): minify_json(self._DEEP) + + +def test_an_integer_past_the_conversion_limit_is_a_json_error(): + from pybreeze.utils.json_format.json_process import minify_json + + huge = '{"n": ' + "9" * 5000 + "}" + with pytest.raises(ITEJsonException): + reformat_json(huge) + with pytest.raises(ITEJsonException): + minify_json(huge) diff --git a/test/test_utils/test_jwt_decoder.py b/test/test_utils/test_jwt_decoder.py index 80930199..38a4ee65 100644 --- a/test/test_utils/test_jwt_decoder.py +++ b/test/test_utils/test_jwt_decoder.py @@ -107,3 +107,12 @@ def test_empty_payload(self): def test_nbf_and_auth_time(self): payload = {"nbf": 1609459200, "auth_time": 1609459200} assert set(humanized_timestamp_claims(payload)) == {"nbf", "auth_time"} + + +def test_a_payload_nested_past_the_recursion_limit_is_a_decode_error(): + def segment(obj_text: str) -> str: + return base64.urlsafe_b64encode(obj_text.encode("utf-8")).decode("ascii").rstrip("=") + + token = ".".join([segment('{"alg": "none"}'), segment("[" * 100000 + "]" * 100000), ""]) + with pytest.raises(JwtDecodeException): + decode_jwt(token) diff --git a/test/test_utils/test_response_analyzer.py b/test/test_utils/test_response_analyzer.py index 26c628b3..587f675a 100644 --- a/test/test_utils/test_response_analyzer.py +++ b/test/test_utils/test_response_analyzer.py @@ -120,3 +120,9 @@ def test_empty(self): assert analysis.status is None assert analysis.headers == {} assert analysis.body == "" + + +def test_a_body_nested_past_the_recursion_limit_is_not_json(): + analysis = analyze_response("HTTP/1.1 200 OK\n\n" + "[" * 100000 + "]" * 100000) + + assert analysis.pretty_body is None diff --git a/test/test_utils/test_timestamp_converter.py b/test/test_utils/test_timestamp_converter.py index 9d0413de..847eeae3 100644 --- a/test/test_utils/test_timestamp_converter.py +++ b/test/test_utils/test_timestamp_converter.py @@ -82,3 +82,11 @@ def test_garbage_raises(self): def test_out_of_range_epoch_raises(self): with pytest.raises(TimestampParseException): convert_timestamp("1" * 40) + + +class TestInstantsDatetimeCannotHold: + @pytest.mark.parametrize("text", ["0001-01-01T00:00:00+01:00", "9999-12-31T23:30:00-01:00"]) + def test_a_date_that_leaves_the_range_in_utc_is_a_parse_error(self, text): + # In UTC these fall before year 1 or after year 9999: OverflowError, not ValueError. + with pytest.raises(TimestampParseException): + convert_timestamp(text) From 7f61479fee5d6516d4576c1a6dec206af2bcb4e3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:11:23 +0800 Subject: [PATCH 072/312] Keep a timestamp's milliseconds and round it down --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 13 +++++++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../timestamp_tools/timestamp_converter.py | 17 +++++++++++------ test/test_utils/test_timestamp_converter.py | 17 +++++++++++++++++ 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 6a9095dd..97af1c5c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1322 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1325 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 929e8cc9..555c78aa 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -813,3 +813,16 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §18 - `progress.md` (#31 removed) - **Open items**: none. + +## U-20260923-51 · 2026-09-23 · The timestamp tool keeps milliseconds and rounds times before 1970 down · #done #tools + +- **What**: `progress.md` #33. `convert_timestamp()` took whole seconds with `int(moment.timestamp())` and made milliseconds from them. So `1700000000123` came back as `epoch_millis` `1700000000000`: the milliseconds that were typed in were gone. `int()` also truncates toward zero, so `-1.5` (1.5 s before 1970) gave `epoch_seconds` `-1` instead of `-2`. +- **Fix**: milliseconds are computed from the instant itself, `(moment - _EPOCH) // timedelta(milliseconds=1)`, with integer arithmetic and no float rounding. Seconds are those milliseconds floor-divided by 1000. Both round down (toward the past), and the `TimestampResult` docstring now says so. +- **Tests**: `test_timestamp_converter.py`: `1700000000123` keeps its `123`; `-1.5` gives `-1500` ms and `-2` s; an ISO time with `.250` gives `...250` ms. +- **Result / numbers**: unit tests 1308 → 1311 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/timestamp_tools/timestamp_converter.py` + - `test/test_utils/test_timestamp_converter.py` + - `architecture_explore.md` §18 + - `progress.md` (#33 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c7ecc403..86630c2a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-51 | 2026-09-23 | The timestamp tool keeps milliseconds and rounds times before 1970 down | #done #tools | [2026-09](2026-09.md) | | U-20260923-50 | 2026-09-23 | Five inputs that made a tool tab raise are reported instead | #done #tools | [2026-09](2026-09.md) | | U-20260923-49 | 2026-09-23 | The diff tab's summary no longer takes a minute on a large text | #done #tools #performance | [2026-09](2026-09.md) | | U-20260923-48 | 2026-09-23 | Generated scripts write JSON bodies and non-BMP characters as Python | #done #tools | [2026-09](2026-09.md) | @@ -131,4 +132,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 68 | +| [2026-09.md](2026-09.md) | 2026-09 | 69 | diff --git a/progress.md b/progress.md index dded3836..846a0c05 100644 --- a/progress.md +++ b/progress.md @@ -9,5 +9,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#32** curl/HAR import fidelity: repeated query keys keep only one value (`curl_parser._split_url_query`, `_finalise_method`, `har_parser._apply_query` store a dict); bash `$'...'` quoting from browsers' Copy as cURL is not understood (`curl_parser._tokenize`); a HAR multipart text value starting with `@` becomes a file upload (`har_parser._multipart_fields`); a HAR file with a UTF-8 BOM is rejected (`har_import_gui.open_file`); `--max-time nan` generates `timeout=nan` (`curl_parser._apply_timeout`). -- **#33** The timestamp tool drops sub-second precision (`timestamp_converter.py:99-103`): `1700000000123` gives `epoch_millis` `...000`, and `-1.5` gives `epoch_seconds` `-1` (truncates toward zero). - **#34** Minor tool output errors: `query_convert.json_to_query` turns `null` into `None` and a nested object into its Python repr; the response inspector keeps only the last `Set-Cookie` header (`response_analyzer.py:80`); `url_to_json` drops an out-of-range port silently. diff --git a/pybreeze/utils/timestamp_tools/timestamp_converter.py b/pybreeze/utils/timestamp_tools/timestamp_converter.py index b135079c..31bd29eb 100644 --- a/pybreeze/utils/timestamp_tools/timestamp_converter.py +++ b/pybreeze/utils/timestamp_tools/timestamp_converter.py @@ -10,7 +10,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pybreeze.utils.exception.exception_tags import ( empty_timestamp_error, @@ -26,14 +26,16 @@ _MILLISECONDS_THRESHOLD = 10 ** 11 # Milliseconds per second _MS_PER_SECOND = 1000 +# The Unix epoch, in UTC +_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) @dataclass(frozen=True) class TimestampResult: """Every representation of one instant. - :param epoch_seconds: whole Unix seconds - :param epoch_millis: whole Unix milliseconds + :param epoch_seconds: whole Unix seconds, rounded down (toward the past) + :param epoch_millis: whole Unix milliseconds, rounded down :param iso_utc: ISO-8601 string in UTC """ @@ -98,9 +100,12 @@ def convert_timestamp(text: str) -> TimestampResult: :raises TimestampParseException: when *text* is empty or unrecognised """ moment = _parse(text) - epoch_seconds = int(moment.timestamp()) + # From the instant itself, not from whole seconds: milliseconds kept their + # sub-second part only as zeros, and int() moved an instant before 1970 + # toward zero -- -1.5 s became -1 rather than -2. + epoch_millis = (moment - _EPOCH) // timedelta(milliseconds=1) return TimestampResult( - epoch_seconds=epoch_seconds, - epoch_millis=epoch_seconds * _MS_PER_SECOND, + epoch_seconds=epoch_millis // _MS_PER_SECOND, + epoch_millis=epoch_millis, iso_utc=moment.isoformat(), ) diff --git a/test/test_utils/test_timestamp_converter.py b/test/test_utils/test_timestamp_converter.py index 847eeae3..f5e87334 100644 --- a/test/test_utils/test_timestamp_converter.py +++ b/test/test_utils/test_timestamp_converter.py @@ -90,3 +90,20 @@ def test_a_date_that_leaves_the_range_in_utc_is_a_parse_error(self, text): # In UTC these fall before year 1 or after year 9999: OverflowError, not ValueError. with pytest.raises(TimestampParseException): convert_timestamp(text) + + +class TestSubSecondPrecision: + def test_milliseconds_keep_their_sub_second_part(self): + result = convert_timestamp("1700000000123") + + assert result.epoch_millis == 1700000000123 + assert result.epoch_seconds == 1700000000 + + def test_an_instant_before_1970_rounds_down_not_toward_zero(self): + result = convert_timestamp("-1.5") + + assert result.epoch_millis == -1500 + assert result.epoch_seconds == -2 + + def test_an_iso_time_with_milliseconds_keeps_them(self): + assert convert_timestamp("2021-01-01T00:00:00.250Z").epoch_millis == 1609459200250 From 98e127f95d99e257ac40517b3093fe600b5a9056 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:15:12 +0800 Subject: [PATCH 073/312] Take literal form fields literally, read HAR with a BOM, ignore a non-finite timeout --- architecture_explore.md | 6 ++--- docs/updates/2026-09.md | 25 +++++++++++++++++++ docs/updates/README.md | 3 ++- progress.md | 2 +- .../pybreeze_ui/tools_gui/har_import_gui.py | 3 ++- pybreeze/utils/curl_import/curl_parser.py | 24 +++++++++++++++--- pybreeze/utils/curl_import/request_body.py | 4 +++ pybreeze/utils/curl_import/request_codegen.py | 2 +- .../utils/curl_import/script_templates.py | 4 +-- pybreeze/utils/har_import/har_parser.py | 19 ++++++++------ test/test_utils/test_curl_import.py | 14 ++++++++++- test/test_utils/test_har_import.py | 12 ++++++++- test/test_utils/test_har_import_gui.py | 10 ++++++++ 13 files changed, 106 insertions(+), 22 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 97af1c5c..c699ef07 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -339,8 +339,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(412) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`-b`、`-G`);`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | -| `har_import/` | `har_parser.py`(310) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | +| `curl_import/` | `curl_parser.py`(430) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | +| `har_import/` | `har_parser.py`(317) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感)+ 資料夾選擇對話框包裝 | @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1325 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1331 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 555c78aa..30117cf0 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -826,3 +826,28 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §18 - `progress.md` (#33 removed) - **Open items**: none. + +## U-20260923-52 · 2026-09-23 · A text form field starting with @ is no longer uploaded as a file · #done #tools + +- **What**: three of the five import faults in `progress.md` #32. + - **`@` in text fields**: multipart fields were all kept in curl's `-F` syntax, where a value starting with `@` means "upload this file". A recorded HAR text field `handle=@alice` therefore became `files = {"handle": open("alice", "rb")}` in the generated script. curl's own `--form-string`, which exists to send such a value literally, was mapped onto `-F` and had the same fault. + - **BOM**: a HAR export saved with a UTF-8 byte-order mark was refused as "not a valid HAR export". + - **Non-finite timeout**: `--max-time nan` (or `inf`) passed the `float()` check and was written as `timeout=nan`, an undefined name in the script. +- **Fix**: + - `CurlRequest.form_strings` holds `name=value` fields taken literally; `has_form` covers both kinds. `--form-string` fills it, and so does a HAR text param; a HAR file param still goes to `form_fields` as `name=@file`. `form_parts()` adds the literal fields to the data fields, and the generators test `has_form`. + - The HAR tab reads files as `utf-8-sig`. + - `_apply_timeout` ignores a value that is not finite. +- **Tests**: + - `test_curl_import.py`: `--form-string` is literal and `-F @file` is still an upload; `nan`/`inf`/`-inf` timeouts are ignored. + - `test_har_import.py`: text params go to `form_strings` and files to `form_fields`; `@alice` stays data. + - `test_har_import_gui.py`: an export with a BOM loads. + - The two tests that pinned the old single list now pin the split. +- **Result / numbers**: unit tests 1311 → 1317 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/{curl_parser,request_body,request_codegen,script_templates}.py` + - `pybreeze/utils/har_import/har_parser.py` + - `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py` + - `test/test_utils/{test_curl_import,test_har_import,test_har_import_gui}.py` + - `architecture_explore.md` §12, §18 + - `progress.md` (#32 narrowed) +- **Open items**: repeated query keys, and bash `$'...'` quoting (`progress.md` #32). diff --git a/docs/updates/README.md b/docs/updates/README.md index 86630c2a..4540292a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-52 | 2026-09-23 | A text form field starting with @ is no longer uploaded as a file | #done #tools | [2026-09](2026-09.md) | | U-20260923-51 | 2026-09-23 | The timestamp tool keeps milliseconds and rounds times before 1970 down | #done #tools | [2026-09](2026-09.md) | | U-20260923-50 | 2026-09-23 | Five inputs that made a tool tab raise are reported instead | #done #tools | [2026-09](2026-09.md) | | U-20260923-49 | 2026-09-23 | The diff tab's summary no longer takes a minute on a large text | #done #tools #performance | [2026-09](2026-09.md) | @@ -132,4 +133,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 69 | +| [2026-09.md](2026-09.md) | 2026-09 | 70 | diff --git a/progress.md b/progress.md index 846a0c05..d133f5ad 100644 --- a/progress.md +++ b/progress.md @@ -8,5 +8,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#32** curl/HAR import fidelity: repeated query keys keep only one value (`curl_parser._split_url_query`, `_finalise_method`, `har_parser._apply_query` store a dict); bash `$'...'` quoting from browsers' Copy as cURL is not understood (`curl_parser._tokenize`); a HAR multipart text value starting with `@` becomes a file upload (`har_parser._multipart_fields`); a HAR file with a UTF-8 BOM is rejected (`har_import_gui.open_file`); `--max-time nan` generates `timeout=nan` (`curl_parser._apply_timeout`). +- **#32** curl/HAR import fidelity: repeated query keys keep only one value (`curl_parser._split_url_query`, `_finalise_method` and `har_parser._apply_query` store `CurlRequest.params` as a `dict[str, str]`; `?id=1&id=2` and `-G -d id=1 -d id=2` lose a value), and bash `$'...'` quoting, which browsers' Copy as cURL (bash) uses for bodies with newlines or quotes, is not understood (`curl_parser._tokenize`): the body keeps a leading `$`, or the command is refused as badly quoted. - **#34** Minor tool output errors: `query_convert.json_to_query` turns `null` into `None` and a nested object into its Python repr; the response inspector keeps only the last `Set-Cookie` header (`response_analyzer.py:80`); `url_to_json` drops an out-of-range port silently. diff --git a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py index 7a1c404a..d6466193 100644 --- a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py @@ -108,7 +108,8 @@ def open_file(self) -> str | None: if not path: return None try: - text = Path(path).read_text(encoding="utf-8") + # utf-8-sig: an export saved with a byte-order mark is still JSON + text = Path(path).read_text(encoding="utf-8-sig") except (OSError, UnicodeDecodeError) as error: pybreeze_logger.info("har_import_gui.py read failed: %r", error) # The reason without the path: str(OSError) carries the file's full diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index 0769d133..d3586df7 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -9,6 +9,7 @@ """ from __future__ import annotations +import math import re import shlex from dataclasses import dataclass, field @@ -63,7 +64,10 @@ class CurlRequest: :param username: basic-auth user, or ``None`` :param password: basic-auth password, or ``None`` :param send_data_as_params: ``True`` when ``-G`` moves the body to the query - :param form_fields: multipart form fragments from ``-F`` / ``--form`` + :param form_fields: multipart form fragments from ``-F`` / ``--form``, in + curl's syntax: a value starting with ``@`` is a file to upload + :param form_strings: multipart ``name=value`` fields taken literally, from + ``--form-string`` or a recorded text field; ``@`` means nothing there :param data_file_refs: filenames whose content forms the body (``-d @file``) :param timeout: request timeout in seconds from ``--max-time`` / ``-m``, or ``None`` when the command sets none @@ -79,14 +83,20 @@ class CurlRequest: password: str | None = None send_data_as_params: bool = False form_fields: list[str] = field(default_factory=list) + form_strings: list[str] = field(default_factory=list) data_file_refs: list[str] = field(default_factory=list) timeout: str | None = None cookies: dict[str, str] = field(default_factory=dict) + @property + def has_form(self) -> bool: + """Whether the request carries a multipart form.""" + return bool(self.form_fields or self.form_strings) + @property def has_body(self) -> bool: """Whether the request carries any body, form or file payload.""" - return bool(self.data_parts or self.form_fields or self.data_file_refs) + return bool(self.data_parts or self.has_form or self.data_file_refs) @property def body(self) -> str: @@ -123,7 +133,7 @@ def header_value(self, name: str) -> str | None: "--data-ascii": "data_file", "--data-binary": "data_file", "--data-raw": "data", "--data-urlencode": "data_urlencode", "--json": "json_flag", - "-F": "form", "--form": "form", "--form-string": "form", + "-F": "form", "--form": "form", "--form-string": "form_string", "-u": "user", "--user": "user", "-b": "cookie", "--cookie": "cookie", "-A": "user_agent", "--user-agent": "user_agent", @@ -267,9 +277,13 @@ def _urlencode_data_part(value: str) -> str: def _apply_timeout(request: CurlRequest, value: str) -> None: """Record a numeric timeout (seconds); ignore a non-numeric value.""" try: - float(value) + seconds = float(value) except ValueError: return + # float() also takes "nan" and "inf", which would be written into the + # script as an undefined name. + if not math.isfinite(seconds): + return request.timeout = value @@ -318,6 +332,8 @@ def _apply_value_flag(request: CurlRequest, kind: str, value: str) -> None: set_default_header(request.headers, "Accept", "application/json") elif kind == "form": request.form_fields.append(value) + elif kind == "form_string": + request.form_strings.append(value) elif kind == "cookie": _apply_cookie(request, value) elif kind == "user_agent": diff --git a/pybreeze/utils/curl_import/request_body.py b/pybreeze/utils/curl_import/request_body.py index 7a65320c..eb83aabf 100644 --- a/pybreeze/utils/curl_import/request_body.py +++ b/pybreeze/utils/curl_import/request_body.py @@ -64,4 +64,8 @@ def form_parts(request: CurlRequest) -> FormParts: file_fields[key] = value[1:].split(";", 1)[0] else: data_fields[key] = value + for fragment in request.form_strings: + key, separator, value = fragment.partition("=") + if separator: + data_fields[key] = value return data_fields, file_fields diff --git a/pybreeze/utils/curl_import/request_codegen.py b/pybreeze/utils/curl_import/request_codegen.py index e75ab983..b91dfb71 100644 --- a/pybreeze/utils/curl_import/request_codegen.py +++ b/pybreeze/utils/curl_import/request_codegen.py @@ -123,7 +123,7 @@ def payload_python_parts(request: CurlRequest) -> tuple[list[str], list[str]]: :param request: the parsed curl request :return: ``(assignment_lines, keyword_arguments)`` """ - if request.form_fields: + if request.has_form: data_fields, file_fields = form_parts(request) sections: list[str] = [] kwargs: list[str] = [] diff --git a/pybreeze/utils/curl_import/script_templates.py b/pybreeze/utils/curl_import/script_templates.py index 42513a6b..45704c8e 100644 --- a/pybreeze/utils/curl_import/script_templates.py +++ b/pybreeze/utils/curl_import/script_templates.py @@ -40,7 +40,7 @@ def _inline_json(value: object) -> str: def _apitestka_payload_lines(request: CurlRequest) -> list[str]: """Return the inline ``data=`` / ``files=`` / ``json=`` kwargs for the payload.""" - if request.form_fields: + if request.has_form: data_fields, file_fields = form_parts(request) lines = [] if data_fields: @@ -164,7 +164,7 @@ def _apply_action_payload(request: CurlRequest, params: dict) -> None: JSON cannot carry file handles, so multipart file uploads are not represented here; only the plain form fields are. Use a Python target for file uploads. """ - if request.form_fields: + if request.has_form: data_fields, _file_fields = form_parts(request) if data_fields: params["data"] = data_fields diff --git a/pybreeze/utils/har_import/har_parser.py b/pybreeze/utils/har_import/har_parser.py index 1c5c957c..00f316be 100644 --- a/pybreeze/utils/har_import/har_parser.py +++ b/pybreeze/utils/har_import/har_parser.py @@ -141,12 +141,17 @@ def _apply_query(request: CurlRequest, raw_request: dict, query: str) -> None: request.params.setdefault(name, value) -def _multipart_fields(params: list[tuple[str, str, str]]) -> list[str]: - """Render multipart params in curl's ``-F`` syntax so form handling is shared.""" - return [ - f"{name}=@{file_name}" if file_name else f"{name}={value}" - for name, value, file_name in params - ] +def _apply_multipart(request: CurlRequest, params: list[tuple[str, str, str]]) -> None: + """Add recorded multipart params: uploads in curl's ``-F`` syntax, text literally. + + A text value goes to ``form_strings``: in ``-F`` syntax one starting with + ``@`` would have become a file to upload. + """ + for name, value, file_name in params: + if file_name: + request.form_fields.append(f"{name}=@{file_name}") + else: + request.form_strings.append(f"{name}={value}") def _post_params(raw_post: dict) -> list[tuple[str, str, str]]: @@ -170,7 +175,7 @@ def _apply_body(request: CurlRequest, raw_request: dict) -> None: media_type = str(raw_post.get("mimeType", "")).lower() params = _post_params(raw_post) if params and media_type.startswith(_MULTIPART_MEDIA_TYPE): - request.form_fields.extend(_multipart_fields(params)) + _apply_multipart(request, params) return if params and _FORM_MEDIA_TYPE in media_type: request.data_parts.extend(f"{name}={value}" for name, value, _file in params) diff --git a/test/test_utils/test_curl_import.py b/test/test_utils/test_curl_import.py index db09de6e..9f9e7645 100644 --- a/test/test_utils/test_curl_import.py +++ b/test/test_utils/test_curl_import.py @@ -472,7 +472,19 @@ def test_multiple_form_fields(self): def test_form_string_flag(self): request = parse_curl("curl --form-string 'a=1' https://x") - assert request.form_fields == ["a=1"] + assert request.form_strings == ["a=1"] + assert request.has_form and request.has_body + + def test_form_string_takes_an_at_sign_literally(self): + from pybreeze.utils.curl_import.request_body import form_parts + + request = parse_curl("curl --form-string 'handle=@alice' -F 'photo=@me.png' https://x") + + assert form_parts(request) == ({"handle": "@alice"}, {"photo": "me.png"}) + + @pytest.mark.parametrize("value", ["nan", "inf", "-inf"]) + def test_a_timeout_that_is_not_finite_is_ignored(self, value): + assert parse_curl(f"curl -m {value} https://x").timeout is None class TestToRequestsCode: diff --git a/test/test_utils/test_har_import.py b/test/test_utils/test_har_import.py index ac65012d..89127b06 100644 --- a/test/test_utils/test_har_import.py +++ b/test/test_utils/test_har_import.py @@ -179,7 +179,17 @@ def test_multipart_params_become_form_fields(self): {"name": "note", "value": "hi"}, {"name": "file", "value": "", "fileName": "a.png"}, ]})))[0] - assert entry.request.form_fields == ["note=hi", "file=@a.png"] + assert entry.request.form_strings == ["note=hi"] + assert entry.request.form_fields == ["file=@a.png"] + + def test_a_text_field_starting_with_at_is_not_a_file(self): + from pybreeze.utils.curl_import.request_body import form_parts + + entry = parse_har(_har(_entry(method="POST", post_data={ + "mimeType": "multipart/form-data; boundary=x", + "params": [{"name": "handle", "value": "@alice"}]})))[0] + + assert form_parts(entry.request) == ({"handle": "@alice"}, {}) def test_no_post_data_leaves_no_body(self): assert not parse_har(_har(_entry()))[0].request.has_body diff --git a/test/test_utils/test_har_import_gui.py b/test/test_utils/test_har_import_gui.py index fc29d631..3e5f6ab7 100644 --- a/test/test_utils/test_har_import_gui.py +++ b/test/test_utils/test_har_import_gui.py @@ -155,6 +155,16 @@ def test_open_file_loads_the_chosen_export(self, widget, tmp_path): assert result == str(path) assert widget.entry_list.count() == 2 + def test_an_export_with_a_byte_order_mark_loads(self, widget, tmp_path): + path = tmp_path / "session.har" + path.write_text(_HAR, encoding="utf-8-sig") # starts with a BOM + with patch( + "pybreeze.pybreeze_ui.tools_gui.har_import_gui.QFileDialog.getOpenFileName", + return_value=(str(path), "HAR export (*.har *.json)"), + ): + assert widget.open_file() == str(path) + assert widget.entry_list.count() == 2 + def test_cancelled_dialog_changes_nothing(self, widget): with patch( "pybreeze.pybreeze_ui.tools_gui.har_import_gui.QFileDialog.getOpenFileName", From 34f425185d73b4e2d77d1a5efdc594c456412584 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:15:18 +0800 Subject: [PATCH 074/312] Correct two line counts in architecture_explore.md --- architecture_explore.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index c699ef07..219d17e8 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -339,8 +339,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(430) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | -| `har_import/` | `har_parser.py`(317) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | +| `curl_import/` | `curl_parser.py`(428) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | +| `har_import/` | `har_parser.py`(315) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感)+ 資料夾選擇對話框包裝 | From 83f37f1c253d8bfaf9895d28d6572c237b68b0cc Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:18:57 +0800 Subject: [PATCH 075/312] Keep every value of a repeated query parameter --- architecture_explore.md | 6 ++-- docs/updates/2026-09.md | 25 ++++++++++++++ docs/updates/README.md | 3 +- progress.md | 2 +- pybreeze/utils/curl_import/curl_parser.py | 34 +++++++++++++++---- pybreeze/utils/curl_import/request_codegen.py | 10 ++++-- pybreeze/utils/har_import/har_parser.py | 11 ++++-- test/test_utils/test_curl_import.py | 24 +++++++++++-- test/test_utils/test_har_import.py | 17 ++++++++++ 9 files changed, 112 insertions(+), 20 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 219d17e8..91d6634b 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -339,8 +339,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(428) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | -| `har_import/` | `har_parser.py`(315) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | +| `curl_import/` | `curl_parser.py`(449) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_query_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | +| `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感)+ 資料夾選擇對話框包裝 | @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1331 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1336 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 30117cf0..ae49bd69 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -851,3 +851,28 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §12, §18 - `progress.md` (#32 narrowed) - **Open items**: repeated query keys, and bash `$'...'` quoting (`progress.md` #32). + +## U-20260923-53 · 2026-09-23 · A query parameter given twice keeps both values · #done #tools + +- **What**: from `progress.md` #32. `CurlRequest.params` was a `dict[str, str]`, so a query parameter given more than once kept one value: + - `curl 'https://x/p?id=1&id=2'` came out as `?id=1`; + - `curl -G -d id=1 -d id=2` as `?id=2`; + - a HAR URL with a repeated key lost the same way. + A test pinned one form of this: `curl -G 'https://x?a=fromurl' -d 'a=fromdata'` kept only `fromdata`. Real curl 8.7.1, asked for `url_effective`, sends `?a=fromurl&a=fromdata` for that command and `?id=1&id=2` for the first. +- **Fix**: + - New `curl_parser.add_query_value()` keeps every value: a key seen once maps to its value, a repeated one to the list of its values. + - `full_url` encodes with `doseq=True`, and `requests` expands a list in `params` the same way. + - `parse_curl` splits the URL's query before adding `-G` data, so the order matches curl's. + - The HAR importer takes the URL's values and, from the recorded `queryString` (which repeats them), only names the URL lacked, with all of their values. + - The requests-script `params` block writes a list value with `python_literal`. +- **Tests**: + - `test_curl_import.py`: the pinned test now expects curl's answer, both values with the URL's first; a repeated URL key and a repeated `-G` key keep every value; the generated `params` evaluates to `{"id": ["1", "2"]}`. + - `test_har_import.py`: URL values are kept once, not doubled by `queryString`; a recorded name the URL lacks keeps all its values. +- **Result / numbers**: unit tests 1317 → 1322 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/{curl_parser,request_codegen}.py` + - `pybreeze/utils/har_import/har_parser.py` + - `test/test_utils/{test_curl_import,test_har_import}.py` + - `architecture_explore.md` §12, §18 + - `progress.md` (#32 narrowed) +- **Open items**: bash `$'...'` quoting (`progress.md` #32). diff --git a/docs/updates/README.md b/docs/updates/README.md index 4540292a..893b3565 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-53 | 2026-09-23 | A query parameter given twice keeps both values | #done #tools | [2026-09](2026-09.md) | | U-20260923-52 | 2026-09-23 | A text form field starting with @ is no longer uploaded as a file | #done #tools | [2026-09](2026-09.md) | | U-20260923-51 | 2026-09-23 | The timestamp tool keeps milliseconds and rounds times before 1970 down | #done #tools | [2026-09](2026-09.md) | | U-20260923-50 | 2026-09-23 | Five inputs that made a tool tab raise are reported instead | #done #tools | [2026-09](2026-09.md) | @@ -133,4 +134,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 70 | +| [2026-09.md](2026-09.md) | 2026-09 | 71 | diff --git a/progress.md b/progress.md index d133f5ad..89a6601f 100644 --- a/progress.md +++ b/progress.md @@ -8,5 +8,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#32** curl/HAR import fidelity: repeated query keys keep only one value (`curl_parser._split_url_query`, `_finalise_method` and `har_parser._apply_query` store `CurlRequest.params` as a `dict[str, str]`; `?id=1&id=2` and `-G -d id=1 -d id=2` lose a value), and bash `$'...'` quoting, which browsers' Copy as cURL (bash) uses for bodies with newlines or quotes, is not understood (`curl_parser._tokenize`): the body keeps a leading `$`, or the command is refused as badly quoted. +- **#32** Bash `$'...'` quoting, which browsers' Copy as cURL (bash) uses for bodies holding newlines or quotes, is not understood (`curl_parser._tokenize`): `--data-raw $'{"m":"a\nb"}'` keeps a leading `$` so the body is never recognised as JSON, and `$'it\'s'` is refused as badly quoted. - **#34** Minor tool output errors: `query_convert.json_to_query` turns `null` into `None` and a nested object into its Python repr; the response inspector keeps only the last `Set-Cookie` header (`response_analyzer.py:80`); `url_to_json` drops an out-of-range port silently. diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index d3586df7..4f3ee799 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -59,7 +59,8 @@ class CurlRequest: :param method: HTTP method (upper-case), e.g. ``GET`` or ``POST`` :param url: request URL, or an empty string when none was found :param headers: request headers as ``name -> value`` (names kept as written) - :param params: query parameters collected from ``-G`` / ``--data`` pairs + :param params: query parameters from the URL and from ``-G`` / ``--data`` + pairs; a key given more than once maps to the list of its values :param data_parts: raw body fragments in the order they appeared :param username: basic-auth user, or ``None`` :param password: basic-auth password, or ``None`` @@ -77,7 +78,7 @@ class CurlRequest: method: str = _DEFAULT_METHOD url: str = "" headers: dict[str, str] = field(default_factory=dict) - params: dict[str, str] = field(default_factory=dict) + params: dict[str, str | list[str]] = field(default_factory=dict) data_parts: list[str] = field(default_factory=list) username: str | None = None password: str | None = None @@ -114,7 +115,7 @@ def full_url(self) -> str: if not self.params: return self.url joiner = "&" if "?" in self.url else "?" - return f"{self.url}{joiner}{urlencode(self.params)}" + return f"{self.url}{joiner}{urlencode(self.params, doseq=True)}" def header_value(self, name: str) -> str | None: """Return a header's value by case-insensitive *name*, or ``None``.""" @@ -383,24 +384,42 @@ def _finalise_method(request: CurlRequest) -> None: # '&': each fragment is query text already, so it is split on '&' and # decoded here, or full_url would encode it a second time. for part in request.data_parts: - request.params.update(parse_qsl(part, keep_blank_values=True)) + for key, value in parse_qsl(part, keep_blank_values=True): + add_query_value(request.params, key, value) request.data_parts = [] +def add_query_value(params: dict[str, str | list[str]], key: str, value: str) -> None: + """Add *value* under *key*, keeping the values already there. + + ``?id=1&id=2`` sends both; a plain dict kept only one of them. A key seen + once maps to its value, a repeated one to the list of its values, which is + what ``requests`` and ``urlencode(doseq=True)`` both expand back. + """ + if key not in params: + params[key] = value + return + existing = params[key] + if isinstance(existing, list): + existing.append(value) + else: + params[key] = [existing, value] + + def _split_url_query(request: CurlRequest) -> None: """Move a query string embedded in the URL into ``params``. Browser "copy as cURL" keeps the query in the URL; splitting it out lets it show up alongside ``-G`` / ``-d`` query pairs, while :attr:`CurlRequest.full_url` can still rebuild the original address. Values - are URL-decoded, and existing params are not overwritten. + are URL-decoded, and a key given more than once keeps every value. """ base, separator, query = request.url.partition("?") if not separator: return request.url = base for key, value in parse_qsl(query, keep_blank_values=True): - request.params.setdefault(key, value) + add_query_value(request.params, key, value) def parse_curl(command: str) -> CurlRequest: @@ -423,6 +442,7 @@ def parse_curl(command: str) -> CurlRequest: request = CurlRequest() _consume_tokens(_expand_short_flags(tokens[1:]), request) - _finalise_method(request) + # The URL's own query first: curl appends -G data after it. _split_url_query(request) + _finalise_method(request) return request diff --git a/pybreeze/utils/curl_import/request_codegen.py b/pybreeze/utils/curl_import/request_codegen.py index b91dfb71..04ad0051 100644 --- a/pybreeze/utils/curl_import/request_codegen.py +++ b/pybreeze/utils/curl_import/request_codegen.py @@ -23,12 +23,16 @@ _SURROGATE = re.compile("[\ud800-\udfff]") -def _format_dict(name: str, mapping: dict[str, str]) -> str | None: - """Render ``name = { ... }`` with one entry per line, or ``None`` if empty.""" +def _format_dict(name: str, mapping: dict[str, str | list[str]]) -> str | None: + """Render ``name = { ... }`` with one entry per line, or ``None`` if empty. + + A value may be a list: a query parameter given more than once. + """ if not mapping: return None lines = [f"{name} = {{"] - lines.extend(f" {python_string(key)}: {python_string(value)}," for key, value in mapping.items()) + lines.extend(f" {python_string(key)}: {python_literal(value, inline=True)}," + for key, value in mapping.items()) lines.append("}") return "\n".join(lines) diff --git a/pybreeze/utils/har_import/har_parser.py b/pybreeze/utils/har_import/har_parser.py index 00f316be..d16abeee 100644 --- a/pybreeze/utils/har_import/har_parser.py +++ b/pybreeze/utils/har_import/har_parser.py @@ -14,7 +14,7 @@ from dataclasses import dataclass, field from urllib.parse import parse_qsl, urlparse -from pybreeze.utils.curl_import.curl_parser import CurlRequest, http_method +from pybreeze.utils.curl_import.curl_parser import CurlRequest, add_query_value, http_method from pybreeze.utils.exception.exception_tags import ( empty_har_error, invalid_har_json_error, @@ -136,9 +136,14 @@ def _apply_query(request: CurlRequest, raw_request: dict, query: str) -> None: them once on the way out. """ for key, value in parse_qsl(query, keep_blank_values=True): - request.params.setdefault(key, value) + add_query_value(request.params, key, value) + # The recorded list repeats the URL's parameters: only a name the URL did + # not carry is taken from it, with all of that name's values. + recorded: dict[str, str | list[str]] = {} for name, value in _header_pairs(raw_request.get("queryString")): - request.params.setdefault(name, value) + add_query_value(recorded, name, value) + for name, values in recorded.items(): + request.params.setdefault(name, values) def _apply_multipart(request: CurlRequest, params: list[tuple[str, str, str]]) -> None: diff --git a/test/test_utils/test_curl_import.py b/test/test_utils/test_curl_import.py index 9f9e7645..ba150290 100644 --- a/test/test_utils/test_curl_import.py +++ b/test/test_utils/test_curl_import.py @@ -322,9 +322,29 @@ def test_full_url_includes_get_flag_params(self): request = parse_curl("curl -G https://x/api -d 'a=1'") assert request.full_url == "https://x/api?a=1" - def test_explicit_params_not_overwritten_by_url_query(self): + def test_url_query_and_get_data_are_both_sent_url_first(self): + # What curl 8 sends for this command: ?a=fromurl&a=fromdata request = parse_curl("curl -G 'https://x?a=fromurl' -d 'a=fromdata'") - assert request.params["a"] == "fromdata" + assert request.params["a"] == ["fromurl", "fromdata"] + assert request.full_url == "https://x?a=fromurl&a=fromdata" + + def test_a_repeated_url_key_keeps_every_value(self): + request = parse_curl("curl 'https://x/p?id=1&id=2&q=a'") + assert request.params == {"id": ["1", "2"], "q": "a"} + assert request.full_url == "https://x/p?id=1&id=2&q=a" + + def test_a_repeated_get_data_key_keeps_every_value(self): + request = parse_curl("curl -G https://x/p -d id=1 -d id=2") + assert request.full_url == "https://x/p?id=1&id=2" + + def test_generated_code_sends_every_value(self): + import ast + + code = to_requests_code(parse_curl("curl 'https://x/p?id=1&id=2'")) + assignment = next( + node for node in ast.parse(code).body + if isinstance(node, ast.Assign) and node.targets[0].id == "params") + assert ast.literal_eval(assignment.value) == {"id": ["1", "2"]} def test_url_query_feeds_requests_params(self): code = to_requests_code(parse_curl("curl 'https://x/api?a=1'")) diff --git a/test/test_utils/test_har_import.py b/test/test_utils/test_har_import.py index 89127b06..c28c0bb7 100644 --- a/test/test_utils/test_har_import.py +++ b/test/test_utils/test_har_import.py @@ -334,3 +334,20 @@ def test_a_line_break_in_a_url_stays_inside_the_comment(self): imports = [node for node in ast.parse(code).body if isinstance(node, ast.Import)] assert [alias.name for node in imports for alias in node.names] == ["requests"] assert "\x0a" in code + + +class TestRepeatedQueryKeys: + def test_every_value_in_the_url_is_kept_once(self): + entry = parse_har(_har(_entry( + url="https://api.example.com/v1/items?id=1&id=2", + query=[{"name": "id", "value": "1"}, {"name": "id", "value": "2"}])))[0] + + assert entry.request.params == {"id": ["1", "2"]} + + def test_a_recorded_name_the_url_lacks_keeps_all_its_values(self): + entry = parse_har(_har(_entry( + url="https://api.example.com/v1/items?q=a", + query=[{"name": "q", "value": "a"}, + {"name": "tag", "value": "x"}, {"name": "tag", "value": "y"}])))[0] + + assert entry.request.params == {"q": "a", "tag": ["x", "y"]} From a78721ddc55cab650eb7926b7c751c877ff42f5a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:19:02 +0800 Subject: [PATCH 076/312] Correct curl_parser.py's line count --- architecture_explore.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/architecture_explore.md b/architecture_explore.md index 91d6634b..884467de 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -339,7 +339,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(449) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_query_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | +| `curl_import/` | `curl_parser.py`(448) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_query_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | From 5cae1a8e480da47bc45ffc68d9acddebaee0562d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:23:01 +0800 Subject: [PATCH 077/312] Understand bash $'...' quoting in pasted curl commands --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 21 +++ docs/updates/README.md | 3 +- progress.md | 1 - pybreeze/utils/curl_import/curl_parser.py | 152 +++++++++++++++++----- test/test_utils/test_curl_import.py | 36 +++++ 6 files changed, 178 insertions(+), 39 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 884467de..a88873c5 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -339,7 +339,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(448) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_query_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | +| `curl_import/` | `curl_parser.py`(530) 完整 curl 解析(bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_query_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1336 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1348 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ae49bd69..6d568771 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -876,3 +876,24 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §12, §18 - `progress.md` (#32 narrowed) - **Open items**: bash `$'...'` quoting (`progress.md` #32). + +## U-20260923-54 · 2026-09-23 · Copy as cURL (bash) commands with $'...' bodies import correctly · #done #tools + +- **What**: the last part of `progress.md` #32. Chrome and Firefox's Copy as cURL (bash) write a body holding a newline or a quote in bash's ANSI-C quoting, `--data-raw $'...'`. `shlex` does not know it: + - the body kept a leading `$`, so a JSON body was never recognised as JSON; + - an escaped quote, `$'it\'s'`, was refused as unbalanced. +- **Fix**: `_expand_ansi_c_quotes()` runs before `shlex`. It follows bash's quoting states, so `$'` counts only outside other quotes, and it rewrites each `$'...'` as the plain single-quoted string it stands for. It decodes `\n \t \r \a \b \e \f \v \\ \' \"`, `\xHH`, `\uHHHH`, `\UHHHHHHHH` and octal, and keeps an unknown escape as written, as bash does. An unterminated one is a parse error. Checked against real bash: `$'it\'s'` gives `it's`, `$'\x41'` gives `A`, and `"a $'b'"` stays literal. +- **Also**: the `form_string` branch added in U-20260923-52 had pushed `_apply_value_flag` to cyclomatic complexity 16, over the cap of 15. Its 15-way elif chain is now a `_VALUE_FLAG_HANDLERS` table with small named handlers, and the curl tests and fuzz tests pass unchanged. +- **Tests**: `test_curl_import.py`, `TestBashAnsiCQuoting`: + - a JSON body whose `\n` escape reaches bash as `\\n` is JSON; + - an escaped quote is a quote; + - eight escapes each give their character; + - `$'...'` inside double or single quotes is literal; + - an unterminated one is refused. +- **Result / numbers**: unit tests 1322 → 1334 passed, 14 skipped. `ruff check` clean, and every function is at or under complexity 15. +- **Files**: + - `pybreeze/utils/curl_import/curl_parser.py` (530 lines) + - `test/test_utils/test_curl_import.py` + - `architecture_explore.md` §12, §18 + - `progress.md` (#32 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 893b3565..63373bbf 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-54 | 2026-09-23 | Copy as cURL (bash) commands with $'...' bodies import correctly | #done #tools | [2026-09](2026-09.md) | | U-20260923-53 | 2026-09-23 | A query parameter given twice keeps both values | #done #tools | [2026-09](2026-09.md) | | U-20260923-52 | 2026-09-23 | A text form field starting with @ is no longer uploaded as a file | #done #tools | [2026-09](2026-09.md) | | U-20260923-51 | 2026-09-23 | The timestamp tool keeps milliseconds and rounds times before 1970 down | #done #tools | [2026-09](2026-09.md) | @@ -134,4 +135,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 71 | +| [2026-09.md](2026-09.md) | 2026-09 | 72 | diff --git a/progress.md b/progress.md index 89a6601f..7f724853 100644 --- a/progress.md +++ b/progress.md @@ -8,5 +8,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#32** Bash `$'...'` quoting, which browsers' Copy as cURL (bash) uses for bodies holding newlines or quotes, is not understood (`curl_parser._tokenize`): `--data-raw $'{"m":"a\nb"}'` keeps a leading `$` so the body is never recognised as JSON, and `$'it\'s'` is refused as badly quoted. - **#34** Minor tool output errors: `query_convert.json_to_query` turns `null` into `None` and a nested object into its Python repr; the response inspector keeps only the last `Set-Cookie` header (`response_analyzer.py:80`); `url_to_json` drops an out-of-range port silently. diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index 4f3ee799..c060eb94 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -12,6 +12,8 @@ import math import re import shlex +import sys +from collections.abc import Callable from dataclasses import dataclass, field from urllib.parse import parse_qsl, quote, urlencode @@ -233,10 +235,80 @@ def _normalise_command(command: str) -> str: return _LINE_CONTINUATION_RE.sub(" ", command.strip()) +# One escape inside bash's $'...' quoting +_ANSI_C_ESCAPE_RE = re.compile( + r"\\(x[0-9a-fA-F]{1,2}|u[0-9a-fA-F]{1,4}|U[0-9a-fA-F]{1,8}|[0-7]{1,3}|.)", re.DOTALL) +# What the single-character escapes stand for +_ANSI_C_SIMPLE = { + "n": "\n", "t": "\t", "r": "\r", "a": "\a", "b": "\b", "e": "\x1b", "E": "\x1b", + "f": "\f", "v": "\v", "\\": "\\", "'": "'", '"': '"', "?": "?", +} + + +def _ansi_c_character(match: re.Match) -> str: + """The character one ``$'...'`` escape stands for; an unknown one stays as written.""" + code = match.group(1) + if code[0] in "xuU" and len(code) > 1: + return chr(min(int(code[1:], 16), sys.maxunicode)) + if code[0] in "01234567": + return chr(int(code, 8) & 0xFF) + return _ANSI_C_SIMPLE.get(code, match.group(0)) + + +def _ansi_c_end(command: str, start: int) -> int: + """Index of the quote closing the ``$'...'`` whose body starts at *start*.""" + index = start + while index < len(command): + if command[index] == "\\": + index += 2 + continue + if command[index] == "'": + return index + index += 1 + pybreeze_logger.error(malformed_curl_command_error) + raise CurlParseException(malformed_curl_command_error) + + +def _expand_ansi_c_quotes(command: str) -> str: + """Rewrite bash ``$'...'`` strings as the plain quoted text they stand for. + + Copy as cURL (bash) in the browsers writes a body holding a newline or a + quote as ``$'...'``, which ``shlex`` does not know: it left a ``$`` in + front of the body, or refused an escaped quote as unbalanced. Only a ``$'`` + outside other quotes starts one, as in bash. + """ + pieces: list[str] = [] + index = 0 + quote = "" + while index < len(command): + char = command[index] + if quote: + if char == quote: + quote = "" + elif char == "\\" and quote == '"': + pieces.append(command[index:index + 2]) + index += 2 + continue + elif char == "\\": + pieces.append(command[index:index + 2]) + index += 2 + continue + elif command.startswith("$'", index): + end = _ansi_c_end(command, index + 2) + pieces.append(shlex.quote(_ANSI_C_ESCAPE_RE.sub(_ansi_c_character, command[index + 2:end]))) + index = end + 1 + continue + elif char in "'\"": + quote = char + pieces.append(char) + index += 1 + return "".join(pieces) + + def _tokenize(command: str) -> list[str]: """Split *command* into shell tokens, raising on unbalanced quotes.""" try: - return shlex.split(command, posix=True) + return shlex.split(_expand_ansi_c_quotes(command), posix=True) except ValueError as error: pybreeze_logger.error(malformed_curl_command_error) raise CurlParseException(malformed_curl_command_error) from error @@ -311,42 +383,52 @@ def _apply_cookie(request: CurlRequest, value: str) -> None: request.cookies[name] = cookie_value +def _apply_method(request: CurlRequest, value: str) -> None: + try: + request.method = http_method(value) + except ValueError as error: + raise CurlParseException(str(error)) from None + + +def _apply_json_flag(request: CurlRequest, value: str) -> None: + # curl --json is shorthand for --data + JSON Content-Type and Accept. + request.data_parts.append(value) + set_default_header(request.headers, "Content-Type", "application/json") + set_default_header(request.headers, "Accept", "application/json") + + +def _apply_user(request: CurlRequest, value: str) -> None: + request.username, _separator, request.password = value.partition(":") + + +def _set_url(request: CurlRequest, value: str) -> None: + request.url = value + + +# What each kind of value-taking flag does to the request +_VALUE_FLAG_HANDLERS: dict[str, Callable[[CurlRequest, str], None]] = { + "method": _apply_method, + "header": _apply_header, + "data": lambda request, value: request.data_parts.append(value), + "data_urlencode": lambda request, value: request.data_parts.append(_urlencode_data_part(value)), + "data_file": _apply_data_or_file, + "json_flag": _apply_json_flag, + "form": lambda request, value: request.form_fields.append(value), + "form_string": lambda request, value: request.form_strings.append(value), + "cookie": _apply_cookie, + "user_agent": lambda request, value: set_default_header(request.headers, "User-Agent", value), + "referer": lambda request, value: set_default_header(request.headers, "Referer", value), + "url": _set_url, + "timeout": _apply_timeout, + "user": _apply_user, +} + + def _apply_value_flag(request: CurlRequest, kind: str, value: str) -> None: """Apply one value-taking flag to *request* according to its *kind*.""" - if kind == "method": - try: - request.method = http_method(value) - except ValueError as error: - raise CurlParseException(str(error)) from None - elif kind == "header": - _apply_header(request, value) - elif kind == "data": - request.data_parts.append(value) - elif kind == "data_urlencode": - request.data_parts.append(_urlencode_data_part(value)) - elif kind == "data_file": - _apply_data_or_file(request, value) - elif kind == "json_flag": - # curl --json is shorthand for --data + JSON Content-Type and Accept. - request.data_parts.append(value) - set_default_header(request.headers, "Content-Type", "application/json") - set_default_header(request.headers, "Accept", "application/json") - elif kind == "form": - request.form_fields.append(value) - elif kind == "form_string": - request.form_strings.append(value) - elif kind == "cookie": - _apply_cookie(request, value) - elif kind == "user_agent": - set_default_header(request.headers, "User-Agent", value) - elif kind == "referer": - set_default_header(request.headers, "Referer", value) - elif kind == "url": - request.url = value - elif kind == "timeout": - _apply_timeout(request, value) - elif kind == "user": - request.username, _sep, request.password = value.partition(":") + handler = _VALUE_FLAG_HANDLERS.get(kind) + if handler is not None: + handler(request, value) def _consume_tokens(tokens: list[str], request: CurlRequest) -> None: diff --git a/test/test_utils/test_curl_import.py b/test/test_utils/test_curl_import.py index ba150290..95b08651 100644 --- a/test/test_utils/test_curl_import.py +++ b/test/test_utils/test_curl_import.py @@ -596,3 +596,39 @@ def test_data_urlencode_is_encoded_once(self): assert request.params == {"q": "hello world"} assert request.full_url == "https://x/api?q=hello+world" + + +class TestBashAnsiCQuoting: + """Copy as cURL (bash) writes a body holding a newline or a quote as $'...'.""" + + def test_a_json_body_with_a_newline_is_json(self): + from pybreeze.utils.curl_import.request_body import body_kind + + # The JSON escape \n reaches bash as \\n inside $'...' + request = parse_curl( + "curl https://x/api -H 'content-type: application/json' " + "--data-raw $'{\"msg\":\"a\\\\nb\"}'") + + assert request.body == '{"msg":"a\\nb"}' + assert body_kind(request) == ("json", {"msg": "a\nb"}) + + def test_an_escaped_quote_is_a_quote(self): + assert parse_curl("curl https://x --data-raw $'it\\'s'").body == "it's" + + @pytest.mark.parametrize(("escape", "character"), [ + ("\\t", "\t"), ("\\\\", "\\"), ("\\x41", "A"), ("\\u00e9", "\u00e9"), + ("\\U0001F600", "\U0001F600"), ("\\101", "A"), ("\\e", "\x1b"), ("\\q", "\\q"), + ]) + def test_each_escape_stands_for_its_character(self, escape, character): + assert parse_curl(f"curl https://x -d $'[{escape}]'").body == f"[{character}]" + + def test_a_dollar_quote_inside_other_quotes_is_literal(self): + assert parse_curl("curl https://x -d \"a $'b'\"").body == "a $'b'" + assert parse_curl("curl https://x -d 'a $b'").body == "a $b" + + def test_an_unterminated_one_is_refused(self): + from pybreeze.utils.exception.exceptions import CurlParseException + + with pytest.raises(CurlParseException): + parse_curl("curl https://x -d $'open") + From 2047de788efcbe88a14f27ebcfe5bb8d0e7e2f9c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:28:03 +0800 Subject: [PATCH 078/312] Handle query null, repeated response headers and a bad port --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 25 +++++++++++++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../tools_gui/response_inspector_gui.py | 4 ++- pybreeze/utils/curl_import/curl_parser.py | 14 ++++++----- pybreeze/utils/exception/exception_tags.py | 3 +++ pybreeze/utils/har_import/har_parser.py | 6 ++--- pybreeze/utils/query_tools/query_convert.py | 15 ++++++++++- .../response_inspector/response_analyzer.py | 10 +++++--- pybreeze/utils/url_tools/url_convert.py | 15 ++++++++++- test/test_utils/test_query_convert.py | 14 +++++++++++ test/test_utils/test_response_analyzer.py | 7 ++++++ .../test_utils/test_response_inspector_gui.py | 10 ++++++++ test/test_utils/test_url_convert.py | 16 ++++++++++++ 15 files changed, 127 insertions(+), 20 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index a88873c5..b62dea91 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -339,7 +339,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(530) 完整 curl 解析(bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_query_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | +| `curl_import/` | `curl_parser.py`(530) 完整 curl 解析(bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1348 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1360 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 6d568771..58d9ad8b 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -897,3 +897,28 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §12, §18 - `progress.md` (#32 removed) - **Open items**: none. + +## U-20260923-55 · 2026-09-23 · Query null, repeated response headers and an out-of-range port are handled · #done #tools + +- **What**: `progress.md` #34, three small wrong outputs from the review: + - **JSON to query**: `{"a": null}` became `a=None`, Python's spelling. A nested object was URL-encoded as its Python repr (`%7B%27c%27...`). + - **Response inspector**: it kept only the last of repeated headers, so two `Set-Cookie` lines showed as one. + - **URL builder**: given a port out of range (`http://host:99999/`), it showed the parts without a port. A URL built back from them silently lost it. +- **Fix**: + - `_coerce_scalar` writes `null` as an empty value (`a=`). It refuses an object, or a list inside a list, with the new `nested_query_value_error`. + - The response inspector's headers map a repeated name to the list of its values (a `Set-Cookie` cannot be comma-joined), and the report prints one line per value. + - `url_to_json` reports the new `url_port_out_of_range_error` when the authority names a port `urlsplit` cannot read. `parse_url` keeps its documented tolerance, which returns `None` for such a port. +- **Rename**: the helper that keeps repeated values, added in U-20260923-53 as `curl_parser.add_query_value`, now also serves response headers. It is renamed `add_repeated_value`, and its docstring covers both uses. +- **Tests**: + - `test_query_convert.py`: `null` gives `a=`; an object, a nested list and an object in a list are refused. + - `test_url_convert.py`: port 99999 is reported; five URLs without a bad port still convert, including IPv6 with and without a port and a password containing `:`. + - `test_response_analyzer.py` and `test_response_inspector_gui.py`: two `Set-Cookie` lines are kept and both are printed. +- **Result / numbers**: unit tests 1334 → 1346 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/{query_tools/query_convert,url_tools/url_convert,response_inspector/response_analyzer,curl_import/curl_parser,har_import/har_parser}.py` + - `pybreeze/utils/exception/exception_tags.py` + - `pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py` + - the four matching tests + - `architecture_explore.md` §12, §18 + - `progress.md` (#34 removed) +- **Open items**: none. That clears the review's findings, #28 to #34. diff --git a/docs/updates/README.md b/docs/updates/README.md index 63373bbf..eeb7373d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-55 | 2026-09-23 | Query null, repeated response headers and an out-of-range port are handled | #done #tools | [2026-09](2026-09.md) | | U-20260923-54 | 2026-09-23 | Copy as cURL (bash) commands with $'...' bodies import correctly | #done #tools | [2026-09](2026-09.md) | | U-20260923-53 | 2026-09-23 | A query parameter given twice keeps both values | #done #tools | [2026-09](2026-09.md) | | U-20260923-52 | 2026-09-23 | A text form field starting with @ is no longer uploaded as a file | #done #tools | [2026-09](2026-09.md) | @@ -135,4 +136,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 72 | +| [2026-09.md](2026-09.md) | 2026-09 | 73 | diff --git a/progress.md b/progress.md index 7f724853..7d94b9ad 100644 --- a/progress.md +++ b/progress.md @@ -8,4 +8,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#34** Minor tool output errors: `query_convert.json_to_query` turns `null` into `None` and a nested object into its Python repr; the response inspector keeps only the last `Set-Cookie` header (`response_analyzer.py:80`); `url_to_json` drops an out-of-range port silently. diff --git a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py index 85fabf1e..8dcf1327 100644 --- a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py @@ -63,7 +63,9 @@ def build_report_text(analysis: ResponseAnalysis) -> str: lines.extend(_status_section(analysis)) if analysis.headers: lines.append(word.get("response_headers_label")) - lines.extend(f"{name}: {value}" for name, value in analysis.headers.items()) + for name, value in analysis.headers.items(): + values = value if isinstance(value, list) else [value] + lines.extend(f"{name}: {one}" for one in values) lines.append("") lines.extend(_jwt_section(analysis)) lines.append(word.get("response_body_label")) diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index c060eb94..84395e2d 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -467,16 +467,18 @@ def _finalise_method(request: CurlRequest) -> None: # decoded here, or full_url would encode it a second time. for part in request.data_parts: for key, value in parse_qsl(part, keep_blank_values=True): - add_query_value(request.params, key, value) + add_repeated_value(request.params, key, value) request.data_parts = [] -def add_query_value(params: dict[str, str | list[str]], key: str, value: str) -> None: +def add_repeated_value(params: dict[str, str | list[str]], key: str, value: str) -> None: """Add *value* under *key*, keeping the values already there. - ``?id=1&id=2`` sends both; a plain dict kept only one of them. A key seen - once maps to its value, a repeated one to the list of its values, which is - what ``requests`` and ``urlencode(doseq=True)`` both expand back. + For anything that may repeat a name: ``?id=1&id=2`` sends both, and a + response may carry several ``Set-Cookie`` lines; a plain dict kept only one + of each. A key seen once maps to its value, a repeated one to the list of + its values, which ``requests`` and ``urlencode(doseq=True)`` both expand + back. """ if key not in params: params[key] = value @@ -501,7 +503,7 @@ def _split_url_query(request: CurlRequest) -> None: return request.url = base for key, value in parse_qsl(query, keep_blank_values=True): - add_query_value(request.params, key, value) + add_repeated_value(request.params, key, value) def parse_curl(command: str) -> CurlRequest: diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index 385618a8..12deb8f3 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -62,6 +62,8 @@ # Query <-> JSON conversion invalid_json_object_error: str = "the input must be a JSON object of key/value pairs" +nested_query_value_error: str = ( + "a query value must be a string, number, true/false or null, or a list of them") invalid_json_for_query_error: str = "can't parse the input as JSON" # Regex testing @@ -72,6 +74,7 @@ invalid_json_for_url_error: str = "can't parse the input as JSON" invalid_url_components_error: str = "the input must be a JSON object of URL parts" unreadable_url_error: str = "the input is not a URL that can be read" +url_port_out_of_range_error: str = "the port must be a number from 0 to 65535" regex_timeout_error: str = ( "the pattern was still running after {seconds} s and was stopped; nested " "repetition such as (a+)+ can take exponentially long on text that almost matches") diff --git a/pybreeze/utils/har_import/har_parser.py b/pybreeze/utils/har_import/har_parser.py index d16abeee..9f630173 100644 --- a/pybreeze/utils/har_import/har_parser.py +++ b/pybreeze/utils/har_import/har_parser.py @@ -14,7 +14,7 @@ from dataclasses import dataclass, field from urllib.parse import parse_qsl, urlparse -from pybreeze.utils.curl_import.curl_parser import CurlRequest, add_query_value, http_method +from pybreeze.utils.curl_import.curl_parser import CurlRequest, add_repeated_value, http_method from pybreeze.utils.exception.exception_tags import ( empty_har_error, invalid_har_json_error, @@ -136,12 +136,12 @@ def _apply_query(request: CurlRequest, raw_request: dict, query: str) -> None: them once on the way out. """ for key, value in parse_qsl(query, keep_blank_values=True): - add_query_value(request.params, key, value) + add_repeated_value(request.params, key, value) # The recorded list repeats the URL's parameters: only a name the URL did # not carry is taken from it, with all of that name's values. recorded: dict[str, str | list[str]] = {} for name, value in _header_pairs(raw_request.get("queryString")): - add_query_value(recorded, name, value) + add_repeated_value(recorded, name, value) for name, values in recorded.items(): request.params.setdefault(name, values) diff --git a/pybreeze/utils/query_tools/query_convert.py b/pybreeze/utils/query_tools/query_convert.py index 06313967..750f8798 100644 --- a/pybreeze/utils/query_tools/query_convert.py +++ b/pybreeze/utils/query_tools/query_convert.py @@ -16,6 +16,7 @@ from pybreeze.utils.exception.exception_tags import ( invalid_json_for_query_error, invalid_json_object_error, + nested_query_value_error, ) from pybreeze.utils.exception.exceptions import QueryConvertException from pybreeze.utils.logging.logger import pybreeze_logger @@ -55,9 +56,21 @@ def query_to_json(query: str) -> str: def _coerce_scalar(value: object) -> str: - """Render a scalar JSON value as the string a query string would carry.""" + """Render a scalar JSON value as the string a query string would carry. + + ``null`` is an empty value (``a=``), not Python's ``None``; an object or a + list inside a list has no query form and is refused rather than sent as + its Python repr. + + :raises QueryConvertException: for an object, or a list nested in a list + """ + if value is None: + return "" if isinstance(value, bool): return "true" if value else "false" + if isinstance(value, (dict, list)): + pybreeze_logger.error(nested_query_value_error) + raise QueryConvertException(nested_query_value_error) return str(value) diff --git a/pybreeze/utils/response_inspector/response_analyzer.py b/pybreeze/utils/response_inspector/response_analyzer.py index f6b90d21..ea52ea7a 100644 --- a/pybreeze/utils/response_inspector/response_analyzer.py +++ b/pybreeze/utils/response_inspector/response_analyzer.py @@ -14,6 +14,7 @@ import re from dataclasses import dataclass, field +from pybreeze.utils.curl_import.curl_parser import add_repeated_value from pybreeze.utils.header_tools.header_analyzer import HEADER_LINE_RE from pybreeze.utils.http_reference.status_codes import StatusInfo, lookup from pybreeze.utils.jwt_tools.jwt_decoder import DecodedJwt, decode_jwt, find_tokens @@ -40,7 +41,8 @@ class ResponseAnalysis: """The structured result of inspecting a response. :param status: the looked-up status, or ``None`` when no status line was found - :param headers: parsed response headers + :param headers: parsed response headers; a name sent more than once (``Set-Cookie``) + maps to the list of its values, which cannot be joined into one :param body: the raw response body :param pretty_body: the body pretty-printed when it is JSON, else ``None`` :param is_json_body: whether the body parsed as JSON @@ -48,7 +50,7 @@ class ResponseAnalysis: """ status: StatusInfo | None = None - headers: dict[str, str] = field(default_factory=dict) + headers: dict[str, str | list[str]] = field(default_factory=dict) body: str = "" pretty_body: str | None = None is_json_body: bool = False @@ -73,7 +75,7 @@ def _parse_head_and_body(text: str) -> tuple[int | None, dict[str, str], str]: status_code = int(_STATUS_LINE_RE.match(lines[0]).group(1)) index = 1 - headers: dict[str, str] = {} + headers: dict[str, str | list[str]] = {} while index < len(lines): line = lines[index] if line.strip() == "": @@ -82,7 +84,7 @@ def _parse_head_and_body(text: str) -> tuple[int | None, dict[str, str], str]: match = HEADER_LINE_RE.match(line) if match is None: break - headers[match.group(1)] = match.group(2).strip() + add_repeated_value(headers, match.group(1), match.group(2).strip()) index += 1 body = "\n".join(lines[index:]).strip() diff --git a/pybreeze/utils/url_tools/url_convert.py b/pybreeze/utils/url_tools/url_convert.py index f377f78e..57f8a8ee 100644 --- a/pybreeze/utils/url_tools/url_convert.py +++ b/pybreeze/utils/url_tools/url_convert.py @@ -13,6 +13,7 @@ invalid_json_for_url_error, invalid_url_components_error, unreadable_url_error, + url_port_out_of_range_error, ) from pybreeze.utils.exception.exceptions import UrlConvertException from pybreeze.utils.logging.logger import pybreeze_logger @@ -57,16 +58,28 @@ def url_to_json(url: str) -> str: :param url: the URL to parse :return: a formatted JSON object of the URL's parts :raises UrlConvertException: when *url* cannot be split into parts at all - (``http://[::1`` -- an unclosed IPv6 bracket -- is one) + (``http://[::1`` -- an unclosed IPv6 bracket -- is one), or names a + port outside 0-65535 """ try: components = parse_url(url) except ValueError as error: pybreeze_logger.error(unreadable_url_error) raise UrlConvertException(unreadable_url_error) from error + if components["port"] is None and _has_port_text(url): + # parse_url leaves such a port out; shown that way, the URL built back + # from these parts would silently lose it. + pybreeze_logger.error(url_port_out_of_range_error) + raise UrlConvertException(url_port_out_of_range_error) return json.dumps(components, indent=4, ensure_ascii=False) +def _has_port_text(url: str) -> bool: + """Whether *url*'s authority names a port at all, readable or not.""" + host_and_port = urlsplit(url.strip()).netloc.rpartition("@")[2] + return bool(host_and_port.rpartition("]")[2].partition(":")[1]) + + def _build_netloc(components: dict, host: str) -> str: """Assemble the ``user:pass@host:port`` authority from *components*.""" if ":" in host and not host.startswith("["): diff --git a/test/test_utils/test_query_convert.py b/test/test_utils/test_query_convert.py index 49bbcee9..7aa5ff38 100644 --- a/test/test_utils/test_query_convert.py +++ b/test/test_utils/test_query_convert.py @@ -76,3 +76,17 @@ def test_query_json_query(self): def test_repeated_key_round_trip(self): assert json_to_query(query_to_json("a=1&a=2")) == "a=1&a=2" + + +class TestValuesWithoutAQueryForm: + def test_null_is_an_empty_value(self): + from pybreeze.utils.query_tools.query_convert import json_to_query + + assert json_to_query('{"a": null, "b": 1}') == "a=&b=1" + + @pytest.mark.parametrize("text", ['{"a": {"c": 1}}', '{"a": [[1]]}', '{"a": [{"c": 1}]}']) + def test_an_object_or_a_nested_list_is_refused(self, text): + from pybreeze.utils.query_tools.query_convert import json_to_query + + with pytest.raises(QueryConvertException): + json_to_query(text) diff --git a/test/test_utils/test_response_analyzer.py b/test/test_utils/test_response_analyzer.py index 587f675a..406936f3 100644 --- a/test/test_utils/test_response_analyzer.py +++ b/test/test_utils/test_response_analyzer.py @@ -126,3 +126,10 @@ def test_a_body_nested_past_the_recursion_limit_is_not_json(): analysis = analyze_response("HTTP/1.1 200 OK\n\n" + "[" * 100000 + "]" * 100000) assert analysis.pretty_body is None + + +def test_a_header_sent_twice_keeps_both_values(): + analysis = analyze_response( + "HTTP/1.1 200 OK\nSet-Cookie: a=1\nSet-Cookie: b=2\nContent-Type: text/plain\n\nok") + + assert analysis.headers == {"Set-Cookie": ["a=1", "b=2"], "Content-Type": "text/plain"} diff --git a/test/test_utils/test_response_inspector_gui.py b/test/test_utils/test_response_inspector_gui.py index a8cd29bc..c2ae1a87 100644 --- a/test/test_utils/test_response_inspector_gui.py +++ b/test/test_utils/test_response_inspector_gui.py @@ -238,3 +238,13 @@ def test_open_body_without_json_is_noop(self, widget_with_window): def test_open_body_before_analyze_is_noop(self, widget_with_window): gui, _window = widget_with_window assert gui.open_body_in_json_format() is None + + +def test_the_report_lists_every_value_of_a_repeated_header(app): + from pybreeze.pybreeze_ui.tools_gui.response_inspector_gui import build_report_text + from pybreeze.utils.response_inspector.response_analyzer import analyze_response + + text = build_report_text(analyze_response( + "HTTP/1.1 200 OK\nSet-Cookie: a=1\nSet-Cookie: b=2\n\nok")) + + assert "Set-Cookie: a=1" in text and "Set-Cookie: b=2" in text diff --git a/test/test_utils/test_url_convert.py b/test/test_utils/test_url_convert.py index ada17fa4..c7d7ecfe 100644 --- a/test/test_utils/test_url_convert.py +++ b/test/test_utils/test_url_convert.py @@ -118,3 +118,19 @@ def test_the_round_trip_keeps_them(self): from pybreeze.utils.url_tools.url_convert import build_url, parse_url assert build_url(parse_url("https://x/?tag=a&tag=b&one=1")) == "https://x/?tag=a&tag=b&one=1" + + +class TestAPortOutOfRange: + def test_it_is_reported_rather_than_dropped(self): + from pybreeze.utils.url_tools.url_convert import url_to_json + + with pytest.raises(UrlConvertException): + url_to_json("http://host:99999/") + + @pytest.mark.parametrize("url", [ + "http://host/", "http://host:8080/", "http://[::1]:8080/", "http://[::1]/", + "http://user:pa:ss@host/"]) + def test_urls_without_a_bad_port_still_convert(self, url): + from pybreeze.utils.url_tools.url_convert import url_to_json + + assert json.loads(url_to_json(url))["scheme"] == "http" From 0dff180da8965387af59418c6c38ce15784f0723 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:32:09 +0800 Subject: [PATCH 079/312] Refuse IPv6 site-local addresses in the SSRF check --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 13 +++++++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/network/url_validation.py | 11 ++++++++--- test/test_utils/test_url_validation.py | 1 + 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index b62dea91..c19cdeec 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1360 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1361 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 58d9ad8b..e6fd3b09 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -922,3 +922,16 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §12, §18 - `progress.md` (#34 removed) - **Open items**: none. That clears the review's findings, #28 to #34. + +## U-20260923-56 · 2026-09-23 · The SSRF check also refuses IPv6 site-local addresses · #done #security + +- **What**: an audit of `url_validation._is_blocked_ip`. It is the SSRF check that CLAUDE.md § Security › Network requires before any request to a user-supplied URL. The audit compared its verdict with `ipaddress.is_global` over 34 special-purpose addresses on Python 3.11: + - IPv4: this-network, RFC 1918, CGNAT, loopback, link-local/metadata, the IETF block, TEST-NETs, benchmarking, multicast, reserved and broadcast. + - IPv6: loopback, unspecified, mapped/compatible, NAT64 (both prefixes), 6to4, Teredo, documentation, ULA, link-local, site-local, multicast and discard. + Wherever the two differed, the check was the stricter one, except for one range. `fec0::/10`, IPv6 site-local, deprecated but still routed inside some networks, passed both the check and `is_global`. +- **Fix**: an IPv6 address that `is_site_local` is refused. The docstring lists it. +- **Known limit, unchanged**: the check resolves the name, and the request that follows resolves it again. A hostile DNS server answering differently the second time (DNS rebinding) is not stopped by this check; stopping it would mean connecting to the address that was checked. +- **Tests**: `test_url_validation.py`: `http://[fec0::1]/` is blocked. +- **Result / numbers**: unit tests 1346 → 1347 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/utils/network/url_validation.py`, `test/test_utils/test_url_validation.py`, `architecture_explore.md` §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index eeb7373d..567fdef6 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-56 | 2026-09-23 | The SSRF check also refuses IPv6 site-local addresses | #done #security | [2026-09](2026-09.md) | | U-20260923-55 | 2026-09-23 | Query null, repeated response headers and an out-of-range port are handled | #done #tools | [2026-09](2026-09.md) | | U-20260923-54 | 2026-09-23 | Copy as cURL (bash) commands with $'...' bodies import correctly | #done #tools | [2026-09](2026-09.md) | | U-20260923-53 | 2026-09-23 | A query parameter given twice keeps both values | #done #tools | [2026-09](2026-09.md) | @@ -136,4 +137,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 73 | +| [2026-09.md](2026-09.md) | 2026-09 | 74 | diff --git a/pybreeze/utils/network/url_validation.py b/pybreeze/utils/network/url_validation.py index 66a5c23a..2dad2a99 100644 --- a/pybreeze/utils/network/url_validation.py +++ b/pybreeze/utils/network/url_validation.py @@ -53,6 +53,10 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: return True if isinstance(ip, ipaddress.IPv4Address): return ip in _CGNAT_NETWORK + # fec0::/10 site-local: deprecated, yet still routed inside some networks, + # and neither is_private nor is_global says so + if ip.is_site_local: + return True return any(_is_blocked_ip(embedded) for embedded in _embedded_ipv4(ip)) @@ -63,9 +67,10 @@ def validate_url(url: str) -> str: 1. Scheme must be ``http`` or ``https`` 2. Hostname must be present 3. Every resolved IP must be a public address. Private, loopback, - link-local, reserved, multicast, unspecified and Carrier-Grade NAT - ranges are blocked, as are IPv6 transition forms (IPv4-mapped, 6to4, - Teredo, NAT64) that tunnel to a blocked IPv4 endpoint. + link-local, reserved, multicast, unspecified, IPv6 site-local and + Carrier-Grade NAT ranges are blocked, as are IPv6 transition forms + (IPv4-mapped, 6to4, Teredo, NAT64) that tunnel to a blocked IPv4 + endpoint. Returns the original *url* on success; raises ``UnsafeURLError`` on failure. """ diff --git a/test/test_utils/test_url_validation.py b/test/test_utils/test_url_validation.py index 5b7a1c07..c2b170d4 100644 --- a/test/test_utils/test_url_validation.py +++ b/test/test_utils/test_url_validation.py @@ -33,6 +33,7 @@ class TestPrivateAddressBlocking: "http://224.0.0.1/", # multicast "http://[::1]/", # IPv6 loopback "http://[fe80::1]/", # IPv6 link-local + "http://[fec0::1]/", # IPv6 site-local (deprecated, still routed in places) ]) def test_blocked(self, url): with pytest.raises(UnsafeURLError): From f996be76de8e947d390661c084d19cbe07e26cc0 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:36:30 +0800 Subject: [PATCH 080/312] Carry the AI review totals on from the saved file --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 12 +++++++ docs/updates/README.md | 3 +- .../connect_gui/url/ai_code_review_gui.py | 31 +++++++++++++++-- test/test_utils/test_ai_code_review_client.py | 33 +++++++++++++++++++ 5 files changed, 77 insertions(+), 6 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index c19cdeec..fe02a186 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -302,7 +302,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ### `url/ai_code_review_gui.py` -獨立的 HTTP client widget:送出程式碼給審查端點、接受/拒絕回覆並記錄統計到 `~/.pybreeze/response_stats.txt`。 +獨立的 HTTP client widget:送出程式碼給審查端點、接受/拒絕回覆並記錄統計到 `~/.pybreeze/response_stats.txt`(開啟時用 `read_stats()` 讀回上次的總數接著算;檔案不是這個格式或讀不了就從 0 開始)。 - 請求走 `ReviewRequestThread(QThread)`,只有 `answered` / `failed` 兩個 signal 碰 UI(和 `SkillsSendGUI` 同一套);送出中再按不會重送;`closeEvent` 不等它,交給 `thread_keeper.let_run_out()`:斷開它和面板的連線、留著參考直到它結束(等它會讓 IDE 凍住最長一個讀取逾時) - `urls.txt` 只存 URL 的 SHA-256 指紋(`url_fingerprint()`):API URL 可能帶權杖,依 CLAUDE.md 要當憑證看待;舊版留下的明文檔會在下次送出時改寫成指紋 @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1361 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1368 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index e6fd3b09..61b1e243 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -935,3 +935,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1346 → 1347 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/utils/network/url_validation.py`, `test/test_utils/test_url_validation.py`, `architecture_explore.md` §18. - **Open items**: none. + +## U-20260923-57 · 2026-09-23 · The AI review panel's accept/reject totals carry across sessions · #done #ai + +- **What**: the AI review panel records accepted and rejected answers in `~/.pybreeze/response_stats.txt`, stored under the home directory "so the data is stable". But the counts started at 0 in every new panel and the file was only ever written, never read. The first accept of a session wrote `Accepted: 1` over whatever total was there. +- **Fix**: new `read_stats(path)` reads the totals back when the panel opens, and counting carries on from them. The file is in the user's home, so it is read as untrusted. Only `Accepted: ` and `Rejected: ` lines with a non-negative whole number of fewer than 16 digits count. A missing, garbled or non-UTF-8 file counts as none yet and is logged at debug. +- **Tests**: `test_ai_code_review_client.py`: + - a panel opening on `Accepted: 7 / Rejected: 2` writes `Accepted: 8 / Rejected: 2` after one accept; + - five malformed files count as `(0, 0)`: empty, garbage, negative or non-numeric, 40 digits, no colon; + - a missing file and a binary file count as `(0, 0)`. +- **Result / numbers**: unit tests 1347 → 1354 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `test/test_utils/test_ai_code_review_client.py`, `architecture_explore.md` §9, §18. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 567fdef6..73857838 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-57 | 2026-09-23 | The AI review panel's accept/reject totals carry across sessions | #done #ai | [2026-09](2026-09.md) | | U-20260923-56 | 2026-09-23 | The SSRF check also refuses IPv6 site-local addresses | #done #security | [2026-09](2026-09.md) | | U-20260923-55 | 2026-09-23 | Query null, repeated response headers and an out-of-range port are handled | #done #tools | [2026-09](2026-09.md) | | U-20260923-54 | 2026-09-23 | Copy as cURL (bash) commands with $'...' bodies import correctly | #done #tools | [2026-09](2026-09.md) | @@ -137,4 +138,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 74 | +| [2026-09.md](2026-09.md) | 2026-09 | 75 | 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 4e76c074..ed1406fc 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 @@ -41,6 +41,8 @@ def looks_like_a_fingerprint(line: str) -> bool: SUPPORTED_METHODS = ("GET", "POST", "PUT", "DELETE") # The methods that carry the code in a body / 會把程式碼放進 body 的方法 METHODS_WITH_A_BODY = ("POST", "PUT") +# A saved count longer than this is not a count this panel wrote +_MAX_COUNT_DIGITS = 16 class ReviewRequestThread(QThread): @@ -86,6 +88,28 @@ def _send(self) -> requests.Response: return send(self._url, **options) +def read_stats(path: str) -> tuple[int, int]: + """Return the ``(accepted, rejected)`` totals saved in *path*. + + The file is ours but lives in the user's home, so it is read as untrusted: + a line that is not ``Accepted: `` or ``Rejected: `` with a + non-negative whole number is skipped, and a missing or unreadable file + counts as none yet. + """ + totals = {"Accepted": 0, "Rejected": 0} + try: + text = Path(path).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + pybreeze_logger.debug("AI review stats not read: %r", error) + return 0, 0 + for line in text.splitlines(): + name, separator, value = line.partition(":") + value = value.strip() + if separator and name.strip() in totals and value.isdecimal() and len(value) < _MAX_COUNT_DIGITS: + totals[name.strip()] = int(value) + return totals["Accepted"], totals["Rejected"] + + class AICodeReviewClient(QWidget): def __init__(self): super().__init__() @@ -96,14 +120,15 @@ def __init__(self): # 目前在飛的請求 / The request in flight, if any self.request_thread: ReviewRequestThread | None = None - # 記錄接受/拒絕次數 - self.accept_count = 0 - self.reject_count = 0 # Store under the user's home (like the SSH known_hosts) so the data is # stable regardless of which directory the IDE was launched from. data_dir = pybreeze_data_dir() self.stats_file = str(data_dir / "response_stats.txt") self.url_file = str(data_dir / "urls.txt") + # 記錄接受/拒絕次數,接著上次的數字 / The running totals, carried on + # from the file: counting from 0 each time wrote one panel's counts over + # every earlier one. + self.accept_count, self.reject_count = read_stats(self.stats_file) # 主佈局 (垂直) main_layout = QVBoxLayout() diff --git a/test/test_utils/test_ai_code_review_client.py b/test/test_utils/test_ai_code_review_client.py index 2cf986bf..ef6c37f7 100644 --- a/test/test_utils/test_ai_code_review_client.py +++ b/test/test_utils/test_ai_code_review_client.py @@ -206,3 +206,36 @@ def test_an_unsupported_method_says_so_and_starts_nothing(self, client, monkeypa assert started == [] assert client.response_panel.toPlainText() + + +class TestTheRunningTotals: + def test_a_new_panel_carries_on_from_the_saved_totals(self, app, tmp_path, monkeypatch): + (tmp_path / "response_stats.txt").write_text("Accepted: 7\nRejected: 2\n", encoding="utf-8") + monkeypatch.setattr(ai_code_review_gui, "pybreeze_data_dir", lambda: tmp_path) + panel = AICodeReviewClient() + + panel.accept_response() + + # It used to start from 0 and write "Accepted: 1" over the 7. + assert (tmp_path / "response_stats.txt").read_text(encoding="utf-8") == "Accepted: 8\nRejected: 2\n" + panel.deleteLater() + + @pytest.mark.parametrize("text", [ + "", "garbage", "Accepted: -3\nRejected: x", "Accepted: " + "9" * 40, "Accepted 5", + ]) + def test_a_file_that_is_not_ours_counts_as_none(self, tmp_path, text): + from pybreeze.pybreeze_ui.connect_gui.url.ai_code_review_gui import read_stats + + path = tmp_path / "response_stats.txt" + path.write_text(text, encoding="utf-8") + + assert read_stats(str(path)) == (0, 0) + + def test_a_missing_or_unreadable_file_counts_as_none(self, tmp_path): + from pybreeze.pybreeze_ui.connect_gui.url.ai_code_review_gui import read_stats + + assert read_stats(str(tmp_path / "absent.txt")) == (0, 0) + binary = tmp_path / "binary.txt" + binary.write_bytes(b"\xff\xfe\x00") + assert read_stats(str(binary)) == (0, 0) + From 3d383af701b3325483182a09066bb126038ca3c5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:37:21 +0800 Subject: [PATCH 081/312] Describe how the Install menu runs pip now, and its prthinker entry --- docs/source/Eng/menu_install.rst | 11 ++++++++--- docs/source/Zh/menu_install.rst | 8 ++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/source/Eng/menu_install.rst b/docs/source/Eng/menu_install.rst index fb375075..c1d141dc 100644 --- a/docs/source/Eng/menu_install.rst +++ b/docs/source/Eng/menu_install.rst @@ -27,6 +27,9 @@ Installs the automation module packages via pip. - Runs ``pip install -U automation_file`` * - **Install MailThunder** - Runs ``pip install -U je_mail_thunder`` + * - **Install prthinker (code review)** + - prthinker is not on PyPI. The first time, asks for its source folder and + remembers it; then runs ``pip install -U [runner]`` Tools Submenu ------------- @@ -43,6 +46,8 @@ Tools Submenu .. note:: - All installation commands run in the shell runner and respect the currently - active virtual environment. If a ``.venv`` or ``venv`` directory is detected, - packages will be installed into that environment. + Each install opens a run window of its own and runs ``python -m pip`` there, + with no shell in between, so a folder name holding ``&`` or ``|`` reaches pip + unchanged. pip runs with the interpreter chosen in the Python environment + menu; when none is chosen, with a ``venv`` or ``.venv`` in the working + directory, and otherwise with the Python found on ``PATH``. diff --git a/docs/source/Zh/menu_install.rst b/docs/source/Zh/menu_install.rst index b8045702..45d70526 100644 --- a/docs/source/Zh/menu_install.rst +++ b/docs/source/Zh/menu_install.rst @@ -27,6 +27,9 @@ Automation 子選單 - 執行 ``pip install -U automation_file`` * - **Install MailThunder** - 執行 ``pip install -U je_mail_thunder`` + * - **Install prthinker (code review)** + - prthinker 不在 PyPI 上。第一次會詢問它的原始碼資料夾並記下來, + 之後執行 ``pip install -U <資料夾>[runner]`` Tools 子選單 ------------ @@ -43,5 +46,6 @@ Tools 子選單 .. note:: - 所有安裝命令都在 Shell 運行器中執行,並遵循目前啟用的虛擬環境。 - 如果偵測到 ``.venv`` 或 ``venv`` 目錄,套件將安裝到該環境中。 + 每次安裝都會開一個自己的執行視窗,在裡面執行 ``python -m pip``,中間不經過 shell, + 所以資料夾名稱裡有 ``&`` 或 ``|`` 也會原樣交給 pip。pip 使用 Python 環境選單選定的 + 直譯器;沒有選的話,使用工作目錄下的 ``venv`` 或 ``.venv``,再沒有就用 ``PATH`` 上找到的 Python。 From ef234631e73bfb79c4d494e2f65260b653ab0246 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:37:34 +0800 Subject: [PATCH 082/312] Record the defects found in the second review --- progress.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/progress.md b/progress.md index 7d94b9ad..a71ade8b 100644 --- a/progress.md +++ b/progress.md @@ -8,3 +8,9 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? +- **#35** A diagram load can still half-clear the canvas and then save over the user's file: `diagram_scene._load_images` (~line 725) raises `TypeError` for an image whose `source` is not a string after the canvas is cleared, and `_open_diagram` keeps the previous `_current_path`. +- **#36** prthinker settings coerce any JSON value with `str()` (`prthinker_setting.load_setting`, ~line 157): a hand-edited `null` becomes `"None"` and is sent as the API key, repository and model, and a list becomes broken argv entries. +- **#37** Diagram editing: Delete from a right-click deletes the selected item, not the clicked one (`diagram_scene.contextMenuEvent`; the view swallows the right press); Bring to Front / Send to Back only step z by 1 (`_change_z`); undo reverses the stacking of overlapping nodes with equal z (`to_dict` lists topmost first); dragging an image records no undo step (`mousePressEvent`); `font_size` from a file is not clamped (`DiagramNode.from_dict`); the property panel's width/height spinboxes cap at 800/600 and shrink larger nodes (`diagram_property_panel.py:168/298`); a horizontal wheel zooms out (`diagram_view.wheelEvent`); deleting the editor with a node selected raises in the panel's `selectionChanged` slot. +- **#38** Mermaid import: arrows and `;` inside quoted labels split statements (`_parse_statement`, `_parse_lines`); a `graph`/`flowchart` header without a direction becomes a node (`_DIRECTION_RE`); `direction LR` inside a subgraph becomes a node (`_SKIP_RE`); text on `---`/`--o`/`--x` links becomes a node and `~~~` drops its target (`_ARROW_SPLIT_RE`, `_normalize_inline_labels`). +- **#39** Menu actions: Automation File's Create Project imports `file_automation`, not `automation_file`, so it does nothing (`build_automation_file_menu.py:37`); TestPioneer refuses `.yaml` and its dialog filter is never applied (`build_test_pioneer_menu.check_file`); a plugin run config without `name` raises `KeyError` in `run_current_file_with` (`build_run_with_menu.py:48`). +- **#40** Renaming a file only in letter case (`a.py` to `A.py`) is refused as already existing on Windows (`file_tree_context_menu._action_rename`). From 09801bdea974a4eda95e046e16cddb420e94744d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:42:12 +0800 Subject: [PATCH 083/312] Make a diagram load all or nothing --- architecture_explore.md | 8 ++--- docs/updates/2026-09.md | 17 ++++++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../diagram_editor/diagram_items.py | 5 ++- .../diagram_editor/diagram_scene.py | 17 ++++++++-- test/test_utils/test_diagram_serialization.py | 31 +++++++++++++++++++ 7 files changed, 72 insertions(+), 10 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index fe02a186..016a2bfd 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -233,13 +233,13 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,574 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,590 行,最大子系統) | 檔案 | 職責 | |---|---| | `diagram_editor_widget.py` (616) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | -| `diagram_scene.py` (814) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | -| `diagram_items.py` (884) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | +| `diagram_scene.py` (825) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | +| `diagram_items.py` (887) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | | `diagram_mermaid_parser.py` (532) | Mermaid flowchart → diagram dict。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (421) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | | `diagram_view.py` (175) | `QGraphicsView`:滾輪縮放(有上下界)、中鍵平移、`drawBackground` 畫格線 | @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1368 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1373 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 61b1e243..cf0db9da 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -947,3 +947,20 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1347 → 1354 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `test/test_utils/test_ai_code_review_client.py`, `architecture_explore.md` §9, §18. - **Open items**: none. + +## U-20260923-58 · 2026-09-23 · A diagram load either happens or leaves the canvas as it was · #done #diagram + +- **What**: `progress.md` #35, from the second review. A diagram image whose `source` was not a string (`"source": 5`, `null`, a list or an object) passed `DiagramImage.from_dict`, which stored it as is. It then raised `TypeError` in `_load_images`, on the path lookup after the per-item guard. By then `load_from_dict` had cleared the canvas, and `_open_diagram` kept the previously opened file as the save target. The next Save wrote the half-loaded canvas over the user's own file. U-20260923-10 had closed the whole-file version of this; this was the per-entry gap. +- **Fix**: + - `DiagramImage.from_dict` drops a `source` that is not text, and `_load_images` reads the item's cleaned `source()`. + - `load_from_dict` snapshots the canvas with `to_dict()` before clearing it. If the load still fails part-way, it puts the snapshot back and re-raises. A load now either happens or changes nothing, whatever the next unforeseen entry is. `test_no_blind_except` accepts this `except Exception`, because it re-raises. +- **Tests**: `test_diagram_serialization.py`: + - four non-text sources each load as an image with an empty source, keeping the rest of the diagram; + - a load made to fail inside `_load_images` leaves the previous canvas in place and raises. +- **Result / numbers**: unit tests 1354 → 1359 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_items}.py` + - `test/test_utils/test_diagram_serialization.py` + - `architecture_explore.md` §7, §18 + - `progress.md` (#35 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 73857838..819a9c84 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-58 | 2026-09-23 | A diagram load either happens or leaves the canvas as it was | #done #diagram | [2026-09](2026-09.md) | | U-20260923-57 | 2026-09-23 | The AI review panel's accept/reject totals carry across sessions | #done #ai | [2026-09](2026-09.md) | | U-20260923-56 | 2026-09-23 | The SSRF check also refuses IPv6 site-local addresses | #done #security | [2026-09](2026-09.md) | | U-20260923-55 | 2026-09-23 | Query null, repeated response headers and an out-of-range port are handled | #done #tools | [2026-09](2026-09.md) | @@ -138,4 +139,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 75 | +| [2026-09.md](2026-09.md) | 2026-09 | 76 | diff --git a/progress.md b/progress.md index a71ade8b..ca9b0039 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#35** A diagram load can still half-clear the canvas and then save over the user's file: `diagram_scene._load_images` (~line 725) raises `TypeError` for an image whose `source` is not a string after the canvas is cleared, and `_open_diagram` keeps the previous `_current_path`. - **#36** prthinker settings coerce any JSON value with `str()` (`prthinker_setting.load_setting`, ~line 157): a hand-edited `null` becomes `"None"` and is sent as the API key, repository and model, and a list becomes broken argv entries. - **#37** Diagram editing: Delete from a right-click deletes the selected item, not the clicked one (`diagram_scene.contextMenuEvent`; the view swallows the right press); Bring to Front / Send to Back only step z by 1 (`_change_z`); undo reverses the stacking of overlapping nodes with equal z (`to_dict` lists topmost first); dragging an image records no undo step (`mousePressEvent`); `font_size` from a file is not clamped (`DiagramNode.from_dict`); the property panel's width/height spinboxes cap at 800/600 and shrink larger nodes (`diagram_property_panel.py:168/298`); a horizontal wheel zooms out (`diagram_view.wheelEvent`); deleting the editor with a node selected raises in the panel's `selectionChanged` slot. - **#38** Mermaid import: arrows and `;` inside quoted labels split statements (`_parse_statement`, `_parse_lines`); a `graph`/`flowchart` header without a direction becomes a node (`_DIRECTION_RE`); `direction LR` inside a subgraph becomes a node (`_SKIP_RE`); text on `---`/`--o`/`--x` links becomes a node and `~~~` drops its target (`_ARROW_SPLIT_RE`, `_normalize_inline_labels`). diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index 501f82db..dbf47765 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -873,10 +873,13 @@ def to_dict(self, img_id: int) -> dict: @classmethod def from_dict(cls, data: dict) -> DiagramImage: + source = data.get("source", "") img = cls( x=data["x"], y=data["y"], w=data.get("w", 200), h=data.get("h", 200), - source=data.get("source", ""), + # A file is anyone's to edit: a source that is not text is dropped, + # not carried on into a path lookup that raises mid-load. + source=source if isinstance(source, str) else "", ) caption = data.get("caption", "") if caption: diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 30fbb04e..7ec657e5 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -721,8 +721,9 @@ def _load_images(self, image_dicts: list) -> None: pybreeze_logger.debug("Skipping malformed diagram image %r: %s", img_d, err) continue self.addItem(img) - # Try to reload pixmap from source - source = img_d.get("source", "") + # Try to reload pixmap from source; from_dict has already dropped + # one that is not text, which raised here after the canvas was cleared + source = img.source() if source: self._try_load_image_source(img, source) @@ -804,11 +805,21 @@ def load_from_dict(self, data: dict) -> None: canvas half-way would be written over the user's own file by the next save. + Each entry is loaded on its own guard, and should anything still fail + part-way, the canvas is put back as it was before the error goes on: + a load either happens or changes nothing. + :param data: a diagram, as :meth:`to_dict` writes it :raises ValueError: when *data* is not a diagram; nothing is cleared then """ self._check_is_a_diagram(data) + previous = self.to_dict() self._clear_items() - self._load_items(data) + try: + self._load_items(data) + except Exception: # restores the canvas, then re-raises + self._clear_items() + self._load_items(previous) + raise self.undo_stack.clear() self.item_count_changed.emit() diff --git a/test/test_utils/test_diagram_serialization.py b/test/test_utils/test_diagram_serialization.py index 06f27ab2..8ad78ec3 100644 --- a/test/test_utils/test_diagram_serialization.py +++ b/test/test_utils/test_diagram_serialization.py @@ -88,6 +88,37 @@ def test_valid_items_load_despite_malformed_entries(self, qt_app): # Only the valid self-connection survives. assert len(scene.get_all_connections()) == 1 + @pytest.mark.parametrize("source", [5, None, ["a.png"], {"x": 1}]) + def test_an_image_source_that_is_not_text_is_dropped(self, qt_app, source): + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({ + "nodes": [{"id": 0, "x": 0, "y": 0, "text": "Kept"}], + "images": [{"x": 0, "y": 0, "w": 10, "h": 10, "source": source}], + }) + + # It used to raise TypeError after the canvas was cleared. + assert [node.text() for node in scene.get_all_nodes()] == ["Kept"] + (image,) = scene.get_all_images() + assert image.source() == "" + + def test_a_load_that_fails_part_way_leaves_the_canvas_as_it_was(self, qt_app, monkeypatch): + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({"nodes": [{"id": 0, "x": 0, "y": 0, "text": "Before"}]}) + + def broken(_image_dicts): + raise RuntimeError("something nobody foresaw") + + monkeypatch.setattr(scene, "_load_images", broken) + with pytest.raises(RuntimeError): + scene.load_from_dict({"nodes": [{"id": 0, "x": 0, "y": 0, "text": "After"}], "images": []}) + monkeypatch.undo() + + assert [node.text() for node in scene.get_all_nodes()] == ["Before"] + def test_invalid_connection_style_falls_back_to_solid(self, qt_app): from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ConnectionStyle From 5efb8c89bfa598ebf0af1acb306b767f5d3d0b6c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:46:45 +0800 Subject: [PATCH 084/312] Keep a prthinker setting that is not text at its default --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../extend/prthinker_extend/prthinker_setting.py | 15 ++++++++++++--- test/test_utils/test_prthinker_setting.py | 13 +++++++++++++ 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 016a2bfd..66470a25 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1373 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1378 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index cf0db9da..7c3b52f2 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -964,3 +964,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §7, §18 - `progress.md` (#35 removed) - **Open items**: none. + +## U-20260923-59 · 2026-09-23 · A hand-edited null no longer becomes the prthinker API key "None" · #done #prthinker #security + +- **What**: `progress.md` #36, from the second review. `prthinker_setting.load_setting()` took every known key from `~/.pybreeze/prthinker_setting.json` through `str()`. A hand-edited `null` therefore became the text `"None"`, which was sent to prthinker as `PRTHINKER_OPENAI_API_KEY=None`, `GITHUB_REPOSITORY=None` and `PRTHINKER_OPENAI_MODEL=None`. The review then failed against the backend with a misleading authentication error instead of "not configured". A list in `extra_arguments` became argv fragments such as `['[--a,', '--b]']`. +- **Fix**: every setting is text, so a value that is not a `str` keeps its default and is logged at debug. Unknown keys are still ignored, and a broken file still yields the defaults. +- **Tests**: `test_prthinker_setting.py`: `null`, a number, `true`, a list and an object each leave the API key and the extra arguments at their defaults, while a valid key beside them (`repository`) is still read. +- **Result / numbers**: unit tests 1359 → 1364 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §18, `progress.md` (#36 removed). +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 819a9c84..62c1f00a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-59 | 2026-09-23 | A hand-edited null no longer becomes the prthinker API key "None" | #done #prthinker #security | [2026-09](2026-09.md) | | U-20260923-58 | 2026-09-23 | A diagram load either happens or leaves the canvas as it was | #done #diagram | [2026-09](2026-09.md) | | U-20260923-57 | 2026-09-23 | The AI review panel's accept/reject totals carry across sessions | #done #ai | [2026-09](2026-09.md) | | U-20260923-56 | 2026-09-23 | The SSRF check also refuses IPv6 site-local addresses | #done #security | [2026-09](2026-09.md) | @@ -139,4 +140,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 76 | +| [2026-09.md](2026-09.md) | 2026-09 | 77 | diff --git a/progress.md b/progress.md index ca9b0039..612ee530 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#36** prthinker settings coerce any JSON value with `str()` (`prthinker_setting.load_setting`, ~line 157): a hand-edited `null` becomes `"None"` and is sent as the API key, repository and model, and a list becomes broken argv entries. - **#37** Diagram editing: Delete from a right-click deletes the selected item, not the clicked one (`diagram_scene.contextMenuEvent`; the view swallows the right press); Bring to Front / Send to Back only step z by 1 (`_change_z`); undo reverses the stacking of overlapping nodes with equal z (`to_dict` lists topmost first); dragging an image records no undo step (`mousePressEvent`); `font_size` from a file is not clamped (`DiagramNode.from_dict`); the property panel's width/height spinboxes cap at 800/600 and shrink larger nodes (`diagram_property_panel.py:168/298`); a horizontal wheel zooms out (`diagram_view.wheelEvent`); deleting the editor with a node selected raises in the panel's `selectionChanged` slot. - **#38** Mermaid import: arrows and `;` inside quoted labels split statements (`_parse_statement`, `_parse_lines`); a `graph`/`flowchart` header without a direction becomes a node (`_DIRECTION_RE`); `direction LR` inside a subgraph becomes a node (`_SKIP_RE`); text on `---`/`--o`/`--x` links becomes a node and `~~~` drops its target (`_ARROW_SPLIT_RE`, `_normalize_inline_labels`). - **#39** Menu actions: Automation File's Create Project imports `file_automation`, not `automation_file`, so it does nothing (`build_automation_file_menu.py:37`); TestPioneer refuses `.yaml` and its dialog filter is never applied (`build_test_pioneer_menu.check_file`); a plugin run config without `name` raises `KeyError` in `run_current_file_with` (`build_run_with_menu.py:48`). diff --git a/pybreeze/extend/prthinker_extend/prthinker_setting.py b/pybreeze/extend/prthinker_extend/prthinker_setting.py index 6d8ef0a0..ebe8207a 100644 --- a/pybreeze/extend/prthinker_extend/prthinker_setting.py +++ b/pybreeze/extend/prthinker_extend/prthinker_setting.py @@ -152,9 +152,18 @@ def load_setting() -> Dict[str, str]: except (OSError, ValueError) as error: pybreeze_logger.error("prthinker settings could not be read: %r", error) return setting - if isinstance(stored, dict): - setting.update( - {key: str(value) for key, value in stored.items() if key in DEFAULT_SETTING}) + if not isinstance(stored, dict): + return setting + for key, value in stored.items(): + if key not in DEFAULT_SETTING: + continue + # Every setting is text. str() made a hand-edited null the text "None", + # which then went out as the API key, the repository and the model, + # and a list became broken arguments; anything else keeps its default. + if isinstance(value, str): + setting[key] = value + else: + pybreeze_logger.debug("prthinker setting %s is not text; using the default", key) return setting diff --git a/test/test_utils/test_prthinker_setting.py b/test/test_utils/test_prthinker_setting.py index 03450cd8..ab643621 100644 --- a/test/test_utils/test_prthinker_setting.py +++ b/test/test_utils/test_prthinker_setting.py @@ -42,6 +42,19 @@ def test_an_unknown_field_is_ignored(self, data_dir): json.dumps({"nonsense": "value"}), encoding="utf-8") assert "nonsense" not in load_setting() + @pytest.mark.parametrize("value", [None, 5, True, ["--a", "--b"], {"k": "v"}]) + def test_a_value_that_is_not_text_keeps_its_default(self, data_dir, value): + # str() made null the text "None", sent on as the API key and model. + (data_dir / "prthinker_setting.json").write_text(json.dumps({ + "openai_api_key": value, "extra_arguments": value, "repository": "owner/name", + }), encoding="utf-8") + + setting = load_setting() + + assert setting["openai_api_key"] == DEFAULT_SETTING["openai_api_key"] + assert setting["extra_arguments"] == DEFAULT_SETTING["extra_arguments"] + assert setting["repository"] == "owner/name" + def test_a_broken_file_does_not_stop_the_feature(self, data_dir): (data_dir / "prthinker_setting.json").write_text("{ not json", encoding="utf-8") assert load_setting() == DEFAULT_SETTING From 9bb2d38cd0d22c34a7d037b55d4f240bed0710a0 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 08:52:53 +0800 Subject: [PATCH 085/312] Fix Create Project, YAML choice and unchecked run configs in the menus --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 25 ++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../process_executor/file_runner_process.py | 8 +- .../build_automation_file_menu.py | 2 +- .../build_test_pioneer_menu.py | 24 ++++-- .../menu/plugin_menu/build_run_with_menu.py | 4 +- .../test_automation_menu_factory.py | 15 ++++ test/test_utils/test_plugin_menu.py | 25 ++++++ test/test_utils/test_testpioneer_menu.py | 83 +++++++++++++++++++ 11 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 test/test_utils/test_testpioneer_menu.py diff --git a/architecture_explore.md b/architecture_explore.md index 66470a25..d93ef9b6 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 87 個 `test_*.py`、1378 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 88 個 `test_*.py`、1387 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 7c3b52f2..ff3cbfc3 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -973,3 +973,28 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1359 → 1364 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §18, `progress.md` (#36 removed). - **Open items**: none. + +## U-20260923-60 · 2026-09-23 · Three menu actions that did nothing or raised now work · #done #menu + +- **What**: `progress.md` #39, from the second review. + - **Automation File › Create Project** called `safe_create_project("file_automation")`. The package is `automation_file`, as the same menu's run actions already use, so the import failed, a log line was written and nothing happened. + - **TestPioneer › Run YAML** accepted only `.yml`: an existing `run.yaml` was refused with "please choose a YAML file". The `Yaml (*.yml)` filter it set on a `QFileDialog` instance never applied either, because `getOpenFileName` was then called as a static method. + - **Run with…**: a plugin run config without `name` raised `KeyError` inside the slot after the file had been saved. JEditor does not validate what a plugin registers. A config without `compiler` raised the same way in `FileRunnerProcess.run_file`. +- **Fix**: + - Create Project names `automation_file`. + - TestPioneer accepts `.yml` and `.yaml` in any case (`_YAML_SUFFIXES`) and passes `YAML (*.yml *.yaml)` to the static dialog call. The module's imports now sit together above the `TYPE_CHECKING` block. + - The run window title uses `run_config.get("name", "Run")`. + - `run_file` reports "The run config names no compiler" in the run window instead of raising, and passes every `arg` as text. +- **Tests**: + - `test_automation_menu_factory.py`: every `safe_create_project("...")` in the automation menus names a package `importlib.util.find_spec` can find, checked without importing, since some of these packages write a log into the working directory when imported. + - New `test_testpioneer_menu.py`: `run.yml`, `run.yaml` and `RUN.YAML` run; the dialog gets both extensions; another file is refused with one message; cancelling does nothing. + - `test_plugin_menu.py`: a config without a name runs in a window titled `Run - main.go`; one without a compiler is reported. +- **Result / numbers**: unit tests 1364 → 1373 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/automation_menu/{automation_file_menu/build_automation_file_menu,test_pioneer_menu/build_test_pioneer_menu}.py` + - `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py` + - `pybreeze/extend/process_executor/file_runner_process.py` + - `test/test_utils/{test_automation_menu_factory,test_plugin_menu,test_testpioneer_menu (new)}.py` + - `architecture_explore.md` §18 + - `progress.md` (#39 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 62c1f00a..803a254d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-60 | 2026-09-23 | Three menu actions that did nothing or raised now work | #done #menu | [2026-09](2026-09.md) | | U-20260923-59 | 2026-09-23 | A hand-edited null no longer becomes the prthinker API key "None" | #done #prthinker #security | [2026-09](2026-09.md) | | U-20260923-58 | 2026-09-23 | A diagram load either happens or leaves the canvas as it was | #done #diagram | [2026-09](2026-09.md) | | U-20260923-57 | 2026-09-23 | The AI review panel's accept/reject totals carry across sessions | #done #ai | [2026-09](2026-09.md) | @@ -140,4 +141,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 77 | +| [2026-09.md](2026-09.md) | 2026-09 | 78 | diff --git a/progress.md b/progress.md index 612ee530..708672c5 100644 --- a/progress.md +++ b/progress.md @@ -10,5 +10,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#37** Diagram editing: Delete from a right-click deletes the selected item, not the clicked one (`diagram_scene.contextMenuEvent`; the view swallows the right press); Bring to Front / Send to Back only step z by 1 (`_change_z`); undo reverses the stacking of overlapping nodes with equal z (`to_dict` lists topmost first); dragging an image records no undo step (`mousePressEvent`); `font_size` from a file is not clamped (`DiagramNode.from_dict`); the property panel's width/height spinboxes cap at 800/600 and shrink larger nodes (`diagram_property_panel.py:168/298`); a horizontal wheel zooms out (`diagram_view.wheelEvent`); deleting the editor with a node selected raises in the panel's `selectionChanged` slot. - **#38** Mermaid import: arrows and `;` inside quoted labels split statements (`_parse_statement`, `_parse_lines`); a `graph`/`flowchart` header without a direction becomes a node (`_DIRECTION_RE`); `direction LR` inside a subgraph becomes a node (`_SKIP_RE`); text on `---`/`--o`/`--x` links becomes a node and `~~~` drops its target (`_ARROW_SPLIT_RE`, `_normalize_inline_labels`). -- **#39** Menu actions: Automation File's Create Project imports `file_automation`, not `automation_file`, so it does nothing (`build_automation_file_menu.py:37`); TestPioneer refuses `.yaml` and its dialog filter is never applied (`build_test_pioneer_menu.check_file`); a plugin run config without `name` raises `KeyError` in `run_current_file_with` (`build_run_with_menu.py:48`). - **#40** Renaming a file only in letter case (`a.py` to `A.py`) is refused as already existing on Windows (`file_tree_context_menu._action_rename`). diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index d1ef6800..71fdb8e0 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -63,8 +63,12 @@ def run_file(self, run_config: dict, file_path: str) -> None: output_flag: str (optional) - flag for output file (e.g. "-o") """ compile_then_run = run_config.get("compile_then_run", False) - compiler = run_config["compiler"] - args = list(run_config.get("args", ())) + compiler = run_config.get("compiler") + if not isinstance(compiler, str) or not compiler: + # A plugin's config is not checked by JEditor when it registers. + self.main_window.append_output("[Error] The run config names no compiler\n", is_error=True) + return + args = [str(arg) for arg in run_config.get("args", ())] if compile_then_run: self._compile_and_run(compiler, args, run_config.get("output_flag", "-o"), file_path) 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 43fe6f4b..e9500f29 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 @@ -34,6 +34,6 @@ def set_automation_file_menu(ui_we_want_to_set: PyBreezeMainWindow): HelpLink("https://github.com/Integration-Automation/FileAutomation", "file_automation_github_label", "file_automation_github_tab_label"), ), - create_project=safe_create_project("file_automation"), + create_project=safe_create_project("automation_file"), create_project_label_key="file_automation_create_project_label", )) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py index 5bf7dbb1..e6430234 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py @@ -3,7 +3,10 @@ from pathlib import Path from typing import TYPE_CHECKING +from PySide6.QtGui import QAction from PySide6.QtWidgets import QFileDialog, QMessageBox +from je_editor import language_wrapper +from test_pioneer import create_template_dir from pybreeze.extend.process_executor.test_pioneer.test_pioneer_process_manager import \ init_and_start_test_pioneer_process @@ -11,9 +14,10 @@ if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -from PySide6.QtGui import QAction -from je_editor import language_wrapper -from test_pioneer import create_template_dir + +# A TestPioneer script is YAML, under either of YAML's extensions +_YAML_SUFFIXES = (".yml", ".yaml") +_YAML_FILTER = "YAML (*.yml *.yaml)" def set_test_pioneer_menu(ui_we_want_to_set: PyBreezeMainWindow): @@ -45,14 +49,16 @@ def set_test_pioneer_menu(ui_we_want_to_set: PyBreezeMainWindow): def check_file(ui_we_want_to_set: PyBreezeMainWindow): - dialog = QFileDialog(ui_we_want_to_set) - dialog.setNameFilter("Yaml (*.yml)") - # getOpenFileName returns (filename, selected_filter); the tuple itself is - # always truthy, so check the filename — an empty one means the user cancelled. - file_path = dialog.getOpenFileName()[0] + """Ask for a TestPioneer YAML file and run it, or say why it cannot be run.""" + # getOpenFileName is static: the filter has to be passed to it, not set on + # a dialog instance that is never shown. It returns (filename, + # selected_filter); the tuple is always truthy, so check the filename -- an + # empty one means the user cancelled. + file_path = QFileDialog.getOpenFileName( + ui_we_want_to_set, filter=_YAML_FILTER)[0] show_messagebox = False if file_path: - if Path(file_path).is_file() and Path(file_path).suffix == ".yml": + if Path(file_path).is_file() and Path(file_path).suffix.lower() in _YAML_SUFFIXES: init_and_start_test_pioneer_process(ui_we_want_to_set, file_path) else: show_messagebox = True diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index f6d865ff..4a311587 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -71,8 +71,10 @@ def run_current_file_with(main_window: PyBreezeMainWindow, run_config: dict) -> msg.exec() return + # JEditor does not check what a plugin registers: a config without a + # name raised KeyError here, after the file had been saved. code_window = open_run_window( - main_window, f"{run_config['name']} - {Path(file_path).name}") + main_window, f"{run_config.get('name', 'Run')} - {Path(file_path).name}") code_window.runner = FileRunnerProcess( main_window=code_window, diff --git a/test/test_utils/test_automation_menu_factory.py b/test/test_utils/test_automation_menu_factory.py index 1b87c944..69e59e80 100644 --- a/test/test_utils/test_automation_menu_factory.py +++ b/test/test_utils/test_automation_menu_factory.py @@ -109,3 +109,18 @@ def test_project_and_gui_actions_survive_and_answer(window): assert gui_action.text() == "GUI" gui_action.trigger() assert window.tab_widget.count() == 1 + + +def test_every_create_project_names_a_package_that_exists(): + # Found by finding the package, not importing it: some of these write a log + # into the working directory as they import. + import importlib.util + import re + from pathlib import Path + + menus = Path(__file__).resolve().parents[2] / "pybreeze" / "pybreeze_ui" / "menu" / "automation_menu" + names = [name for path in menus.rglob("*.py") + for name in re.findall(r'safe_create_project\("([^"]+)"\)', path.read_text(encoding="utf-8"))] + + assert names, "no create-project entries found" + assert [name for name in names if importlib.util.find_spec(name) is None] == [] diff --git a/test/test_utils/test_plugin_menu.py b/test/test_utils/test_plugin_menu.py index be860cdd..758d7d17 100644 --- a/test/test_utils/test_plugin_menu.py +++ b/test/test_utils/test_plugin_menu.py @@ -320,3 +320,28 @@ def test_a_cancelled_save_as_runs_nothing(self, window, editor_tab, monkeypatch) editor_tab(text="print(1)\n") monkeypatch.setattr(run_with, "choose_file_get_save_file_path", lambda _w: False) assert save_current_file_for_run(window) is None + + +class TestRunConfigsJEditorDoesNotCheck: + def test_a_config_without_a_name_still_runs(self, window, tmp_path, monkeypatch): + script = tmp_path / "main.go" + script.touch() + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) + started: list[object] = [] + monkeypatch.setattr( + run_with, "FileRunnerProcess", + lambda **kwargs: type("R", (), {"run_file": lambda *a: started.append(a)})()) + + run_current_file_with(window, {"compiler": "go", "suffixes": (".go",)}) + + assert started + assert window.current_run_code_window[-1].windowTitle() == "Run - main.go" + + def test_a_config_without_a_compiler_is_reported_in_the_run_window(self, app): + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + run_window = CodeWindow() + FileRunnerProcess(run_window).run_file({"name": "Broken"}, "main.go") + + assert "names no compiler" in run_window.code_result.toPlainText() diff --git a/test/test_utils/test_testpioneer_menu.py b/test/test_utils/test_testpioneer_menu.py new file mode 100644 index 00000000..15f25f3e --- /dev/null +++ b/test/test_utils/test_testpioneer_menu.py @@ -0,0 +1,83 @@ +"""Running a TestPioneer script from its menu.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.automation_menu.test_pioneer_menu import build_test_pioneer_menu as menu + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def chosen(monkeypatch) -> dict: + """Answer the file dialog with ``chosen["path"]``; record the filter and what ran.""" + state: dict = {"path": "", "ran": [], "filter": None, "told": 0} + + def get_open_file_name(_parent=None, *args, filter="", **kwargs): + state["filter"] = filter + return state["path"], filter + + class Message: + def __init__(self, _parent=None) -> None: + """Collects nothing but the fact it was shown.""" + + def setWindowTitle(self, _title) -> None: + """Title is not checked.""" + + def setText(self, _text) -> None: + """Text is not checked.""" + + def exec(self) -> None: + state["told"] += 1 + + monkeypatch.setattr(menu.QFileDialog, "getOpenFileName", staticmethod(get_open_file_name)) + monkeypatch.setattr(menu, "QMessageBox", Message) + monkeypatch.setattr( + menu, "init_and_start_test_pioneer_process", lambda _window, path: state["ran"].append(path)) + return state + + +@pytest.mark.parametrize("name", ["run.yml", "run.yaml", "RUN.YAML"]) +def test_either_yaml_extension_runs(app, tmp_path, chosen, name): + script = tmp_path / name + script.write_text("jobs: []", encoding="utf-8") + chosen["path"] = str(script) + + menu.check_file(None) + + assert chosen["ran"] == [str(script)] + assert chosen["told"] == 0 + + +def test_the_dialog_is_given_the_yaml_filter(app, chosen): + menu.check_file(None) + + assert "*.yaml" in chosen["filter"] and "*.yml" in chosen["filter"] + + +def test_another_file_is_refused(app, tmp_path, chosen): + script = tmp_path / "run.txt" + script.write_text("x", encoding="utf-8") + chosen["path"] = str(script) + + menu.check_file(None) + + assert chosen["ran"] == [] + assert chosen["told"] == 1 + + +def test_cancelling_does_nothing(app, chosen): + menu.check_file(None) + + assert chosen["ran"] == [] and chosen["told"] == 0 From f9151b5b5347fa2295915cc2d40b29dfcbb00344 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 09:02:33 +0800 Subject: [PATCH 086/312] Let a file be renamed to another case of its name --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../editor_main/file_tree_context_menu.py | 13 ++++++++++++- test/test_utils/test_file_tree_context_menu.py | 12 ++++++++++++ 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index d93ef9b6..2bfe0ba9 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 88 個 `test_*.py`、1387 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 88 個 `test_*.py`、1388 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ff3cbfc3..e7bf306b 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -998,3 +998,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §18 - `progress.md` (#39 removed) - **Open items**: none. + +## U-20260923-61 · 2026-09-23 · A file can be renamed to a different case of its name on Windows · #done #editor + +- **What**: `progress.md` #40, from the second review. The file tree's Rename refused `main.py` → `Main.py` on Windows with "already exists". The filesystem there is case-insensitive, so `target.exists()` found the file being renamed. +- **Fix**: `_action_rename` refuses only when the target exists and is a different file. The new `_is_the_same_file()` uses `Path.samefile`, and a comparison that fails counts as different. A change of case is then an ordinary rename, and open tabs follow it as before. +- **Tests**: `test_file_tree_context_menu.py`: `main.py` renamed to `Main.py` gives no warning and leaves one file, `Main.py`, with the same content. Renaming onto a different existing file is still refused, by the existing test. +- **Result / numbers**: unit tests 1373 → 1374 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_file_tree_context_menu.py`, `architecture_explore.md` §18, `progress.md` (#40 removed). +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 803a254d..3f27adcf 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-61 | 2026-09-23 | A file can be renamed to a different case of its name on Windows | #done #editor | [2026-09](2026-09.md) | | U-20260923-60 | 2026-09-23 | Three menu actions that did nothing or raised now work | #done #menu | [2026-09](2026-09.md) | | U-20260923-59 | 2026-09-23 | A hand-edited null no longer becomes the prthinker API key "None" | #done #prthinker #security | [2026-09](2026-09.md) | | U-20260923-58 | 2026-09-23 | A diagram load either happens or leaves the canvas as it was | #done #diagram | [2026-09](2026-09.md) | @@ -141,4 +142,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 78 | +| [2026-09.md](2026-09.md) | 2026-09 | 79 | diff --git a/progress.md b/progress.md index 708672c5..1c39a5c8 100644 --- a/progress.md +++ b/progress.md @@ -10,4 +10,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#37** Diagram editing: Delete from a right-click deletes the selected item, not the clicked one (`diagram_scene.contextMenuEvent`; the view swallows the right press); Bring to Front / Send to Back only step z by 1 (`_change_z`); undo reverses the stacking of overlapping nodes with equal z (`to_dict` lists topmost first); dragging an image records no undo step (`mousePressEvent`); `font_size` from a file is not clamped (`DiagramNode.from_dict`); the property panel's width/height spinboxes cap at 800/600 and shrink larger nodes (`diagram_property_panel.py:168/298`); a horizontal wheel zooms out (`diagram_view.wheelEvent`); deleting the editor with a node selected raises in the panel's `selectionChanged` slot. - **#38** Mermaid import: arrows and `;` inside quoted labels split statements (`_parse_statement`, `_parse_lines`); a `graph`/`flowchart` header without a direction becomes a node (`_DIRECTION_RE`); `direction LR` inside a subgraph becomes a node (`_SKIP_RE`); text on `---`/`--o`/`--x` links becomes a node and `~~~` drops its target (`_ARROW_SPLIT_RE`, `_normalize_inline_labels`). -- **#40** Renaming a file only in letter case (`a.py` to `A.py`) is refused as already existing on Windows (`file_tree_context_menu._action_rename`). diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index 0824ca06..420015f3 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -232,6 +232,15 @@ def _start_auto_save(editor: EditorWidget, file_path: Path) -> None: editor.rename_self_tab() +def _is_the_same_file(first: Path, second: Path) -> bool: + """Whether *first* and *second* name one file (a different case of one name, say).""" + try: + return first.samefile(second) + except OSError as error: + pybreeze_logger.debug("Could not compare %s and %s: %r", first, second, error) + return False + + def _action_rename(tree_view: QTreeView, main_window, path: Path | None) -> None: if path is None: return @@ -245,7 +254,9 @@ def _action_rename(tree_view: QTreeView, main_window, path: Path | None) -> None if not ok or not new_name.strip() or new_name.strip() == path.name: return target = path.parent / new_name.strip() - if target.exists(): + # On a case-insensitive filesystem "A.py" already exists when renaming + # "a.py" to it -- it is the same file, and a change of case is a rename. + if target.exists() and not _is_the_same_file(target, path): QMessageBox.warning( tree_view, word.get("file_tree_ctx_error"), diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py index d008ebf1..3c952b4b 100644 --- a/test/test_utils/test_file_tree_context_menu.py +++ b/test/test_utils/test_file_tree_context_menu.py @@ -212,6 +212,18 @@ def test_renaming_onto_an_existing_file_is_refused( assert occupied.read_text(encoding="utf-8") == "keep me" assert warnings + def test_a_change_of_case_is_a_rename(self, tree, tmp_path, monkeypatch, warnings): + # On Windows "Main.py" already "exists" -- as the very file being renamed. + original = tmp_path / "main.py" + original.write_text("print(1)", encoding="utf-8") + answer(monkeypatch, "Main.py") + + _action_rename(tree, FakeWindow(), original) + + assert warnings == [] + assert [child.name for child in tmp_path.iterdir()] == ["Main.py"] + assert (tmp_path / "Main.py").read_text(encoding="utf-8") == "print(1)" + def test_an_open_tab_follows_the_rename(self, tree, tmp_path, monkeypatch): original = tmp_path / "open.py" original.touch() From a8d61226154812e2156e0cb485617f8dad4f0dda Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 11:31:23 +0800 Subject: [PATCH 087/312] Fix right-click, stacking, undo order, sizes and wheel in the diagram editor --- architecture_explore.md | 12 +- docs/updates/2026-09.md | 37 ++++ docs/updates/README.md | 3 +- progress.md | 1 - .../diagram_editor/diagram_items.py | 12 +- .../diagram_editor/diagram_property_panel.py | 23 ++- .../diagram_editor/diagram_scene.py | 45 +++- .../diagram_editor/diagram_view.py | 7 +- test/test_utils/test_diagram_editing.py | 193 ++++++++++++++++++ 9 files changed, 309 insertions(+), 24 deletions(-) create mode 100644 test/test_utils/test_diagram_editing.py diff --git a/architecture_explore.md b/architecture_explore.md index 2bfe0ba9..02fde6ba 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -233,16 +233,16 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,590 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,645 行,最大子系統) | 檔案 | 職責 | |---|---| | `diagram_editor_widget.py` (616) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | -| `diagram_scene.py` (825) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | -| `diagram_items.py` (887) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | +| `diagram_scene.py` (854) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點之上/之下。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | +| `diagram_items.py` (895) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | | `diagram_mermaid_parser.py` (532) | Mermaid flowchart → diagram dict。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | -| `diagram_property_panel.py` (421) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | -| `diagram_view.py` (175) | `QGraphicsView`:滾輪縮放(有上下界)、中鍵平移、`drawBackground` 畫格線 | +| `diagram_property_panel.py` (434) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | +| `diagram_view.py` (180) | `QGraphicsView`:滾輪縮放(有上下界;橫向滾輪不縮放)、中鍵平移、`drawBackground` 畫格線 | | `diagram_commands.py` (28) | `DiagramSnapshotCommand(QUndoCommand)` — 快照式 undo,存變更前後完整場景狀態 | | `diagram_net_utils.py` (104) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗、20 MB 大小上限、15 秒 timeout | @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 88 個 `test_*.py`、1388 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1400 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index e7bf306b..259d4f58 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1007,3 +1007,40 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: unit tests 1373 → 1374 passed, 14 skipped. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_file_tree_context_menu.py`, `architecture_explore.md` §18, `progress.md` (#40 removed). - **Open items**: none. + +## U-20260923-62 · 2026-09-23 · Diagram editing: right-click, stacking, undo, sizes, wheel and teardown · #done #diagram + +- **What**: `progress.md` #37, eight editing faults from the second review, each reproduced there: + - **Right-click Delete** removed the *selected* item, not the one clicked: the view pans on a right press, so the scene never selected what was under the cursor. + - **Bring to Front / Send to Back** stepped z by 1, so a node at 0 stayed under one at 5. + - **Undo** turned overlapping nodes of equal z the other way up. `to_dict` listed items topmost first, and a restore re-added them in that order, putting the first-listed at the bottom. + - **Dragging an image** recorded no undo step: the Move snapshot started only for nodes. + - **Font size**: `font_size` from a file was not clamped. A 1000 pt label loaded as such, although the panel offers 6–48. + - **Property panel**: its width and height spinboxes stopped at 800 and 600, so changing only the height of a 1200-wide node shrank it to 800. Image sides stopped at 2000. + - **Wheel**: a sideways wheel or trackpad swipe (`angleDelta().y() == 0`) zoomed out. + - **Teardown**: deleting the editor with a node selected raised `RuntimeError` in the panel's `selectionChanged` slot, which the dying scene still emits. +- **Fix**: + - A right-click on an item outside the selection makes it the selection first. + - `_change_z` places the selected nodes above the highest, or below the lowest, of the others, keeping their order among themselves. + - `get_all_*` list items bottom-first (`_bottom_first()`), so saves and restores keep the stacking. + - A press on a selected image starts the Move undo step. + - `_clamped_font_size()` applies the panel's range (`MIN_FONT_SIZE`/`MAX_FONT_SIZE`, now shared with the panel) when a node is built and when the size is set. + - The panel's node and image sides range up to `MAX_ITEM_SIZE`. + - The view passes a sideways wheel on instead of zooming. + - The panel's slot returns when `shiboken6.isValid(scene)` is false. +- **Tests**: new `test_diagram_editing.py`, 12 tests: + - right-click Delete removes the clicked node, and a click inside a multi-selection keeps it; + - front and back go past every other node, and back keeps the selected nodes' order; + - undo keeps equal-z overlap the same way up; + - a press on a selected image opens a Move step; + - three font sizes are clamped (1000 to 48, 1 to 6, 12.7 to 12); + - the panel shows a 1200×900 node as is; + - the slot survives a deleted scene; + - a sideways wheel does not zoom and a vertical one does. +- **Result / numbers**: unit tests 1386 passed, 14 skipped. `ruff check` clean, and every function is under complexity 15. +- **Files**: + - `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_items,diagram_property_panel,diagram_view}.py` + - `test/test_utils/test_diagram_editing.py` (new) + - `architecture_explore.md` §7, §18 + - `progress.md` (#37 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 3f27adcf..12516359 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-62 | 2026-09-23 | Diagram editing: right-click, stacking, undo, sizes, wheel and teardown | #done #diagram | [2026-09](2026-09.md) | | U-20260923-61 | 2026-09-23 | A file can be renamed to a different case of its name on Windows | #done #editor | [2026-09](2026-09.md) | | U-20260923-60 | 2026-09-23 | Three menu actions that did nothing or raised now work | #done #menu | [2026-09](2026-09.md) | | U-20260923-59 | 2026-09-23 | A hand-edited null no longer becomes the prthinker API key "None" | #done #prthinker #security | [2026-09](2026-09.md) | @@ -142,4 +143,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 79 | +| [2026-09.md](2026-09.md) | 2026-09 | 80 | diff --git a/progress.md b/progress.md index 1c39a5c8..6eefd6fa 100644 --- a/progress.md +++ b/progress.md @@ -8,5 +8,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#37** Diagram editing: Delete from a right-click deletes the selected item, not the clicked one (`diagram_scene.contextMenuEvent`; the view swallows the right press); Bring to Front / Send to Back only step z by 1 (`_change_z`); undo reverses the stacking of overlapping nodes with equal z (`to_dict` lists topmost first); dragging an image records no undo step (`mousePressEvent`); `font_size` from a file is not clamped (`DiagramNode.from_dict`); the property panel's width/height spinboxes cap at 800/600 and shrink larger nodes (`diagram_property_panel.py:168/298`); a horizontal wheel zooms out (`diagram_view.wheelEvent`); deleting the editor with a node selected raises in the panel's `selectionChanged` slot. - **#38** Mermaid import: arrows and `;` inside quoted labels split statements (`_parse_statement`, `_parse_lines`); a `graph`/`flowchart` header without a direction becomes a node (`_DIRECTION_RE`); `direction LR` inside a subgraph becomes a node (`_SKIP_RE`); text on `---`/`--o`/`--x` links becomes a node and `~~~` drops its target (`_ARROW_SPLIT_RE`, `_normalize_inline_labels`). diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index dbf47765..83993046 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -112,6 +112,14 @@ def __init__(self, w: float, h: float): # contents transform to a scale nothing can be drawn at. MAX_ITEM_SIZE = 10000.0 _MIN_IMAGE_SIDE = 40.0 +# The label sizes the property panel offers; a file may say anything +MIN_FONT_SIZE = 6 +MAX_FONT_SIZE = 48 + + +def _clamped_font_size(size: int) -> int: + """*size* as a whole point size within what the panel offers.""" + return max(MIN_FONT_SIZE, min(int(size), MAX_FONT_SIZE)) _NODE_PEN_COLOR = "#455a64" _NODE_BRUSH_COLOR = "#e3f2fd" _NODE_SELECTED_COLOR = "#1565c0" @@ -298,7 +306,7 @@ def __init__( # Colors self._fill_color = _safe_color(style.fill_color, _NODE_BRUSH_COLOR) self._border_color = _safe_color(style.border_color, _NODE_PEN_COLOR) - self._font_size = style.font_size + self._font_size = _clamped_font_size(style.font_size) # Shape body (child) self.body: QGraphicsItem = self._make_body(w, h) @@ -465,7 +473,7 @@ def set_border_color(self, color: QColor) -> None: self.body.setPen(QPen(color, 2)) def set_font_size(self, size: int) -> None: - self._font_size = max(6, min(size, 48)) + self._font_size = _clamped_font_size(size) self.label.setFont(QFont(_LABEL_FONT_FAMILY, self._font_size)) self._center_label() diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py index 8c539085..e87e2ca3 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py @@ -18,9 +18,13 @@ QVBoxLayout, QWidget, ) +import shiboken6 from je_editor import language_wrapper from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( + MAX_FONT_SIZE, + MAX_ITEM_SIZE, + MIN_FONT_SIZE, ConnectionStyle, DiagramConnection, DiagramImage, @@ -32,6 +36,11 @@ from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene +# The smallest sides the items themselves allow +_MIN_NODE_SIDE = 40 +_MIN_NODE_HEIGHT = 20 + + def _lang(key: str, fallback: str = "") -> str: return language_wrapper.language_word_dict.get(key, fallback or key) @@ -166,12 +175,12 @@ def _build_node_group(self) -> None: nf.addRow(_lang("diagram_editor_prop_text", "Text"), self._node_text) self._node_w = QSpinBox() - self._node_w.setRange(40, 800) + self._node_w.setRange(int(_MIN_NODE_SIDE), int(MAX_ITEM_SIZE)) self._node_w.valueChanged.connect(self._on_node_size) nf.addRow(_lang("diagram_editor_prop_width", "Width"), self._node_w) self._node_h = QSpinBox() - self._node_h.setRange(20, 600) + self._node_h.setRange(int(_MIN_NODE_HEIGHT), int(MAX_ITEM_SIZE)) self._node_h.valueChanged.connect(self._on_node_size) nf.addRow(_lang("diagram_editor_prop_height", "Height"), self._node_h) @@ -190,7 +199,7 @@ def _build_node_group(self) -> None: nf.addRow(_lang("diagram_editor_prop_border_color", "Border"), self._node_border) self._node_font = QSpinBox() - self._node_font.setRange(6, 48) + self._node_font.setRange(MIN_FONT_SIZE, MAX_FONT_SIZE) self._node_font.valueChanged.connect(self._on_node_font) nf.addRow(_lang("diagram_editor_prop_font_size", "Font"), self._node_font) @@ -238,12 +247,12 @@ def _build_image_group(self) -> None: imf.addRow(_lang("diagram_editor_prop_source", "Source"), self._img_source) self._img_w = QSpinBox() - self._img_w.setRange(40, 2000) + self._img_w.setRange(int(_MIN_NODE_SIDE), int(MAX_ITEM_SIZE)) self._img_w.valueChanged.connect(self._on_img_size) imf.addRow(_lang("diagram_editor_prop_width", "Width"), self._img_w) self._img_h = QSpinBox() - self._img_h.setRange(40, 2000) + self._img_h.setRange(int(_MIN_NODE_SIDE), int(MAX_ITEM_SIZE)) self._img_h.valueChanged.connect(self._on_img_size) imf.addRow(_lang("diagram_editor_prop_height", "Height"), self._img_h) @@ -256,6 +265,10 @@ def _build_image_group(self) -> None: # ------------------------------------------------------------------ def _on_selection_changed(self) -> None: + # A scene being destroyed with the editor still emits this as it + # empties; asking it for its selection then raised RuntimeError. + if not shiboken6.isValid(self._scene): + return self._updating = True self._current_node = None self._current_conn = None diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 7ec657e5..3cbd9505 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -253,7 +253,7 @@ def mousePressEvent(self, event) -> None: if self._mode == ToolMode.SELECT: super().mousePressEvent(event) - if any(isinstance(i, DiagramNode) for i in self.selectedItems()): + if any(isinstance(i, (DiagramNode, DiagramImage)) for i in self.selectedItems()): self.begin_undo("Move") return @@ -332,6 +332,13 @@ def contextMenuEvent(self, event) -> None: menu = QMenu() item = self._node_at(event.scenePos()) conn = self._connection_at(event.scenePos()) + clicked = item if item is not None else conn + # The view pans on a right press, so the scene never selected what was + # clicked: Delete removed whatever was selected before. A click on an + # item outside the selection makes it the selection, as elsewhere. + if clicked is not None and not clicked.isSelected(): + self.clearSelection() + clicked.setSelected(True) if item is not None or conn is not None: menu.addAction( @@ -403,11 +410,24 @@ def _cancel_connection(self) -> None: self._temp_line = None def _change_z(self, direction: int) -> None: - """Raise or lower the selected nodes. Undoable, and kept in the file.""" + """Put the selected nodes above (1) or below (-1) every other node. + + Their order among themselves is kept. Undoable, and kept in the file. + Stepping z by one left a node under any other whose z was already + higher. + """ + chosen = sorted((item for item in self.selectedItems() if isinstance(item, DiagramNode)), + key=lambda node: node.zValue()) + others = [node.zValue() for node in self.get_all_nodes() if node not in chosen] + if not chosen or not others: + return with self.undo_scope("Change Z"): - for item in self.selectedItems(): - if isinstance(item, DiagramNode): - item.setZValue(item.zValue() + direction) + if direction > 0: + start = max(others) + 1 + else: + start = min(others) - len(chosen) + for offset, node in enumerate(chosen): + node.setZValue(start + offset) # ------------------------------------------------------------------ # Operations (all undoable) @@ -621,17 +641,26 @@ def add_image(self, pixmap: QPixmap, source: str, pos: QPointF | None = None) -> return img def get_all_images(self) -> list[DiagramImage]: - return [item for item in self.items() if isinstance(item, DiagramImage)] + return [item for item in self._bottom_first() if isinstance(item, DiagramImage)] # ------------------------------------------------------------------ # Serialisation # ------------------------------------------------------------------ def get_all_nodes(self) -> list[DiagramNode]: - return [item for item in self.items() if isinstance(item, DiagramNode)] + return [item for item in self._bottom_first() if isinstance(item, DiagramNode)] def get_all_connections(self) -> list[DiagramConnection]: - return [item for item in self.items() if isinstance(item, DiagramConnection)] + return [item for item in self._bottom_first() if isinstance(item, DiagramConnection)] + + def _bottom_first(self) -> list: + """Every item, lowest in the stacking order first. + + Saved and restored in this order, items of equal z come back stacked as + they were: a later item is drawn above an earlier one. Listed topmost + first, an undo turned overlapping nodes the other way up. + """ + return self.items(Qt.SortOrder.AscendingOrder) def to_dict(self) -> dict: nodes = self.get_all_nodes() diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py index 9d997fb2..cac38616 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py @@ -119,7 +119,12 @@ def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # --- zoom --- def wheelEvent(self, event) -> None: - factor = _ZOOM_FACTOR if event.angleDelta().y() > 0 else 1.0 / _ZOOM_FACTOR + vertical = event.angleDelta().y() + if vertical == 0: + # A sideways wheel or trackpad swipe: not a zoom (it used to zoom out) + super().wheelEvent(event) + return + factor = _ZOOM_FACTOR if vertical > 0 else 1.0 / _ZOOM_FACTOR current = self.transform().m11() if current * factor < _MIN_SCALE or current * factor > _MAX_SCALE: return diff --git a/test/test_utils/test_diagram_editing.py b/test/test_utils/test_diagram_editing.py new file mode 100644 index 00000000..05ec6650 --- /dev/null +++ b/test/test_utils/test_diagram_editing.py @@ -0,0 +1,193 @@ +"""Editing a diagram: what the context menu, stacking, undo, panel and wheel act on.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QEvent, QPoint, QPointF, Qt +from PySide6.QtGui import QWheelEvent +from PySide6.QtWidgets import QApplication, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.diagram_editor import diagram_scene as scene_module +from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage, DiagramNode +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _node(scene: DiagramScene, x: float, text: str, z: float = 0) -> DiagramNode: + node = DiagramNode(x=x, y=0, w=100, h=60, text=text) + node.setZValue(z) + scene.addItem(node) + return node + + +def _top_node(scene: DiagramScene, pos: QPointF) -> DiagramNode: + """The node drawn on top at *pos* (a hit on its label counts as the node).""" + for item in scene.items(pos): + owner = item if isinstance(item, DiagramNode) else item.parentItem() + if isinstance(owner, DiagramNode): + return owner + raise AssertionError("no node there") + + +def _right_click_and_choose(scene: DiagramScene, monkeypatch, pos: QPointF, label: str) -> None: + """Open the context menu at *pos* and pick the entry called *label*.""" + + class Menu: + def __init__(self, *_args) -> None: + self.entries: dict = {} + + def addAction(self, text, slot=None): + self.entries[text] = slot + + def addSeparator(self) -> None: + """Separators are not choices.""" + + def actions(self) -> list: + return list(self.entries) + + def exec(self, *_args) -> None: + self.entries[label]() + + monkeypatch.setattr(scene_module, "QMenu", Menu) + event = QGraphicsSceneContextMenuEvent(QEvent.Type.GraphicsSceneContextMenu) + event.setScenePos(pos) + scene.contextMenuEvent(event) + + +class TestTheContextMenu: + def test_delete_removes_the_node_that_was_clicked(self, app, monkeypatch): + scene = DiagramScene() + selected = _node(scene, 0, "selected") + clicked = _node(scene, 300, "clicked") + selected.setSelected(True) + + _right_click_and_choose(scene, monkeypatch, QPointF(350, 30), "Delete") + + # It used to delete "selected" and leave "clicked". + assert [node.text() for node in scene.get_all_nodes()] == ["selected"] + assert clicked.scene() is None + + def test_a_click_inside_the_selection_keeps_the_selection(self, app, monkeypatch): + scene = DiagramScene() + first = _node(scene, 0, "a") + second = _node(scene, 300, "b") + first.setSelected(True) + second.setSelected(True) + + _right_click_and_choose(scene, monkeypatch, QPointF(350, 30), "Delete") + + assert scene.get_all_nodes() == [] + + +class TestStacking: + def test_bring_to_front_goes_above_every_other_node(self, app): + scene = DiagramScene() + low = _node(scene, 0, "low", z=0) + _node(scene, 10, "high", z=5) + low.setSelected(True) + + scene._change_z(1) + + assert low.zValue() > 5 + + def test_send_to_back_goes_below_every_other_node_keeping_their_order(self, app): + scene = DiagramScene() + _node(scene, 0, "floor", z=-3) + first = _node(scene, 10, "first", z=1) + second = _node(scene, 20, "second", z=2) + first.setSelected(True) + second.setSelected(True) + + scene._change_z(-1) + + assert first.zValue() < second.zValue() < -3 + + def test_undo_keeps_overlapping_nodes_of_equal_z_the_same_way_up(self, app): + scene = DiagramScene() + _node(scene, 0, "under") + _node(scene, 50, "over") # added later, same z: drawn on top + assert _top_node(scene, QPointF(75, 30)).text() == "over" + + with scene.undo_scope("Move"): + scene.get_all_nodes()[0].moveBy(5, 0) + scene.undo_stack.undo() + + assert _top_node(scene, QPointF(75, 30)).text() == "over" + + +class TestUndoAndSizes: + def test_pressing_on_a_selected_image_starts_an_undo_step(self, app): + scene = DiagramScene() + image = DiagramImage(x=0, y=0, w=100, h=100, source="") + scene.addItem(image) + image.setSelected(True) + press = QGraphicsSceneMouseEvent(QEvent.Type.GraphicsSceneMousePress) + press.setScenePos(QPointF(50, 50)) + press.setButton(Qt.MouseButton.LeftButton) + press.setButtons(Qt.MouseButton.LeftButton) + + scene.mousePressEvent(press) + + assert scene._pending_undo_desc == "Move" + + @pytest.mark.parametrize(("saved", "loaded"), [(1000, 48), (1, 6), (12.7, 12)]) + def test_a_font_size_from_a_file_is_kept_in_range(self, app, saved, loaded): + node = DiagramNode.from_dict({"x": 0, "y": 0, "text": "a", "font_size": saved}) + + assert node.to_dict(0)["font_size"] == loaded + + def test_the_panel_does_not_shrink_a_large_node(self, app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_property_panel import DiagramPropertyPanel + + scene = DiagramScene() + node = DiagramNode(x=0, y=0, w=1200, h=900, text="big") + scene.addItem(node) + panel = DiagramPropertyPanel(scene) + node.setSelected(True) + + assert panel._node_w.value() == 1200 + assert panel._node_h.value() == 900 + panel.deleteLater() + + def test_a_scene_already_destroyed_does_not_raise_in_the_panel(self, app): + import shiboken6 + + from pybreeze.pybreeze_ui.diagram_editor.diagram_property_panel import DiagramPropertyPanel + + scene = DiagramScene() + panel = DiagramPropertyPanel(scene) + shiboken6.delete(scene) + + panel._on_selection_changed() # raised RuntimeError before + panel.deleteLater() + + +class TestTheWheel: + def _wheel(self, view, dx: int, dy: int) -> None: + event = QWheelEvent( + QPointF(10, 10), QPointF(10, 10), QPoint(0, 0), QPoint(dx, dy), + Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier, + Qt.ScrollPhase.NoScrollPhase, False) + view.wheelEvent(event) + + def test_a_sideways_wheel_does_not_zoom(self, app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView + + view = DiagramView(DiagramScene()) + + self._wheel(view, 120, 0) + + assert view.transform().m11() == 1.0 + self._wheel(view, 0, 120) + assert view.transform().m11() > 1.0 + view.deleteLater() From 4fcaf94627dff0978c90b044fc3c4daaeb43042d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 11:31:42 +0800 Subject: [PATCH 088/312] Keep Mermaid label text out of statement splitting and accept more headers and links --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 30 ++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../diagram_editor/diagram_mermaid_parser.py | 93 ++++++++++++++++--- test/test_utils/test_mermaid_parser.py | 55 +++++++++++ 6 files changed, 172 insertions(+), 16 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 02fde6ba..9a6bffe4 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -233,14 +233,14 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,645 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,714 行,最大子系統) | 檔案 | 職責 | |---|---| | `diagram_editor_widget.py` (616) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | | `diagram_scene.py` (854) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點之上/之下。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | | `diagram_items.py` (895) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | -| `diagram_mermaid_parser.py` (532) | Mermaid flowchart → diagram dict。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | +| `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (434) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | | `diagram_view.py` (180) | `QGraphicsView`:滾輪縮放(有上下界;橫向滾輪不縮放)、中鍵平移、`drawBackground` 畫格線 | | `diagram_commands.py` (28) | `DiagramSnapshotCommand(QUndoCommand)` — 快照式 undo,存變更前後完整場景狀態 | @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1400 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1415 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 259d4f58..04a70ee2 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1044,3 +1044,33 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §7, §18 - `progress.md` (#37 removed) - **Open items**: none. + +## U-20260923-63 · 2026-09-23 · Mermaid import: syntax inside labels, bare headers, directions and more links · #done #diagram + +- **What**: `progress.md` #38, Mermaid import faults from the second review: + - arrows and `;` were split out of the raw text, so `A["a --> b"]-->B` became three nodes and two edges, and `A["a;b"]-->B` lost A's label; + - a `graph` or `flowchart` header with no direction became a node called "graph"; + - `direction LR` inside a subgraph became a node called "direction"; + - text on `---`, `--o` and `--x` links became a node, and `A ~~~ B` dropped B. +- **Fix**: + - `_protect()` replaces every quoted or bracketed segment with a placeholder before anything is split, whether by `;` in `_parse_lines` or by arrow in `_parse_statement`. Brackets nest, and a quote inside brackets hides its brackets. `_restore()` puts each segment back as a node or arrow is parsed. + - The header's direction is optional, defaulting to TD, and needs a word boundary, so a node named `graphics` is still a node. + - `direction ` is skipped like `subgraph` and `end`. + - Text on any normal link (`-- t -->`, `-- t ---`, `-- t --o`, `-- t --x`) is the link's label. + - `~~~` is a link body that registers both nodes and draws no connection, as in Mermaid, where it only affects placement. +- **Tests**: `test_mermaid_parser.py`: + - four labels holding `-->`, `;`, `--` text or `~~~`; + - a bracket inside a quoted label; + - three direction-less headers; + - `direction` inside a subgraph; + - node names that start like keywords; + - four labelled link forms; + - an invisible link. + The existing 54 Mermaid tests and the fuzz tests pass unchanged. +- **Result / numbers**: unit tests 1401 passed, 14 skipped. `ruff check` clean, and every function is under complexity 15. +- **Files**: + - `pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py` (603 lines) + - `test/test_utils/test_mermaid_parser.py` + - `architecture_explore.md` §7, §18 + - `progress.md` (#38 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 12516359..e956e401 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-63 | 2026-09-23 | Mermaid import: syntax inside labels, bare headers, directions and more links | #done #diagram | [2026-09](2026-09.md) | | U-20260923-62 | 2026-09-23 | Diagram editing: right-click, stacking, undo, sizes, wheel and teardown | #done #diagram | [2026-09](2026-09.md) | | U-20260923-61 | 2026-09-23 | A file can be renamed to a different case of its name on Windows | #done #editor | [2026-09](2026-09.md) | | U-20260923-60 | 2026-09-23 | Three menu actions that did nothing or raised now work | #done #menu | [2026-09](2026-09.md) | @@ -143,4 +144,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 80 | +| [2026-09.md](2026-09.md) | 2026-09 | 81 | diff --git a/progress.md b/progress.md index 6eefd6fa..7d94b9ad 100644 --- a/progress.md +++ b/progress.md @@ -8,4 +8,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#38** Mermaid import: arrows and `;` inside quoted labels split statements (`_parse_statement`, `_parse_lines`); a `graph`/`flowchart` header without a direction becomes a node (`_DIRECTION_RE`); `direction LR` inside a subgraph becomes a node (`_SKIP_RE`); text on `---`/`--o`/`--x` links becomes a node and `~~~` drops its target (`_ARROW_SPLIT_RE`, `_normalize_inline_labels`). diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py index f670f537..c42947c5 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py @@ -37,12 +37,14 @@ class _EdgeInfo: # Regex patterns # --------------------------------------------------------------------------- +# The header's direction is optional: "graph" alone means top-down _DIRECTION_RE = re.compile( - r"^\s*(?:graph|flowchart)\s+(TD|TB|LR|RL|BT)", re.IGNORECASE + r"^\s*(?:graph|flowchart)\b(?:\s+(TD|TB|LR|RL|BT)\b)?", re.IGNORECASE ) _COMMENT_RE = re.compile(r"%%.*$") _SKIP_RE = re.compile( - r"^\s*(?:subgraph|end\b|style\b|classDef\b|class\s|click\b|linkStyle\b)", + r"^\s*(?:subgraph|end\b|style\b|classDef\b|class\s|click\b|linkStyle\b" + r"|direction\s+(?:TD|TB|LR|RL|BT)\b)", re.IGNORECASE, ) @@ -55,7 +57,7 @@ class _EdgeInfo: r"\s*" r"(" r"ox]?" # optional right head: arrow / circle / cross r"(?:\|[^|]*\|)?" # optional |label| r")" @@ -72,8 +74,12 @@ class _EdgeInfo: def _normalize_inline_labels(line: str) -> str: - """Convert ``-- label -->`` style to ``-->|label|`` pipe style.""" - line = re.sub(rf"--\s+(\S[^|]{{0,{_LABEL_MAX}}}?)\s+-->", r"-->|\1|", line) + """Convert ``-- label -->`` style to ``-->|label|`` pipe style. + + The text may sit on any normal link: ``-- label -->``, ``-- label ---`` + (no head), ``-- label --o`` or ``-- label --x``. + """ + line = re.sub(rf"--\s+(\S[^|]{{0,{_LABEL_MAX}}}?)\s+(-{{2,}}[>ox]?)", r"\2|\1|", line) line = re.sub(rf"-\.\s+(\S[^|]{{0,{_LABEL_MAX}}}?)\s+\.->", r"-.->|\1|", line) line = re.sub(rf"==\s+(\S[^|]{{0,{_LABEL_MAX}}}?)\s+==>", r"==>|\1|", line) return line @@ -100,6 +106,65 @@ def _normalize_inline_labels(line: str) -> str: ) +# Marks a protected label in text being split: NUL cannot occur in mermaid text +_STASH_MARK = "\x00" +_OPENERS = {"(": ")", "[": "]", "{": "}"} + + +def _segment_end(text: str, start: int) -> int: + """Index just past the quoted or bracketed segment that starts at *start*. + + Brackets nest and a quote inside brackets hides its brackets; an unclosed + segment runs to the end of *text*. + """ + if text[start] == '"': + end = text.find('"', start + 1) + return len(text) if end < 0 else end + 1 + depth = 0 + index = start + while index < len(text): + char = text[index] + if char == '"': + index = _segment_end(text, index) + continue + if char in _OPENERS: + depth += 1 + elif char in ")]}": + depth -= 1 + if depth == 0: + return index + 1 + index += 1 + return len(text) + + +def _protect(text: str) -> tuple[str, list[str]]: + """Replace every quoted or bracketed segment of *text* with a placeholder. + + Arrows and ``;`` are found by pattern in what is left: a label such as + ``A["a --> b"]`` or ``A["a;b"]`` used to be split at its own arrow or + semicolon. :func:`_restore` puts the segments back. + """ + stash: list[str] = [] + pieces: list[str] = [] + index = 0 + while index < len(text): + char = text[index] + if char == '"' or char in _OPENERS: + end = _segment_end(text, index) + pieces.append(f"{_STASH_MARK}{len(stash)}{_STASH_MARK}") + stash.append(text[index:end]) + index = end + continue + pieces.append(char) + index += 1 + return "".join(pieces), stash + + +def _restore(text: str, stash: list[str]) -> str: + """Put back the segments :func:`_protect` took out of *text*.""" + return re.sub(f"{_STASH_MARK}(\\d+){_STASH_MARK}", lambda match: stash[int(match.group(1))], text) + + def _unquote(text: str) -> str: """Drop the surrounding double quotes mermaid uses to allow special chars.""" if len(text) >= 2 and text[0] == '"' and text[-1] == '"': @@ -416,10 +481,11 @@ def _parse_statement( edges: list[_EdgeInfo], ) -> None: """Parse one ``;``-delimited mermaid statement, updating *nodes* and *edges*.""" - stmt = _normalize_inline_labels(stmt.strip()) - if not stmt: + masked, stash = _protect(stmt.strip()) + masked = _normalize_inline_labels(masked) + if not masked: return - parts = [p for p in _ARROW_SPLIT_RE.split(stmt) if p.strip()] + parts = [_restore(p, stash) for p in _ARROW_SPLIT_RE.split(masked) if p.strip()] if len(parts) < 3: if parts: _parse_node_group(parts[0], nodes) @@ -429,6 +495,10 @@ def _parse_statement( src_ids = _parse_node_group(parts[idx], nodes) label, style, width = _parse_arrow(parts[idx + 1]) tgt_ids = _parse_node_group(parts[idx + 2], nodes) + if "~~~" in parts[idx + 1]: + # An invisible link only places its nodes; there is no line to draw. + idx += 2 + continue # mermaid joins every source to every target ("A & B --> C & D"). for src_id in src_ids: for tgt_id in tgt_ids: @@ -450,7 +520,7 @@ def _parse_direction(line: str) -> tuple[str, str] | None: match = _DIRECTION_RE.match(line) if match is None: return None - direction = match.group(1).upper() + direction = (match.group(1) or "TD").upper() direction = "TD" if direction == "TB" else direction remainder = line[match.end():].lstrip(" ;").strip() return direction, remainder @@ -492,8 +562,9 @@ def _parse_lines(text: str, nodes: dict[str, _NodeInfo], edges: list[_EdgeInfo]) continue if _SKIP_RE.match(line): continue - for stmt in line.split(";"): - _parse_statement(stmt, nodes, edges) + masked, stash = _protect(line) + for stmt in masked.split(";"): + _parse_statement(_restore(stmt, stash), nodes, edges) return direction diff --git a/test/test_utils/test_mermaid_parser.py b/test/test_utils/test_mermaid_parser.py index 6f5a3738..d9046495 100644 --- a/test/test_utils/test_mermaid_parser.py +++ b/test/test_utils/test_mermaid_parser.py @@ -284,3 +284,58 @@ def test_complex_graph_has_no_crossings(self): def test_tree_has_no_crossings(self): r = parse_mermaid("graph TD\nR-->A\nR-->B\nA-->X\nA-->Y\nB-->Z") assert _count_crossings(r) == 0 + + +def _node_texts(result) -> list: + return [node["text"] for node in result["nodes"]] + + +def _edges(result): + texts = [node["text"] for node in result["nodes"]] + return [(texts[c["source"]], texts[c["target"]], c["label"]) for c in result["connections"]] + + +class TestTextThatLooksLikeSyntax: + @pytest.mark.parametrize("label", ["a --> b", "a;b", "x -- y --> z", "p ~~~ q"]) + def test_an_arrow_or_semicolon_inside_a_quoted_label_is_label(self, label): + result = parse_mermaid(f'graph TD\nA["{label}"]-->B') + + assert _node_texts(result) == [label, "B"] + assert _edges(result) == [(label, "B", "")] + + def test_a_bracket_inside_quotes_does_not_end_the_label(self): + result = parse_mermaid('graph TD\nA["list[0]; done"] --> B') + + assert _node_texts(result) == ["list[0]; done", "B"] + + +class TestHeadersAndDirectives: + @pytest.mark.parametrize("header", ["graph", "flowchart", "Flowchart"]) + def test_a_header_without_a_direction_is_not_a_node(self, header): + result = parse_mermaid(f"{header}\nA-->B") + + assert _node_texts(result) == ["A", "B"] + + def test_direction_inside_a_subgraph_is_not_a_node(self): + result = parse_mermaid("graph LR\nsubgraph S\ndirection TB\nA-->B\nend") + + assert _node_texts(result) == ["A", "B"] + + def test_a_node_whose_name_starts_like_a_keyword_is_still_a_node(self): + assert "graphics" in _node_texts(parse_mermaid("graph TD\ngraphics-->directions")) + + +class TestMoreLinks: + @pytest.mark.parametrize("link", ["-- text ---", "-- text --o", "-- text --x", "-- text -->"]) + def test_text_on_any_normal_link_is_its_label(self, link): + result = parse_mermaid(f"graph TD\nA {link} B") + + assert _node_texts(result) == ["A", "B"] + assert _edges(result) == [("A", "B", "text")] + + def test_an_invisible_link_keeps_both_nodes_and_draws_nothing(self): + result = parse_mermaid("graph TD\nA ~~~ B") + + assert _node_texts(result) == ["A", "B"] + assert result["connections"] == [] + From 04e935e6729d73c63f274da5d7fb06767529df63 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 11:31:48 +0800 Subject: [PATCH 089/312] Keep diagram editor shortcuts to the editor when it is docked --- architecture_explore.md | 6 +++--- docs/updates/2026-09.md | 12 ++++++++++++ docs/updates/README.md | 3 ++- .../diagram_editor/diagram_editor_widget.py | 6 ++++++ test/test_utils/test_diagram_editor_widget.py | 15 +++++++++++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 9a6bffe4..461c346c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -233,11 +233,11 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,714 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,722 行,最大子系統) | 檔案 | 職責 | |---|---| -| `diagram_editor_widget.py` (616) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | +| `diagram_editor_widget.py` (624) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | | `diagram_scene.py` (854) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點之上/之下。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | | `diagram_items.py` (895) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | | `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1415 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1416 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 04a70ee2..32243b46 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1074,3 +1074,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §7, §18 - `progress.md` (#38 removed) - **Open items**: none. + +## U-20260923-64 · 2026-09-23 · Diagram editor shortcuts act only while it has focus · #fix #diagram + +- **What**: opened as a dock, the diagram editor shares the main window with the code editor, and its shortcuts (Ctrl+Z, Ctrl+Y, Ctrl+C, Ctrl+V, Ctrl+D, Ctrl+=, Ctrl+-, Delete, ...) were window-wide. Where the code editor had no action on the key, the diagram took it, so Ctrl+D in the code editor duplicated a diagram node. Where both bound the same key, Qt found the shortcut ambiguous and neither ran. +- **Fix**: `_bind_shortcuts` gives every shortcut the `WidgetWithChildrenShortcut` context, so it fires only while focus is in the diagram editor (canvas, panel or toolbars). +- **Tests**: `test_diagram_editor_widget.py::TestShortcuts` checks that every shortcut of the editor has that context. +- **Result / numbers**: unit tests 1402 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py` (624 lines) + - `test/test_utils/test_diagram_editor_widget.py` + - `architecture_explore.md` §7, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index e956e401..a759e607 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-64 | 2026-09-23 | Diagram editor shortcuts act only while it has focus | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-63 | 2026-09-23 | Mermaid import: syntax inside labels, bare headers, directions and more links | #done #diagram | [2026-09](2026-09.md) | | U-20260923-62 | 2026-09-23 | Diagram editing: right-click, stacking, undo, sizes, wheel and teardown | #done #diagram | [2026-09](2026-09.md) | | U-20260923-61 | 2026-09-23 | A file can be renamed to a different case of its name on Windows | #done #editor | [2026-09](2026-09.md) | @@ -144,4 +145,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 81 | +| [2026-09.md](2026-09.md) | 2026-09 | 82 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index c49e6ea1..0a503d31 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -340,6 +340,12 @@ def _bind_shortcuts(self) -> None: ] for key, slot in shortcuts: sc = QShortcut(key, self) + # Only while the diagram editor has focus. Opened as a dock it + # shares the window with the code editor, whose own Ctrl+Z, Ctrl+C, + # Ctrl+D, Ctrl+= ... a window-wide shortcut would take over, or -- + # where the editor binds the same key -- leave both ambiguous and + # doing nothing. + sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) sc.activated.connect(slot) # ------------------------------------------------------------------ diff --git a/test/test_utils/test_diagram_editor_widget.py b/test/test_utils/test_diagram_editor_widget.py index f6804dcd..61282e9f 100644 --- a/test/test_utils/test_diagram_editor_widget.py +++ b/test/test_utils/test_diagram_editor_widget.py @@ -90,3 +90,18 @@ def test_a_file_that_is_not_a_diagram_leaves_the_canvas_alone( # The next save must still go to the file that is open, unharmed. editor._write_json(editor._current_path) assert json.loads(editor._current_path.read_text(encoding="utf-8"))["nodes"] + + +class TestShortcuts: + def test_every_shortcut_applies_only_while_the_editor_has_focus(self, editor): + # Opened as a dock, the editor shares the window with the code editor; + # window-wide, its Ctrl+D or Ctrl+Z took over, or collided with, the code + # editor's own. + from PySide6.QtCore import Qt + from PySide6.QtGui import QShortcut + + shortcuts = editor.findChildren(QShortcut) + + assert len(shortcuts) >= 11 + assert {shortcut.context() for shortcut in shortcuts} == { + Qt.ShortcutContext.WidgetWithChildrenShortcut} From 2fe6fd97bc719835772593432849c0985d8782a7 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 12:01:02 +0800 Subject: [PATCH 090/312] Keep the prompt editor from saving its missing-file note or dropping edits --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 22 ++++++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 4 + .../extend_traditional_chinese.py | 4 + .../prompt_edit_gui/prompt_editor_widget.py | 61 ++++++++++---- test/test_utils/test_prompt_store.py | 79 +++++++++++++++++++ 7 files changed, 160 insertions(+), 17 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 461c346c..79ff853b 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -261,7 +261,7 @@ extend_ai_gui/ │ ├── code_review_thread.py SenderThread(QThread):跑八步審查鏈 │ └── cot_code_review_gui.py UI;關閉時請審查停在目前這一步,交給 let_run_out(),不等 ├── prompt_edit_gui/ -│ ├── prompt_editor_widget.py 共用編輯器(QFileSystemWatcher 熱更新,watcher 以編輯器為 parent、關閉時停止監看;有未存編輯時先問再重新載入) +│ ├── prompt_editor_widget.py 共用編輯器(QFileSystemWatcher 熱更新,watcher 以編輯器為 parent、關閉時停止監看;有未存編輯時,外部改動、「重新載入」和切換模板都先問;還沒有檔案的模板只用 placeholder 說明,存檔不會把說明寫進去) │ ├── cot_prompt_editor_widget.py 8 個 CoT 模板的檔案清單+語言鍵 │ ├── skills_prompt_editor_widget.py 2 個 Skill 模板的檔案清單+語言鍵 │ ├── prompt_file_io.py 共用存檔(先寫 `.saving` 再 `os.replace()`;失敗跳警告對話框,不顯示路徑) @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1416 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1420 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 32243b46..caf96d2a 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1086,3 +1086,25 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_diagram_editor_widget.py` - `architecture_explore.md` §7, §18 - **Open items**: none. + +## U-20260923-65 · 2026-09-23 · Prompt editor: no saved 'does not exist' note, no lost edits on switch · #fix #ai + +- **What**: two faults in the prompt editor shared by the CoT and Skill template editors (`prompt_editor_widget.py`), both reproduced headless: + - For a template with no file yet, the edit area's *text* was the note "(File linter.md does not exist)". Save wrote that note to disk, and since `load_prompt` treats any non-empty file as an override, every later review sent the note as the prompt, with no `{code_diff}` in it, so no code reached the model. + - Choosing another template in the selector, or pressing Reload, replaced unsaved edits without asking. Only a change made on disk asked first. +- **Fix**: + - The note is the edit area's placeholder, and the text is empty. Saving that writes an empty file, which `load_prompt` already treats as "use the built-in". + - `_may_discard_edits()` asks before unsaved edits are lost, for a template switch, Reload and a change on disk alike. Answering No on a switch puts the selector back without reloading. + - New language keys `prompt_editor_switch_over_edits` and `prompt_editor_reload_button_over_edits`, in English and Traditional Chinese. +- **Tests**: `test_prompt_store.py`: + - saving a template that has no file leaves the built-in in use; + - keeping edits on a switch stays on the template with the edits; + - letting them go switches; + - Reload with edits asks and keeps them on No. +- **Result / numbers**: unit tests 1406 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_prompt_store.py` + - `architecture_explore.md` §8, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index a759e607..c7255184 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-65 | 2026-09-23 | Prompt editor: no saved 'does not exist' note, no lost edits on switch | #fix #ai | [2026-09](2026-09.md) | | U-20260923-64 | 2026-09-23 | Diagram editor shortcuts act only while it has focus | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-63 | 2026-09-23 | Mermaid import: syntax inside labels, bare headers, directions and more links | #done #diagram | [2026-09](2026-09.md) | | U-20260923-62 | 2026-09-23 | Diagram editing: right-click, stacking, undo, sizes, wheel and teardown | #done #diagram | [2026-09](2026-09.md) | @@ -145,4 +146,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 82 | +| [2026-09.md](2026-09.md) | 2026-09 | 83 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 1b8e2954..be918f5e 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -195,6 +195,10 @@ "ssh_file_viewer_dialog_title_save_as": "Save as", "prompt_editor_not_utf8": "{filename} is not UTF-8 text. It is shown with the characters that could not be read replaced; saving writes it back as UTF-8.", + "prompt_editor_switch_over_edits": + "{filename} has edits that are not saved. Switch templates and lose them?", + "prompt_editor_reload_button_over_edits": + "{filename} has edits that are not saved. Reload it from disk and lose them?", "prompt_editor_reload_over_edits": "{filename} changed on disk. Reload it and lose the edits made here?", "ssh_state_shell_and_files": "Connected: shell and files", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 414f8a9c..875e1f7c 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -195,6 +195,10 @@ "ssh_file_viewer_dialog_title_save_as": "另存新檔", "prompt_editor_not_utf8": "{filename} 不是 UTF-8 文字檔。讀不出來的字元已經換掉後顯示;存檔時會改存成 UTF-8。", + "prompt_editor_switch_over_edits": + "{filename} 還有沒存的編輯。要切換模板並捨棄它們嗎?", + "prompt_editor_reload_button_over_edits": + "{filename} 還有沒存的編輯。要從磁碟重新載入並捨棄它們嗎?", "prompt_editor_reload_over_edits": "{filename} 在磁碟上被改過了。要重新載入並捨棄這裡還沒存的編輯嗎?", "ssh_state_shell_and_files": "已連線:終端與檔案", diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py index 18d38c0a..ebbf797c 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py @@ -62,13 +62,16 @@ def __init__(self, files: list[str], templates: dict[str, str], self.templates = templates self._labels = labels self.current_file: str | None = None + # The template whose file is in the edit area, to go back to when the + # user keeps their edits rather than switch away from them. + self._shown_index = -1 word = language_wrapper.language_word_dict self.setWindowTitle(word.get(labels.window_title)) self.file_selector = QComboBox() self.file_selector.addItems(self.prompt_files) - self.file_selector.currentIndexChanged.connect(self.load_file_content) + self.file_selector.currentIndexChanged.connect(self._on_template_chosen) self.middle_editor = QTextEdit() group = QGroupBox(word.get(labels.edit_group)) @@ -77,8 +80,7 @@ def __init__(self, files: list[str], templates: dict[str, str], group.setLayout(group_layout) self.reload_button = QPushButton(word.get(labels.reload_button)) - self.reload_button.clicked.connect( - lambda: self.load_file_content(self.file_selector.currentIndex())) + self.reload_button.clicked.connect(self._on_reload_clicked) self.save_button = QPushButton(word.get(labels.save_button)) self.save_button.clicked.connect(self.save_file) self.create_button = QPushButton(word.get(labels.create_button)) @@ -110,17 +112,56 @@ def __init__(self, files: list[str], templates: dict[str, str], if self.prompt_files: self.load_file_content(0) + def _on_template_chosen(self, index: int) -> None: + """Show the chosen template, unless that would throw away unsaved edits.""" + if index == self._shown_index: + return + if not self._may_discard_edits( + "prompt_editor_switch_over_edits", self._shown_name()): + # Put the selector back without coming here again. + self.file_selector.blockSignals(True) + self.file_selector.setCurrentIndex(self._shown_index) + self.file_selector.blockSignals(False) + return + self.load_file_content(index) + + def _on_reload_clicked(self) -> None: + """Reload the file from disk, asking first when that loses edits.""" + if self._may_discard_edits("prompt_editor_reload_button_over_edits", self._shown_name()): + self.load_file_content(self.file_selector.currentIndex()) + + def _shown_name(self) -> str: + """The file name of the template in the edit area.""" + return Path(self.current_file).name if self.current_file else "" + + def _may_discard_edits(self, question_key: str, filename: str) -> bool: + """True when nothing unsaved is in the edit area, or the user lets it go.""" + if not self.middle_editor.document().isModified(): + return True + word = language_wrapper.language_word_dict + reply = QMessageBox.question( + self, word.get(self._labels.info_title), + word.get(question_key).format(filename=filename), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) + return reply == QMessageBox.StandardButton.Yes + def load_file_content(self, index: int) -> None: """載入選擇的檔案內容 / Show the selected file, or say it is not there yet.""" name = self.prompt_files[index] + self._shown_index = index self.current_file = str(prompt_path(name)) path = Path(self.current_file) if path.is_file(): + self.middle_editor.setPlaceholderText("") self._show_file(path) return - self._show_text( + # Said as a placeholder, not as text: text would be what a save writes, + # and a saved "does not exist" note overrode the built-in prompt. + self.middle_editor.setPlaceholderText( language_wrapper.language_word_dict.get( self._labels.file_not_exist).format(filename=name)) + self._show_text("") def _show_file(self, path: Path) -> None: """Put the prompt file in the edit area, however it was encoded.""" @@ -192,16 +233,8 @@ def on_file_changed(self, path: str) -> None: """ if path != self.current_file: return - if self.middle_editor.document().isModified(): - word = language_wrapper.language_word_dict - reply = QMessageBox.question( - self, word.get(self._labels.info_title), - word.get("prompt_editor_reload_over_edits").format(filename=Path(path).name), - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No) - if reply != QMessageBox.StandardButton.Yes: - return - self.load_file_content(self.file_selector.currentIndex()) + if self._may_discard_edits("prompt_editor_reload_over_edits", Path(path).name): + self.load_file_content(self.file_selector.currentIndex()) def save_file(self) -> None: """把編輯區內容存回檔案 / Write the edit area back to the file.""" diff --git a/test/test_utils/test_prompt_store.py b/test/test_utils/test_prompt_store.py index 11e71faa..9e0eaee4 100644 --- a/test/test_utils/test_prompt_store.py +++ b/test/test_utils/test_prompt_store.py @@ -333,3 +333,82 @@ def test_closing_it_stops_watching(self, prompts, monkeypatch): assert editor.watcher.files() == [] editor.deleteLater() + + +class TestATemplateWithNoFileYet: + def test_saving_it_does_not_write_the_does_not_exist_note(self, prompts, monkeypatch): + # The note used to be the edit area's text, so Save wrote it to disk and + # every review then sent "(File linter.md does not exist)" as the prompt. + editor = _cot_editor(monkeypatch) + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + + assert editor.middle_editor.toPlainText() == "" + assert "linter.md" in editor.middle_editor.placeholderText() + editor.save_file() + + assert load_prompt("linter.md", "built-in") == "built-in" + editor.close() + editor.deleteLater() + + +class TestSwitchingTemplatesWithUnsavedEdits: + @staticmethod + def _typed(editor, text): + editor.middle_editor.selectAll() + editor.middle_editor.textCursor().insertText(text) + + def test_keeping_the_edits_stays_on_the_template(self, prompts, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + write(prompts, "linter.md", "on disk") + editor = _cot_editor(monkeypatch) + linter = editor.prompt_files.index("linter.md") + editor.file_selector.setCurrentIndex(linter) + self._typed(editor, "MY EDITS") + monkeypatch.setattr( + QMessageBox, "question", + staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)) + + editor.file_selector.setCurrentIndex((linter + 1) % len(editor.prompt_files)) + + assert editor.file_selector.currentIndex() == linter + assert editor.middle_editor.toPlainText() == "MY EDITS" + assert editor.current_file == str(prompt_path("linter.md")) + editor.close() + editor.deleteLater() + + def test_letting_them_go_switches(self, prompts, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + editor = _cot_editor(monkeypatch) + linter = editor.prompt_files.index("linter.md") + editor.file_selector.setCurrentIndex(linter) + self._typed(editor, "MY EDITS") + monkeypatch.setattr( + QMessageBox, "question", + staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)) + other = (linter + 1) % len(editor.prompt_files) + + editor.file_selector.setCurrentIndex(other) + + assert editor.current_file == str(prompt_path(editor.prompt_files[other])) + assert editor.middle_editor.toPlainText() != "MY EDITS" + editor.close() + editor.deleteLater() + + def test_reload_asks_before_losing_edits(self, prompts, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + write(prompts, "linter.md", "on disk") + editor = _cot_editor(monkeypatch) + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + self._typed(editor, "MY EDITS") + monkeypatch.setattr( + QMessageBox, "question", + staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)) + + editor.reload_button.click() + + assert editor.middle_editor.toPlainText() == "MY EDITS" + editor.close() + editor.deleteLater() From 518da99d26d6ba55938398395612cb8ba3e8df5c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 12:03:56 +0800 Subject: [PATCH 091/312] Keep URLs out of AI panel logs and survive damaged or shared panel files --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 28 ++++++++++++ docs/updates/README.md | 3 +- progress.md | 1 + .../connect_gui/url/ai_code_review_gui.py | 43 ++++++++++++++++--- .../code_review/code_review_thread.py | 4 +- .../extend_ai_gui/code_review/cot_chain.py | 21 ++++++++- .../extend_ai_gui/skills/skills_send_gui.py | 3 +- test/test_utils/test_ai_code_review_client.py | 37 ++++++++++++++++ test/test_utils/test_cot_session_reuse.py | 24 +++++++++++ test/test_utils/test_prompt_store.py | 24 +++++++++++ test/test_utils/test_skills_request.py | 13 ++++++ 12 files changed, 190 insertions(+), 13 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 79ff853b..22e10639 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1420 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1431 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index caf96d2a..487d9d56 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1108,3 +1108,31 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_prompt_store.py` - `architecture_explore.md` §8, §18 - **Open items**: none. + +## U-20260923-66 · 2026-09-23 · AI panels: no URLs in logs, damaged urls.txt, shared vote totals, prompt fields · #fix #ai #security + +- **What**: faults found by a review of `extend_ai_gui/` and `connect_gui/url/`, each reproduced headless: + - **URLs in the log**: the CoT chain (`code_review_thread.py`) and the Skill sender (`skills_send_gui.py`) logged a failed request with `%r`. A `requests` error names the whole URL ("Max retries exceeded with url: /x?token=..."), and an API URL may carry a token. The AI review client already logged only the error's type. + - **A damaged `urls.txt`**: `record_url` read `~/.pybreeze/urls.txt` without a guard. A file that is not UTF-8, or is locked, raised from the Send slot on every click, so no request could be sent. + - **Two review panels open at once**: each counted votes on from the totals it read when it opened. The second one to save wrote the first one's votes away (5/5 on disk; 3 accepts in A, then 1 reject in B, saved 5/6). + - **An edited CoT prompt that reaches into a placeholder**: `{code_diff[x]}` raised a `TypeError` that the fallback did not catch, which ended the whole chain silently. `{code_diff.upper}` sent ``, and `{code_diff[0]}` sent one character of the code. +- **Fix**: + - Both threads log only `type(error).__name__`. + - `_read_recorded_lines()` treats an unreadable file as empty, and a failed write of the file is logged at debug level; the request goes out either way. + - `_count_verdict()` re-reads the totals from disk before adding a vote. + - `cot_chain._fill()` refuses any field that is not a bare name, which sends the step to the built-in template, and the fallback also catches `TypeError`. Escaped braces and format specs still work. +- **Tests**: + - `test_ai_code_review_client.py`: a damaged file, a file that cannot be written, two panels voting. + - `test_skills_request.py` and `test_cot_session_reuse.py`: a failed request does not log the URL's token. + - `test_prompt_store.py`: five field forms fall back to the built-in, and escapes and specs still fill. +- **Result / numbers**: unit tests 1417 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/code_review/{cot_chain,code_review_thread}.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py` + - `test/test_utils/{test_ai_code_review_client,test_skills_request,test_cot_session_reuse,test_prompt_store}.py` + - `architecture_explore.md` §18 +- **Open items**: + - `CoTCodeReviewGUI` cannot be opened from any menu. + - Its URL check resolves DNS on the UI thread. + - Its answers carry over from one run to the next. diff --git a/docs/updates/README.md b/docs/updates/README.md index c7255184..8c98691f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-66 | 2026-09-23 | AI panels: no URLs in logs, damaged urls.txt, shared vote totals, prompt fields | #fix #ai #security | [2026-09](2026-09.md) | | U-20260923-65 | 2026-09-23 | Prompt editor: no saved 'does not exist' note, no lost edits on switch | #fix #ai | [2026-09](2026-09.md) | | U-20260923-64 | 2026-09-23 | Diagram editor shortcuts act only while it has focus | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-63 | 2026-09-23 | Mermaid import: syntax inside labels, bare headers, directions and more links | #done #diagram | [2026-09](2026-09.md) | @@ -146,4 +147,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 83 | +| [2026-09.md](2026-09.md) | 2026-09 | 84 | diff --git a/progress.md b/progress.md index 7d94b9ad..521ba2f6 100644 --- a/progress.md +++ b/progress.md @@ -8,3 +8,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? +- **#41** `CoTCodeReviewGUI` (`extend_ai_gui/code_review/cot_code_review_gui.py`) is not reachable from any menu: `_WIDGET_FACTORIES`, the tab and the dock tables in `menu/tools/tools_menu.py` have no entry for it. Its Send runs `validate_url` (a blocking DNS lookup) on the UI thread before the worker validates again (`cot_code_review_gui.py:75`), and its answers and response selector are never cleared between runs. 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 ed1406fc..254ecbc1 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 @@ -110,6 +110,22 @@ def read_stats(path: str) -> tuple[int, int]: return totals["Accepted"], totals["Rejected"] +def _read_recorded_lines(path: Path) -> list[str]: + """Return the non-empty lines of the sent-URLs file, none when it cannot be read. + + A damaged or locked file must not stop the request: raised from the Send + slot, it did so on every click. + """ + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + return [] + except (OSError, UnicodeDecodeError) as error: + pybreeze_logger.debug("Sent URLs not read: %r", error) + return [] + return [line.strip() for line in text.splitlines() if line.strip()] + + class AICodeReviewClient(QWidget): def __init__(self): super().__init__() @@ -269,31 +285,44 @@ def record_url(self, url: str) -> bool: sitting in the user's home directory. """ path = Path(self.url_file) - lines = [] - if path.is_file(): - lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() - if line.strip()] + lines = _read_recorded_lines(path) seen = {line if looks_like_a_fingerprint(line) else url_fingerprint(line) for line in lines} fingerprint = url_fingerprint(url) is_new = fingerprint not in seen if is_new or any(not looks_like_a_fingerprint(line) for line in lines): seen.add(fingerprint) - path.write_text("\n".join(sorted(seen)) + "\n", encoding="utf-8") + try: + path.write_text("\n".join(sorted(seen)) + "\n", encoding="utf-8") + except OSError as error: + # Only the "seen before" note is lost; the request still goes out. + pybreeze_logger.debug("Sent URLs not recorded: %r", error) return is_new def accept_response(self): """Accept response code and save""" - self.accept_count += 1 + self._count_verdict(accepted=True) self.response_panel.append(f"\n{self.word_dict.get('ai_code_review_gui_status_accepted')}") self.save_stats() def reject_response(self): """Reject response code and save""" - self.reject_count += 1 + self._count_verdict(accepted=False) self.response_panel.append(f"\n{self.word_dict.get('ai_code_review_gui_status_rejected')}") self.save_stats() + def _count_verdict(self, accepted: bool) -> None: + """Add one verdict to the totals on disk, not to this panel's copy of them. + + Another review panel may have saved since this one opened; counting on + from the numbers read at opening wrote its votes away. + """ + self.accept_count, self.reject_count = read_stats(self.stats_file) + if accepted: + self.accept_count += 1 + else: + self.reject_count += 1 + def save_stats(self): """Save accept/reject counts""" try: 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 b505f48d..35e8ce1c 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 @@ -70,7 +70,9 @@ def _ask(self, session: requests.Session, file: str, prompt: str) -> tuple[str, ) body = read_capped_text(resp) except (requests.RequestException, ResponseTooLargeError) as error: - pybreeze_logger.error("CoT code review send failed for %s: %r", file, error) + # Not %r: a requests error carries the whole URL, which may hold a token. + pybreeze_logger.error( + "CoT code review send failed for %s: %s", file, type(error).__name__) word = language_wrapper.language_word_dict return f"{word.get('cot_gui_error_sending')} {file} {error}", False if not succeeded(resp): diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py index 4b64ce39..ea416b4a 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py @@ -11,6 +11,7 @@ from __future__ import annotations from collections.abc import Mapping +from string import Formatter from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_RELATION from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.global_rule import ( @@ -88,8 +89,8 @@ def build_prompt(step: str, results: Mapping[str, str]) -> str | None: } template = load_prompt(step, built_in) try: - body = template.format(**filled) - except (KeyError, IndexError, ValueError) as error: + body = _fill(template, filled) + except (KeyError, IndexError, ValueError, TypeError) as error: # An edited prompt that names a placeholder the chain cannot fill would # otherwise take the whole review down. Fall back to the built-in and say # so, rather than failing a step the user cannot debug from the UI. @@ -98,3 +99,19 @@ def build_prompt(step: str, results: Mapping[str, str]) -> str | None: step, error) body = built_in.format(**filled) return build_global_rule_template(prompt=body) + + +def _fill(template: str, filled: Mapping[str, str]) -> str: + """Fill *template*'s placeholders from *filled*, by plain name only. + + An edited prompt may say ``{code_diff.upper}`` or ``{code_diff[0]}``: + ``str.format`` would quietly put a bound method's repr, or one character, + into the prompt, or raise a ``TypeError`` that ended the whole chain. A + field that is not a bare name is refused like an unknown one. + + :raises ValueError: when a field is not a bare placeholder name + """ + for _literal, field, _spec, _conversion in Formatter().parse(template): + if field is not None and not field.isidentifier(): + raise ValueError(f"placeholder {field!r} is not a plain name") + return template.format_map(filled) 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 d76d88a0..50d5e979 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 @@ -47,7 +47,8 @@ def run(self): status_code=response.status_code, text=text) (self.error if is_error else self.answered).emit(message) except (requests.RequestException, ResponseTooLargeError, UnsafeURLError) as e: - pybreeze_logger.error("Skills send request failed: %r", e) + # Not %r: a requests error carries the whole URL, which may hold a token. + pybreeze_logger.error("Skills send request failed: %s", type(e).__name__) self.error.emit(language_wrapper.language_word_dict.get("skills_exception").format(error=str(e))) diff --git a/test/test_utils/test_ai_code_review_client.py b/test/test_utils/test_ai_code_review_client.py index ef6c37f7..3308d342 100644 --- a/test/test_utils/test_ai_code_review_client.py +++ b/test/test_utils/test_ai_code_review_client.py @@ -239,3 +239,40 @@ def test_a_missing_or_unreadable_file_counts_as_none(self, tmp_path): binary.write_bytes(b"\xff\xfe\x00") assert read_stats(str(binary)) == (0, 0) + +class TestASentUrlsFileThatCannotBeUsed: + def test_a_damaged_file_does_not_stop_the_request(self, client): + from pathlib import Path + + Path(client.url_file).write_bytes(b"\xff\xfe not utf-8") + + # It used to raise from the Send slot, on every click. + assert client.record_url(_A_URL) is True + assert url_fingerprint(_A_URL) in stored(client) + + def test_a_file_that_cannot_be_written_does_not_stop_the_request(self, client, monkeypatch): + from pathlib import Path + + def refuse(*_args, **_kwargs): + raise PermissionError(13, "Access is denied") + + monkeypatch.setattr(Path, "write_text", refuse) + + assert client.record_url(_A_URL) is True + + +class TestTwoPanelsOpenAtOnce: + def test_neither_writes_away_the_others_votes(self, app, tmp_path, monkeypatch): + (tmp_path / "response_stats.txt").write_text("Accepted: 5\nRejected: 5\n", encoding="utf-8") + monkeypatch.setattr(ai_code_review_gui, "pybreeze_data_dir", lambda: tmp_path) + first, second = AICodeReviewClient(), AICodeReviewClient() + + for _ in range(3): + first.accept_response() + second.reject_response() + + # The second panel used to count on from the 5/5 it read at opening + # and save 5/6, losing the first panel's three accepts. + assert (tmp_path / "response_stats.txt").read_text(encoding="utf-8") == "Accepted: 8\nRejected: 6\n" + first.deleteLater() + second.deleteLater() diff --git a/test/test_utils/test_cot_session_reuse.py b/test/test_utils/test_cot_session_reuse.py index 924267ef..10a1efbe 100644 --- a/test/test_utils/test_cot_session_reuse.py +++ b/test/test_utils/test_cot_session_reuse.py @@ -228,3 +228,27 @@ def test_an_error_status_is_a_failed_step_not_an_answer(): assert "HTTP 500" in received["linter.md"] later = _prompts(session)[linter_step + 1:] assert not any("Internal Server Error" in prompt for prompt in later) + + +def test_a_failed_step_does_not_log_the_url(monkeypatch): + # A requests error names the whole URL, and an API URL may carry a token. + _qt_app() + from pybreeze.pybreeze_ui.extend_ai_gui.code_review import code_review_thread + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread + + logged: list = [] + monkeypatch.setattr( + code_review_thread.pybreeze_logger, "error", + lambda message, *args: logged.append(message % args)) + + class _Refusing(_FakeSession): + def post(self, url, **kwargs): + import requests + + raise requests.ConnectionError(f"Max retries exceeded with url: {url}") + + thread = SenderThread(files=["linter.md"], code="print('x')", + url="https://example.com/api?token=not-a-real-token") + thread._run_templates(_Refusing(), "print('x')") + + assert logged and all("not-a-real-token" not in line for line in logged) diff --git a/test/test_utils/test_prompt_store.py b/test/test_utils/test_prompt_store.py index 9e0eaee4..0ec00bc7 100644 --- a/test/test_utils/test_prompt_store.py +++ b/test/test_utils/test_prompt_store.py @@ -412,3 +412,27 @@ def test_reload_asks_before_losing_edits(self, prompts, monkeypatch): assert editor.middle_editor.toPlainText() == "MY EDITS" editor.close() editor.deleteLater() + + +class TestAnEditedPromptThatReachesIntoAPlaceholder: + @pytest.mark.parametrize("edited", [ + "Review {code_diff[x]}", # a TypeError that used to end the whole chain + "Review {code_diff.upper}", # quietly sent "" + "Review {code_diff[0]}", # quietly sent one character of the code + "Review {0}", + "Review {}", + ]) + def test_it_falls_back_to_the_built_in(self, prompts, edited): + write(prompts, "linter.md", edited) + + prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) + + assert CODE in prompt + assert "built-in method" not in prompt + + def test_escaped_braces_and_a_format_spec_still_work(self, prompts): + write(prompts, "linter.md", "{{literal}} then {code_diff!s:>1}") + + prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) + + assert "{literal}" in prompt and CODE in prompt diff --git a/test/test_utils/test_skills_request.py b/test/test_utils/test_skills_request.py index 2b132a52..67eec392 100644 --- a/test/test_utils/test_skills_request.py +++ b/test/test_utils/test_skills_request.py @@ -93,3 +93,16 @@ def test_a_failed_request_is_an_error(self, monkeypatch): assert answered == [] assert "refused" in errors[0] + + +def test_a_failed_request_does_not_log_the_url(monkeypatch): + # A requests error names the whole URL, and an API URL may carry a token. + logged: list = [] + monkeypatch.setattr( + skills_send_gui.pybreeze_logger, "error", + lambda message, *args: logged.append(message % args)) + + _run(monkeypatch, requests.ConnectionError( + "Max retries exceeded with url: /v1?token=not-a-real-token")) + + assert logged and all("not-a-real-token" not in line for line in logged) From 2298e70860593e0afb64a8d91f606444616e8fa7 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 12:11:14 +0800 Subject: [PATCH 092/312] Open the CoT code review panel from the AI menus and check its URL off the UI thread --- README.md | 4 +- architecture_explore.md | 6 +-- docs/updates/2026-09.md | 24 +++++++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../extend_multi_language/extend_english.py | 4 ++ .../extend_traditional_chinese.py | 4 ++ .../code_review/cot_code_review_gui.py | 14 ++++--- pybreeze/pybreeze_ui/menu/tools/tools_menu.py | 8 ++++ test/test_utils/test_cot_session_reuse.py | 41 +++++++++++++++++++ test/test_utils/test_tools_menu_docks.py | 13 ++++++ 11 files changed, 110 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6fc0d517..2bdce1f8 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,9 @@ One settings form holds the inference backend (`remote`, `local`, OpenAI-compati ![CoT prompt editor](images/cot_prompt_editor.png) -Create and manage the multi-step review chain: first summary → first code review → linter → code smell detector → total summary. Each step's result feeds the final summary. Files are watched, so an external edit shows up immediately. +Create and manage the multi-step review chain: first summary → first code review → a judge of that review → linter → code smell detector → step-by-step analysis → total summary → a judge of the summary. Each step quotes the answers it needs from the steps before it. Files are watched, so an external edit shows up immediately. + +Run the chain from **Tools → AI → CoT Code Review** (a tab, or a dock from the Dock menu): paste the code, give the endpoint URL, and each step's answer appears in the selector as it arrives. ### Skill Prompt Editor & Skill Send diff --git a/architecture_explore.md b/architecture_explore.md index 22e10639..5201d97e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -184,7 +184,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.3 `tools/tools_menu.py` — 表格驅動的工具註冊 -這是全專案設計最乾淨的一塊。三張表把 19 個工具的「建構」「分頁開啟」「dock 開啟」完全解耦: +這是全專案設計最乾淨的一塊。三張表把 20 個工具的「建構」「分頁開啟」「dock 開啟」完全解耦: - `_WIDGET_FACTORIES: dict[str, Callable]` — widget key → 建構 lambda - `_TAB_ACTIONS: tuple[...]` — (widget key, 主視窗屬性, 選單屬性, action 語言鍵, 分頁標籤鍵) @@ -259,7 +259,7 @@ extend_ai_gui/ ├── code_review/ │ ├── cot_chain.py 接線表(純邏輯,無 Qt):哪步引用哪步 │ ├── code_review_thread.py SenderThread(QThread):跑八步審查鏈 -│ └── cot_code_review_gui.py UI;關閉時請審查停在目前這一步,交給 let_run_out(),不等 +│ └── cot_code_review_gui.py UI(工具 → AI 的分頁與 dock);URL 只由 worker 驗證,UI 執行緒不查 DNS;每次送出先清掉上一輪的回覆;關閉時請審查停在目前這一步,交給 let_run_out(),不等 ├── prompt_edit_gui/ │ ├── prompt_editor_widget.py 共用編輯器(QFileSystemWatcher 熱更新,watcher 以編輯器為 parent、關閉時停止監看;有未存編輯時,外部改動、「重新載入」和切換模板都先問;還沒有檔案的模板只用 placeholder 說明,存檔不會把說明寫進去) │ ├── cot_prompt_editor_widget.py 8 個 CoT 模板的檔案清單+語言鍵 @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1431 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1435 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 487d9d56..974274a1 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1136,3 +1136,27 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `CoTCodeReviewGUI` cannot be opened from any menu. - Its URL check resolves DNS on the UI thread. - Its answers carry over from one run to the next. + +## U-20260923-67 · 2026-09-23 · CoT code review panel: in the menus, no DNS on the UI thread, fresh runs · #done #ai + +- **What**: `progress.md` #41. The CoT code review panel (`CoTCodeReviewGUI`), which sends code through the chain the CoT prompt editor edits, had no entry in any menu, so it could not be opened. It also had two faults: + - it checked the URL on the UI thread before the worker checked it again, and that check's DNS lookup froze the IDE for as long as the resolver took; + - its answers and response selector were never cleared, so a failed second run's `error` sat beside answers about the previous code. +- **Fix**: + - A `CoTCodeReview` row in `_WIDGET_FACTORIES`, `_TAB_ACTIONS`, `_DOCK_ACTIONS` and `_DOCK_TITLES`, so it opens from **Tools → AI** as a tab and from the Dock menu's AI submenu. Language keys `extend_tools_menu_cot_code_review_*` in English and Traditional Chinese. + - The panel leaves the URL check to the worker, which reports a refused URL as the `error` answer. + - Every Send clears the answers, the selector and the view first. + - README: the chain's eight steps listed as they run, and where to run it. +- **Tests**: + - `test_tools_menu_docks.py`: the dock builds the panel, and every widget in `_WIDGET_FACTORIES` has a tab, a dock and a dock title, so a widget with no menu entry cannot happen again. + - `test_cot_session_reuse.py`: Send resolves nothing on the UI thread, and a new run clears the last run's answers. +- **Result / numbers**: unit tests 1421 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/tools/tools_menu.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_tools_menu_docks.py`, `test_cot_session_reuse.py` + - `README.md` + - `architecture_explore.md` §5.3, §8, §18 + - `progress.md` (#41 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 8c98691f..098dd157 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-67 | 2026-09-23 | CoT code review panel: in the menus, no DNS on the UI thread, fresh runs | #done #ai | [2026-09](2026-09.md) | | U-20260923-66 | 2026-09-23 | AI panels: no URLs in logs, damaged urls.txt, shared vote totals, prompt fields | #fix #ai #security | [2026-09](2026-09.md) | | U-20260923-65 | 2026-09-23 | Prompt editor: no saved 'does not exist' note, no lost edits on switch | #fix #ai | [2026-09](2026-09.md) | | U-20260923-64 | 2026-09-23 | Diagram editor shortcuts act only while it has focus | #fix #diagram | [2026-09](2026-09.md) | @@ -147,4 +148,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 84 | +| [2026-09.md](2026-09.md) | 2026-09 | 85 | diff --git a/progress.md b/progress.md index 521ba2f6..7d94b9ad 100644 --- a/progress.md +++ b/progress.md @@ -8,4 +8,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#41** `CoTCodeReviewGUI` (`extend_ai_gui/code_review/cot_code_review_gui.py`) is not reachable from any menu: `_WIDGET_FACTORIES`, the tab and the dock tables in `menu/tools/tools_menu.py` have no entry for it. Its Send runs `validate_url` (a blocking DNS lookup) on the UI thread before the worker validates again (`cot_code_review_gui.py:75`), and its answers and response selector are never cleared between runs. diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index be918f5e..c6022cb6 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -293,6 +293,8 @@ "extend_tools_menu_ai_code_review_tab_label": "AI Code-Review", "extend_tools_menu_cot_prompt_editor_tab_action": _COT_PROMPT_EDITOR, "extend_tools_menu_cot_prompt_editor_tab_label": _COT_PROMPT_EDITOR, + "extend_tools_menu_cot_code_review_tab_action": "CoT Code Review Tab", + "extend_tools_menu_cot_code_review_tab_label": "CoT Code Review", "extend_tools_menu_skill_prompt_editor_tab_action": _SKILL_PROMPT_EDITOR, "extend_tools_menu_skill_prompt_editor_tab_label": _SKILL_PROMPT_EDITOR, "extend_tools_menu_skill_prompt_send_tab_label": "Skill Send GUI", @@ -301,6 +303,8 @@ "extend_tools_menu_ssh_client_dock_action": "SSH Client Dock", "extend_tools_menu_ai_code_review_dock_action": "AI Code-Review Dock", "extend_tools_menu_cot_prompt_editor_dock_action": "CoT Prompt Editor Dock", + "extend_tools_menu_cot_code_review_dock_action": "CoT Code Review Dock", + "extend_tools_menu_cot_code_review_dock_title": "CoT Code Review", "extend_tools_menu_skill_prompt_editor_dock_action": "Skill Prompt Editor Dock", "extend_tools_menu_ssh_client_dock_title": "SSH Client", "extend_tools_menu_ai_code_review_dock_title": "AI Code-Review", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 875e1f7c..b4da76d7 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -293,6 +293,8 @@ "extend_tools_menu_ai_code_review_tab_label": "AI 程式碼審查", "extend_tools_menu_cot_prompt_editor_tab_action": _COT_PROMPT_EDITOR, "extend_tools_menu_cot_prompt_editor_tab_label": _COT_PROMPT_EDITOR, + "extend_tools_menu_cot_code_review_tab_action": "CoT 程式碼審查分頁", + "extend_tools_menu_cot_code_review_tab_label": "CoT 程式碼審查", "extend_tools_menu_skill_prompt_editor_tab_action": _SKILL_PROMPT_EDITOR, "extend_tools_menu_skill_prompt_editor_tab_label": _SKILL_PROMPT_EDITOR, "extend_tools_menu_skill_prompt_send_tab_label": "Skill 提示詞傳送 GUI", @@ -301,6 +303,8 @@ "extend_tools_menu_ssh_client_dock_action": "SSH 用戶端停駐窗格", "extend_tools_menu_ai_code_review_dock_action": "AI 程式碼審查停駐窗格", "extend_tools_menu_cot_prompt_editor_dock_action": "CoT 提示詞編輯器停駐窗格", + "extend_tools_menu_cot_code_review_dock_action": "CoT 程式碼審查停駐窗格", + "extend_tools_menu_cot_code_review_dock_title": "CoT 程式碼審查", "extend_tools_menu_skill_prompt_editor_dock_action": "Skill 提示詞編輯器停駐窗格", "extend_tools_menu_ssh_client_dock_title": "SSH 用戶端", "extend_tools_menu_ai_code_review_dock_title": "AI 程式碼審查", 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 86790159..ff4a4b70 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 @@ -7,7 +7,6 @@ from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread from pybreeze.pybreeze_ui.thread_keeper import let_run_out -from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url class CoTCodeReviewGUI(QWidget): @@ -72,17 +71,20 @@ def start_sending(self): if not url: QMessageBox.warning(self, "Warning", language_wrapper.language_word_dict.get("cot_gui_error_no_url")) return - try: - validate_url(url) - except UnsafeURLError as e: - QMessageBox.warning(self, "Warning", str(e)) - return + # The URL is checked by the worker, which reports a refusal as the + # "error" answer: checked here too, its DNS lookup froze the IDE. # Ignore re-submits while a run is in flight so we never drop a running # QThread or interleave two review passes into the same response store. if self.thread is not None and self.thread.isRunning(): return + # A new run starts from nothing: answers about the previous code, or its + # "error", must not sit beside this run's. + self.responses.clear() + self.response_selector.clear() + self.response_view.clear() + # 啟動傳送 Thread self.send_button.setEnabled(False) self.thread = SenderThread(files=self.files, code=self.code_paste_area.toPlainText(), url=url) diff --git a/pybreeze/pybreeze_ui/menu/tools/tools_menu.py b/pybreeze/pybreeze_ui/menu/tools/tools_menu.py index e67854a9..8b308241 100644 --- a/pybreeze/pybreeze_ui/menu/tools/tools_menu.py +++ b/pybreeze/pybreeze_ui/menu/tools/tools_menu.py @@ -11,6 +11,7 @@ from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_main_widget import SSHMainWidget from pybreeze.pybreeze_ui.connect_gui.url.ai_code_review_gui import AICodeReviewClient from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget +from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import CoTPromptEditor from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.skills_prompt_editor_widget import \ SkillPromptEditor @@ -42,6 +43,7 @@ "SSH": lambda _win: SSHMainWidget(), "AICodeReview": lambda _win: AICodeReviewClient(), "CoTPromptEditor": lambda _win: CoTPromptEditor(), + "CoTCodeReview": lambda _win: CoTCodeReviewGUI(), "SkillPromptEditor": lambda _win: SkillPromptEditor(), "SkillSendGUI": lambda _win: SkillsSendGUI(), "DiagramEditor": lambda _win: DiagramEditorWidget(), @@ -65,6 +67,7 @@ "SSH": "extend_tools_menu_ssh_client_dock_title", "AICodeReview": "extend_tools_menu_ai_code_review_dock_title", "CoTPromptEditor": "extend_tools_menu_cot_prompt_editor_dock_title", + "CoTCodeReview": "extend_tools_menu_cot_code_review_dock_title", "SkillPromptEditor": "extend_tools_menu_skill_prompt_editor_dock_title", "SkillSendGUI": "extend_tools_menu_skill_prompt_send_dock_title", "DiagramEditor": "extend_tools_menu_diagram_editor_dock_title", @@ -96,6 +99,9 @@ ("CoTPromptEditor", "tools_ai_cot_prompt_editor_action", "tools_ai_menu", "extend_tools_menu_cot_prompt_editor_tab_action", "extend_tools_menu_cot_prompt_editor_tab_label"), + ("CoTCodeReview", "tools_ai_cot_code_review_action", "tools_ai_menu", + "extend_tools_menu_cot_code_review_tab_action", + "extend_tools_menu_cot_code_review_tab_label"), ("SkillPromptEditor", "tools_ai_skill_prompt_editor_action", "tools_ai_menu", "extend_tools_menu_skill_prompt_editor_tab_action", "extend_tools_menu_skill_prompt_editor_tab_label"), @@ -141,6 +147,8 @@ "extend_tools_menu_ai_code_review_dock_action"), ("CoTPromptEditor", "tools_cot_prompt_editor_dock_action", "dock_ai_menu", "extend_tools_menu_cot_prompt_editor_dock_action"), + ("CoTCodeReview", "tools_cot_code_review_dock_action", "dock_ai_menu", + "extend_tools_menu_cot_code_review_dock_action"), ("SkillPromptEditor", "tools_skill_prompt_editor_dock_action", "dock_ai_menu", "extend_tools_menu_skill_prompt_editor_dock_action"), ("SkillSendGUI", "tools_skill_send_dock_action", "dock_ai_menu", diff --git a/test/test_utils/test_cot_session_reuse.py b/test/test_utils/test_cot_session_reuse.py index 10a1efbe..5796498c 100644 --- a/test/test_utils/test_cot_session_reuse.py +++ b/test/test_utils/test_cot_session_reuse.py @@ -252,3 +252,44 @@ def post(self, url, **kwargs): thread._run_templates(_Refusing(), "print('x')") assert logged and all("not-a-real-token" not in line for line in logged) + + +def _sending_gui(monkeypatch): + """A CoT panel whose worker is built but never started.""" + from pybreeze.pybreeze_ui.extend_ai_gui.code_review import code_review_thread + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI + + _qt_app() + monkeypatch.setattr(code_review_thread.SenderThread, "start", lambda self: None) + gui = CoTCodeReviewGUI() + gui.url_input.setText("https://review.example/api") + return gui + + +def test_sending_resolves_nothing_on_the_ui_thread(monkeypatch): + # The panel checked the URL itself before the worker did, and that check's + # DNS lookup froze the IDE for as long as the resolver took. + import socket + + looked_up: list = [] + monkeypatch.setattr(socket, "getaddrinfo", lambda *a, **k: looked_up.append(a) or []) + gui = _sending_gui(monkeypatch) + + gui.start_sending() + + assert looked_up == [] + assert gui.thread is not None + gui.deleteLater() + + +def test_a_new_run_clears_the_last_runs_answers(monkeypatch): + gui = _sending_gui(monkeypatch) + gui.handle_response("error", "Cannot resolve hostname") + gui.handle_response("linter.md", "about the previous code") + + gui.start_sending() + + assert gui.responses == {} + assert gui.response_selector.count() == 0 + assert gui.response_view.toPlainText() == "" + gui.deleteLater() diff --git a/test/test_utils/test_tools_menu_docks.py b/test/test_utils/test_tools_menu_docks.py index 2d870235..cfbb3a24 100644 --- a/test/test_utils/test_tools_menu_docks.py +++ b/test/test_utils/test_tools_menu_docks.py @@ -11,6 +11,8 @@ from je_editor.pyside_ui.main_ui.dock.destroy_dock import DestroyDock from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI +from pybreeze.pybreeze_ui.menu.tools import tools_menu from pybreeze.pybreeze_ui.menu.tools.tools_menu import add_dock from pybreeze.pybreeze_ui.tools_gui.curl_import_gui import CurlImportGUI from pybreeze.pybreeze_ui.tools_gui.diff_gui import DiffGUI @@ -37,6 +39,7 @@ def app(): @pytest.mark.parametrize( "widget_type,widget_class", [ + ("CoTCodeReview", CoTCodeReviewGUI), ("CurlImport", CurlImportGUI), ("HarImport", HarImportGUI), ("JwtDecoder", JwtDecoderGUI), @@ -71,3 +74,13 @@ def test_add_dock_unknown_type_adds_nothing(app): assert attached == [] finally: window.deleteLater() + + +def test_every_tool_widget_has_a_tab_a_dock_and_a_dock_title(): + # The CoT code review panel had a widget and no entry in any menu, so there + # was no way to open it. + factories = set(tools_menu._WIDGET_FACTORIES) + + assert {entry[0] for entry in tools_menu._TAB_ACTIONS} == factories + assert {entry[0] for entry in tools_menu._DOCK_ACTIONS} == factories + assert set(tools_menu._DOCK_TITLES) == factories From 9d7f2cc9f4ef4900d96146a46e470a8f4d0ac357 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 12:17:35 +0800 Subject: [PATCH 093/312] Report a failed save before Run with instead of raising from the menu --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 20 +++++++++++++ docs/updates/README.md | 3 +- progress.md | 7 +++++ .../extend_multi_language/extend_english.py | 1 + .../extend_traditional_chinese.py | 1 + .../menu/plugin_menu/build_run_with_menu.py | 28 +++++++++++++++++-- test/test_utils/test_plugin_menu.py | 20 +++++++++++++ 8 files changed, 79 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 5201d97e..81c8649a 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -195,7 +195,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.4 插件選單 - **`build_plugin_menu.py`** — 讀 `je_editor.plugins.get_all_plugin_metadata()`,每個插件一個子選單(About + 每個副檔名一個 Run 動作,動作直接呼叫 `run_current_file_with()`);另有「Plugin Browser」分頁入口 -- **`build_run_with_menu.py`** — 讀 `get_all_plugin_run_configs()`,在 Run 選單下加「Run with…」。`run_current_file_with()` 先經 `save_current_file_for_run()` 存檔(已有檔名的分頁照 JEditor 自己存檔的方式寫:`write_file_with_encoding()` 用分頁的編碼與行尾,寫之前 `mark_ignore_next_file_change()`、寫完 `mark_saved()`;沒檔名的走 JEditor 的另存新檔),再驗副檔名、交給 `FileRunnerProcess`。Plugins 選單的 Run 動作也走這一條 +- **`build_run_with_menu.py`** — 讀 `get_all_plugin_run_configs()`,在 Run 選單下加「Run with…」。`run_current_file_with()` 先經 `save_current_file_for_run()` 存檔(已有檔名的分頁照 JEditor 自己存檔的方式寫:`write_file_with_encoding()` 用分頁的編碼與行尾,寫成功後才 `mark_ignore_next_file_change()` 與 `mark_saved()`;存檔失敗跳警告、不執行;沒檔名的走 JEditor 的另存新檔),再驗副檔名、交給 `FileRunnerProcess`。Plugins 選單的 Run 動作也走這一條 ### 5.5 安裝選單 @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1435 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1436 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 974274a1..93450432 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1160,3 +1160,23 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §5.3, §8, §18 - `progress.md` (#41 removed) - **Open items**: none. + +## U-20260923-68 · 2026-09-23 · Run with: a save that fails is reported and runs nothing · #fix #plugins + +- **What**: a review of `menu/` and `editor_main/` found that Run with... (and the Plugins menu's Run entries, which share `save_current_file_for_run`) did not handle a failed save. Reproduced headless with a Big5 tab holding a character Big5 cannot encode; a read-only or locked file fails the same way. + - `write_file_with_encoding` raised `JEditorSaveFileException` out of the menu slot. Nothing ran, and nothing said why. + - `mark_ignore_next_file_change()` had been called before the write, so the tab's flag stayed on and swallowed the next real change made to the file outside the editor. +- **Fix**: + - The save is in `_save()`. `save_current_file_for_run` catches `JEditorSaveFileException`, logs the cause, warns with the file name and the reason (new language key `run_with_save_failed`), and returns `None`, so nothing runs. + - The tab is told to expect the write only after the write has happened. The watcher's signal is queued, so it still arrives after the flag is set. +- **Tests**: `test_plugin_menu.py::TestASaveThatFailsBeforeARun`: a Big5 tab holding "€" is reported, returns no path, and leaves the tab's flag alone. The existing save tests pass unchanged. +- **Result / numbers**: unit tests 1422 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_plugin_menu.py` + - `architecture_explore.md` §5.4, §18 + - `progress.md` (#42–#48, the review's other findings) +- **Open items**: + - #42–#48 in `progress.md`. + - In JEditor, `write_file_with_encoding` opens the file with `"w"` before encoding, so an encoding failure leaves it empty on disk (JEDITOR repo). diff --git a/docs/updates/README.md b/docs/updates/README.md index 098dd157..61e2c508 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-68 | 2026-09-23 | Run with: a save that fails is reported and runs nothing | #fix #plugins | [2026-09](2026-09.md) | | U-20260923-67 | 2026-09-23 | CoT code review panel: in the menus, no DNS on the UI thread, fresh runs | #done #ai | [2026-09](2026-09.md) | | U-20260923-66 | 2026-09-23 | AI panels: no URLs in logs, damaged urls.txt, shared vote totals, prompt fields | #fix #ai #security | [2026-09](2026-09.md) | | U-20260923-65 | 2026-09-23 | Prompt editor: no saved 'does not exist' note, no lost edits on switch | #fix #ai | [2026-09](2026-09.md) | @@ -148,4 +149,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 85 | +| [2026-09.md](2026-09.md) | 2026-09 | 86 | diff --git a/progress.md b/progress.md index 7d94b9ad..1b5c1b17 100644 --- a/progress.md +++ b/progress.md @@ -8,3 +8,10 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? +- **#42** AutoControl's Stop Record (`menu/automation_menu/auto_control_menu/build_autocontrol_menu.py:63-66`) calls `je_auto_control.stop_record()` only when the current tab is an `EditorWidget`, so from any other tab the global hooks keep recording; and it inserts `str(list)` (a Python repr, not the JSON the AutoControl runner reads) or the text `None` when nothing was recording. +- **#43** TestPioneer's Create template (`menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py:35`) writes under `Path.cwd()`, not the IDE's working directory, overwrites an existing `.TestPioneer/.TestPioneer.yml` without asking, and lets a write failure (`ProjectException`) escape the slot. +- **#44** File tree Delete (`editor_main/file_tree_context_menu.py:451-463`) closes the file's tabs, bypassing JEditor's unsaved-changes prompt, before the delete runs; when `unlink`/`rmtree` then fails (a locked or read-only file), the file stays but its tab and unsaved edits are gone. +- **#45** prthinker's Review current file (`menu/automation_menu/prthinker_menu/build_prthinker_menu.py:69-72`) reviews the file on disk even when its tab has unsaved edits, without saving first or saying so. +- **#46** `PyBreezeMainWindow.closeEvent` (`editor_main/main_ui.py:103-122`) closes run windows, tool tabs and docks unguarded before `super().closeEvent`; one widget whose close raises stops JEditor's own close (open files, settings, auto-save threads). +- **#47** [DECIDE] `PyBreezeMainWindow.closeEvent` closes every `DestroyDock` (`editor_main/main_ui.py:121`), which includes JEditor's docked file editors; their `closeEvent` writes the buffer back, so exiting saves unsaved dock edits without asking and rewrites an LF file with CRLF. Should docked editors be skipped (JEditor alone never saved them on exit), or asked about? +- **#48** Install prthinker (`menu/install_menu/automation_menu/build_automation_install_menu.py:105-120`) calls the static `getExistingDirectory` through an instance, so the dialog has no parent, and saves any existing folder as prthinker's source before pip runs, with no check that it is one. diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index c6022cb6..46bf8f3b 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -348,6 +348,7 @@ "plugin_menu_run_with": "Run with {name}", # Run with Menu "run_with_menu_label": "Run with...", + "run_with_save_failed": "{file} could not be saved, so it was not run: {error}", "run_with_suffix_mismatch": "Current file ({suffix}) does not match expected suffixes: {expected}", # File Tree Context Menu "file_tree_ctx_new_file": "New File", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index b4da76d7..d45a6376 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -699,6 +699,7 @@ "plugin_menu_run_with": "以 {name} 執行", # Run with Menu "run_with_menu_label": "以...執行", + "run_with_save_failed": "{file} 無法存檔,所以沒有執行:{error}", "run_with_suffix_mismatch": "目前的檔案 ({suffix}) 與預期的副檔名不符: {expected}", # Plugin Browser "plugin_browser_tab_name": "插件瀏覽器", diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index 4a311587..5a5acc18 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -12,13 +12,16 @@ from PySide6.QtGui import QAction from PySide6.QtWidgets import QMessageBox -from je_editor import EditorWidget, get_all_plugin_run_configs, language_wrapper +from je_editor import ( + EditorWidget, JEditorSaveFileException, get_all_plugin_run_configs, language_wrapper +) from je_editor.pyside_ui.dialog.file_dialog.save_file_dialog import choose_file_get_save_file_path from je_editor.utils.encodings.text_codec import DEFAULT_ENCODING, LINE_ENDING_LF from je_editor.utils.file.save.save_file import write_file_with_encoding from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess from pybreeze.extend.process_executor.process_executor_utils import open_run_window +from pybreeze.utils.logging.logger import pybreeze_logger if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -32,20 +35,41 @@ def save_current_file_for_run(main_window: PyBreezeMainWindow) -> str | None: one), with the tab's file watcher told to expect the write (otherwise it asks whether to reload the file PyBreeze just wrote), and with the tab's unsaved marker cleared. A tab without a file goes through JEditor's Save As dialog. + + A save that fails -- a character the tab's encoding cannot hold, a read-only + or locked file -- is reported and runs nothing: the run would otherwise use + what is on disk, not what is in the tab. """ widget = main_window.tab_widget.currentWidget() if not isinstance(widget, EditorWidget): return None + try: + return _save(main_window, widget) + except JEditorSaveFileException as error: + pybreeze_logger.error("Save before run failed: %r", error.__cause__ or error) + QMessageBox.warning( + main_window, language_wrapper.language_word_dict.get("run_with_menu_label"), + language_wrapper.language_word_dict.get("run_with_save_failed").format( + file=Path(str(widget.current_file or "")).name, + error=error.__cause__ or error)) + return None + + +def _save(main_window: PyBreezeMainWindow, widget: EditorWidget) -> str | None: + """Write *widget* to its file, or through Save As; the path, or None if cancelled.""" if not widget.current_file: if not choose_file_get_save_file_path(main_window): return None # The save dialog can be accepted without a path being set. return widget.current_file or None - widget.mark_ignore_next_file_change() write_file_with_encoding( str(widget.current_file), widget.code_edit.toPlainText(), getattr(widget, "file_encoding", DEFAULT_ENCODING), getattr(widget, "line_ending", LINE_ENDING_LF)) + # Only after a write that happened: set before one that failed, the flag + # stayed on and swallowed the next real change made outside the editor. + # The watcher's signal is queued, so it still arrives after this. + widget.mark_ignore_next_file_change() widget.mark_saved() return widget.current_file diff --git a/test/test_utils/test_plugin_menu.py b/test/test_utils/test_plugin_menu.py index 758d7d17..9535c2e4 100644 --- a/test/test_utils/test_plugin_menu.py +++ b/test/test_utils/test_plugin_menu.py @@ -322,6 +322,26 @@ def test_a_cancelled_save_as_runs_nothing(self, window, editor_tab, monkeypatch) assert save_current_file_for_run(window) is None +class TestASaveThatFailsBeforeARun: + """A read-only or locked file, or a character the tab's encoding cannot hold.""" + + def test_it_is_reported_and_runs_nothing(self, window, editor_tab, tmp_path, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + warned: list = [] + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: warned.append(a))) + target = tmp_path / "legacy.txt" + target.write_bytes("舊".encode("big5")) + tab = editor_tab(current_file=str(target), text="€ is not in Big5\n", file_encoding="big5") + + # It used to raise out of the menu slot, so nothing said why nothing ran. + assert save_current_file_for_run(window) is None + assert warned and "legacy.txt" in warned[0][2] + # ... and the tab still expects no write of its own, so the next real + # change made outside the editor is not swallowed. + assert tab.events == [] + + class TestRunConfigsJEditorDoesNotCheck: def test_a_config_without_a_name_still_runs(self, window, tmp_path, monkeypatch): script = tmp_path / "main.go" From 3677ba72535858654280266b2b3b144a4dc28eb3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 12:34:04 +0800 Subject: [PATCH 094/312] Stop AutoControl recording from any tab and insert it as runnable JSON --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 24 +++++ docs/updates/README.md | 3 +- progress.md | 1 - .../extend_multi_language/extend_english.py | 5 ++ .../extend_traditional_chinese.py | 11 ++- .../build_autocontrol_menu.py | 37 ++++++-- test/test_utils/test_autocontrol_record.py | 89 +++++++++++++++++++ 8 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 test/test_utils/test_autocontrol_record.py diff --git a/architecture_explore.md b/architecture_explore.md index 81c8649a..7c8a228a 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -164,7 +164,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.1 `automation_menu_factory.py` — 選單工廠 -`build_automation_menu(ui, spec)` 依一份 `AutomationMenu` 描述組出標準自動化子選單:`Run` 子選單(`RunAction` 列表)/ `Help`(`HelpLink` 列表,文件+GitHub,開內嵌瀏覽器分頁)/ `Project`(建立範本目錄)/ GUI 分頁,每一段各由一個小函式建(`_add_run_menu` 等),沒有項目的段落不建。三個描述都是 frozen dataclass。六個自動化模組全部靠它,`build_*_menu.py` 只剩一份 `AutomationMenu(...)`。每個 QAction 都以它所在的選單為 parent,由 Qt 持有;AutoControl 額外的 `Record` 子選單也一樣。 +`build_automation_menu(ui, spec)` 依一份 `AutomationMenu` 描述組出標準自動化子選單:`Run` 子選單(`RunAction` 列表)/ `Help`(`HelpLink` 列表,文件+GitHub,開內嵌瀏覽器分頁)/ `Project`(建立範本目錄)/ GUI 分頁,每一段各由一個小函式建(`_add_run_menu` 等),沒有項目的段落不建。三個描述都是 frozen dataclass。六個自動化模組全部靠它,`build_*_menu.py` 只剩一份 `AutomationMenu(...)`。每個 QAction 都以它所在的選單為 parent,由 Qt 持有;AutoControl 額外的 `Record` 子選單也一樣;它的停止錄製不論前面是哪個分頁都會停,把動作以 AutoControl 執行器讀的 JSON 插在編輯分頁的游標處(沒有編輯分頁就放剪貼簿),沒錄到東西就告知。 `safe_create_project(import_name)` 回傳延遲 import 的 closure,模組沒裝時只記 log 不炸選單。 @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 89 個 `test_*.py`、1436 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 90 個 `test_*.py`、1440 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 93450432..759e6caf 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1180,3 +1180,27 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Open items**: - #42–#48 in `progress.md`. - In JEditor, `write_file_with_encoding` opens the file with `"w"` before encoding, so an encoding failure leaves it empty on disk (JEDITOR repo). + +## U-20260923-69 · 2026-09-23 · AutoControl Record Stop: stops from any tab, inserts runnable JSON · #done #autocontrol + +- **What**: `progress.md` #42, AutoControl's Record Stop (`build_autocontrol_menu.stop_record`): + - It called `je_auto_control.stop_record()` only when the tab in front was an editor. From a tool tab, a browser tab or JupyterLab, nothing happened and the global keyboard and mouse hooks kept recording. + - It inserted `str(actions)`, a Python repr with single quotes. The AutoControl runner reads a script with `json.loads`, so the inserted recording could not be run. + - With nothing recorded, or recording never started, `stop_record()` returns `None`, and the text `None` was inserted. +- **Fix**: + - Recording always stops first. + - The actions are inserted as `json.dumps` at the cursor of the editor tab in front. With no editor tab in front they go on the clipboard, and the user is told. + - With nothing recorded the user is told and nothing is inserted. + - New language keys `autocontrol_record_nothing` and `autocontrol_record_copied`. The Traditional Chinese labels of the Record menu read 錄製 / 開始錄製 / 停止錄製 instead of the half-English "紀錄 Start" / "紀錄 Stop". +- **Tests**: new `test_autocontrol_record.py`: + - the recording goes in as JSON that `json.loads` reads back; + - a tool tab in front still stops recording, and the recording goes to the clipboard; + - `None` and an empty recording insert nothing and say so. +- **Result / numbers**: unit tests 1426 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_autocontrol_record.py` (new) + - `architecture_explore.md` §5.1, §18 + - `progress.md` (#42 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 61e2c508..aba89dbe 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-69 | 2026-09-23 | AutoControl Record Stop: stops from any tab, inserts runnable JSON | #done #autocontrol | [2026-09](2026-09.md) | | U-20260923-68 | 2026-09-23 | Run with: a save that fails is reported and runs nothing | #fix #plugins | [2026-09](2026-09.md) | | U-20260923-67 | 2026-09-23 | CoT code review panel: in the menus, no DNS on the UI thread, fresh runs | #done #ai | [2026-09](2026-09.md) | | U-20260923-66 | 2026-09-23 | AI panels: no URLs in logs, damaged urls.txt, shared vote totals, prompt fields | #fix #ai #security | [2026-09](2026-09.md) | @@ -149,4 +150,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 86 | +| [2026-09.md](2026-09.md) | 2026-09 | 87 | diff --git a/progress.md b/progress.md index 1b5c1b17..1cf9c7b1 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#42** AutoControl's Stop Record (`menu/automation_menu/auto_control_menu/build_autocontrol_menu.py:63-66`) calls `je_auto_control.stop_record()` only when the current tab is an `EditorWidget`, so from any other tab the global hooks keep recording; and it inserts `str(list)` (a Python repr, not the JSON the AutoControl runner reads) or the text `None` when nothing was recording. - **#43** TestPioneer's Create template (`menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py:35`) writes under `Path.cwd()`, not the IDE's working directory, overwrites an existing `.TestPioneer/.TestPioneer.yml` without asking, and lets a write failure (`ProjectException`) escape the slot. - **#44** File tree Delete (`editor_main/file_tree_context_menu.py:451-463`) closes the file's tabs, bypassing JEditor's unsaved-changes prompt, before the delete runs; when `unlink`/`rmtree` then fails (a locked or read-only file), the file stays but its tab and unsaved edits are gone. - **#45** prthinker's Review current file (`menu/automation_menu/prthinker_menu/build_prthinker_menu.py:69-72`) reviews the file on disk even when its tab has unsaved edits, without saving first or saying so. diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 46bf8f3b..d0268e03 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -43,6 +43,8 @@ "autocontrol_record_menu_label": "Record", "autocontrol_record_start_label": "Record Start", "autocontrol_record_stop_label": "Record Stop", + "autocontrol_record_nothing": "Nothing was recorded. Start recording first, then stop it.", + "autocontrol_record_copied": "The recording was copied to the clipboard: no editor tab is in front to insert it into.", # File Automation Menu "file_automation_menu_label": "FileAutomation", "file_automation_run_script_label": "Run FileAutomation Script", @@ -103,6 +105,9 @@ # Test Pioneer Menu "test_pioneer_label": "TestPioneer", "test_pioneer_create_template_label": "Create TestPioneer Yaml template", + "test_pioneer_template_exists": "{path} already exists. Replace it with a fresh template and lose its content?", + "test_pioneer_template_failed": "The template could not be created at {path}: {error}", + "test_pioneer_template_created": "Template created: {path}", "test_pioneer_run_yaml": "Execute Test Pioneer Yaml", "test_pioneer_not_choose_yaml": "Please choose a Yaml file", # prthinker code review diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index d45a6376..dcbee9d4 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -40,9 +40,11 @@ "autocontrol_github_label": "開啟 Autocontrol GitHub", "autocontrol_github_tab_label": "Autocontrol GitHub", "autocontrol_create_project_label": "建立 Autocontrol 專案", - "autocontrol_record_menu_label": "紀錄", - "autocontrol_record_start_label": "紀錄 Start", - "autocontrol_record_stop_label": "紀錄 Stop", + "autocontrol_record_menu_label": "錄製", + "autocontrol_record_start_label": "開始錄製", + "autocontrol_record_stop_label": "停止錄製", + "autocontrol_record_nothing": "沒有錄到任何動作。請先開始錄製,再停止。", + "autocontrol_record_copied": "錄製結果已複製到剪貼簿:目前最前面的不是編輯分頁,沒有地方可以插入。", # File Automation Menu "file_automation_menu_label": "FileAutomation", "file_automation_run_script_label": "運行 FileAutomation 腳本", @@ -103,6 +105,9 @@ # Test Pioneer Menu "test_pioneer_label": "TestPioneer", "test_pioneer_create_template_label": "建立 TestPioneer Yaml 模板", + "test_pioneer_template_exists": "{path} 已經存在。要用新的範本取代,並捨棄它目前的內容嗎?", + "test_pioneer_template_failed": "無法在 {path} 建立範本:{error}", + "test_pioneer_template_created": "已建立範本:{path}", "test_pioneer_run_yaml": "執行 Test Pioneer Yaml", "test_pioneer_not_choose_yaml": "請選擇 Yaml 檔案", # prthinker code review 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 6e6615ee..96d6f7a5 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 @@ -1,9 +1,11 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING import je_auto_control -from PySide6.QtGui import QAction, QTextCharFormat +from PySide6.QtGui import QAction, QGuiApplication, QTextCharFormat +from PySide6.QtWidgets import QMessageBox 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 @@ -58,11 +60,30 @@ def set_autocontrol_menu(ui_we_want_to_set: PyBreezeMainWindow): record_menu.addAction(stop_record_action) -def stop_record(editor_instance: PyBreezeMainWindow): +def stop_record(editor_instance: PyBreezeMainWindow) -> None: + """Stop recording and put the recorded actions where they can be run. + + Recording stops whatever tab is in front: it used to stop only from an + editor tab, and from any other the keyboard and mouse hooks kept recording. + The actions go in as the JSON the AutoControl runner reads (they went in as + a Python repr, which it cannot), at the cursor of the editor tab in front, + or on the clipboard when there is none. When nothing was recorded -- or + recording was never started -- the user is told, instead of getting "None". + """ + actions = je_auto_control.stop_record() + lang = language_wrapper.language_word_dict + title = lang.get("autocontrol_record_menu_label") + if not actions: + QMessageBox.information(editor_instance, title, lang.get("autocontrol_record_nothing")) + return + script = json.dumps(actions) widget = editor_instance.tab_widget.currentWidget() - if isinstance(widget, EditorWidget): - text_cursor = widget.code_edit.textCursor() - text_format = QTextCharFormat() - text_format.setForeground(actually_color_dict.get("normal_output_color")) - text_cursor.insertText(str(je_auto_control.stop_record()), text_format) - text_cursor.insertBlock() + if not isinstance(widget, EditorWidget): + QGuiApplication.clipboard().setText(script) + QMessageBox.information(editor_instance, title, lang.get("autocontrol_record_copied")) + return + text_cursor = widget.code_edit.textCursor() + text_format = QTextCharFormat() + text_format.setForeground(actually_color_dict.get("normal_output_color")) + text_cursor.insertText(script, text_format) + text_cursor.insertBlock() diff --git a/test/test_utils/test_autocontrol_record.py b/test/test_utils/test_autocontrol_record.py new file mode 100644 index 00000000..7b4e5c67 --- /dev/null +++ b/test/test_utils/test_autocontrol_record.py @@ -0,0 +1,89 @@ +"""AutoControl's Record Stop: recording stops from any tab, and what it gives back can be run.""" +from __future__ import annotations + +import json +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMainWindow, QMessageBox, QPlainTextEdit, QTabWidget, QWidget + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.automation_menu.auto_control_menu import build_autocontrol_menu as record_menu + +_RECORDED = [["AC_type_keyboard", {"keycode": 65}], + ["mouse_left", {"mouse_keycode": "mouse_left", "x": 10, "y": 20}]] + + +class EditorTab(QWidget): + """Stands in for a JEditor editor tab.""" + + def __init__(self) -> None: + super().__init__() + self.code_edit = QPlainTextEdit() + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def window(app, monkeypatch): + monkeypatch.setattr(record_menu, "EditorWidget", EditorTab) + made = QMainWindow() + made.tab_widget = QTabWidget() + made.told = [] + monkeypatch.setattr(QMessageBox, "information", staticmethod(lambda *a, **k: made.told.append(a[2]))) + yield made + made.deleteLater() + + +def _recording(monkeypatch, actions) -> list: + stopped: list = [] + + def stop(): + stopped.append(True) + return actions + + monkeypatch.setattr(record_menu.je_auto_control, "stop_record", stop) + return stopped + + +def test_the_recording_goes_in_as_json_the_runner_reads(window, monkeypatch): + _recording(monkeypatch, _RECORDED) + tab = EditorTab() + window.tab_widget.addTab(tab, "script.json") + + record_menu.stop_record(window) + + # It went in as str(list): single quotes, which json.loads refuses. + assert json.loads(tab.code_edit.toPlainText()) == _RECORDED + + +def test_recording_stops_whatever_tab_is_in_front(window, monkeypatch): + stopped = _recording(monkeypatch, _RECORDED) + window.tab_widget.addTab(QWidget(), "a tool tab") + + record_menu.stop_record(window) + + # From a tool tab it used to stop nothing, and the hooks kept recording. + assert stopped == [True] + assert json.loads(QApplication.clipboard().text()) == _RECORDED + assert window.told + + +@pytest.mark.parametrize("nothing", [None, []]) +def test_nothing_recorded_says_so_and_inserts_nothing(window, monkeypatch, nothing): + _recording(monkeypatch, nothing) + tab = EditorTab() + window.tab_widget.addTab(tab, "script.json") + + record_menu.stop_record(window) + + # It used to insert the text "None". + assert tab.code_edit.toPlainText() == "" + assert window.told From 0986658769055d998fdaebb6288064fcb9866847 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 12:34:15 +0800 Subject: [PATCH 095/312] Create the TestPioneer template in the working directory and ask before replacing it --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 26 ++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../build_test_pioneer_menu.py | 38 ++++++++- test/test_utils/test_test_pioneer_template.py | 85 +++++++++++++++++++ 6 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 test/test_utils/test_test_pioneer_template.py diff --git a/architecture_explore.md b/architecture_explore.md index 7c8a228a..006b8b42 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -179,7 +179,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.2 非工廠的兩個選單 -- **`test_pioneer_menu/`** — 建範本目錄 + `QFileDialog` 選 `.yml`(會驗副檔名,選錯跳 `QMessageBox`) +- **`test_pioneer_menu/`** — 建範本目錄(寫在 IDE 的工作目錄,已有範本先問是否取代,寫入失敗跳警告)+ `QFileDialog` 選 `.yml`(會驗副檔名,選錯跳 `QMessageBox`) - **`prthinker_menu/`** — 審查目前檔案 / 審查 PR(`QInputDialog` 問編號,範圍 1–1,000,000)/ 設定對話框 / Help ### 5.3 `tools/tools_menu.py` — 表格驅動的工具註冊 @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 90 個 `test_*.py`、1440 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 91 個 `test_*.py`、1444 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 759e6caf..1d263ccf 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1204,3 +1204,29 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §5.1, §18 - `progress.md` (#42 removed) - **Open items**: none. + +## U-20260923-70 · 2026-09-23 · TestPioneer Create template: in the working directory, asks before replacing · #done #testpioneer + +- **What**: `progress.md` #43, TestPioneer's Create template called `test_pioneer.create_template_dir()` with no arguments: + - It wrote under the process's current directory, not the IDE's working directory. The two are usually the same, since JEditor changes into a folder it opens, but not always. + - TestPioneer rewrites `.TestPioneer/.TestPioneer.yml` whenever the folder exists, so a second click wiped an edited template without asking. + - A write that failed raised `ProjectException` (or `OSError`, from creating the folder) out of the menu slot. +- **Fix**: `create_template(main_window)`: + - It writes to the main window's `working_dir`, falling back to the current directory. + - When the template exists, it asks before replacing it, defaulting to No. + - A failure is logged and shown as a warning. TestPioneer does not export `ProjectException`, a bare `Exception` subclass, so the handler catches `Exception` with a `# noqa: BLE001` reason. + - A success names the file created. + - New language keys `test_pioneer_template_exists`, `_failed` and `_created`. +- **Tests**: new `test_test_pioneer_template.py`: + - it goes in the working directory, not the current one; + - an edited template survives a No; + - a Yes replaces it; + - a failed write is reported, not raised. +- **Result / numbers**: unit tests 1430 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py` + - `test/test_utils/test_test_pioneer_template.py` (new) + - The three language keys went into both language files in the commit of U-20260923-69. + - `architecture_explore.md` §5.2, §18 + - `progress.md` (#43 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index aba89dbe..20a4ea55 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-70 | 2026-09-23 | TestPioneer Create template: in the working directory, asks before replacing | #done #testpioneer | [2026-09](2026-09.md) | | U-20260923-69 | 2026-09-23 | AutoControl Record Stop: stops from any tab, inserts runnable JSON | #done #autocontrol | [2026-09](2026-09.md) | | U-20260923-68 | 2026-09-23 | Run with: a save that fails is reported and runs nothing | #fix #plugins | [2026-09](2026-09.md) | | U-20260923-67 | 2026-09-23 | CoT code review panel: in the menus, no DNS on the UI thread, fresh runs | #done #ai | [2026-09](2026-09.md) | @@ -150,4 +151,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 87 | +| [2026-09.md](2026-09.md) | 2026-09 | 88 | diff --git a/progress.md b/progress.md index 1cf9c7b1..4b901aca 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#43** TestPioneer's Create template (`menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py:35`) writes under `Path.cwd()`, not the IDE's working directory, overwrites an existing `.TestPioneer/.TestPioneer.yml` without asking, and lets a write failure (`ProjectException`) escape the slot. - **#44** File tree Delete (`editor_main/file_tree_context_menu.py:451-463`) closes the file's tabs, bypassing JEditor's unsaved-changes prompt, before the delete runs; when `unlink`/`rmtree` then fails (a locked or read-only file), the file stays but its tab and unsaved edits are gone. - **#45** prthinker's Review current file (`menu/automation_menu/prthinker_menu/build_prthinker_menu.py:69-72`) reviews the file on disk even when its tab has unsaved edits, without saving first or saying so. - **#46** `PyBreezeMainWindow.closeEvent` (`editor_main/main_ui.py:103-122`) closes run windows, tool tabs and docks unguarded before `super().closeEvent`; one widget whose close raises stops JEditor's own close (open files, settings, auto-save threads). diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py index e6430234..a13cb4c3 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py @@ -10,6 +10,7 @@ from pybreeze.extend.process_executor.test_pioneer.test_pioneer_process_manager import \ init_and_start_test_pioneer_process +from pybreeze.utils.logging.logger import pybreeze_logger if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -18,6 +19,8 @@ # A TestPioneer script is YAML, under either of YAML's extensions _YAML_SUFFIXES = (".yml", ".yaml") _YAML_FILTER = "YAML (*.yml *.yaml)" +# Where TestPioneer puts its template, under the project directory +_TEMPLATE_DIR = ".TestPioneer" def set_test_pioneer_menu(ui_we_want_to_set: PyBreezeMainWindow): @@ -32,7 +35,7 @@ def set_test_pioneer_menu(ui_we_want_to_set: PyBreezeMainWindow): ui_we_want_to_set.create_template_action = QAction( language_wrapper.language_word_dict.get("test_pioneer_create_template_label")) ui_we_want_to_set.create_template_action.triggered.connect( - lambda: create_template_dir() + lambda: create_template(ui_we_want_to_set) ) ui_we_want_to_set.test_pioneer_menu.addAction( ui_we_want_to_set.create_template_action @@ -48,6 +51,39 @@ def set_test_pioneer_menu(ui_we_want_to_set: PyBreezeMainWindow): ) +def create_template(ui_we_want_to_set: PyBreezeMainWindow) -> None: + """Create TestPioneer's template in the project directory, asking before replacing one. + + TestPioneer rewrites ``.TestPioneer/.TestPioneer.yml`` whenever the folder + exists, so a second click used to wipe an edited template without a word. + The project directory is the IDE's working directory, falling back to the + process's, and a failure to write is reported rather than raised from the + menu slot. + """ + word = language_wrapper.language_word_dict + title = word.get("test_pioneer_create_template_label") + project = Path(getattr(ui_we_want_to_set, "working_dir", None) or Path.cwd()) + template = project / _TEMPLATE_DIR / f"{_TEMPLATE_DIR}.yml" + if template.exists(): + reply = QMessageBox.question( + ui_we_want_to_set, title, + word.get("test_pioneer_template_exists").format(path=template), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) + if reply != QMessageBox.StandardButton.Yes: + return + try: + create_template_dir(project_path=str(project), parent_name=_TEMPLATE_DIR) + except Exception as error: # noqa: BLE001 — TestPioneer raises its unexported ProjectException, a bare Exception subclass, for a write it could not make; it is logged and reported + pybreeze_logger.error("TestPioneer template not created in %s: %r", project, error) + QMessageBox.warning( + ui_we_want_to_set, title, + word.get("test_pioneer_template_failed").format(path=template, error=error)) + return + QMessageBox.information( + ui_we_want_to_set, title, word.get("test_pioneer_template_created").format(path=template)) + + def check_file(ui_we_want_to_set: PyBreezeMainWindow): """Ask for a TestPioneer YAML file and run it, or say why it cannot be run.""" # getOpenFileName is static: the filter has to be passed to it, not set on diff --git a/test/test_utils/test_test_pioneer_template.py b/test/test_utils/test_test_pioneer_template.py new file mode 100644 index 00000000..f4b0a1df --- /dev/null +++ b/test/test_utils/test_test_pioneer_template.py @@ -0,0 +1,85 @@ +"""TestPioneer's Create template: where it writes, and what it does to a template already there.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMainWindow, QMessageBox + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.automation_menu.test_pioneer_menu import build_test_pioneer_menu as menu + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def window(app, tmp_path, monkeypatch): + made = QMainWindow() + made.working_dir = str(tmp_path / "project") + (tmp_path / "project").mkdir() + made.told = [] + for kind in ("information", "warning"): + monkeypatch.setattr( + QMessageBox, kind, + staticmethod(lambda *a, kind=kind, **k: made.told.append((kind, a[2])))) + yield made + made.deleteLater() + + +def _template(window): + from pathlib import Path + + return Path(window.working_dir) / ".TestPioneer" / ".TestPioneer.yml" + + +def test_it_goes_in_the_ide_working_directory(window, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + menu.create_template(window) + + assert _template(window).is_file() + assert not (tmp_path / ".TestPioneer").exists() + assert window.told[-1][0] == "information" + + +def test_an_edited_template_is_kept_unless_the_user_says_so(window, monkeypatch): + template = _template(window) + template.parent.mkdir() + template.write_text("my edited steps", encoding="utf-8") + monkeypatch.setattr( + QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)) + + menu.create_template(window) + + # A second click used to overwrite it without asking. + assert template.read_text(encoding="utf-8") == "my edited steps" + + +def test_saying_yes_replaces_it(window, monkeypatch): + template = _template(window) + template.parent.mkdir() + template.write_text("my edited steps", encoding="utf-8") + monkeypatch.setattr( + QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)) + + menu.create_template(window) + + assert template.read_text(encoding="utf-8") != "my edited steps" + + +def test_a_failed_write_is_reported_not_raised(window, monkeypatch): + def refuse(*_args, **_kwargs): + raise OSError(13, "Access is denied") + + monkeypatch.setattr(menu, "create_template_dir", refuse) + + menu.create_template(window) + + assert window.told and window.told[-1][0] == "warning" From b91bb2074138e0623a3f6567e0fd8a5f44cbb43f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 12:34:16 +0800 Subject: [PATCH 096/312] Save the current file before prthinker reviews it --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 16 +++++ docs/updates/README.md | 3 +- progress.md | 1 - .../prthinker_menu/build_prthinker_menu.py | 15 ++++- test/test_utils/test_prthinker_menu_review.py | 59 +++++++++++++++++++ 6 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 test/test_utils/test_prthinker_menu_review.py diff --git a/architecture_explore.md b/architecture_explore.md index 006b8b42..3cc593a1 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -180,7 +180,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.2 非工廠的兩個選單 - **`test_pioneer_menu/`** — 建範本目錄(寫在 IDE 的工作目錄,已有範本先問是否取代,寫入失敗跳警告)+ `QFileDialog` 選 `.yml`(會驗副檔名,選錯跳 `QMessageBox`) -- **`prthinker_menu/`** — 審查目前檔案 / 審查 PR(`QInputDialog` 問編號,範圍 1–1,000,000)/ 設定對話框 / Help +- **`prthinker_menu/`** — 審查目前檔案(先照 Run with... 的方式存檔:`save_current_file_for_run()`)/ 審查 PR(`QInputDialog` 問編號,範圍 1–1,000,000)/ 設定對話框 / Help ### 5.3 `tools/tools_menu.py` — 表格驅動的工具註冊 @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 91 個 `test_*.py`、1444 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 92 個 `test_*.py`、1447 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 1d263ccf..abbc05c9 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1230,3 +1230,19 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §5.2, §18 - `progress.md` (#43 removed) - **Open items**: none. + +## U-20260923-71 · 2026-09-23 · prthinker Review current file saves the file first · #done #prthinker + +- **What**: `progress.md` #45. prthinker's Review current file reads the file from disk. A tab with unsaved edits passed the "is it a file" check, so the review ran on the last saved version, with nothing said and no save offered. +- **Fix**: `_review_current_file` saves first with `save_current_file_for_run()`, the same helper Run with... uses: the tab's own encoding and line ending, Save As for a tab with no file, and a warning when the save fails. A cancelled Save As reviews nothing. A tab that is not an editor still gets the "needs a saved file" message. +- **Tests**: new `test_prthinker_menu_review.py`: + - the file is saved before it is reviewed; + - a save that did not happen reviews nothing and adds no second message; + - a tool tab gets the message. +- **Result / numbers**: unit tests 1433 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/automation_menu/prthinker_menu/build_prthinker_menu.py` + - `test/test_utils/test_prthinker_menu_review.py` (new) + - `architecture_explore.md` §5.2, §18 + - `progress.md` (#45 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 20a4ea55..5f66266b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-71 | 2026-09-23 | prthinker Review current file saves the file first | #done #prthinker | [2026-09](2026-09.md) | | U-20260923-70 | 2026-09-23 | TestPioneer Create template: in the working directory, asks before replacing | #done #testpioneer | [2026-09](2026-09.md) | | U-20260923-69 | 2026-09-23 | AutoControl Record Stop: stops from any tab, inserts runnable JSON | #done #autocontrol | [2026-09](2026-09.md) | | U-20260923-68 | 2026-09-23 | Run with: a save that fails is reported and runs nothing | #fix #plugins | [2026-09](2026-09.md) | @@ -151,4 +152,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 88 | +| [2026-09.md](2026-09.md) | 2026-09 | 89 | diff --git a/progress.md b/progress.md index 4b901aca..3b305c2a 100644 --- a/progress.md +++ b/progress.md @@ -9,7 +9,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#44** File tree Delete (`editor_main/file_tree_context_menu.py:451-463`) closes the file's tabs, bypassing JEditor's unsaved-changes prompt, before the delete runs; when `unlink`/`rmtree` then fails (a locked or read-only file), the file stays but its tab and unsaved edits are gone. -- **#45** prthinker's Review current file (`menu/automation_menu/prthinker_menu/build_prthinker_menu.py:69-72`) reviews the file on disk even when its tab has unsaved edits, without saving first or saying so. - **#46** `PyBreezeMainWindow.closeEvent` (`editor_main/main_ui.py:103-122`) closes run windows, tool tabs and docks unguarded before `super().closeEvent`; one widget whose close raises stops JEditor's own close (open files, settings, auto-save threads). - **#47** [DECIDE] `PyBreezeMainWindow.closeEvent` closes every `DestroyDock` (`editor_main/main_ui.py:121`), which includes JEditor's docked file editors; their `closeEvent` writes the buffer back, so exiting saves unsaved dock edits without asking and rewrites an LF file with CRLF. Should docked editors be skipped (JEditor alone never saved them on exit), or asked about? - **#48** Install prthinker (`menu/install_menu/automation_menu/build_automation_install_menu.py:105-120`) calls the static `getExistingDirectory` through an instance, so the dialog has no parent, and saves any existing folder as prthinker's source before pip runs, with no check that it is one. diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/prthinker_menu/build_prthinker_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/prthinker_menu/build_prthinker_menu.py index fb6b0ba7..0eeaffcc 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/prthinker_menu/build_prthinker_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/prthinker_menu/build_prthinker_menu.py @@ -13,13 +13,14 @@ from PySide6.QtGui import QAction from PySide6.QtWidgets import QInputDialog, QMessageBox -from je_editor import language_wrapper +from je_editor import EditorWidget, language_wrapper from pybreeze.extend.process_executor.prthinker.prthinker_process import ( review_current_file, review_pull_request ) from pybreeze.pybreeze_ui.dialog.prthinker_setting_dialog import PRThinkerSettingDialog from pybreeze.pybreeze_ui.menu.menu_utils import open_web_browser +from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import save_current_file_for_run if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -78,7 +79,17 @@ def set_prthinker_menu(ui_we_want_to_set: PyBreezeMainWindow) -> None: def _review_current_file(ui_we_want_to_set: PyBreezeMainWindow) -> None: - """審查目前的檔案,沒有存檔就說一聲 / Review the current file, saying so when there is none.""" + """先存檔再審查目前的檔案 / Save the current file, then review it. + + 審查讀的是磁碟上的內容:先存檔,未存的編輯才會被審到,跟 Run with... 一樣。 + The review reads the file on disk, so it is saved first, the way Run with... + does: an unsaved edit used to be left out of the review without a word. A + cancelled Save As reviews nothing; a failed save has already said why. + """ + if save_current_file_for_run(ui_we_want_to_set) is None: + if not isinstance(ui_we_want_to_set.tab_widget.currentWidget(), EditorWidget): + _tell(ui_we_want_to_set, "prthinker_need_saved_file_message") + return if not review_current_file(ui_we_want_to_set): _tell(ui_we_want_to_set, "prthinker_need_saved_file_message") diff --git a/test/test_utils/test_prthinker_menu_review.py b/test/test_utils/test_prthinker_menu_review.py new file mode 100644 index 00000000..8b8902ea --- /dev/null +++ b/test/test_utils/test_prthinker_menu_review.py @@ -0,0 +1,59 @@ +"""prthinker's Review current file: the file is saved before the review reads it from disk.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget, QWidget + +from pybreeze.pybreeze_ui.menu.automation_menu.prthinker_menu import build_prthinker_menu as menu + + +class EditorTab(QWidget): + """Stands in for a JEditor editor tab.""" + + +@pytest.fixture() +def window(monkeypatch): + QApplication.instance() or QApplication([]) + monkeypatch.setattr(menu, "EditorWidget", EditorTab) + made = QMainWindow() + made.tab_widget = QTabWidget() + made.events = [] + monkeypatch.setattr(menu, "_tell", lambda _window, key: made.events.append(("told", key))) + monkeypatch.setattr( + menu, "review_current_file", lambda _window: made.events.append("reviewed") or True) + yield made + made.deleteLater() + + +def test_the_file_is_saved_before_it_is_reviewed(window, monkeypatch): + window.tab_widget.addTab(EditorTab(), "main.py") + monkeypatch.setattr( + menu, "save_current_file_for_run", lambda _window: window.events.append("saved") or "main.py") + + menu._review_current_file(window) + + # The review reads the file on disk; unsaved edits used to be left out. + assert window.events == ["saved", "reviewed"] + + +def test_a_save_that_did_not_happen_reviews_nothing(window, monkeypatch): + window.tab_widget.addTab(EditorTab(), "untitled") + monkeypatch.setattr(menu, "save_current_file_for_run", lambda _window: None) + + menu._review_current_file(window) + + # Cancelled, or failed and already reported: nothing more to say. + assert window.events == [] + + +def test_a_tab_that_is_not_an_editor_says_what_is_needed(window, monkeypatch): + window.tab_widget.addTab(QWidget(), "a tool tab") + monkeypatch.setattr(menu, "save_current_file_for_run", lambda _window: None) + + menu._review_current_file(window) + + assert window.events == [("told", "prthinker_need_saved_file_message")] From df09377465192e2cf154f7d84f5c19d9f5b23d21 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 12:47:21 +0800 Subject: [PATCH 097/312] Close only the tabs whose files a file tree Delete removed --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 13 ++++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../editor_main/file_tree_context_menu.py | 25 +++++++++++----- .../test_utils/test_file_tree_context_menu.py | 30 +++++++++++++++++++ 6 files changed, 64 insertions(+), 12 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 3cc593a1..803a78e8 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -73,7 +73,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE - `add_menu_to_menubar()` — 建構全部選單(見 §5) - `syntax_extend_package()` — 註冊 `.json` / `.yml` 自動化關鍵字高亮 - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁(`_add_extend_tabs()`:每一個分頁各自建,建不起來的只記 log,不會讓整個 IDE 起不來) - - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁都先關掉(關閉會停掉它的自動存檔),再刪 + - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) 3. `apply_stylesheet()` 套 qt_material 主題(預設 `dark_amber.xml`) 4. `showMaximized()` → `startup_setting()` → `app.exec()` @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 92 個 `test_*.py`、1447 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 92 個 `test_*.py`、1448 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index abbc05c9..e5058f8e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1246,3 +1246,16 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §5.2, §18 - `progress.md` (#45 removed) - **Open items**: none. + +## U-20260923-72 · 2026-09-23 · File tree Delete closes only the tabs whose files are gone · #done #filetree + +- **What**: `progress.md` #44. File tree Delete closed every tab open on the file, or under the folder, with `editor.close()` and `removeTab`, and only then deleted. When the delete failed, the file stayed on disk but its tab and any unsaved edits were gone. Deleting a folder could also stop part-way. Causes: a file locked by another program on Windows, a read-only file, a file in use. +- **Fix**: `_action_delete` stops the tabs' auto-save first with `_stop_auto_save()`, as rename does, so no buffer is written back mid-delete. Then it deletes. Afterwards only a tab whose file no longer exists is closed; a tab whose file survived gets its auto-save back with `_start_auto_save()`. The watcher's change signal for a file that is gone is ignored by JEditor, so no reload question appears. +- **Tests**: `test_file_tree_context_menu.py::TestDeleting::test_a_delete_that_fails_keeps_the_tab_open`: `unlink` is refused, the file stays, the tab stays open and its auto-save restarts. The existing delete tests pass unchanged. +- **Result / numbers**: unit tests 1434 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py` + - `test/test_utils/test_file_tree_context_menu.py` + - `architecture_explore.md` §3, §18 + - `progress.md` (#44 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 5f66266b..db95f5f2 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-72 | 2026-09-23 | File tree Delete closes only the tabs whose files are gone | #done #filetree | [2026-09](2026-09.md) | | U-20260923-71 | 2026-09-23 | prthinker Review current file saves the file first | #done #prthinker | [2026-09](2026-09.md) | | U-20260923-70 | 2026-09-23 | TestPioneer Create template: in the working directory, asks before replacing | #done #testpioneer | [2026-09](2026-09.md) | | U-20260923-69 | 2026-09-23 | AutoControl Record Stop: stops from any tab, inserts runnable JSON | #done #autocontrol | [2026-09](2026-09.md) | @@ -152,4 +153,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 89 | +| [2026-09.md](2026-09.md) | 2026-09 | 90 | diff --git a/progress.md b/progress.md index 3b305c2a..a52de903 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#44** File tree Delete (`editor_main/file_tree_context_menu.py:451-463`) closes the file's tabs, bypassing JEditor's unsaved-changes prompt, before the delete runs; when `unlink`/`rmtree` then fails (a locked or read-only file), the file stays but its tab and unsaved edits are gone. - **#46** `PyBreezeMainWindow.closeEvent` (`editor_main/main_ui.py:103-122`) closes run windows, tool tabs and docks unguarded before `super().closeEvent`; one widget whose close raises stops JEditor's own close (open files, settings, auto-save threads). - **#47** [DECIDE] `PyBreezeMainWindow.closeEvent` closes every `DestroyDock` (`editor_main/main_ui.py:121`), which includes JEditor's docked file editors; their `closeEvent` writes the buffer back, so exiting saves unsaved dock edits without asking and rewrites an LF file with CRLF. Should docked editors be skipped (JEditor alone never saved them on exit), or asked about? - **#48** Install prthinker (`menu/install_menu/automation_menu/build_automation_install_menu.py:105-120`) calls the static `getExistingDirectory` through an instance, so the dialog has no parent, and saves any existing folder as prthinker's source before pip runs, with no check that it is one. diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index 420015f3..98645eea 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -288,14 +288,12 @@ def _action_delete(tree_view: QTreeView, main_window, path: Path | None) -> None if reply != QMessageBox.StandardButton.Yes: return - # Every tab open on the file, or on a file under the folder, closes first: - # closing stops its auto-save, which would otherwise keep writing the - # buffer back while the files are being removed. - for editor, _file in _editors_under(main_window, path): - index = main_window.tab_widget.indexOf(editor) - editor.close() - if index >= 0: - main_window.tab_widget.removeTab(index) + # Every tab open on the file, or on a file under the folder, stops its + # auto-save first, which would otherwise write the buffer back -- recreating + # the file -- while it is being removed. + open_tabs = _editors_under(main_window, path) + for editor, _file in open_tabs: + _stop_auto_save(editor) def _delete() -> None: if path.is_dir(): @@ -304,6 +302,17 @@ def _delete() -> None: path.unlink() _perform_file_op(tree_view, _delete) + # Only a tab whose file is gone closes. The delete can fail -- a locked or + # read-only file -- or remove only part of a folder, and closing the tabs + # beforehand lost a file's tab and its unsaved edits while the file stayed. + for editor, file_path in open_tabs: + if file_path.exists(): + _start_auto_save(editor, file_path) + continue + index = main_window.tab_widget.indexOf(editor) + editor.close() + if index >= 0: + main_window.tab_widget.removeTab(index) def _action_copy_path(tree_view: QTreeView, path: Path | None, relative: bool = False) -> None: diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py index 3c952b4b..73cc281f 100644 --- a/test/test_utils/test_file_tree_context_menu.py +++ b/test/test_utils/test_file_tree_context_menu.py @@ -7,6 +7,7 @@ from __future__ import annotations import os +from pathlib import Path os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -82,6 +83,7 @@ def __init__(self, path: str) -> None: self.code_edit = type("Edit", (), {"current_file": path})() self.renamed = False self.closed = False + self.code_save_thread = None def rename_self_tab(self) -> None: self.renamed = True @@ -311,6 +313,34 @@ def under(_window, path): assert window.tab_widget.count() == 1 assert not folder.exists() + def test_a_delete_that_fails_keeps_the_tab_open(self, tree, tmp_path, monkeypatch): + # The tabs used to close before the delete ran, so a locked file stayed + # on disk while its tab, and any unsaved edits in it, were gone. + target = tmp_path / "locked.py" + target.touch() + window = FakeWindow() + editor = FakeEditor(str(target)) + window.tab_widget.addTab(editor, "locked.py") + monkeypatch.setattr(ctx, "_editors_under", lambda _w, _p: [(editor, target)]) + restarted: list = [] + monkeypatch.setattr( + ctx, "init_new_auto_save_thread", + lambda file_path, widget: restarted.append(file_path)) + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: None)) + + def refuse(self, *args, **kwargs): + raise PermissionError(13, "The process cannot access the file") + + monkeypatch.setattr(Path, "unlink", refuse) + confirm(monkeypatch, yes=True) + _action_delete(tree, window, target) + + assert target.exists() + assert not editor.closed + assert window.tab_widget.count() == 1 + # Its auto-save, stopped for the delete, runs again. + assert restarted == [str(target)] + class TestCopyingThePath: def test_the_absolute_path_reaches_the_clipboard(self, tree, tmp_path): From 8e2c57a224731650e8a247964cf24c4ed108a7ac Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 12:47:28 +0800 Subject: [PATCH 098/312] Keep closing the IDE when one tab, dock or run window raises on close --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 16 +++++ docs/updates/README.md | 3 +- progress.md | 3 +- pybreeze/pybreeze_ui/editor_main/main_ui.py | 23 ++++++-- .../test_main_window_close_guard.py | 59 +++++++++++++++++++ 6 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 test/test_utils/test_main_window_close_guard.py diff --git a/architecture_explore.md b/architecture_explore.md index 803a78e8..394c2a9b 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -96,7 +96,7 @@ Template Method 定義的子行程生命週期: | 讀取 | 兩條 daemon Thread 各自經 `queue_pump.read_stream_into_queue()` 對 stdout / stderr `readline()`,原樣塞進 `Queue`(保留縮排、行尾與空行);**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | | 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則,交給 `CodeWindow.append_output()` | | 收尾 | `exit_program()`:join 執行緒(timeout 2s)→ drain queue(`max_messages=None` 一次抽乾)→ `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | -| 停止 | `stop()`:子行程還在跑就 `terminate()`(只有它本身,它再開的行程不管),之後照一般結束的路徑回報。經 `CodeWindow.stop_runner()` 呼叫;關閉 IDE 時 `PyBreezeMainWindow.closeEvent()` 對每個執行視窗都呼叫一次 | +| 停止 | `stop()`:子行程還在跑就 `terminate()`(只有它本身,它再開的行程不管),之後照一般結束的路徑回報。經 `CodeWindow.stop_runner()` 呼叫;關閉 IDE 時 `PyBreezeMainWindow.closeEvent()` 對每個執行視窗都呼叫一次(經 `_close_guarded()`:一個視窗、分頁或 dock 關閉時丟例外只記錄,其餘照關,JEditor 自己的 `closeEvent` 一定會跑到) | 三種啟動介面: @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 92 個 `test_*.py`、1448 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 93 個 `test_*.py`、1450 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index e5058f8e..5da3a577 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1259,3 +1259,19 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §3, §18 - `progress.md` (#44 removed) - **Open items**: none. + +## U-20260923-73 · 2026-09-23 · Closing the IDE survives a tab, dock or run window that raises · #done #lifecycle + +- **What**: `progress.md` #46. `PyBreezeMainWindow.closeEvent` stops and closes each run window, then closes every tool tab and dock, then runs JEditor's own `closeEvent`. Nothing guarded those closes, and some of the widgets are third-party (`EDITOR_EXTEND_TAB`) or plugin widgets. When one raised, the rest were never closed. JEditor's close never ran either, so the open files were not recorded, the user settings not written and the editors' auto-save threads not stopped. +- **Fix**: `_close_guarded(widget, *steps)` runs each closing step and logs a step that raises (`# noqa: BLE001` with the reason), then goes on to the next. The run windows' `stop_runner`/`close` and every tab's and dock's `close` go through it. +- **Also**: `progress.md` #47 is no longer a decision. Closing a docked JEditor editor is meant to save it, and the damage came from JEditor's dock: it saved when nothing was edited, in UTF-8 with platform line endings. That is fixed in JEditor (JEDITOR U-20260923-07), so #47 now waits on the next `je_editor` release. +- **Tests**: new `test_main_window_close_guard.py`: + - a failing step is logged and the next step still runs; + - a tool tab that raises on close leaves the tabs before and after it closed. +- **Result / numbers**: unit tests 1436 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/editor_main/main_ui.py` + - `test/test_utils/test_main_window_close_guard.py` (new) + - `architecture_explore.md` §4, §18 + - `progress.md` (#46 removed, #47 reworded) +- **Open items**: #47 [BLOCKED] on the `je_editor` release. diff --git a/docs/updates/README.md b/docs/updates/README.md index db95f5f2..61cb7ba0 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-73 | 2026-09-23 | Closing the IDE survives a tab, dock or run window that raises | #done #lifecycle | [2026-09](2026-09.md) | | U-20260923-72 | 2026-09-23 | File tree Delete closes only the tabs whose files are gone | #done #filetree | [2026-09](2026-09.md) | | U-20260923-71 | 2026-09-23 | prthinker Review current file saves the file first | #done #prthinker | [2026-09](2026-09.md) | | U-20260923-70 | 2026-09-23 | TestPioneer Create template: in the working directory, asks before replacing | #done #testpioneer | [2026-09](2026-09.md) | @@ -153,4 +154,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 90 | +| [2026-09.md](2026-09.md) | 2026-09 | 91 | diff --git a/progress.md b/progress.md index a52de903..f06bf7a9 100644 --- a/progress.md +++ b/progress.md @@ -8,6 +8,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#46** `PyBreezeMainWindow.closeEvent` (`editor_main/main_ui.py:103-122`) closes run windows, tool tabs and docks unguarded before `super().closeEvent`; one widget whose close raises stops JEditor's own close (open files, settings, auto-save threads). -- **#47** [DECIDE] `PyBreezeMainWindow.closeEvent` closes every `DestroyDock` (`editor_main/main_ui.py:121`), which includes JEditor's docked file editors; their `closeEvent` writes the buffer back, so exiting saves unsaved dock edits without asking and rewrites an LF file with CRLF. Should docked editors be skipped (JEditor alone never saved them on exit), or asked about? +- **#47** [BLOCKED] Closing the IDE closes JEditor's docked file editors (`editor_main/main_ui.py`, `_close_tool_tabs_and_docks`), and the published `je_editor` dock (`FullEditorWidget.closeEvent`) writes its buffer back on close whether edited or not, in UTF-8 with platform line endings, so exiting rewrites an LF file as CRLF. Fixed in JEditor (JEDITOR U-20260923-07: writes only when edited, in the file's own encoding and line ending); waits for the `je_editor` release that carries it, then bump the pin and add a PyBreeze test. - **#48** Install prthinker (`menu/install_menu/automation_menu/build_automation_install_menu.py:105-120`) calls the static `getExistingDirectory` through an instance, so the dialog has no parent, and saves any existing folder as prthinker's source before pip runs, with no check that it is one. diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index 395a3db4..47a33266 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -27,6 +27,22 @@ } +def _close_guarded(widget: QWidget, *steps) -> None: + """Run *widget*'s closing *steps*, logging a failure instead of raising it. + + The IDE's close runs every tab's and dock's close before JEditor's own, and + some of them are third-party (``EDITOR_EXTEND_TAB``) or plugin widgets. One + that raised stopped the rest: JEditor's close never ran, so the open files + were not recorded, the settings not written and the editors' auto-save + threads not stopped. + """ + for step in steps: + try: + step() + except Exception as error: # noqa: BLE001 — any widget may raise on close; the IDE must still close the rest + pybreeze_logger.error("%s did not close cleanly: %r", type(widget).__name__, error) + + class PyBreezeMainWindow(EditorMain): def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, extend: bool = False) -> None: @@ -101,8 +117,7 @@ def closeEvent(self, event) -> None: # process, and without a console nobody would see it still running. # Over a copy: a window that closes drops itself from the list. for run_window in tuple(self.current_run_code_window): - run_window.stop_runner() - run_window.close() + _close_guarded(run_window, run_window.stop_runner, run_window.close) self._close_tool_tabs_and_docks() super().closeEvent(event) @@ -117,9 +132,9 @@ def _close_tool_tabs_and_docks(self) -> None: for index in range(self.tab_widget.count() - 1, -1, -1): widget = self.tab_widget.widget(index) if widget is not None and not isinstance(widget, EditorWidget): - widget.close() + _close_guarded(widget, widget.close) for dock in self.findChildren(DestroyDock): - dock.close() + _close_guarded(dock, dock.close) def debug_close(self) -> None: """Close the window and leave the event loop. Used by the startup tests. diff --git a/test/test_utils/test_main_window_close_guard.py b/test/test_utils/test_main_window_close_guard.py new file mode 100644 index 00000000..00afcbbf --- /dev/null +++ b/test/test_utils/test_main_window_close_guard.py @@ -0,0 +1,59 @@ +"""Closing the IDE: one tab, dock or run window that raises on close does not stop the rest.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget, QWidget + +from pybreeze.pybreeze_ui.editor_main import main_ui +from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow, _close_guarded + + +class Refusing(QWidget): + def close(self) -> bool: + raise RuntimeError("a third-party tab that fails to close") + + +class Recording(QWidget): + def __init__(self, closed: list) -> None: + super().__init__() + self._closed = closed + + def close(self) -> bool: + self._closed.append(self) + return super().close() + + +def test_a_failing_step_is_logged_and_the_next_still_runs(monkeypatch): + QApplication.instance() or QApplication([]) + logged: list = [] + monkeypatch.setattr(main_ui.pybreeze_logger, "error", lambda *args: logged.append(args)) + ran: list = [] + + def fail(): + raise OSError("the run's child could not be stopped") + + _close_guarded(QWidget(), fail, lambda: ran.append("close")) + + assert ran == ["close"] + assert logged + + +def test_every_other_tool_tab_is_still_closed(monkeypatch): + QApplication.instance() or QApplication([]) + monkeypatch.setattr(main_ui.pybreeze_logger, "error", lambda *args: None) + window = QMainWindow() + window.tab_widget = QTabWidget() + closed: list = [] + before, after = Recording(closed), Recording(closed) + for tab in (before, Refusing(), after): + window.tab_widget.addTab(tab, "tool") + + # It used to raise here, and JEditor's own close -- open files, settings, + # auto-save threads -- never ran. + PyBreezeMainWindow._close_tool_tabs_and_docks(window) + + assert before in closed and after in closed + window.deleteLater() From bc579b1a266321b6f9b76e52f2663964fe933ce3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 12:47:30 +0800 Subject: [PATCH 099/312] Parent the prthinker folder dialog and keep only prthinker's source --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 21 +++++++++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../prthinker_extend/prthinker_setting.py | 26 ++++++++++++++++--- .../build_automation_install_menu.py | 8 +++--- test/test_utils/test_install_menu.py | 22 ++++++++++++++++ test/test_utils/test_prthinker_setting.py | 15 ++++++++++- 8 files changed, 89 insertions(+), 11 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 394c2a9b..b02728cb 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -365,7 +365,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 - `SECRET_SETTINGS` 四個欄位在 `loggable()` 中一律縮成 `(set)` / 空字串 - 模型名稱依後端交給不同變數(`MODEL_ENVIRONMENT`):`remote` / `local` 是 `PRTHINKER_MODEL_NAME`,其他後端是各自的 `PRTHINKER__MODEL`。prthinker 只有 local 讀 `PRTHINKER_MODEL_NAME`(remote 由伺服器決定模型,只拿來標示) - `extra_arguments()` 經 `split_arguments()` 以命令列規則斷詞(引號內空白不拆);反斜線是路徑分隔字元的平台(Windows)上反斜線不當跳脫字元,`C:\reviews` 才不會變成 `C:reviews`。解析失敗當作沒有而不是讓整次審查失敗 -- `install_target()` 回傳 `[runner]`,因為 prthinker 不在 PyPI 上 +- `install_target()` 回傳 `[runner]`,因為 prthinker 不在 PyPI 上;資料夾要有 `pyproject.toml` 且專案名稱是 prthinker,否則不給目標、也不記住 - 規則檢索(`rag`,`RAG_MODES = ("off", "remote")`)**永遠明講**,而且兩個變數都送:`off` 給 `PRTHINKER_RAG_ENABLED=false` + `PRTHINKER_REMOTE_RAG=false`,`remote` 兩個都 `true`(走伺服器的 `/rag`),認不得的值當 `off`。子行程繼承 IDE 的環境,少送一個就會被使用者 shell 裡的設定決定。prthinker 的預設是本機 FAISS 檢索,但那份索引(`codes/`)只在它的原始碼庫、被排除在套件外,從這裡裝的 prthinker 一跑就 `ModuleNotFoundError: codes` 支援的後端:`remote / local / openai / anthropic / gemini / cohere / mistral / claude-cli / codex-cli`;平台:`github / gitlab / gitea`。 @@ -433,7 +433,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 93 個 `test_*.py`、1450 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 93 個 `test_*.py`、1452 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 5da3a577..d472a24e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1275,3 +1275,24 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §4, §18 - `progress.md` (#46 removed, #47 reworded) - **Open items**: #47 [BLOCKED] on the `je_editor` release. + +## U-20260923-74 · 2026-09-23 · Install prthinker: a parented dialog, and only prthinker's source is kept · #done #prthinker + +- **What**: `progress.md` #48, Install prthinker (`build_automation_install_menu.install_prthinker`): + - It called the static `QFileDialog.getExistingDirectory` through a new instance, `QFileDialog(parent=...).getExistingDirectory(caption=...)`. The parent went to the unused instance, so the folder dialog had no parent and could open behind the main window. + - `install_target()` accepted any existing folder. A wrong pick was saved as prthinker's source before pip ran, and every later click went straight to a pip that failed. It could only be cleared in the prthinker settings dialog. +- **Fix**: + - The dialog is called statically with the main window as parent. + - `install_target()` requires the folder's `pyproject.toml` to name the project `prthinker`. The name is matched by a regular expression: `tomllib` is 3.11+, and the IDE supports 3.10. A folder that fails the check is neither saved nor installed, and the user gets the existing "choose prthinker's source" message. A saved folder that no longer qualifies falls back to asking. + - Checked against the real prthinker checkout in the workspace, which qualifies; the PyBreeze folder does not. +- **Tests**: + - `test_prthinker_setting.py`: the source folder qualifies; any other folder, and a project with another name, does not. + - `test_install_menu.py`: a remembered source installs; a folder that is not prthinker's is not saved and starts no pip. +- **Result / numbers**: unit tests 1438 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/extend/prthinker_extend/prthinker_setting.py` + - `pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py` + - `test/test_utils/test_prthinker_setting.py`, `test_install_menu.py` + - `architecture_explore.md` §14, §18 + - `progress.md` (#48 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 61cb7ba0..924c789e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-74 | 2026-09-23 | Install prthinker: a parented dialog, and only prthinker's source is kept | #done #prthinker | [2026-09](2026-09.md) | | U-20260923-73 | 2026-09-23 | Closing the IDE survives a tab, dock or run window that raises | #done #lifecycle | [2026-09](2026-09.md) | | U-20260923-72 | 2026-09-23 | File tree Delete closes only the tabs whose files are gone | #done #filetree | [2026-09](2026-09.md) | | U-20260923-71 | 2026-09-23 | prthinker Review current file saves the file first | #done #prthinker | [2026-09](2026-09.md) | @@ -154,4 +155,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 91 | +| [2026-09.md](2026-09.md) | 2026-09 | 92 | diff --git a/progress.md b/progress.md index f06bf7a9..b9d48e84 100644 --- a/progress.md +++ b/progress.md @@ -9,4 +9,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#47** [BLOCKED] Closing the IDE closes JEditor's docked file editors (`editor_main/main_ui.py`, `_close_tool_tabs_and_docks`), and the published `je_editor` dock (`FullEditorWidget.closeEvent`) writes its buffer back on close whether edited or not, in UTF-8 with platform line endings, so exiting rewrites an LF file as CRLF. Fixed in JEditor (JEDITOR U-20260923-07: writes only when edited, in the file's own encoding and line ending); waits for the `je_editor` release that carries it, then bump the pin and add a PyBreeze test. -- **#48** Install prthinker (`menu/install_menu/automation_menu/build_automation_install_menu.py:105-120`) calls the static `getExistingDirectory` through an instance, so the dialog has no parent, and saves any existing folder as prthinker's source before pip runs, with no check that it is one. diff --git a/pybreeze/extend/prthinker_extend/prthinker_setting.py b/pybreeze/extend/prthinker_extend/prthinker_setting.py index ebe8207a..1670bf3c 100644 --- a/pybreeze/extend/prthinker_extend/prthinker_setting.py +++ b/pybreeze/extend/prthinker_extend/prthinker_setting.py @@ -18,6 +18,7 @@ import json import os +import re import shlex from pathlib import Path from typing import Dict, List @@ -114,6 +115,9 @@ # The extras to install with: ``runner`` is the set that reviews without # pulling in a model INSTALL_EXTRAS = "runner" +# The project name line of prthinker's pyproject.toml; parsed by hand, since the +# stdlib's tomllib arrived in 3.11 and this runs on 3.10 +_PRTHINKER_PROJECT_NAME = re.compile(r'^name\s*=\s*["\']prthinker["\']\s*$', re.MULTILINE) def setting_path() -> Path: @@ -309,16 +313,32 @@ def install_target(source_path: str) -> str: prthinker is installed from source rather than from PyPI, so what is given is a folder and not a package name. + 資料夾要是 prthinker 自己的原始碼(``pyproject.toml`` 的專案名稱是 prthinker), + 選錯資料夾才不會被記下來,之後每次都拿去跑一個一定失敗的 pip。 + The folder has to be prthinker's own source (its ``pyproject.toml`` names the + project prthinker): a wrong one used to be saved and handed to a pip that + could only fail, every time, until it was cleared in the settings. + :param source_path: 框架原始碼的資料夾 / the framework's source folder - :return: pip 的安裝目標,路徑不存在時為空字串 / the target for pip, or an - empty string when the path is not a folder + :return: pip 的安裝目標,不是 prthinker 原始碼時為空字串 / the target for + pip, or an empty string when the path is not prthinker's source folder """ path = Path(source_path.strip()) if source_path.strip() else None - if path is None or not path.is_dir(): + if path is None or not _is_prthinker_source(path): return "" return f"{path}[{INSTALL_EXTRAS}]" +def _is_prthinker_source(folder: Path) -> bool: + """Whether *folder* holds a ``pyproject.toml`` whose project is prthinker.""" + try: + text = (folder / "pyproject.toml").read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + pybreeze_logger.debug("Not a prthinker source folder %s: %r", folder, error) + return False + return _PRTHINKER_PROJECT_NAME.search(text) is not None + + def loggable(setting: Dict[str, str]) -> Dict[str, str]: """ 可以寫進紀錄的設定 diff --git a/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py b/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py index 1f0e53cb..0243d5c3 100644 --- a/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py +++ b/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py @@ -102,9 +102,11 @@ def install_prthinker(ui_we_want_to_set: PyBreezeMainWindow) -> None: setting = load_setting() target = install_target(setting.get("source_path", "")) if not target: - chosen = QFileDialog(parent=ui_we_want_to_set).getExistingDirectory( - caption=language_wrapper.language_word_dict.get( - "prthinker_choose_source_path_label")) + # Static: called through an instance, the dialog had no parent and + # could open behind the main window. + chosen = QFileDialog.getExistingDirectory( + ui_we_want_to_set, + language_wrapper.language_word_dict.get("prthinker_choose_source_path_label")) target = install_target(chosen or "") if not target: messagebox = QMessageBox(ui_we_want_to_set) diff --git a/test/test_utils/test_install_menu.py b/test/test_utils/test_install_menu.py index be2337d6..f60a7762 100644 --- a/test/test_utils/test_install_menu.py +++ b/test/test_utils/test_install_menu.py @@ -114,9 +114,31 @@ def test_a_remembered_source_folder_is_installed(self, app, processes, monkeypat build_automation_install_menu as install_menu, ) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "prthinker"\n', encoding="utf-8") monkeypatch.setattr(install_menu, "load_setting", lambda: {"source_path": str(tmp_path)}) install_menu.install_prthinker(object()) (run,) = _runs(processes) assert run[0] == "pip" and run[1][2].startswith(str(tmp_path)) + + def test_a_folder_that_is_not_its_source_is_not_saved(self, app, processes, monkeypatch, tmp_path): + from PySide6.QtWidgets import QFileDialog, QMessageBox + + from pybreeze.pybreeze_ui.menu.install_menu.automation_menu import ( + build_automation_install_menu as install_menu, + ) + + saved: list = [] + monkeypatch.setattr(install_menu, "load_setting", lambda: {"source_path": ""}) + monkeypatch.setattr(install_menu, "save_setting", saved.append) + monkeypatch.setattr( + QFileDialog, "getExistingDirectory", staticmethod(lambda *a, **k: str(tmp_path))) + monkeypatch.setattr(QMessageBox, "exec", lambda self: 0) + + install_menu.install_prthinker(None) + + # A wrong pick used to be remembered, so every later click ran a pip that failed. + assert saved == [] + assert _runs(processes) == [] diff --git a/test/test_utils/test_prthinker_setting.py b/test/test_utils/test_prthinker_setting.py index ab643621..8c185e36 100644 --- a/test/test_utils/test_prthinker_setting.py +++ b/test/test_utils/test_prthinker_setting.py @@ -200,12 +200,25 @@ def test_an_unclosed_quote_costs_only_the_extra_arguments(self): class TestInstallingFromSource: """prthinker is not on PyPI, so pip is pointed at a folder.""" - def test_a_real_folder_becomes_a_target_with_the_extras(self, tmp_path): + @staticmethod + def _source(folder, name="prthinker"): + (folder / "pyproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "1.0"\n', encoding="utf-8") + return folder + + def test_its_source_folder_becomes_a_target_with_the_extras(self, tmp_path): + self._source(tmp_path) assert install_target(str(tmp_path)) == f"{tmp_path}[{INSTALL_EXTRAS}]" def test_surrounding_spaces_are_dropped(self, tmp_path): + self._source(tmp_path) assert install_target(f" {tmp_path} ") == f"{tmp_path}[{INSTALL_EXTRAS}]" + def test_any_other_folder_is_no_target(self, tmp_path): + # A wrong pick used to be saved and handed to a pip that could only fail. + assert install_target(str(tmp_path)) == "" + assert install_target(str(self._source(tmp_path, name="prthinker-docs"))) == "" + def test_nothing_chosen_is_no_target(self): assert install_target("") == "" From 97c9c227dc001b01fc6d875a1ec6e52f260d9a1c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 12:53:16 +0800 Subject: [PATCH 100/312] Bring Send back however a review request ends, and vote only on an answer --- architecture_explore.md | 5 +- docs/updates/2026-09.md | 20 +++++++ docs/updates/README.md | 3 +- .../connect_gui/url/ai_code_review_gui.py | 26 +++++++-- test/test_utils/test_ai_code_review_client.py | 56 ++++++++++++++++++- 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index b02728cb..6b11493a 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -306,7 +306,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 - 請求走 `ReviewRequestThread(QThread)`,只有 `answered` / `failed` 兩個 signal 碰 UI(和 `SkillsSendGUI` 同一套);送出中再按不會重送;`closeEvent` 不等它,交給 `thread_keeper.let_run_out()`:斷開它和面板的連線、留著參考直到它結束(等它會讓 IDE 凍住最長一個讀取逾時) - `urls.txt` 只存 URL 的 SHA-256 指紋(`url_fingerprint()`):API URL 可能帶權杖,依 CLAUDE.md 要當憑證看待;舊版留下的明文檔會在下次送出時改寫成指紋 -- 非 2xx(含不跟隨的轉址)會把狀態碼寫進面板,不會只留空白 +- 非 2xx(含不跟隨的轉址)走 `failed`,狀態碼寫進面板,不會只留空白 +- 接受/拒絕只在收到回答(`answered`)後可按,每個回答只能評一次;Send 按鈕由執行緒的 `finished` 恢復,請求不論怎麼結束都回得來 --- @@ -433,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 93 個 `test_*.py`、1452 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 93 個 `test_*.py`、1456 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index d472a24e..9b79794d 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1296,3 +1296,23 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §14, §18 - `progress.md` (#48 removed) - **Open items**: none. + +## U-20260923-75 · 2026-09-23 · AI review panel: Send always comes back, and only an answer can be voted on · #fix #ai + +- **What**: two faults in the AI code review panel (`connect_gui/url/ai_code_review_gui.py`), found in the review sweep: + - Send was re-enabled only from the `answered` and `failed` slots. A request thread that ended any other way emitted neither, and Send stayed greyed out until the panel was reopened. + - Accept and Reject were always enabled. A vote was counted with an empty panel, after an error, or several times for one answer, and the totals in `~/.pybreeze/response_stats.txt` drifted. +- **Fix**: + - Send comes back from the thread's `finished` signal. `let_run_out` already cuts `finished` off when the panel closes first. + - Accept and Reject start disabled, are enabled by an answer, and are disabled again by a vote or a new Send. + - A non-2xx status (a redirect that is not followed included) is now a `failed`, so an error page cannot be voted on. The panel shows it as an error with the status line. +- **Tests**: `test_ai_code_review_client.py`: + - the two status tests now expect `failed`; + - no vote before an answer; one vote per answer; no vote on a failure; + - Send comes back after a request that emitted nothing. +- **Result / numbers**: unit tests 1442 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py` + - `test/test_utils/test_ai_code_review_client.py` + - `architecture_explore.md` §9, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 924c789e..672b94f8 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-75 | 2026-09-23 | AI review panel: Send always comes back, and only an answer can be voted on | #fix #ai | [2026-09](2026-09.md) | | U-20260923-74 | 2026-09-23 | Install prthinker: a parented dialog, and only prthinker's source is kept | #done #prthinker | [2026-09](2026-09.md) | | U-20260923-73 | 2026-09-23 | Closing the IDE survives a tab, dock or run window that raises | #done #lifecycle | [2026-09](2026-09.md) | | U-20260923-72 | 2026-09-23 | File tree Delete closes only the tabs whose files are gone | #done #filetree | [2026-09](2026-09.md) | @@ -155,4 +156,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 92 | +| [2026-09.md](2026-09.md) | 2026-09 | 93 | 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 254ecbc1..d28b8a50 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 @@ -72,8 +72,9 @@ def run(self) -> None: self.answered.emit(body) else: # Without this a 302 (redirects are not followed) or a 500 with - # an empty body would leave the panel looking like a success. - self.answered.emit(f"HTTP {response.status_code} {response.reason}\n{body}") + # an empty body would leave the panel looking like a success, + # open to an accept or reject vote. + self.failed.emit(f"HTTP {response.status_code} {response.reason}\n{body}") except (requests.RequestException, ResponseTooLargeError, UnsafeURLError) as error: # Not %r: a requests error carries the whole URL, which may hold a token. pybreeze_logger.error("AI code review request failed: %s", type(error).__name__) @@ -156,6 +157,8 @@ def __init__(self): main_layout.addWidget(self.send_button) main_layout.addLayout(self._build_verdict_buttons()) self.setLayout(main_layout) + # 沒有回答就沒有可以評的 / Nothing to vote on until an answer arrives + self._set_verdict_enabled(False) def _build_request_row(self) -> QHBoxLayout: """上方:URL 與 Method / The URL and method row.""" @@ -249,22 +252,33 @@ def send_request(self): self.word_dict.get("ai_code_review_gui_message_url_already_recorded")) self.send_button.setEnabled(False) + self._set_verdict_enabled(False) self.request_thread = ReviewRequestThread(method, url, code_content) self.request_thread.answered.connect(self.on_answered) self.request_thread.failed.connect(self.on_failed) + # Not from answered/failed: a thread that ends any other way emits + # neither, and Send stayed greyed out until the panel was reopened. + self.request_thread.finished.connect(self._on_request_finished) self.request_thread.start() def on_answered(self, body: str) -> None: - """Show what came back and let the next request be sent.""" + """Show what came back; it may now be accepted or rejected, once.""" self.response_panel.append(body) - self.send_button.setEnabled(True) + self._set_verdict_enabled(True) def on_failed(self, message: str) -> None: - """Show why nothing came back and let the next request be sent.""" + """Show why no answer came back.""" self.response_panel.setPlainText( f"{self.word_dict.get('ai_code_review_gui_message_error')}: {message}") + + def _on_request_finished(self) -> None: + """Let the next request be sent, however this one ended.""" self.send_button.setEnabled(True) + def _set_verdict_enabled(self, enabled: bool) -> None: + self.accept_button.setEnabled(enabled) + self.reject_button.setEnabled(enabled) + def closeEvent(self, event) -> None: """Let a request still in flight run out without this panel. @@ -301,12 +315,14 @@ def record_url(self, url: str) -> bool: def accept_response(self): """Accept response code and save""" + self._set_verdict_enabled(False) self._count_verdict(accepted=True) self.response_panel.append(f"\n{self.word_dict.get('ai_code_review_gui_status_accepted')}") self.save_stats() def reject_response(self): """Reject response code and save""" + self._set_verdict_enabled(False) self._count_verdict(accepted=False) self.response_panel.append(f"\n{self.word_dict.get('ai_code_review_gui_status_rejected')}") self.save_stats() diff --git a/test/test_utils/test_ai_code_review_client.py b/test/test_utils/test_ai_code_review_client.py index 3308d342..2c556a2d 100644 --- a/test/test_utils/test_ai_code_review_client.py +++ b/test/test_utils/test_ai_code_review_client.py @@ -10,6 +10,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest +from PySide6.QtCore import QThread, Signal from PySide6.QtWidgets import QApplication from pybreeze.extend_multi_language.update_language_dict import update_language_dict @@ -121,7 +122,9 @@ class Response: answered, failed = self._run(monkeypatch, Response()) - assert answered and "500" in answered[0] + # A failure, not an answer: an answer can be voted on. + assert answered == [] + assert failed and "500" in failed[0] def test_a_redirect_is_reported_not_shown_as_an_empty_answer(self, app, monkeypatch): # requests calls every status below 400 "ok", a 302 included. @@ -133,7 +136,8 @@ class Response: answered, failed = self._run(monkeypatch, Response()) - assert answered and answered[0].startswith("HTTP 302 Found") + assert answered == [] + assert failed and failed[0].startswith("HTTP 302 Found") def test_a_request_that_fails_does_not_log_the_url(self, app, monkeypatch): logged: list = [] @@ -208,6 +212,54 @@ def test_an_unsupported_method_says_so_and_starts_nothing(self, client, monkeypa assert client.response_panel.toPlainText() +class TestVotingOnAnAnswer: + def test_there_is_nothing_to_vote_on_before_an_answer(self, client): + # It used to count a vote with an empty panel. + assert not client.accept_button.isEnabled() + assert not client.reject_button.isEnabled() + + def test_an_answer_can_be_voted_on_once(self, client): + client.on_answered("looks fine") + assert client.accept_button.isEnabled() and client.reject_button.isEnabled() + + client.accept_button.click() + + assert not client.accept_button.isEnabled() + assert not client.reject_button.isEnabled() + + def test_a_failure_cannot_be_voted_on(self, client): + client.on_failed("HTTP 500 Internal Server Error\n") + + assert not client.accept_button.isEnabled() + + +class _Silent(QThread): + """A request that ends without emitting answered or failed.""" + + answered = Signal(str) + failed = Signal(str) + + def __init__(self, *_args) -> None: + super().__init__() + + def run(self) -> None: + """Nothing to report.""" + + +class TestSendComesBack: + def test_however_the_request_ended(self, client, monkeypatch): + monkeypatch.setattr(ai_code_review_gui, "ReviewRequestThread", _Silent) + monkeypatch.setattr(client, "record_url", lambda url: True) + client.url_input.setText(_A_URL) + + client.send_request() + assert not client.send_button.isEnabled() + client.request_thread.wait(5000) + QApplication.processEvents() + + assert client.send_button.isEnabled() + + class TestTheRunningTotals: def test_a_new_panel_carries_on_from_the_saved_totals(self, app, tmp_path, monkeypatch): (tmp_path / "response_stats.txt").write_text("Accepted: 7\nRejected: 2\n", encoding="utf-8") From 2495838e85144b8565b2523fec9b3df38885cb7e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 12:53:18 +0800 Subject: [PATCH 101/312] Parent the run-folder dialog, report an empty folder, and keep utils free of Qt --- architecture.md | 2 +- architecture_explore.md | 8 +-- docs/updates/2026-09.md | 21 ++++++ docs/updates/README.md | 3 +- .../process_executor_utils.py | 25 ++++++- .../extend_multi_language/extend_english.py | 2 + .../extend_traditional_chinese.py | 2 + .../utils/file_process/get_dir_file_list.py | 14 ---- test/test_utils/test_run_folder.py | 69 +++++++++++++++++++ test/test_utils/test_utils_has_no_qt.py | 30 ++++++++ 10 files changed, 153 insertions(+), 23 deletions(-) create mode 100644 test/test_utils/test_run_folder.py create mode 100644 test/test_utils/test_utils_has_no_qt.py diff --git a/architecture.md b/architecture.md index 9e4e2741..8ae342f3 100644 --- a/architecture.md +++ b/architecture.md @@ -36,7 +36,7 @@ their output reaches the UI through Queue + QTimer. | `pybreeze/extend/process_executor/` | Subprocess isolation layer: `TaskProcessManager`, `process_executor_utils.py`, `FileRunnerProcess`, `queue_pump.py`, one sub-package per automation package, plus `test_pioneer/` and `prthinker/` | | `pybreeze/extend/mail_thunder_extend/`, `prthinker_extend/` | Post-test email hook; prthinker settings and argument assembly (pure logic) | | `pybreeze/extend_multi_language/` | PyBreeze's English and Traditional Chinese strings, merged into JEditor's dictionaries | -| `pybreeze/utils/` | Pure logic (only `file_process/get_dir_file_list.py` imports Qt): request parsing and codegen, HTTP tools, `network/` SSRF validation and capped reads, exceptions, logging, `app_dirs.py`, `subprocess_util.py` | +| `pybreeze/utils/` | Pure logic, no Qt or JEditor (`test_utils_has_no_qt.py` guards it): request parsing and codegen, HTTP tools, `network/` SSRF validation and capped reads, exceptions, logging, `app_dirs.py`, `subprocess_util.py` | | `test/test_utils/` | Unit tests (pure logic and headless widgets). `test/unit_test/start_automation/` holds the launch tests | | `pyproject.toml`, `dev.toml` | Stable packaging (CI bumps and publishes it) and the unpublished dev packaging (keep its dependencies identical) | | `.github/workflows/` | `dev.yml`, `stable.yml` (unit tests on a Windows matrix, then SonarCloud) | diff --git a/architecture_explore.md b/architecture_explore.md index 6b11493a..62f7bd01 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -111,7 +111,7 @@ Template Method 定義的子行程生命週期: | `build_process()` | 取當前分頁的程式碼(或傳入的 `exec_str`)→ `start_process()`。沒給 `exec_str` 又不是編輯器分頁時,開一個執行視窗寫明「腳本要在前面的編輯器分頁裡」(`report_no_script_tab()`),不會把 `None` 交給套件 | | `start_process()` | 建 `CodeWindow` + `TaskProcessManager` → `start_test_process()` | | `build_process_from_file()` | 以檔案路徑執行單一檔案 | -| `run_dir_files_with_package()` | 問使用者選資料夾,對每個 `.json` 開一個執行視窗批次跑 | +| `run_dir_files_with_package()` | 問使用者選資料夾(`_ask_for_action_files()`,對話框掛在主視窗上;資料夾裡沒有 `.json` 就明說),對每個 `.json` 開一個執行視窗批次跑 | | `open_run_window()` | 開一個執行視窗、掛進 `main_window.current_run_code_window`,並接上 `finished_and_closed`:使用者關掉一個已經跑完的執行視窗時主視窗就放掉它(以前這份清單只增不減,每次執行都留下一個視窗、一個執行器、兩個 queue 和一個 timer)。插件執行也走這裡 | | `build_task_process()` | 共用建構:`open_run_window()` 並帶上主視窗選定的直譯器、決定要不要接 `send_after_test`。建好的 `TaskProcessManager` 掛在執行視窗的 `runner` 上,所以呼叫端可以不留參考。prthinker 審查也走這裡 | @@ -344,10 +344,10 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | -| `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感)+ 資料夾選擇對話框包裝 | +| `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | | `manager/package_manager/` | `PackageManager`(單例 `package_manager`)持有 `syntax_check_list` | -**分層原則很一致**:`utils/` 幾乎不 import Qt(例外只有 `get_dir_file_list` 的 QFileDialog),所以 61 個單元測試全部跑得動。 +**分層原則**:`utils/` 不 import Qt 或 JEditor,由 `test_utils_has_no_qt.py` 守著,所以全部是不需要視窗的純邏輯測試。 --- @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 93 個 `test_*.py`、1456 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 95 個 `test_*.py`、1461 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 9b79794d..f67b6196 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1316,3 +1316,24 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_ai_code_review_client.py` - `architecture_explore.md` §9, §18 - **Open items**: none. + +## U-20260923-76 · 2026-09-23 · Run a folder: a parented dialog, a message when nothing matches, and a Qt-free utils · #fix #executor #refactor + +- **What**: the "run every file in a folder" actions (`run_dir_files_with_package`, used by each automation menu's multi-file entries) asked for the folder through `utils/file_process/get_dir_file_list.ask_and_get_dir_files_as_list`: + - it called the static `getExistingDirectory` through a throwaway `QFileDialog(parent=main_window)`, so the dialog had no parent and could open behind the IDE (the same fault as #48); + - a folder with no `.json` in it did nothing, with no word to the user; + - it was the only Qt import in `utils/`, which is meant to be pure logic. +- **Fix**: + - The wrapper is gone from `utils/`. `process_executor_utils._ask_for_action_files()` opens the dialog statically with the main window as parent. When the folder holds no action JSON it says so, in a message box with the new `run_folder_title`/`run_folder_no_action_files` keys (English and Traditional Chinese). + - `get_dir_files_as_list()` is unchanged. +- **Tests**: + - new `test_run_folder.py`: the dialog's parent is the main window; cancelling is silent; nested JSON files are found and other files skipped; an empty folder is reported. + - new `test_utils_has_no_qt.py`: no module under `pybreeze/utils/` imports PySide6 or je_editor. +- **Result / numbers**: unit tests 1447 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/extend/process_executor/process_executor_utils.py` + - `pybreeze/utils/file_process/get_dir_file_list.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_run_folder.py`, `test_utils_has_no_qt.py` (new) + - `architecture.md` §2 table, `architecture_explore.md` §4.2, §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 672b94f8..da269058 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-76 | 2026-09-23 | Run a folder: a parented dialog, a message when nothing matches, and a Qt-free utils | #fix #executor #refactor | [2026-09](2026-09.md) | | U-20260923-75 | 2026-09-23 | AI review panel: Send always comes back, and only an answer can be voted on | #fix #ai | [2026-09](2026-09.md) | | U-20260923-74 | 2026-09-23 | Install prthinker: a parented dialog, and only prthinker's source is kept | #done #prthinker | [2026-09](2026-09.md) | | U-20260923-73 | 2026-09-23 | Closing the IDE survives a tab, dock or run window that raises | #done #lifecycle | [2026-09](2026-09.md) | @@ -156,4 +157,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 93 | +| [2026-09.md](2026-09.md) | 2026-09 | 94 | diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index 25deafb7..3a32324b 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -3,14 +3,15 @@ import json from typing import TYPE_CHECKING -from je_editor import EditorWidget +from PySide6.QtWidgets import QFileDialog, QMessageBox +from je_editor import EditorWidget, language_wrapper from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow from pybreeze.extend.mail_thunder_extend.mail_thunder_setting import send_after_test 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.file_process.get_dir_file_list import ask_and_get_dir_files_as_list +from pybreeze.utils.file_process.get_dir_file_list import get_dir_files_as_list from pybreeze.utils.logging.logger import pybreeze_logger if TYPE_CHECKING: @@ -105,7 +106,7 @@ def run_dir_files_with_package( bad directory pick from crashing the menu callback. """ try: - execute_list = ask_and_get_dir_files_as_list(main_window) + execute_list = _ask_for_action_files(main_window) if not execute_list: return for execute_file in execute_list: @@ -114,6 +115,24 @@ def run_dir_files_with_package( pybreeze_logger.error("%s multi file error: %r", package, error) +def _ask_for_action_files(main_window: PyBreezeMainWindow) -> list[str]: + """Ask for a folder and return the action JSON files under it, none when cancelled. + + The dialog is parented to the main window (it was not, and could open + behind it). A folder with no JSON in it says so; it used to do nothing. + """ + folder = QFileDialog.getExistingDirectory(main_window) + if not folder: + return [] + files = get_dir_files_as_list(folder, ".json") + if not files: + lang = language_wrapper.language_word_dict + QMessageBox.information( + main_window, lang.get("run_folder_title"), + lang.get("run_folder_no_action_files").format(folder=folder)) + return files + + def open_run_window(main_window: PyBreezeMainWindow, title: str = "") -> CodeWindow: """Open a run window and keep it on the main window until it is closed. diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index d0268e03..e4325282 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -352,6 +352,8 @@ "plugin_menu_about": "About", "plugin_menu_run_with": "Run with {name}", # Run with Menu + "run_folder_title": "Run a folder", + "run_folder_no_action_files": "There are no action JSON files in {folder}, so nothing was run.", "run_with_menu_label": "Run with...", "run_with_save_failed": "{file} could not be saved, so it was not run: {error}", "run_with_suffix_mismatch": "Current file ({suffix}) does not match expected suffixes: {expected}", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index dcbee9d4..58b20f36 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -703,6 +703,8 @@ "plugin_menu_about": "關於", "plugin_menu_run_with": "以 {name} 執行", # Run with Menu + "run_folder_title": "執行資料夾", + "run_folder_no_action_files": "{folder} 裡沒有動作 JSON 檔,所以沒有執行任何東西。", "run_with_menu_label": "以...執行", "run_with_save_failed": "{file} 無法存檔,所以沒有執行:{error}", "run_with_suffix_mismatch": "目前的檔案 ({suffix}) 與預期的副檔名不符: {expected}", diff --git a/pybreeze/utils/file_process/get_dir_file_list.py b/pybreeze/utils/file_process/get_dir_file_list.py index cecaad19..4e447786 100644 --- a/pybreeze/utils/file_process/get_dir_file_list.py +++ b/pybreeze/utils/file_process/get_dir_file_list.py @@ -5,10 +5,6 @@ from os.path import abspath from os.path import join -from PySide6.QtWidgets import QFileDialog, QMainWindow - -from pybreeze.utils.logging.logger import pybreeze_logger - def get_dir_files_as_list(dir_path: str | None = None, default_search_file_extension: str = ".json") -> list: """ @@ -28,13 +24,3 @@ def get_dir_files_as_list(dir_path: str | None = None, default_search_file_exten if file.lower().endswith(extension) ] - -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: - pybreeze_logger.warning("Not select any dir") - return None diff --git a/test/test_utils/test_run_folder.py b/test/test_utils/test_run_folder.py new file mode 100644 index 00000000..6d7d02be --- /dev/null +++ b/test/test_utils/test_run_folder.py @@ -0,0 +1,69 @@ +"""Running a folder of action files: which files, and what the user hears when there are none.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QFileDialog, QMainWindow, QMessageBox + +from pybreeze.extend.process_executor import process_executor_utils +from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + +@pytest.fixture(scope="module") +def window(): + QApplication.instance() or QApplication([]) + update_language_dict() + made = QMainWindow() + yield made + made.deleteLater() + + +@pytest.fixture() +def picked(monkeypatch): + """The folder the dialog returns, and the parents it was opened with.""" + state = {"folder": "", "parents": [], "told": []} + + def pick(parent, *_args, **_kwargs): + state["parents"].append(parent) + return state["folder"] + + monkeypatch.setattr(QFileDialog, "getExistingDirectory", staticmethod(pick)) + monkeypatch.setattr( + QMessageBox, "information", staticmethod(lambda *args: state["told"].append(args))) + return state + + +def test_the_dialog_belongs_to_the_main_window(window, picked): + process_executor_utils._ask_for_action_files(window) + + # It was opened through a throwaway instance, so it had no parent. + assert picked["parents"] == [window] + + +def test_cancelling_runs_nothing_and_says_nothing(window, picked): + assert process_executor_utils._ask_for_action_files(window) == [] + assert picked["told"] == [] + + +def test_the_json_files_under_the_folder_are_returned(window, picked, tmp_path): + (tmp_path / "nested").mkdir() + (tmp_path / "nested" / "login.json").write_text("[]", encoding="utf-8") + (tmp_path / "notes.txt").write_text("", encoding="utf-8") + picked["folder"] = str(tmp_path) + + files = process_executor_utils._ask_for_action_files(window) + + assert [os.path.basename(file) for file in files] == ["login.json"] + assert picked["told"] == [] + + +def test_a_folder_with_nothing_to_run_says_so(window, picked, tmp_path): + picked["folder"] = str(tmp_path) + + assert process_executor_utils._ask_for_action_files(window) == [] + # It used to do nothing at all. + (told,) = picked["told"] + assert str(tmp_path) in told[2] diff --git a/test/test_utils/test_utils_has_no_qt.py b/test/test_utils/test_utils_has_no_qt.py new file mode 100644 index 00000000..b4976fc2 --- /dev/null +++ b/test/test_utils/test_utils_has_no_qt.py @@ -0,0 +1,30 @@ +"""`pybreeze/utils/` is pure logic: nothing in it imports Qt or JEditor.""" +from __future__ import annotations + +import ast +from pathlib import Path + +import pybreeze.utils + +_UTILS = Path(pybreeze.utils.__file__).parent +_UI_PACKAGES = ("PySide6", "je_editor") + + +def _imported_roots(source: Path) -> set[str]: + roots: set[str] = set() + for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"))): + if isinstance(node, ast.Import): + roots.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + roots.add(node.module.split(".")[0]) + return roots + + +def test_no_module_in_utils_imports_a_ui_package(): + offenders = [ + str(source.relative_to(_UTILS)) for source in sorted(_UTILS.rglob("*.py")) + if _imported_roots(source) & set(_UI_PACKAGES) + ] + + # A folder dialog lived here once; dialogs belong with their callers. + assert offenders == [] From 89446f25ac8fb81da9a6fb63d3a6878640773ecf Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:03:34 +0800 Subject: [PATCH 102/312] Close an SFTP session that comes up after Disconnect or tab close --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 18 +++++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_file_viewer_widget.py | 64 +++++++++++---- test/test_utils/test_ssh_reentrancy.py | 81 +++++++++++++++++++ 5 files changed, 152 insertions(+), 20 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 62f7bd01..7cd4a4e7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -288,14 +288,14 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,604 行) +### `ssh/`(1,636 行) | 檔案 | 職責 | |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,ANSI escape 用 regex 剝除;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (793) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;連線交給 `SshConnectThread`,成功後才列出根目錄;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (829) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 95 個 `test_*.py`、1461 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 95 個 `test_*.py`、1464 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index f67b6196..7c7b646e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1337,3 +1337,21 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_run_folder.py`, `test_utils_has_no_qt.py` (new) - `architecture.md` §2 table, `architecture_explore.md` §4.2, §12, §18 - **Open items**: none. + +## U-20260923-77 · 2026-09-23 · SFTP: a session that comes up after Disconnect or tab close is closed · #fix #ssh + +- **What**: the SFTP file tree's connect (`connect_gui/ssh/ssh_file_viewer_widget.py`, `SFTPClientWrapper.connect`) runs on the connect thread and kept the client only in `self._ssh`, which Disconnect and tab close set to `None` from the UI thread. While the TCP connect runs (up to 10 s), `SSHClient.close()` does nothing, so the connect went on and logged in. Then `self._ssh.get_transport()` raised `AttributeError`, which `SshConnectThread` does not catch. Neither `connected` nor `failed` was emitted, the status was not updated, and an authenticated session stayed open, sending keepalives, until the IDE exited. Found in the review sweep of `connect_gui/ssh/`. +- **Fix**: + - `connect()` works on its own client in a local variable. Once SFTP is open, it keeps the session only if the wrapper still holds that client; otherwise it closes it and raises `ConnectAbandoned` (a `RuntimeError`, so the thread reports it as a failure). On any failure it closes only its own client, and clears `self._ssh` only if that is still its own. + - The login is split out as `_log_in()`. + - `_disconnect()` while a connect is running hands the thread to `let_run_out()`. A Disconnect the user asked for no longer ends in a "connection failed" box, and Connect can be clicked again at once. +- **Tests**: `test_ssh_reentrancy.py::TestTheRealWrapperGivenUpOn`, using the real wrapper with a stand-in paramiko client: + - a session that comes up after `close()` is closed and not kept; + - a connect left alone is kept; + - Disconnect while connecting shows no failure and frees Connect. +- **Result / numbers**: unit tests 1450 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `test/test_utils/test_ssh_reentrancy.py` + - `architecture_explore.md` §9, §18 +- **Open items**: none here. The same sweep's other SSH findings are taken up one by one. diff --git a/docs/updates/README.md b/docs/updates/README.md index da269058..f999e14c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-77 | 2026-09-23 | SFTP: a session that comes up after Disconnect or tab close is closed | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-76 | 2026-09-23 | Run a folder: a parented dialog, a message when nothing matches, and a Qt-free utils | #fix #executor #refactor | [2026-09](2026-09.md) | | U-20260923-75 | 2026-09-23 | AI review panel: Send always comes back, and only an answer can be voted on | #fix #ai | [2026-09](2026-09.md) | | U-20260923-74 | 2026-09-23 | Install prthinker: a parented dialog, and only prthinker's source is kept | #done #prthinker | [2026-09](2026-09.md) | @@ -157,4 +158,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 94 | +| [2026-09.md](2026-09.md) | 2026-09 | 95 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index bba33c25..82bffee2 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -87,6 +87,10 @@ def remote_join(directory: str, name: str) -> str: return joined if joined.startswith("/") else f"/{joined}" +class ConnectAbandoned(RuntimeError): + """The session came up after the connect was given up on, and was closed.""" + + class SFTPClientWrapper: """ Lightweight wrapper around Paramiko SFTP client. @@ -105,33 +109,52 @@ def connect(self, host: str, port: int, username: str, password: str, """ Establish SSH + SFTP connection. 建立 SSH + SFTP 連線。 + + Runs on the connect thread while ``close()`` may run on the UI thread + (Disconnect, or the tab closing). The session is kept only if it is + still this connect's when it is up; otherwise it is closed and + ``ConnectAbandoned`` raised. Closing during the TCP connect cannot stop + it, and the session it went on to log in used to stay open, unseen, + until the IDE exited. """ self.close() - self._ssh = paramiko.SSHClient() - apply_host_key_policy(self._ssh, parent_widget) + ssh = paramiko.SSHClient() + self._ssh = ssh + apply_host_key_policy(ssh, parent_widget) pybreeze_logger.info("SFTP connecting to %s:%s", host, port) try: - if use_key and key_path: - pkey = load_private_key(key_path, password, context="SFTP") - if pkey is None: - raise ValueError( - self.word_dict.get("ssh_command_widget_error_message_unsupported_private_key") - ) - self._ssh.connect(hostname=host, port=port, username=username, pkey=pkey, timeout=10, - disabled_algorithms=SHA1_ALGORITHMS) - else: - self._ssh.connect(hostname=host, port=port, username=username, password=password, - timeout=10, disabled_algorithms=SHA1_ALGORITHMS) - transport = self._ssh.get_transport() + self._log_in(ssh, host, port, username, password, use_key, key_path) + transport = ssh.get_transport() if transport is not None: transport.set_keepalive(SSH_KEEPALIVE_SECONDS) - self._sftp = self._ssh.open_sftp() + sftp = ssh.open_sftp() + if self._ssh is not ssh: + raise ConnectAbandoned("SFTP connect abandoned") + self._sftp = sftp except Exception: # A failure partway (auth, keepalive, or open_sftp) must not leak the # half-open SSH transport: tear it down so connect() is all-or-nothing. - self.close() + # Only this connect's own: a newer one may be under way by now. + ssh.close() + if self._ssh is ssh: + self._ssh = None raise + def _log_in(self, ssh: paramiko.SSHClient, host: str, port: int, username: str, + password: str, use_key: bool, key_path: str) -> None: + """Connect *ssh* with the key file, or the password when no key is used.""" + if not (use_key and key_path): + ssh.connect(hostname=host, port=port, username=username, password=password, + timeout=10, disabled_algorithms=SHA1_ALGORITHMS) + return + pkey = load_private_key(key_path, password, context="SFTP") + if pkey is None: + raise ValueError( + self.word_dict.get("ssh_command_widget_error_message_unsupported_private_key") + ) + ssh.connect(hostname=host, port=port, username=username, pkey=pkey, timeout=10, + disabled_algorithms=SHA1_ALGORITHMS) + def close(self): """ Close SFTP and SSH safely. @@ -440,7 +463,16 @@ def _disconnect(self): """ Disconnect SSH. 斷線 SSH。 + + A connect still under way is given up on: it is cut off from this + widget (no "connection failed" for a disconnect the user asked for), + and the session it may still bring up is closed by + ``SFTPClientWrapper.connect`` itself. Connect can be clicked again. """ + connecting = self._connecting + if connecting is not None and connecting.isRunning(): + let_run_out(connecting, connecting.connected, connecting.failed) + self._connecting = None self.client.close() self._clear_tree() self.state_changed.emit() diff --git a/test/test_utils/test_ssh_reentrancy.py b/test/test_utils/test_ssh_reentrancy.py index 91ff49b7..d118ed8b 100644 --- a/test/test_utils/test_ssh_reentrancy.py +++ b/test/test_utils/test_ssh_reentrancy.py @@ -219,6 +219,87 @@ def test_closing_while_connecting_closes_the_late_session(self, app): assert manager.closes > closes_at_close +class LateSession(FakeClient): + """A paramiko client that logs in after *release* and opens SFTP.""" + + def __init__(self, release: threading.Event) -> None: + super().__init__(release) + self.sftp_opened = False + + def get_transport(self) -> None: + return None + + def open_sftp(self) -> object: + self.sftp_opened = True + return object() + + +class TestTheRealWrapperGivenUpOn: + """Closed while it connects, the SFTP wrapper closes the session it still brings up.""" + + def _connect_in_background(self, monkeypatch, client: FakeClient): + monkeypatch.setattr(tree_mod.paramiko, "SSHClient", lambda: client) + monkeypatch.setattr(tree_mod, "apply_host_key_policy", lambda _client, _parent: None) + wrapper = tree_mod.SFTPClientWrapper() + raised: list = [] + + def connect() -> None: + try: + wrapper.connect("host", 22, "user", "pw") + except Exception as error: # noqa: BLE001 — the test records what the connect raised + raised.append(error) + else: + raised.append(None) + + worker = threading.Thread(target=connect) + worker.start() + return wrapper, raised, worker + + def test_a_session_up_after_close_is_closed_and_not_kept(self, app, monkeypatch): + release = threading.Event() + client = LateSession(release) + wrapper, raised, worker = self._connect_in_background(monkeypatch, client) + _wait_for(lambda: client.connect_thread is not None) + + wrapper.close() # Disconnect during the TCP connect: nothing to close yet + client.closed = False + release.set() + worker.join(WAIT_SECONDS) + + # It used to log in, fail on the emptied wrapper with an AttributeError + # no one caught, and leave the session open until the IDE exited. + assert isinstance(raised[0], tree_mod.ConnectAbandoned) + assert client.closed + assert not wrapper.connected + + def test_a_connect_left_alone_is_kept(self, app, monkeypatch): + release = threading.Event() + release.set() + client = LateSession(release) + wrapper, raised, worker = self._connect_in_background(monkeypatch, client) + worker.join(WAIT_SECONDS) + + assert raised == [None] + assert wrapper.connected and not client.closed + + def test_disconnect_while_connecting_reports_no_failure(self, app, monkeypatch): + release = threading.Event() + widget = _tree(FakeManager(release, OSError("closed under it"))) + shown = [] + monkeypatch.setattr(tree_mod.QMessageBox, "critical", lambda *args: shown.append(args)) + + widget._connect() + thread = widget._connecting + widget._disconnect() + release.set() + _wait_for(lambda: not is_kept(thread)) + QApplication.processEvents() + + assert shown == [] + assert widget._connecting is None # Connect can be clicked again + widget.close() + + class TestHostKeyAsker: """The unknown-host question is shown on the UI thread whoever asks it.""" From 58360ed7e33c4f2afe766f5cb8961f1343a90704 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:03:38 +0800 Subject: [PATCH 103/312] Let go of a run window closed mid-run once its run ends --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 20 +++++ docs/updates/README.md | 3 +- .../process_executor/file_runner_process.py | 3 + .../python_task_process_manager.py | 1 + .../show_code_window/code_window.py | 20 ++++- test/test_utils/test_code_window.py | 78 +++++++++++++++++++ 7 files changed, 124 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 7cd4a4e7..63f775ec 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -112,7 +112,7 @@ Template Method 定義的子行程生命週期: | `start_process()` | 建 `CodeWindow` + `TaskProcessManager` → `start_test_process()` | | `build_process_from_file()` | 以檔案路徑執行單一檔案 | | `run_dir_files_with_package()` | 問使用者選資料夾(`_ask_for_action_files()`,對話框掛在主視窗上;資料夾裡沒有 `.json` 就明說),對每個 `.json` 開一個執行視窗批次跑 | -| `open_run_window()` | 開一個執行視窗、掛進 `main_window.current_run_code_window`,並接上 `finished_and_closed`:使用者關掉一個已經跑完的執行視窗時主視窗就放掉它(以前這份清單只增不減,每次執行都留下一個視窗、一個執行器、兩個 queue 和一個 timer)。插件執行也走這裡 | +| `open_run_window()` | 開一個執行視窗、掛進 `main_window.current_run_code_window`,並接上 `finished_and_closed`:使用者關掉一個已經跑完的執行視窗時主視窗就放掉它(以前這份清單只增不減,每次執行都留下一個視窗、一個執行器、兩個 queue 和一個 timer);執行中被關掉的視窗記下 `_closed_while_running`,等執行器在結束路徑尾端呼叫 `CodeWindow.run_ended()` 時才放掉(排到 timer 的 slot 回來之後才發,視窗是 timer 的 parent)。插件執行也走這裡 | | `build_task_process()` | 共用建構:`open_run_window()` 並帶上主視窗選定的直譯器、決定要不要接 `send_after_test`。建好的 `TaskProcessManager` 掛在執行視窗的 `runner` 上,所以呼叫端可以不留參考。prthinker 審查也走這裡 | ### 4.3 各自動化模組(Strategy) @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 95 個 `test_*.py`、1464 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 95 個 `test_*.py`、1468 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 7c7b646e..f92fe701 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1355,3 +1355,23 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_ssh_reentrancy.py` - `architecture_explore.md` §9, §18 - **Open items**: none here. The same sweep's other SSH findings are taken up one by one. + +## U-20260923-78 · 2026-09-23 · A run window closed mid-run is let go of when its run ends · #fix #executor + +- **What**: a run window (`show_code_window/code_window.py`) emits `finished_and_closed`, which lets the main window drop it from `current_run_code_window`, only from `closeEvent` and only when nothing is running. A window closed while its program still ran never emitted it. The window stayed in the list for the rest of the session, with its executor, queues, timer and up to 10,000 lines of output: the growth the signal was added to stop. Found in the review sweep of `show_code_window/`. +- **Fix**: + - `closeEvent` records `_closed_while_running` instead. + - New `CodeWindow.run_ended()`: when that flag is set, it emits `finished_and_closed` through `QTimer.singleShot(0, ...)`. The list may hold the window's last reference, and the executor's timer, whose slot is still on the stack, is the window's child. + - `TaskProcessManager.exit_program()` calls it last, after the task-done hook. `FileRunnerProcess._finish()` calls it after its output and binary clean-up, and after a failed compile that starts nothing next. +- **Tests**: `test_code_window.py`: + - a window closed mid-run is kept while the run goes on; + - it is let go of after the run ends, not synchronously; + - a window still open is not let go of; + - `FileRunnerProcess` calls `run_ended` after a real run. +- **Result / numbers**: unit tests 1454 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/show_code_window/code_window.py` + - `pybreeze/extend/process_executor/python_task_process_manager.py`, `file_runner_process.py` + - `test/test_utils/test_code_window.py` + - `architecture_explore.md` §4.2, §18 +- **Open items**: none. #2 (whether closing a run window should stop its run) is still the owner's decision. diff --git a/docs/updates/README.md b/docs/updates/README.md index f999e14c..4734dff4 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-78 | 2026-09-23 | A run window closed mid-run is let go of when its run ends | #fix #executor | [2026-09](2026-09.md) | | U-20260923-77 | 2026-09-23 | SFTP: a session that comes up after Disconnect or tab close is closed | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-76 | 2026-09-23 | Run a folder: a parented dialog, a message when nothing matches, and a Qt-free utils | #fix #executor #refactor | [2026-09](2026-09.md) | | U-20260923-75 | 2026-09-23 | AI review panel: Send always comes back, and only an answer can be voted on | #fix #ai | [2026-09](2026-09.md) | @@ -158,4 +159,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 95 | +| [2026-09.md](2026-09.md) | 2026-09 | 96 | diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index 71fdb8e0..9bb46ba2 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -198,6 +198,8 @@ def _finish(self) -> None: self.process = None if after_exit is not None: after_exit(exit_code) + if self.process is None: # the compile failed: nothing runs next + self.main_window.run_ended() return self.main_window.append_output( f"\n[Process exited with code {exit_code}]\n", @@ -210,6 +212,7 @@ def _finish(self) -> None: os.remove(self._cleanup_binary) except OSError as error: pybreeze_logger.debug("Could not remove compiled binary %s: %s", self._cleanup_binary, error) + self.main_window.run_ended() def _drain_queues(self) -> None: """Drain all remaining messages from output/error queues to UI.""" diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 427c6ffd..43786e86 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -202,6 +202,7 @@ def exit_program(self): self.task_done_trigger_function() except Exception as error: # noqa: BLE001 — a failing hook (e.g. the report mail) must not break the run window pybreeze_logger.error("Task done trigger failed: %r", error) + self.main_window.run_ended() def drain_and_display_queue(self): pump_message_queue( diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index 04cc47cd..457d45ec 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict -from PySide6.QtCore import Signal +from PySide6.QtCore import QTimer, Signal from PySide6.QtGui import QGuiApplication, QTextCharFormat, QTextCursor from PySide6.QtWidgets import QWidget, QGridLayout, QTextEdit, QScrollArea @@ -42,6 +42,7 @@ def __init__(self): # an executor nobody else holds is collected once its reader threads # end, and the rest of the output and the exit line never arrive. self.runner: TaskProcessManager | FileRunnerProcess | None = None + self._closed_while_running = False self.grid_layout = QGridLayout() self.code_result = QTextEdit() self.code_result.setLineWrapMode(self.code_result.LineWrapMode.NoWrap) @@ -70,11 +71,26 @@ def closeEvent(self, event) -> None: A run that is still going keeps its window in the list: it is where the rest of the output goes, and closing the IDE stops it from there. + It is let go of when the run ends (``run_ended``); it used to stay for + the rest of the session. """ - if not self.is_running(): + if self.is_running(): + self._closed_while_running = True + else: self.finished_and_closed.emit() super().closeEvent(event) + def run_ended(self) -> None: + """Called by the executor once its run is over and its output is in. + + A window the user closed while the run went on can go now. Emitted + after the executor's timer slot returns, not from inside it: the list + may hold the last reference to this window, and the timer is its child. + """ + if self._closed_while_running: + self._closed_while_running = False + QTimer.singleShot(0, self, self.finished_and_closed.emit) + def is_running(self) -> bool: """Whether the executor writing here still has a child running.""" runner = self.runner diff --git a/test/test_utils/test_code_window.py b/test/test_utils/test_code_window.py index 2f768a5c..065965f6 100644 --- a/test/test_utils/test_code_window.py +++ b/test/test_utils/test_code_window.py @@ -146,3 +146,81 @@ def test_a_reader_who_scrolled_up_is_left_alone(self, qt_app): assert scroll_bar.value() == 10 assert scroll_bar.maximum() > 10 + + +class TestClosedWhileRunning: + """A run window closed mid-run is let go of when its run ends, not at IDE exit.""" + + class Running: + def __init__(self) -> None: + self.returncode = None + + def poll(self): + return self.returncode + + def _window(self): + from types import SimpleNamespace + + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + process = self.Running() + window.runner = SimpleNamespace(process=process) + forgotten: list = [] + window.finished_and_closed.connect(lambda: forgotten.append(window)) + return window, process, forgotten + + def test_it_is_kept_while_the_run_goes_on(self, qt_app): + window, _process, forgotten = self._window() + + window.close() + qt_app.processEvents() + + assert forgotten == [] + + def test_it_is_let_go_of_when_the_run_ends(self, qt_app): + window, process, forgotten = self._window() + window.close() + + process.returncode = 0 + window.run_ended() + assert forgotten == [] # not from inside the executor's timer slot + qt_app.processEvents() + + # It used to stay in the main window's list until the IDE exited. + assert forgotten == [window] + + def test_a_window_still_open_is_not_let_go_of(self, qt_app): + window, process, forgotten = self._window() + + process.returncode = 0 + window.run_ended() + qt_app.processEvents() + + assert forgotten == [] + + +def test_the_file_runner_says_when_the_run_ended(qt_app, tmp_path): + import sys + import time + + from PySide6.QtWidgets import QApplication + + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + script = tmp_path / "quick.py" + script.write_text("print('done')\n", encoding="utf-8") + window = CodeWindow() + ended: list = [] + window.run_ended = lambda: ended.append(True) + runner = FileRunnerProcess(window) + + runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + deadline = time.monotonic() + 30 + while not ended: + assert time.monotonic() < deadline, "the run never ended" + QApplication.processEvents() + time.sleep(0.01) + + assert ended == [True] From 3e740e2f7fedaa0975806580107bdeb35025a0ac Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:03:39 +0800 Subject: [PATCH 104/312] Show why JupyterLab did not start, and do not log a closed tab as a failure --- architecture_explore.md | 6 +-- docs/updates/2026-09.md | 19 +++++++ docs/updates/README.md | 3 +- .../jupyter_lab_gui/jupyter_lab_thread.py | 15 ++++-- .../jupyter_lab_gui/jupyter_lab_widget.py | 17 ++++-- test/test_utils/test_jupyter_ready.py | 53 +++++++++++++++++++ 6 files changed, 101 insertions(+), 12 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 63f775ec..c07747f6 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -313,8 +313,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 10. `pybreeze_ui/jupyter_lab_gui/` -- `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `get_venv_python()` → `is_jupyter_installed()`(缺就自動裝)→ 啟動 server(`_start_server()`,在 `_process_lock` 裡先看 `_stopped`:`stop()` 之後就不再啟動,即使還在安裝)→ `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)`。server 的輸出寫進暫存檔而不是管線(server 起來後沒人讀管線,緩衝區滿了它會卡在 `write()`);提早結束時錯誤訊息取這個檔案的尾巴 -- `jupyter_lab_widget.py` — 收到 URL 後用 `QWebEngineView.setUrl()` 載入;`closeEvent` 一律關掉 server(launcher 執行緒在 lab 載入完就結束了,只停「還在跑的執行緒」等於從不停 server);還在安裝或啟動的 launcher 交給 `let_run_out()`,不在 UI 執行緒等(安裝可能要好幾分鐘),也不用 `blockSignals`(那會連 `finished` 一起擋掉,keeper 永遠放不掉它)。IDE 關閉時 `PyBreezeMainWindow._close_tool_tabs_and_docks()` 會關掉所有非編輯器分頁與 `DestroyDock`,這個 `closeEvent` 才會被呼叫到 +- `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `get_venv_python()` → `is_jupyter_installed()`(缺就自動裝)→ 啟動 server(`_start_server()`,在 `_process_lock` 裡先看 `_stopped`:`stop()` 之後就不再啟動,即使還在安裝)→ `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)`。server 的輸出寫進暫存檔而不是管線(server 起來後沒人讀管線,緩衝區滿了它會卡在 `write()`);提早結束時錯誤訊息取這個檔案的尾巴。失敗時 `error_occurred` 送的是原因(例外訊息,最多 2,000 字),traceback 只進 log;已經 `stop()`(分頁關了)的失敗不算失敗,只記 debug +- `jupyter_lab_widget.py` — 收到 URL 後用 `QWebEngineView.setUrl()` 載入;失敗時在狀態列顯示「初始化失敗:原因」(純文字、可選取、自動換行);`closeEvent` 一律關掉 server(launcher 執行緒在 lab 載入完就結束了,只停「還在跑的執行緒」等於從不停 server);還在安裝或啟動的 launcher 交給 `let_run_out()`,不在 UI 執行緒等(安裝可能要好幾分鐘),也不用 `blockSignals`(那會連 `finished` 一起擋掉,keeper 永遠放不掉它)。IDE 關閉時 `PyBreezeMainWindow._close_tool_tabs_and_docks()` 會關掉所有非編輯器分頁與 `DestroyDock`,這個 `closeEvent` 才會被呼叫到 安全前提(CLAUDE.md 已明列):server 只綁 localhost,因此 token/password 刻意留空、`disable_check_xsrf=True` 才能內嵌。**不設 `allow_origin`**:loopback 擋不住瀏覽器,開放來源的話使用者逛到的任何網頁都能操作這個沒有 token 的 server。 @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 95 個 `test_*.py`、1468 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 95 個 `test_*.py`、1471 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index f92fe701..a08aa23e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1375,3 +1375,22 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_code_window.py` - `architecture_explore.md` §4.2, §18 - **Open items**: none. #2 (whether closing a run window should stop its run) is still the owner's decision. + +## U-20260923-79 · 2026-09-23 · JupyterLab tab says why it did not start; a closed tab is no failure · #fix #jupyter + +- **What**: two faults in the JupyterLab tab (`jupyter_lab_gui/`), found in the review sweep: + - Whatever stopped the lab showed only "JupyterLab init failed": no venv found, a failed `pip install`, a timeout, or the server exiting early. The reason, including the output tail `_wait_until_ready` collects for it, went to the log only. + - Closing the tab while the server started logged a false error. `stop()` ends the server, the wait then sees it exit, and the launcher logged "JupyterLab launch failed" with a traceback. +- **Fix**: + - `JupyterLauncherThread.run()` emits the exception's message (its last 2,000 characters, since pip's stderr can be long) and logs the traceback with lazy formatting instead of an f-string. A failure after `stop()` is logged at debug level and not reported. + - `JupyterLabWidget.show_error()` shows "init failed: " as plain text (`Qt.TextFormat.PlainText`: the output is not markup), word-wrapped and selectable, and no longer logs the message a second time. +- **Tests**: `test_jupyter_ready.py::TestWhatAFailureShows`: + - the reason reaches the tab and the traceback goes only to the log; + - a failure after the tab closed is neither reported nor logged as an error; + - the tab shows the reason as plain text. +- **Result / numbers**: unit tests 1457 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py`, `jupyter_lab_widget.py` + - `test/test_utils/test_jupyter_ready.py` + - `architecture_explore.md` §10, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 4734dff4..bbd0bd39 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-79 | 2026-09-23 | JupyterLab tab says why it did not start; a closed tab is no failure | #fix #jupyter | [2026-09](2026-09.md) | | U-20260923-78 | 2026-09-23 | A run window closed mid-run is let go of when its run ends | #fix #executor | [2026-09](2026-09.md) | | U-20260923-77 | 2026-09-23 | SFTP: a session that comes up after Disconnect or tab close is closed | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-76 | 2026-09-23 | Run a folder: a parented dialog, a message when nothing matches, and a Qt-free utils | #fix #executor #refactor | [2026-09](2026-09.md) | @@ -159,4 +160,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 96 | +| [2026-09.md](2026-09.md) | 2026-09 | 97 | 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 e83dfe4b..f5588876 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py @@ -16,6 +16,8 @@ from pybreeze.utils.subprocess_util import no_window_creationflags JUPYTER_STARTUP_TIMEOUT = 60 +# How much of a failure's reason the tab shows: pip's stderr can run long +_SHOWN_REASON_CHARACTERS = 2000 def find_free_port() -> int: @@ -116,13 +118,18 @@ def run(self): self.server_ready.emit(f"http://localhost:{port}/lab") # OSError includes the TimeoutError of a server that never came up - except (OSError, ValueError, RuntimeError, subprocess.SubprocessError): - err = traceback.format_exc() + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + if self._stopped.is_set(): + # The tab closed: stop() ended the server, and the wait saw it + # exit. Not a failure, and it used to be logged as one. + pybreeze_logger.debug("JupyterLab launch stopped with its tab: %r", error) + return # Tear down a half-started server so a startup timeout doesn't leave an # orphaned JupyterLab process running and holding the port. self.stop() - self.error_occurred.emit(err) - pybreeze_logger.error(f"JupyterLab launch failed: {err}") + pybreeze_logger.error("JupyterLab launch failed: %s", traceback.format_exc()) + # The reason, not the traceback: the tab shows it. + self.error_occurred.emit(str(error)[-_SHOWN_REASON_CHARACTERS:]) def _start_server(self, python_exe: str, port: int) -> subprocess.Popen: """Start the server on *port*, its output going to ``self._output``. 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 47b0b991..21219239 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py @@ -2,14 +2,13 @@ import sys -from PySide6.QtCore import QUrl +from PySide6.QtCore import Qt, QUrl from PySide6.QtWebEngineWidgets import QWebEngineView from PySide6.QtWidgets import QApplication, QVBoxLayout, QWidget, QLabel from je_editor import language_wrapper from pybreeze.pybreeze_ui.jupyter_lab_gui.jupyter_lab_thread import JupyterLauncherThread from pybreeze.pybreeze_ui.thread_keeper import let_run_out -from pybreeze.utils.logging.logger import pybreeze_logger class JupyterLabWidget(QWidget): @@ -48,9 +47,19 @@ def load_lab(self, url): self.browser.show() def show_error(self, msg): + """Say that the lab did not start, and why. + + The reason (no venv found, a failed pip install, the server's last + output) used to go to the log only, behind a bare "init failed". The + launcher has logged it already. + """ if self.status_label: - self.status_label.setText(language_wrapper.language_word_dict.get("jupyterlab_init_failed")) - pybreeze_logger.error(msg) + # Plain text: pip's or the server's output is not markup + self.status_label.setTextFormat(Qt.TextFormat.PlainText) + self.status_label.setWordWrap(True) + self.status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.status_label.setText( + f"{language_wrapper.language_word_dict.get('jupyterlab_init_failed')}: {msg}") def closeEvent(self, event): """Stop the server with the tab. diff --git a/test/test_utils/test_jupyter_ready.py b/test/test_utils/test_jupyter_ready.py index fc9d166a..4ee06580 100644 --- a/test/test_utils/test_jupyter_ready.py +++ b/test/test_utils/test_jupyter_ready.py @@ -102,3 +102,56 @@ def _boom(_port): # The half-started server must be terminated, not left holding the port. assert proc.terminated is True assert errors + + +class TestWhatAFailureShows: + def _run(self, monkeypatch, fail, stop_first=False): + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + + monkeypatch.setattr(mod, "get_venv_python", fail) + thread = mod.JupyterLauncherThread() + if stop_first: + thread.stop() + errors: list = [] + logged: list = [] + thread.error_occurred.connect(errors.append) + monkeypatch.setattr(mod.pybreeze_logger, "error", lambda *args: logged.append(args)) + thread.run() + return errors, logged + + def test_the_reason_reaches_the_tab_without_the_traceback(self, qt_app, monkeypatch): + def no_venv(): + raise RuntimeError("Cannot find venv python executable") + + errors, logged = self._run(monkeypatch, no_venv) + + assert errors == ["Cannot find venv python executable"] + assert logged # the traceback still goes to the log + + def test_a_tab_closed_during_startup_is_not_a_failure(self, qt_app, monkeypatch): + def exited(): + raise RuntimeError("JupyterLab exited early (code 1): ") + + # stop() ends the server; the wait then sees it gone. That used to be + # logged as "JupyterLab launch failed". + errors, logged = self._run(monkeypatch, exited, stop_first=True) + + assert errors == [] + assert logged == [] + + def test_the_tab_says_why_as_plain_text(self, qt_app, monkeypatch): + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_widget + from PySide6.QtCore import Qt + + update_language_dict() + monkeypatch.setattr(jupyter_lab_widget.JupyterLauncherThread, "start", lambda self: None) + tab = jupyter_lab_widget.JupyterLabWidget() + + tab.show_error("ERROR: No matching distribution for jupyterlab") + + # It used to say only "init failed". + assert "No matching distribution" in tab.status_label.text() + assert tab.status_label.textFormat() == Qt.TextFormat.PlainText + tab.close() + tab.deleteLater() From 03c8cb62aff9077fa695f571f3d1a68bcbf4f3cc Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:03:41 +0800 Subject: [PATCH 105/312] Close the SSH session when the shell ends on the server --- architecture_explore.md | 6 ++-- docs/updates/2026-09.md | 14 ++++++++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_command_widget.py | 10 ++++++ test/test_utils/test_ssh_teardown.py | 36 +++++++++++++++++++ 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index c07747f6..bd62640d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -288,13 +288,13 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,636 行) +### `ssh/`(1,646 行) | 檔案 | 職責 | |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | -| `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,ANSI escape 用 regex 剝除;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | +| `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,ANSI escape 用 regex 剝除;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | | `ssh_file_viewer_widget.py` (829) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 95 個 `test_*.py`、1471 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 95 個 `test_*.py`、1473 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index a08aa23e..c3a95c9b 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1394,3 +1394,17 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_jupyter_ready.py` - `architecture_explore.md` §10, §18 - **Open items**: none. + +## U-20260923-80 · 2026-09-23 · SSH shell ended by the server closes its session and reports both halves · #fix #ssh + +- **What**: when the shell ended on the server's side (`exit`, a dropped link), `SSHCommandWidget._on_closed` (`connect_gui/ssh/ssh_command_widget.py`) only printed a line and wrote "disconnected" into the status label. The SSH client and its transport stayed open, sending keepalives, until the next Connect, Disconnect or tab close. `state_changed` was not emitted either, and the label is shared with the file tree, so a file tree that was still connected was shown as disconnected. Found in the review sweep of `connect_gui/ssh/`. +- **Fix**: `_on_closed` does what Disconnect does: `_cleanup()` closes the reader, channel and client, then it emits `state_changed`, and the combined view reports both halves again. +- **Tests**: `test_ssh_teardown.py::TestTheShellEndingOnTheServer`: + - the channel and the client are closed; + - with the file tree still connected, the shared label says "files only". +- **Result / numbers**: unit tests 1459 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py` + - `test/test_utils/test_ssh_teardown.py` + - `architecture_explore.md` §9, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index bbd0bd39..f667ee69 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-80 | 2026-09-23 | SSH shell ended by the server closes its session and reports both halves | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-79 | 2026-09-23 | JupyterLab tab says why it did not start; a closed tab is no failure | #fix #jupyter | [2026-09](2026-09.md) | | U-20260923-78 | 2026-09-23 | A run window closed mid-run is let go of when its run ends | #fix #executor | [2026-09](2026-09.md) | | U-20260923-77 | 2026-09-23 | SFTP: a session that comes up after Disconnect or tab close is closed | #fix #ssh | [2026-09](2026-09.md) | @@ -160,4 +161,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 97 | +| [2026-09.md](2026-09.md) | 2026-09 | 98 | 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 f83e8134..86b3eda3 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -331,11 +331,21 @@ def _on_data(self, data: bytes): self._insert_output(self._decoder.feed(data)) def _on_closed(self, msg: str): + """The shell ended on the server's side (``exit``, a dropped link). + + The session goes with it, as for Disconnect: it used to stay open, + sending keepalives, until the next Connect or the tab closing. And the + status is reported through ``state_changed``, which the combined view + turns into both halves' state; writing "disconnected" into the shared + label hid a file tree that was still connected. + """ self.append_text(f"\n{self.word_dict.get('ssh_command_widget_log_message_channel_closed')}" f" {msg}\n") + self._cleanup() self.login_widget.status_label.setText(self.word_dict.get( 'ssh_command_widget_status_label_disconnected' )) + self.state_changed.emit() def send_command(self): cmd = self.command_input_edit.text() diff --git a/test/test_utils/test_ssh_teardown.py b/test/test_utils/test_ssh_teardown.py index 20fa2224..f58e5b4f 100644 --- a/test/test_utils/test_ssh_teardown.py +++ b/test/test_utils/test_ssh_teardown.py @@ -267,3 +267,39 @@ def test_it_reports_both_halves(self, app, shell_up, files_up, expected): tab.report_connection_state() assert expected in tab.login_widget.status_label.text() + + +class TestTheShellEndingOnTheServer: + """``exit`` in the shell, or a dropped link: the reader reports the channel closed.""" + + def test_the_session_is_closed_with_it(self, app): + widget = TestClosingTheShell()._connected_widget(app) + channel, client = widget.shell_channel, widget.ssh_client + + widget._on_closed("EOF") + + # It used to stay open, sending keepalives, until the next Connect. + assert channel.closed and client.closed + assert widget.ssh_client is None + + def test_the_shared_label_still_reports_a_connected_file_tree(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_main_widget import SSHMainWidget + + tab = SSHMainWidget() + tab.command_widget.reader_thread = FakeReaderThread() + tab.command_widget.shell_channel = FakeChannel() + tab.command_widget.ssh_client = FakeClient() + + class Client: + connected = True + + @staticmethod + def close() -> None: + """Nothing to close in a stand-in.""" + + tab.file_tree.client = Client() + + tab.command_widget._on_closed("EOF") + + # It used to say "Disconnected" with the file tree still up. + assert "files only" in tab.login_widget.status_label.text() From 7a2482b95671ae46916b6457d973ce43ec1baddb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:03:42 +0800 Subject: [PATCH 106/312] Ask about an unknown SSH host key once per Connect and keep every accepted host --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 24 ++++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_host_key_policy.py | 84 ++++++++++--- test/test_utils/test_ssh_host_key_policy.py | 116 ++++++++++++++++++ 5 files changed, 214 insertions(+), 19 deletions(-) create mode 100644 test/test_utils/test_ssh_host_key_policy.py diff --git a/architecture_explore.md b/architecture_explore.md index bd62640d..2d85b49c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -288,7 +288,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,646 行) +### `ssh/`(1,700 行) | 檔案 | 職責 | |---|---| @@ -296,7 +296,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,ANSI escape 用 regex 剝除;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | | `ssh_file_viewer_widget.py` (829) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | -| `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等 | +| `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 95 個 `test_*.py`、1473 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 96 個 `test_*.py`、1478 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index c3a95c9b..ce0e6e15 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1408,3 +1408,27 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_ssh_teardown.py` - `architecture_explore.md` §9, §18 - **Open items**: none. + +## U-20260923-81 · 2026-09-23 · Unknown SSH host key: asked once per Connect, and no accepted host written away · #fix #ssh #security + +- **What**: the SSH host key policy (`connect_gui/ssh/ssh_host_key_policy.py`, `InteractiveHostKeyPolicy.missing_host_key`), found in the review sweep: + - One Connect in the SSH tab starts two connects, the shell's and the file tree's. Each client loaded `~/.pybreeze/ssh_known_hosts` at the click, so a new host brought up two identical fingerprint questions, the second on top of the first. + - Accepting saved with `client.save_host_keys()`, which writes the client's own copy of the file as read at its Connect. Two tabs accepting two new hosts at the same time wrote one of them away, and that host was asked about again next time. +- **Fix**: + - The lookup, question and save run one at a time under a module-level lock. Only connect threads take it, since both widgets connect off the UI thread, so the UI thread showing the question never waits on it. + - Before asking, the file is read again; a key the other half has just accepted is taken without a question. + - A "no" is remembered for 10 seconds per (host, fingerprint), so the other half is refused without asking again. A deliberate retry later is asked as usual. + - Saving reads the file as it is now and adds the key (`_store()`). + - A different key for a known host still reaches the question. +- **Tests**: new `test_ssh_host_key_policy.py`, with real paramiko RSA keys: + - both halves of one Connect ask once; + - a "no" is not asked again by the other half; + - two tabs accepting two hosts keep both in the file; + - another key for a known host is still asked about; + - questions from two threads come one at a time. +- **Result / numbers**: unit tests 1464 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py` + - `test/test_utils/test_ssh_host_key_policy.py` (new) + - `architecture_explore.md` §9, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index f667ee69..8095168d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-81 | 2026-09-23 | Unknown SSH host key: asked once per Connect, and no accepted host written away | #fix #ssh #security | [2026-09](2026-09.md) | | U-20260923-80 | 2026-09-23 | SSH shell ended by the server closes its session and reports both halves | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-79 | 2026-09-23 | JupyterLab tab says why it did not start; a closed tab is no failure | #fix #jupyter | [2026-09](2026-09.md) | | U-20260923-78 | 2026-09-23 | A run window closed mid-run is let go of when its run ends | #fix #executor | [2026-09](2026-09.md) | @@ -161,4 +162,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 98 | +| [2026-09.md](2026-09.md) | 2026-09 | 99 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py index f7b4e070..5bb1c4e2 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py @@ -10,6 +10,8 @@ import base64 import hashlib +import threading +import time from pathlib import Path from typing import TYPE_CHECKING @@ -36,6 +38,55 @@ def _fingerprint_sha256(key: paramiko.PKey) -> str: return "SHA256:" + base64.b64encode(digest).rstrip(b"=").decode("ascii") +# Held while a host key is looked up, asked about and stored. Only connect +# threads take it (both SSH widgets connect off the UI thread), so the UI +# thread that shows the question never waits on it. +_DECISION_LOCK = threading.Lock() +# (host, fingerprint) -> when the user said no: the other half of the same +# Connect is refused without a second question. +_RECENT_DECLINES: dict[tuple[str, str], float] = {} +_DECLINE_REMEMBERED_SECONDS = 10.0 + + +def _declined_just_now(hostname: str, fingerprint: str) -> bool: + declined_at = _RECENT_DECLINES.get((hostname, fingerprint)) + return declined_at is not None and time.monotonic() - declined_at < _DECLINE_REMEMBERED_SECONDS + + +def _read_known_hosts() -> paramiko.HostKeys: + """The known hosts file as it is now; empty when it is missing or unreadable.""" + known = paramiko.HostKeys() + path = _known_hosts_path() + if path.is_file(): + try: + known.load(str(path)) + except OSError as err: + pybreeze_logger.warning("Failed to load PyBreeze known_hosts: %s", err) + return known + + +def _is_trusted_on_disk(hostname: str, key: paramiko.PKey) -> bool: + """Whether *key* was accepted for *hostname* since this connect read the file.""" + entry = _read_known_hosts().lookup(hostname) + return entry is not None and entry.get(key.get_name()) == key + + +def _store(hostname: str, key_type: str, key: paramiko.PKey) -> None: + """Add the key to the file as it is now. + + Not ``client.save_host_keys``: that writes the client's own copy, read at + its Connect, over hosts another tab accepted since. + """ + known = _read_known_hosts() + known.add(hostname, key_type, key) + try: + known.save(str(_known_hosts_path())) + except OSError as err: + pybreeze_logger.warning( + "Failed to persist SSH host key for %s: %s", hostname, err + ) + + class InteractiveHostKeyPolicy(paramiko.MissingHostKeyPolicy): """Policy that prompts the user to verify unknown host keys. @@ -71,21 +122,24 @@ def missing_host_key( host=hostname, key_type=key_type, fingerprint=fingerprint ) - if not host_key_asker().ask(self._parent, title, message): - pybreeze_logger.warning( - "SSH host key for %s rejected by user (%s)", hostname, fingerprint - ) - raise paramiko.SSHException( - f"Host key for {hostname} rejected by user." - ) - - client.get_host_keys().add(hostname, key_type, key) - try: - client.save_host_keys(str(_known_hosts_path())) - except OSError as err: - pybreeze_logger.warning( - "Failed to persist SSH host key for %s: %s", hostname, err - ) + # One Connect starts two connects (shell and file tree), each with the + # file as it was at the click: one at a time, and each looks at the + # file again first, so the user is asked once. + with _DECISION_LOCK: + if _is_trusted_on_disk(hostname, key): + client.get_host_keys().add(hostname, key_type, key) + return + if _declined_just_now(hostname, fingerprint) or not host_key_asker().ask( + self._parent, title, message): + _RECENT_DECLINES[(hostname, fingerprint)] = time.monotonic() + pybreeze_logger.warning( + "SSH host key for %s rejected by user (%s)", hostname, fingerprint + ) + raise paramiko.SSHException( + f"Host key for {hostname} rejected by user." + ) + client.get_host_keys().add(hostname, key_type, key) + _store(hostname, key_type, key) pybreeze_logger.info( "SSH host key for %s accepted and stored (%s)", hostname, fingerprint ) diff --git a/test/test_utils/test_ssh_host_key_policy.py b/test/test_utils/test_ssh_host_key_policy.py new file mode 100644 index 00000000..49c85add --- /dev/null +++ b/test/test_utils/test_ssh_host_key_policy.py @@ -0,0 +1,116 @@ +"""An unknown SSH host key: asked about once per Connect, and never written away.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import threading + +import paramiko +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_host_key_policy as policy_mod + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture(scope="module") +def keys(): + return paramiko.RSAKey.generate(1024), paramiko.RSAKey.generate(1024) + + +@pytest.fixture() +def asked(app, tmp_path, monkeypatch): + """Answers every question with ``asked["answer"]`` and counts them.""" + state = {"answer": True, "count": 0} + + class Asker: + def ask(self, _parent, _title, _message) -> bool: + state["count"] += 1 + return state["answer"] + + monkeypatch.setattr(policy_mod, "pybreeze_data_dir", lambda: tmp_path) + monkeypatch.setattr(policy_mod, "host_key_asker", Asker) + monkeypatch.setattr(policy_mod, "_RECENT_DECLINES", {}) + return state + + +def _meet(hostname: str, key) -> paramiko.SSHClient: + """What a connect does on meeting *key*: the client read the file at its Connect.""" + client = paramiko.SSHClient() + policy_mod.InteractiveHostKeyPolicy().missing_host_key(client, hostname, key) + return client + + +def test_both_halves_of_one_connect_ask_once(asked, keys): + # The shell and the file tree each met the unknown key; there used to be + # two identical questions, one on top of the other. + _meet("host.example", keys[0]) + second = _meet("host.example", keys[0]) + + assert asked["count"] == 1 + assert second.get_host_keys().lookup("host.example") + + +def test_a_no_is_not_asked_again_by_the_other_half(asked, keys): + asked["answer"] = False + + for _ in range(2): + with pytest.raises(paramiko.SSHException): + _meet("host.example", keys[0]) + + assert asked["count"] == 1 + + +def test_two_tabs_accepting_two_hosts_keep_both(asked, keys): + # Each client saved its own copy of the file, read at its Connect, so the + # last one to save wrote the other host away. + first, second = paramiko.SSHClient(), paramiko.SSHClient() + policy = policy_mod.InteractiveHostKeyPolicy() + policy.missing_host_key(first, "one.example", keys[0]) + policy.missing_host_key(second, "two.example", keys[1]) + + known = paramiko.HostKeys(str(policy_mod._known_hosts_path())) + assert known.lookup("one.example") and known.lookup("two.example") + + +def test_another_key_for_a_known_host_is_still_asked_about(asked, keys): + _meet("host.example", keys[0]) + + _meet("host.example", keys[1]) + + assert asked["count"] == 2 + + +def test_the_questions_come_one_at_a_time(asked, keys, monkeypatch): + inside = threading.Event() + release = threading.Event() + overlapping: list = [] + + class SlowAsker: + def ask(self, *_args) -> bool: + overlapping.append(inside.is_set()) + inside.set() + release.wait(5) + inside.clear() + return True + + monkeypatch.setattr(policy_mod, "host_key_asker", SlowAsker) + workers = [threading.Thread(target=_meet, args=(name, keys[0])) + for name in ("one.example", "two.example")] + for worker in workers: + worker.start() + inside.wait(5) + release.set() + for worker in workers: + worker.join(5) + + assert overlapping == [False, False] From e52b92422aa4dc74b8091967273619ef4fc55bb9 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:03:58 +0800 Subject: [PATCH 107/312] Record the jupyter_server version floor as a decision --- progress.md | 1 + 1 file changed, 1 insertion(+) diff --git a/progress.md b/progress.md index b9d48e84..2efd633d 100644 --- a/progress.md +++ b/progress.md @@ -9,3 +9,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#47** [BLOCKED] Closing the IDE closes JEditor's docked file editors (`editor_main/main_ui.py`, `_close_tool_tabs_and_docks`), and the published `je_editor` dock (`FullEditorWidget.closeEvent`) writes its buffer back on close whether edited or not, in UTF-8 with platform line endings, so exiting rewrites an LF file as CRLF. Fixed in JEditor (JEDITOR U-20260923-07: writes only when edited, in the file's own encoding and line ending); waits for the `je_editor` release that carries it, then bump the pin and add a PyBreeze test. +- **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? From c7a5d3278f02e91cebdda5939bc5f9b4036828e3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:12:41 +0800 Subject: [PATCH 108/312] Take turns on the SFTP session and replace a downloaded file only when complete --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 23 +++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 2 + .../extend_traditional_chinese.py | 1 + .../connect_gui/ssh/ssh_file_viewer_widget.py | 100 +++++++++---- test/test_utils/test_sftp_session_sharing.py | 135 ++++++++++++++++++ 7 files changed, 236 insertions(+), 34 deletions(-) create mode 100644 test/test_utils/test_sftp_session_sharing.py diff --git a/architecture_explore.md b/architecture_explore.md index 2d85b49c..957718b7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -288,14 +288,14 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,700 行) +### `ssh/`(1,740 行) | 檔案 | 職責 | |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,ANSI escape 用 regex 剝除;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (829) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都先 `_require_connection()`;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (869) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單在 UI 執行緒上的建資料夾/改名/刪除最多等 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),不凍住 IDE;下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 96 個 `test_*.py`、1478 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 97 個 `test_*.py`、1483 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ce0e6e15..6bd18f66 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1432,3 +1432,26 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_ssh_host_key_policy.py` (new) - `architecture_explore.md` §9, §18 - **Open items**: none. + +## U-20260923-82 · 2026-09-23 · SFTP: one request at a time on the session, and a download replaces its file only when complete · #fix #ssh + +- **What**: two faults in the SFTP file tree (`connect_gui/ssh/ssh_file_viewer_widget.py`, `SFTPClientWrapper`), found in the review sweep: + - The session was used from several places at once. Every folder expand starts its own `SftpListThread`, a transfer runs beside them, and Create folder, Rename and Delete call the session from the UI thread. paramiko's `SFTPClient._read_response` throws away a reply that the wrong thread reads, and the thread that sent that request then waits forever, since an SFTP channel has no read timeout. On a slow link, expanding two folders quickly could leave one on "Loading…" for good, and Create folder during a download could freeze the IDE. + - A download called `sftp.get(remote, local)`, which empties the local file before the first byte arrives. A dropped link, or a Disconnect during the transfer, left the file the user had chosen to replace cut short. +- **Fix**: + - Every request goes through `_session(wait)`, which holds `_in_use` (a `threading.Lock`) for one request and checks the connection under it. Listings and transfers run on worker threads and wait their turn. The menu's calls on the UI thread wait at most `UI_WAIT_SECONDS` (1 s), then raise `SftpBusy`, a `RuntimeError` that the context menu's existing handler shows as "The SFTP session is busy…" (new key `ssh_file_viewer_message_session_busy`, English and Traditional Chinese). + - `download()` writes to a temporary file beside the target (`tempfile.mkstemp(..., suffix=".part")`) and moves it into place with `os.replace` only when complete. On any failure the partial file is deleted and the original is untouched. + - The unused `is_dir()` is removed. +- **Tests**: new `test_sftp_session_sharing.py`: + - two listings take turns; + - a menu action during a transfer is refused, not frozen, and the two never overlap; + - a menu action on a free session goes through; + - a download replaces the file only when complete and leaves no partial file; + - a download that fails leaves the old file whole. +- **Result / numbers**: unit tests 1469 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_sftp_session_sharing.py` (new) + - `architecture_explore.md` §9, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 8095168d..39f06b7c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-82 | 2026-09-23 | SFTP: one request at a time on the session, and a download replaces its file only when complete | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-81 | 2026-09-23 | Unknown SSH host key: asked once per Connect, and no accepted host written away | #fix #ssh #security | [2026-09](2026-09.md) | | U-20260923-80 | 2026-09-23 | SSH shell ended by the server closes its session and reports both halves | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-79 | 2026-09-23 | JupyterLab tab says why it did not start; a closed tab is no failure | #fix #jupyter | [2026-09](2026-09.md) | @@ -162,4 +163,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 99 | +| [2026-09.md](2026-09.md) | 2026-09 | 100 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index e4325282..f6ecdae4 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -210,6 +210,8 @@ "ssh_state_shell_only": "Connected: shell only (the file tree did not connect)", "ssh_state_files_only": "Connected: files only (the shell did not connect)", "ssh_state_neither": "Disconnected", + "ssh_file_viewer_message_session_busy": + "The SFTP session is busy with a transfer or a listing. Try again when it is done.", "ssh_file_viewer_dialog_title_transfer_running": "A transfer is already running", "ssh_file_viewer_dialog_message_transfer_running": "One file at a time: wait for the transfer that is running to finish.", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 58b20f36..f39ff6e1 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -210,6 +210,7 @@ "ssh_state_shell_only": "已連線:只有終端(檔案樹沒連上)", "ssh_state_files_only": "已連線:只有檔案(終端沒連上)", "ssh_state_neither": "未連線", + "ssh_file_viewer_message_session_busy": "SFTP 連線正在傳輸檔案或列出目錄,請等它完成後再試。", "ssh_file_viewer_dialog_title_transfer_running": "已有傳輸進行中", "ssh_file_viewer_dialog_message_transfer_running": "一次只傳一個檔案:請等目前的傳輸結束。", diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 82bffee2..026bb933 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -4,6 +4,10 @@ import posixpath import re import stat +import tempfile +import threading +from collections.abc import Iterator +from contextlib import contextmanager, suppress import paramiko from PySide6.QtCore import Qt, QEvent, QThread, Signal @@ -43,6 +47,9 @@ def format_size(num_bytes: int) -> str: # Keepalive interval (seconds) so an idle SFTP session is not dropped by the # TCP stack, a NAT/firewall, or the SSH server (≈ OpenSSH ServerAliveInterval). SSH_KEEPALIVE_SECONDS = 30 +# How long a menu action on the UI thread waits for the session before +# saying it is busy: long enough for a listing, short enough not to freeze +UI_WAIT_SECONDS = 1.0 def natural_key(name: str) -> list: @@ -87,6 +94,10 @@ def remote_join(directory: str, name: str) -> str: return joined if joined.startswith("/") else f"/{joined}" +class SftpBusy(RuntimeError): + """Another request holds the SFTP session (a transfer, a listing); try again after it.""" + + class ConnectAbandoned(RuntimeError): """The session came up after the connect was given up on, and was closed.""" @@ -102,6 +113,11 @@ def __init__(self): self._ssh: paramiko.SSHClient | None = None self._sftp: paramiko.SFTPClient | None = None self.root_path: str = "/" + # One request at a time on the session: paramiko's SFTP client throws + # away a reply read by the wrong thread, and the thread that sent that + # request then waits for it forever. Listings, a transfer and the menu's + # own calls used to share it freely. + self._in_use = threading.Lock() def connect(self, host: str, port: int, username: str, password: str, use_key: bool = False, key_path: str = "", @@ -191,76 +207,100 @@ def _require_connection(self) -> None: self.word_dict.get("ssh_command_widget_dialog_title_not_connected") ) - def list_dir(self, path: str): - """ - List directory entries with stat attributes. - 列出目錄項目(含屬性)。 + @contextmanager + def _session(self, wait: float | None = None) -> Iterator[paramiko.SFTPClient]: + """Hold the session for one request and yield it, open. + + :param wait: seconds to wait for a request already running; ``None`` + waits as long as it takes (worker threads). The menu's calls run on + the UI thread and wait briefly: a transfer can hold the session for + minutes, and they are refused with ``SftpBusy`` instead. """ - self._require_connection() - sftp = self._sftp - if sftp is None: # closed from the UI thread since the check + acquired = self._in_use.acquire() if wait is None else self._in_use.acquire(timeout=wait) + if not acquired: + raise SftpBusy(self.word_dict.get("ssh_file_viewer_message_session_busy")) + try: self._require_connection() - return sftp.listdir_attr(path) + sftp = self._sftp + if sftp is None: # closed from the UI thread since the check + self._require_connection() + yield sftp + finally: + self._in_use.release() - def is_dir(self, path: str) -> bool: + def list_dir(self, path: str): """ - Determine if target path is a directory. - 判斷路徑是否為目錄。 + List directory entries with stat attributes. Worker thread. + 列出目錄項目(含屬性)。 """ - self._require_connection() - try: - st = self._sftp.stat(path) - return stat.S_ISDIR(st.st_mode) - except OSError: - return False + with self._session() as sftp: + return sftp.listdir_attr(path) def mkdir(self, path: str): """ Create directory. 建立目錄。 """ - self._require_connection() - self._sftp.mkdir(path) + with self._session(UI_WAIT_SECONDS) as sftp: + sftp.mkdir(path) def remove_file(self, path: str): """ Remove file. 刪除檔案。 """ - self._require_connection() - self._sftp.remove(path) + with self._session(UI_WAIT_SECONDS) as sftp: + sftp.remove(path) def remove_dir(self, path: str): """ Remove empty directory. 刪除空目錄。 """ - self._require_connection() - self._sftp.rmdir(path) + with self._session(UI_WAIT_SECONDS) as sftp: + sftp.rmdir(path) def rename(self, old_path: str, new_path: str): """ Rename file/folder. 重新命名檔案/資料夾。 """ - self._require_connection() - self._sftp.rename(old_path, new_path) + with self._session(UI_WAIT_SECONDS) as sftp: + sftp.rename(old_path, new_path) def download(self, remote_path: str, local_path: str): """ - Download remote to local. + Download remote to local. Worker thread. 下載遠端檔案至本地。 + + The file arrives beside *local_path* under a temporary name and takes + its place only when complete: ``get`` empties its target before the + first byte, so a dropped link or a Disconnect used to leave the file + the user chose to replace cut short. """ self._require_connection() - self._sftp.get(remote_path, local_path) + folder = os.path.dirname(os.path.abspath(local_path)) + handle, partial = tempfile.mkstemp( + prefix=f".{os.path.basename(local_path)}.", suffix=".part", dir=folder) + os.close(handle) + complete = False + try: + with self._session() as sftp: + sftp.get(remote_path, partial) + os.replace(partial, local_path) + complete = True + finally: + if not complete: + with suppress(OSError): + os.remove(partial) def upload(self, local_path: str, remote_path: str): """ - Upload local to remote. + Upload local to remote. Worker thread. 上傳本地檔案至遠端。 """ - self._require_connection() - self._sftp.put(local_path, remote_path) + with self._session() as sftp: + sftp.put(local_path, remote_path) class SftpTransferThread(QThread): diff --git a/test/test_utils/test_sftp_session_sharing.py b/test/test_utils/test_sftp_session_sharing.py new file mode 100644 index 00000000..944f09fd --- /dev/null +++ b/test/test_utils/test_sftp_session_sharing.py @@ -0,0 +1,135 @@ +"""One SFTP session, several users: one request at a time, and no file left cut short.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import threading + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + +WAIT_SECONDS = 5 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeSftp: + """Records overlapping requests; ``get`` writes *content* and can be held or made to fail.""" + + def __init__(self) -> None: + self.active = 0 + self.overlapped = False + self.hold = threading.Event() + self.hold.set() + self.inside = threading.Event() + self.fail_after: bytes | None = None + self.content = b"new contents" + self._count = threading.Lock() + + def _request(self) -> None: + with self._count: + self.active += 1 + self.overlapped = self.overlapped or self.active > 1 + self.inside.set() + self.hold.wait(WAIT_SECONDS) + with self._count: + self.active -= 1 + + def listdir_attr(self, _path): + self._request() + return [] + + def mkdir(self, _path) -> None: + self._request() + + def get(self, _remote, local) -> None: + with open(local, "wb") as target: + if self.fail_after is not None: + target.write(self.fail_after) + raise OSError("the link went away") + self._request() + target.write(self.content) + + +def _wrapper(sftp: FakeSftp) -> viewer.SFTPClientWrapper: + wrapper = viewer.SFTPClientWrapper() + wrapper._ssh = object() + wrapper._sftp = sftp + return wrapper + + +class TestOneRequestAtATime: + def test_two_listings_take_turns(self, app): + sftp = FakeSftp() + sftp.hold.clear() + wrapper = _wrapper(sftp) + workers = [threading.Thread(target=wrapper.list_dir, args=(path,)) for path in ("/home", "/var")] + for worker in workers: + worker.start() + sftp.inside.wait(WAIT_SECONDS) + sftp.hold.set() + for worker in workers: + worker.join(WAIT_SECONDS) + + # Each expand used to run its own listing on the session at once; one + # thread could read the other's reply and leave it waiting forever. + assert not sftp.overlapped + + def test_a_menu_action_during_a_transfer_is_refused_not_frozen(self, app, tmp_path): + sftp = FakeSftp() + sftp.hold.clear() + wrapper = _wrapper(sftp) + download = threading.Thread( + target=wrapper.download, args=("/big.bin", str(tmp_path / "big.bin"))) + download.start() + sftp.inside.wait(WAIT_SECONDS) + + with pytest.raises(viewer.SftpBusy): + wrapper.mkdir("/new") + + sftp.hold.set() + download.join(WAIT_SECONDS) + assert not sftp.overlapped + + def test_a_menu_action_on_a_free_session_goes_through(self, app): + sftp = FakeSftp() + + _wrapper(sftp).mkdir("/new") + + assert sftp.active == 0 + + +class TestADownload: + def test_it_replaces_the_file_only_when_complete(self, app, tmp_path): + target = tmp_path / "report.txt" + target.write_bytes(b"the old report") + sftp = FakeSftp() + + _wrapper(sftp).download("/report.txt", str(target)) + + assert target.read_bytes() == b"new contents" + assert list(tmp_path.iterdir()) == [target] + + def test_one_that_fails_leaves_the_old_file_whole(self, app, tmp_path): + target = tmp_path / "report.txt" + target.write_bytes(b"the old report") + sftp = FakeSftp() + sftp.fail_after = b"half" + + with pytest.raises(OSError): + _wrapper(sftp).download("/report.txt", str(target)) + + # get() emptied the target before the first byte; a dropped link left + # it cut short. + assert target.read_bytes() == b"the old report" + assert list(tmp_path.iterdir()) == [target] # no partial file left behind From 4c3e41ea637e0c5216897d3da99784e8aa4b244f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:50:28 +0800 Subject: [PATCH 109/312] Convert epoch values and JWT claims before 1970 on Windows --- architecture_explore.md | 4 ++-- docs/updates/2026-09.md | 15 +++++++++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/jwt_tools/jwt_decoder.py | 7 ++++--- .../utils/timestamp_tools/timestamp_converter.py | 16 ++++++++++++++-- test/test_utils/test_jwt_decoder.py | 7 +++++++ test/test_utils/test_timestamp_converter.py | 8 ++++++++ 7 files changed, 52 insertions(+), 8 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 957718b7..b552977e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -215,7 +215,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `CurlImportGUI` | `utils/curl_import/` | 貼上 curl 指令 → 產生 requests / pytest / APITestka(py & json) / LoadDensity 腳本 | | `HarImportGUI` | `utils/har_import/` | 開 `.har` → 列出錄到的請求(可只看 API-like)→ 批次產生腳本 | | `JwtDecoderGUI` | `utils/jwt_tools/` | 解 JWT header/payload(不驗簽),時間戳轉可讀 UTC | -| `TimestampGUI` | `utils/timestamp_tools/` | epoch(自動判秒/毫秒)↔ ISO-8601 | +| `TimestampGUI` | `utils/timestamp_tools/` | epoch(自動判秒/毫秒)↔ ISO-8601。epoch 換算用 `utc_from_epoch_seconds()`(epoch + `timedelta`;`datetime.fromtimestamp` 在 Windows 上拒絕 1970 年前幾小時以外的值),JWT 的時間戳 claim 也用它 | | `HashGUI` | `utils/hash_tools/` | 多演算法摘要 | | `QueryJsonGUI` | `utils/query_tools/` | query string ↔ JSON 雙向 | | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 97 個 `test_*.py`、1483 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 97 個 `test_*.py`、1487 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 6bd18f66..ef75b4d4 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1455,3 +1455,18 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_sftp_session_sharing.py` (new) - `architecture_explore.md` §9, §18 - **Open items**: none. + +## U-20260923-83 · 2026-09-23 · Epoch values and JWT claims before 1970 convert on Windows · #fix #tools + +- **What**: on Windows, the Timestamp tool rejected any epoch value more than about 12 hours before 1970. `convert_timestamp("-86400")` reported "not a recognized epoch number", while the ISO form of the same instant converted. `datetime.fromtimestamp(v, tz=utc)` goes through the C runtime, which raises `OSError 22` there. The JWT decoder's `format_timestamp_claim` used the same call, so a claim before 1970 was silently left out of the readable times. Found in the review sweep of `tools_gui/` and `diagram_editor/`. +- **Fix**: new `utc_from_epoch_seconds()` (`utils/timestamp_tools/timestamp_converter.py`) computes the Unix epoch plus a `timedelta`, which works on every platform. It raises `OverflowError` outside the years `datetime` holds and `ValueError` for NaN. The converter and `jwt_decoder.format_timestamp_claim` both use it. +- **Tests**: + - `test_timestamp_converter.py`: a day before 1970 and 1960-01-01 convert. + - `test_jwt_decoder.py`: a claim before 1970 is shown; NaN is not a timestamp. +- **Result / numbers**: unit tests 1473 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/timestamp_tools/timestamp_converter.py` + - `pybreeze/utils/jwt_tools/jwt_decoder.py` + - `test/test_utils/test_timestamp_converter.py`, `test_jwt_decoder.py` + - `architecture_explore.md` §6, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 39f06b7c..1e010881 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-83 | 2026-09-23 | Epoch values and JWT claims before 1970 convert on Windows | #fix #tools | [2026-09](2026-09.md) | | U-20260923-82 | 2026-09-23 | SFTP: one request at a time on the session, and a download replaces its file only when complete | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-81 | 2026-09-23 | Unknown SSH host key: asked once per Connect, and no accepted host written away | #fix #ssh #security | [2026-09](2026-09.md) | | U-20260923-80 | 2026-09-23 | SSH shell ended by the server closes its session and reports both halves | #fix #ssh | [2026-09](2026-09.md) | @@ -163,4 +164,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 100 | +| [2026-09.md](2026-09.md) | 2026-09 | 101 | diff --git a/pybreeze/utils/jwt_tools/jwt_decoder.py b/pybreeze/utils/jwt_tools/jwt_decoder.py index 9c5c7a70..310efad3 100644 --- a/pybreeze/utils/jwt_tools/jwt_decoder.py +++ b/pybreeze/utils/jwt_tools/jwt_decoder.py @@ -14,7 +14,6 @@ import json import re from dataclasses import dataclass -from datetime import datetime, timezone from pybreeze.utils.exception.exception_tags import ( empty_jwt_error, @@ -23,6 +22,7 @@ ) from pybreeze.utils.exception.exceptions import JwtDecodeException from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.utils.timestamp_tools.timestamp_converter import utc_from_epoch_seconds # A JWT is three base64url segments joined by dots _JWT_SEGMENT_COUNT = 3 @@ -119,8 +119,9 @@ def format_timestamp_claim(value: object) -> str | None: if isinstance(value, bool) or not isinstance(value, (int, float)): return None try: - return datetime.fromtimestamp(value, tz=timezone.utc).isoformat() - except (OverflowError, OSError, ValueError): + # Not fromtimestamp: on Windows it refused claims before 1970 + return utc_from_epoch_seconds(value).isoformat() + except (OverflowError, ValueError): return None diff --git a/pybreeze/utils/timestamp_tools/timestamp_converter.py b/pybreeze/utils/timestamp_tools/timestamp_converter.py index 31bd29eb..d4ee167d 100644 --- a/pybreeze/utils/timestamp_tools/timestamp_converter.py +++ b/pybreeze/utils/timestamp_tools/timestamp_converter.py @@ -53,12 +53,24 @@ def detect_epoch_unit(value: float) -> str: return "ms" if abs(value) >= _MILLISECONDS_THRESHOLD else "s" +def utc_from_epoch_seconds(seconds: float) -> datetime: + """The UTC instant *seconds* after (or before) the Unix epoch. + + Not ``datetime.fromtimestamp``: on Windows it goes through the C runtime, + which refuses anything more than a few hours before 1970 with ``OSError``. + + :raises OverflowError: outside the years datetime can hold, or infinite + :raises ValueError: not a number (NaN) + """ + return _EPOCH + timedelta(seconds=seconds) + + def _from_epoch(value: float) -> datetime: """Build a UTC datetime from an epoch number, auto-detecting its unit.""" seconds = value / _MS_PER_SECOND if detect_epoch_unit(value) == "ms" else value try: - return datetime.fromtimestamp(seconds, tz=timezone.utc) - except (OverflowError, OSError, ValueError) as error: + return utc_from_epoch_seconds(seconds) + except (OverflowError, ValueError) as error: pybreeze_logger.error(unrecognized_timestamp_error) raise TimestampParseException(unrecognized_timestamp_error) from error diff --git a/test/test_utils/test_jwt_decoder.py b/test/test_utils/test_jwt_decoder.py index 38a4ee65..ac679233 100644 --- a/test/test_utils/test_jwt_decoder.py +++ b/test/test_utils/test_jwt_decoder.py @@ -91,6 +91,13 @@ def test_none_returns_none(self): def test_out_of_range_returns_none(self): assert format_timestamp_claim(10 ** 30) is None + def test_a_claim_before_1970_is_shown(self): + # It was silently left out on Windows. + assert format_timestamp_claim(-86400) == "1969-12-31T00:00:00+00:00" + + def test_not_a_number_returns_none(self): + assert format_timestamp_claim(float("nan")) is None + class TestHumanizedTimestampClaims: def test_extracts_known_claims(self): diff --git a/test/test_utils/test_timestamp_converter.py b/test/test_utils/test_timestamp_converter.py index f5e87334..413fb3a9 100644 --- a/test/test_utils/test_timestamp_converter.py +++ b/test/test_utils/test_timestamp_converter.py @@ -105,5 +105,13 @@ def test_an_instant_before_1970_rounds_down_not_toward_zero(self): assert result.epoch_millis == -1500 assert result.epoch_seconds == -2 + @pytest.mark.parametrize("text,iso", [ + ("-86400", "1969-12-31T00:00:00+00:00"), + ("-315619200", "1960-01-01T00:00:00+00:00"), + ]) + def test_a_day_or_more_before_1970_converts_on_every_platform(self, text, iso): + # fromtimestamp() refused these on Windows: "not a recognized epoch number" + assert convert_timestamp(text).iso_utc == iso + def test_an_iso_time_with_milliseconds_keeps_them(self): assert convert_timestamp("2021-01-01T00:00:00.250Z").epoch_millis == 1609459200250 From 2ab8a0f0b3cdd434d4e06dda43783fbc93e1a8cf Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:50:29 +0800 Subject: [PATCH 110/312] Show a diff when only a final newline or a line ending differs --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 15 +++++++++ docs/updates/README.md | 3 +- pybreeze/utils/diff_tools/text_diff.py | 42 +++++++++++++++++++++----- test/test_utils/test_text_diff.py | 18 +++++++++++ 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index b552977e..e24418f2 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -221,7 +221,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | | `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組。pattern 在另一個行程裡跑(`find_matches_bounded()`,spawn,5 秒後結束掉),分頁用 `RegexMatchThread` 等它:`re` 開始比對後就停不下來,災難性回溯只有整個行程能停 | | `HttpStatusGUI` | `utils/http_reference/` | 狀態碼參考,可依碼前綴或描述搜尋 | -| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計(統計取自 unified diff 同一套 `SequenceMatcher` 的 opcodes,和畫面上的 diff 一致;以前用 `ndiff`,逐行再比字元,3000 行全改要一分多鐘) | +| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計(統計取自 unified diff 同一套 `SequenceMatcher` 的 opcodes,和畫面上的 diff 一致;以前用 `ndiff`,逐行再比字元,3000 行全改要一分多鐘)。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | | `JsonFormatGUI` | `utils/json_format/` | 美化 / 壓縮 / 驗證 | | `HeaderAnalyzerGUI` | `utils/header_tools/` | HTTP header 安全稽核(HSTS、CSP、CORS、Set-Cookie、banner…)| | `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT | @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 97 個 `test_*.py`、1487 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 97 個 `test_*.py`、1490 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ef75b4d4..f86f688f 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1470,3 +1470,18 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_timestamp_converter.py`, `test_jwt_decoder.py` - `architecture_explore.md` §6, §18 - **Open items**: none. + +## U-20260923-84 · 2026-09-23 · Diff shows a change when only a final newline or a line ending differs · #fix #tools + +- **What**: the Diff tool (`utils/diff_tools/text_diff.py`) compared lines without their endings but decided "identical" on the raw strings. For `"a\n"` against `"a"` it said the texts differ, then showed "+0 / -0" and an empty diff. The same happened for `\r\n` against `\n`. Found in the review sweep of `tools_gui/` and `diagram_editor/`. +- **Fix**: `_line_lists()` compares lines without their endings, as before. Only when that finds no difference while the texts still differ does it keep the real endings. The shown diff then marks an added or removed line with no newline with diff's own `\ No newline at end of file`, and names any other ending (`(line ends with '\r\n')`). All other results are unchanged: `"a"` against `"a\nb"` is still one added line. +- **Tests**: `test_text_diff.py::TestOnlyTheLineEndingsDiffer`: + - a missing final newline shows as a change; + - a different line ending is named; + - other changes count as before. +- **Result / numbers**: unit tests 1476 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/diff_tools/text_diff.py` + - `test/test_utils/test_text_diff.py` + - `architecture_explore.md` §6, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 1e010881..c135961e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-84 | 2026-09-23 | Diff shows a change when only a final newline or a line ending differs | #fix #tools | [2026-09](2026-09.md) | | U-20260923-83 | 2026-09-23 | Epoch values and JWT claims before 1970 convert on Windows | #fix #tools | [2026-09](2026-09.md) | | U-20260923-82 | 2026-09-23 | SFTP: one request at a time on the session, and a download replaces its file only when complete | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-81 | 2026-09-23 | Unknown SSH host key: asked once per Connect, and no accepted host written away | #fix #ssh #security | [2026-09](2026-09.md) | @@ -164,4 +165,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 101 | +| [2026-09.md](2026-09.md) | 2026-09 | 102 | diff --git a/pybreeze/utils/diff_tools/text_diff.py b/pybreeze/utils/diff_tools/text_diff.py index 9211643c..01c8fbdf 100644 --- a/pybreeze/utils/diff_tools/text_diff.py +++ b/pybreeze/utils/diff_tools/text_diff.py @@ -14,6 +14,8 @@ _RIGHT_LABEL = "actual" # Lines of unchanged context kept around each change in the unified diff _CONTEXT_LINES = 3 +# diff's own note under a last line that has no newline +_NO_NEWLINE_MARK = "\\ No newline at end of file" @dataclass(frozen=True) @@ -30,9 +32,34 @@ class DiffSummary: is_equal: bool -def _split_lines(text: str) -> list[str]: - """Split *text* into lines, keeping a stable count for empty input.""" - return text.splitlines() +def _line_lists(left: str, right: str) -> tuple[list[str], list[str]]: + """The two texts as the lists of lines to compare, each line ending in ``\\n``. + + Lines are compared without their endings. Only when that finds nothing + while the texts still differ -- a final newline, or ``\\r\\n`` against + ``\\n`` -- are the real endings kept: the summary called such texts + different, and the diff showed nothing. + """ + left_lines, right_lines = left.splitlines(), right.splitlines() + if left_lines != right_lines or left == right: + return [f"{line}\n" for line in left_lines], [f"{line}\n" for line in right_lines] + return left.splitlines(keepends=True), right.splitlines(keepends=True) + + +def _shown(diff_line: str) -> str: + """One line of the unified diff, its line ending taken off for display. + + An added or removed line with no newline gets diff's own marker on the + next line; one with another ending (``\\r\\n``, a lone ``\\r``) names it. + Unchanged context lines are shown without comment. + """ + content = diff_line.rstrip("\r\n") + ending = diff_line[len(content):] + if diff_line[:1] not in ("+", "-") or diff_line.startswith(("+++", "---")) or ending == "\n": + return content + if not ending: + return f"{content}\n{_NO_NEWLINE_MARK}" + return f"{content} (line ends with {ending!r})" def unified_diff( @@ -46,15 +73,16 @@ def unified_diff( :param right_label: header label for the second text :return: the unified diff text (empty when the inputs are identical) """ + left_lines, right_lines = _line_lists(left, right) diff_lines = difflib.unified_diff( - _split_lines(left), - _split_lines(right), + left_lines, + right_lines, fromfile=left_label, tofile=right_label, lineterm="", n=_CONTEXT_LINES, ) - return "\n".join(diff_lines) + return "\n".join(_shown(line) for line in diff_lines) def diff_summary(left: str, right: str) -> DiffSummary: @@ -70,7 +98,7 @@ def diff_summary(left: str, right: str) -> DiffSummary: :param right: the second (actual) text :return: the counts of added and removed lines and whether they are equal """ - matcher = difflib.SequenceMatcher(None, _split_lines(left), _split_lines(right)) + matcher = difflib.SequenceMatcher(None, *_line_lists(left, right)) added = 0 removed = 0 for tag, left_start, left_end, right_start, right_end in matcher.get_opcodes(): diff --git a/test/test_utils/test_text_diff.py b/test/test_utils/test_text_diff.py index c0e43155..5005d775 100644 --- a/test/test_utils/test_text_diff.py +++ b/test/test_utils/test_text_diff.py @@ -88,3 +88,21 @@ def test_every_line_changed_in_a_large_text_is_quick(self): # ndiff took over a minute here; the opcodes take milliseconds. assert time.perf_counter() - started < 5 assert (summary.added, summary.removed) == (3000, 3000) + + +class TestOnlyTheLineEndingsDiffer: + """Texts that differ in a final newline or a line ending: the diff says where.""" + + def test_a_missing_final_newline_shows_as_a_change(self): + # It said "not identical" and showed an empty diff: +0 / -0. + summary = diff_summary("a\n", "a") + + assert (summary.added, summary.removed, summary.is_equal) == (1, 1, False) + assert unified_diff("a\n", "a").endswith("+a\n\\ No newline at end of file") + + def test_a_different_line_ending_is_named(self): + assert "-a (line ends with '\\r\\n')" in unified_diff("a\r\nb", "a\nb") + + def test_other_changes_are_counted_as_before(self): + # A line added after a last line without a newline is one added line. + assert diff_summary("a", "a\nb").added == 1 From 4320c7b1aa21838f983016e04ddf94dd8c5f7804 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:50:30 +0800 Subject: [PATCH 111/312] Tell the user when a tool's Save to file fails --- architecture_explore.md | 4 ++-- docs/updates/2026-09.md | 13 +++++++++++++ docs/updates/README.md | 3 ++- .../extend_multi_language/extend_english.py | 2 ++ .../extend_traditional_chinese.py | 2 ++ .../pybreeze_ui/tools_gui/output_actions.py | 8 +++++++- test/test_utils/test_output_actions.py | 17 +++++++++++++++++ 7 files changed, 45 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index e24418f2..d3629649 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -229,7 +229,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 兩個橫向共用機制 - **`tool_tabs.open_tool_tab()`** — 工具之間互相「轉交」:Response Inspector 把狀態碼丟給 HTTP Status、headers 丟給 Header Analyzer、JWT 丟給 JWT Decoder、JSON body 丟給 JSON Format;curl 匯入把 URL 丟給 URL Builder。開新分頁並自動聚焦。 -- **`output_actions.OutputActions`** — 統一的「複製 / 在編輯器開啟 / 存檔」三顆按鈕,綁在工具的唯讀輸出 `QTextEdit` 上;副檔名與檔名可傳 callable 動態決定。 +- **`output_actions.OutputActions`** — 統一的「複製 / 在編輯器開啟 / 存檔」三顆按鈕,綁在工具的唯讀輸出 `QTextEdit` 上;副檔名與檔名可傳 callable 動態決定。存檔失敗(唯讀資料夾、被鎖住的檔案)會跳警告,說出檔名與原因 --- @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 97 個 `test_*.py`、1490 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 97 個 `test_*.py`、1491 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index f86f688f..435424cf 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1485,3 +1485,16 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_text_diff.py` - `architecture_explore.md` §6, §18 - **Open items**: none. + +## U-20260923-85 · 2026-09-23 · A tool's Save to file says when the file was not saved · #fix #tools + +- **What**: "Save to file" under every tool's output (`tools_gui/output_actions.py`, `OutputActions.save_to_file`) caught an `OSError`, logged it and returned. A save into a read-only folder or over a locked file showed nothing, so the user took the file as saved. Found in the review sweep of `tools_gui/` and `diagram_editor/`. +- **Fix**: the failure is shown in a warning with the file name and the reason (`strerror`, not the full path). New keys `output_actions_save_failed_title` and `output_actions_save_failed_message`, in English and Traditional Chinese. +- **Tests**: `test_output_actions.py::TestSaveToFile::test_a_failed_write_is_reported`. +- **Result / numbers**: unit tests 1477 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/tools_gui/output_actions.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_output_actions.py` + - `architecture_explore.md` §6, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c135961e..e685078d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-85 | 2026-09-23 | A tool's Save to file says when the file was not saved | #fix #tools | [2026-09](2026-09.md) | | U-20260923-84 | 2026-09-23 | Diff shows a change when only a final newline or a line ending differs | #fix #tools | [2026-09](2026-09.md) | | U-20260923-83 | 2026-09-23 | Epoch values and JWT claims before 1970 convert on Windows | #fix #tools | [2026-09](2026-09.md) | | U-20260923-82 | 2026-09-23 | SFTP: one request at a time on the session, and a download replaces its file only when complete | #fix #ssh | [2026-09](2026-09.md) | @@ -165,4 +166,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 102 | +| [2026-09.md](2026-09.md) | 2026-09 | 103 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index f6ecdae4..10125639 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -423,6 +423,8 @@ "output_actions_open_editor": "Open in editor tab", "output_actions_save": "Save to file...", "output_actions_editor_tab_label": "Generated", + "output_actions_save_failed_title": "Not saved", + "output_actions_save_failed_message": "{file} could not be saved: {error}", "output_actions_save_dialog_title": "Save output", "output_actions_filter_python": "Python (*.py)", "output_actions_filter_json": "JSON (*.json)", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index f39ff6e1..188c40b0 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -397,6 +397,8 @@ "output_actions_open_editor": "開成編輯器分頁", "output_actions_save": "存成檔案...", "output_actions_editor_tab_label": "產生內容", + "output_actions_save_failed_title": "沒有存檔", + "output_actions_save_failed_message": "{file} 無法存檔:{error}", "output_actions_save_dialog_title": "儲存輸出", "output_actions_filter_python": "Python (*.py)", "output_actions_filter_json": "JSON (*.json)", diff --git a/pybreeze/pybreeze_ui/tools_gui/output_actions.py b/pybreeze/pybreeze_ui/tools_gui/output_actions.py index dbba4ed6..90598287 100644 --- a/pybreeze/pybreeze_ui/tools_gui/output_actions.py +++ b/pybreeze/pybreeze_ui/tools_gui/output_actions.py @@ -13,7 +13,7 @@ from typing import Callable from PySide6.QtWidgets import ( - QApplication, QFileDialog, QHBoxLayout, QPushButton, QTextEdit, QWidget + QApplication, QFileDialog, QHBoxLayout, QMessageBox, QPushButton, QTextEdit, QWidget ) from je_editor import language_wrapper @@ -127,5 +127,11 @@ def save_to_file(self) -> str | None: Path(path).write_text(self._output.toPlainText(), encoding="utf-8") except OSError as error: pybreeze_logger.error("output_actions.py save failed: %r", error) + # It used to go to the log only, and the user took it as saved. + word = language_wrapper.language_word_dict + QMessageBox.warning( + self._parent, word.get("output_actions_save_failed_title"), + word.get("output_actions_save_failed_message").format( + file=Path(path).name, error=error.strerror or error)) return None return path diff --git a/test/test_utils/test_output_actions.py b/test/test_utils/test_output_actions.py index 7e15c13f..ed4e3d3e 100644 --- a/test/test_utils/test_output_actions.py +++ b/test/test_utils/test_output_actions.py @@ -131,6 +131,23 @@ def test_empty_output_is_noop(self, app): assert actions.save_to_file() is None parent.deleteLater() + def test_a_failed_write_is_reported(self, app, tmp_path): + parent, output, actions = _make(app) + output.setPlainText("content") + target = tmp_path / "no-such-folder" / "out.txt" + warned: list = [] + with patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", + return_value=(str(target), ""), + ), patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QMessageBox.warning", + side_effect=lambda *args: warned.append(args), + ): + assert actions.save_to_file() is None + # It went to the log only, and the user took the file as saved. + assert warned and "out.txt" in warned[0][2] + parent.deleteLater() + class TestSuggestedFilename: def test_static(self, app): From 4ffd282a56017237fb12f4b96df881204d566881 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:50:32 +0800 Subject: [PATCH 112/312] Open a diagram whose font size is not a finite number --- architecture_explore.md | 6 +++--- docs/updates/2026-09.md | 12 ++++++++++++ docs/updates/README.md | 3 ++- progress.md | 2 ++ .../pybreeze_ui/diagram_editor/diagram_items.py | 13 +++++++++++-- test/test_utils/test_diagram_editing.py | 6 +++++- 6 files changed, 35 insertions(+), 7 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index d3629649..ae1bb3a3 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -233,13 +233,13 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,722 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,731 行,最大子系統) | 檔案 | 職責 | |---|---| | `diagram_editor_widget.py` (624) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | | `diagram_scene.py` (854) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點之上/之下。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | -| `diagram_items.py` (895) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | +| `diagram_items.py` (904) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範)。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級 | | `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (434) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | | `diagram_view.py` (180) | `QGraphicsView`:滾輪縮放(有上下界;橫向滾輪不縮放)、中鍵平移、`drawBackground` 畫格線 | @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 97 個 `test_*.py`、1491 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 97 個 `test_*.py`、1495 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 435424cf..e3200e42 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1498,3 +1498,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_output_actions.py` - `architecture_explore.md` §6, §18 - **Open items**: none. + +## U-20260923-86 · 2026-09-23 · A diagram file with an infinite or non-numeric font size opens · #fix #diagram + +- **What**: a `.diagram.json` whose `font_size` was `1e999` could not be opened, and nothing said why. `json.loads` reads `1e999` as infinity, and `int(inf)` in `_clamped_font_size` (`diagram_editor/diagram_items.py`) raises `OverflowError`. Neither `_load_nodes` nor `_open_diagram` catches that (they catch `ValueError`, `TypeError`, `KeyError` and `OSError`), so it left the Qt slot. The canvas was put back, and the user saw nothing. Found in the review sweep of `tools_gui/` and `diagram_editor/`. +- **Fix**: `_clamped_font_size` catches `OverflowError`, `TypeError` and `ValueError` and uses the default label size (10). Infinity, NaN, a string or `null` in the file now open with the default size instead of failing. +- **Tests**: `test_diagram_editing.py::test_a_font_size_from_a_file_is_kept_in_range` gains infinity, NaN, a string and `None`. +- **Result / numbers**: unit tests 1481 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py` + - `test/test_utils/test_diagram_editing.py` + - `architecture_explore.md` §7, §18 +- **Open items**: the same sweep found that text typed on the canvas is not recorded for undo, and that undo blanks images the editor cannot reload; recorded in `progress.md`. diff --git a/docs/updates/README.md b/docs/updates/README.md index e685078d..0532d19f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-86 | 2026-09-23 | A diagram file with an infinite or non-numeric font size opens | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-85 | 2026-09-23 | A tool's Save to file says when the file was not saved | #fix #tools | [2026-09](2026-09.md) | | U-20260923-84 | 2026-09-23 | Diff shows a change when only a final newline or a line ending differs | #fix #tools | [2026-09](2026-09.md) | | U-20260923-83 | 2026-09-23 | Epoch values and JWT claims before 1970 convert on Windows | #fix #tools | [2026-09](2026-09.md) | @@ -166,4 +167,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 103 | +| [2026-09.md](2026-09.md) | 2026-09 | 104 | diff --git a/progress.md b/progress.md index 2efd633d..d2679d14 100644 --- a/progress.md +++ b/progress.md @@ -10,3 +10,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#47** [BLOCKED] Closing the IDE closes JEditor's docked file editors (`editor_main/main_ui.py`, `_close_tool_tabs_and_docks`), and the published `je_editor` dock (`FullEditorWidget.closeEvent`) writes its buffer back on close whether edited or not, in UTF-8 with platform line endings, so exiting rewrites an LF file as CRLF. Fixed in JEditor (JEDITOR U-20260923-07: writes only when edited, in the file's own encoding and line ending); waits for the `je_editor` release that carries it, then bump the pin and add a PyBreeze test. - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? +- **#50** Diagram editor: text typed on the canvas (double-click editing, `_EditableLabel` in `diagram_editor/diagram_items.py`, nodes and connection labels) takes no undo snapshot, and undo restores whole-scene snapshots (`diagram_scene.py`, `undo_scope`), so the next Ctrl+Z / Ctrl+Y brings back the old text and the typed text cannot be recovered. Only the property panel's text box records text edits. +- **#51** Diagram editor: undo/redo rebuilds every image from its `source` (`diagram_scene.py`, image reload), but `add_image` never puts the pixmap in `_pixmap_cache`. An image added through the "All Files" filter with an extension outside the allowlist (e.g. `.tiff`) goes blank on the first undo, and an image added from a URL is downloaded again; if that download fails the image stays blank and the failure is only logged at debug level. diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index 83993046..0cfa7230 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -118,8 +118,17 @@ def __init__(self, w: float, h: float): def _clamped_font_size(size: int) -> int: - """*size* as a whole point size within what the panel offers.""" - return max(MIN_FONT_SIZE, min(int(size), MAX_FONT_SIZE)) + """*size* as a whole point size within what the panel offers. + + A file may say anything: ``1e999`` loads as infinity, which ``int()`` + refuses with ``OverflowError`` -- not the ``ValueError`` Open reports, so + the open failed with no message. Anything that is not a finite number + gets the default size. + """ + try: + return max(MIN_FONT_SIZE, min(int(size), MAX_FONT_SIZE)) + except (OverflowError, TypeError, ValueError): + return _LABEL_FONT_SIZE _NODE_PEN_COLOR = "#455a64" _NODE_BRUSH_COLOR = "#e3f2fd" _NODE_SELECTED_COLOR = "#1565c0" diff --git a/test/test_utils/test_diagram_editing.py b/test/test_utils/test_diagram_editing.py index 05ec6650..142003ad 100644 --- a/test/test_utils/test_diagram_editing.py +++ b/test/test_utils/test_diagram_editing.py @@ -140,7 +140,11 @@ def test_pressing_on_a_selected_image_starts_an_undo_step(self, app): assert scene._pending_undo_desc == "Move" - @pytest.mark.parametrize(("saved", "loaded"), [(1000, 48), (1, 6), (12.7, 12)]) + @pytest.mark.parametrize(("saved", "loaded"), [ + (1000, 48), (1, 6), (12.7, 12), + # 1e999 in the file loads as infinity: Open failed with no message + (float("inf"), 10), (float("nan"), 10), ("big", 10), (None, 10), + ]) def test_a_font_size_from_a_file_is_kept_in_range(self, app, saved, loaded): node = DiagramNode.from_dict({"x": 0, "y": 0, "text": "a", "font_size": saved}) From b1d45741104c5010c1a293a120552ff3e65c0fcf Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:50:33 +0800 Subject: [PATCH 113/312] Import curl -I, --oauth2-bearer, fragments and repeated -F as curl sends them, and report malformed URLs --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 27 ++++++ docs/updates/README.md | 3 +- pybreeze/utils/curl_import/curl_parser.py | 59 +++++++++++-- pybreeze/utils/curl_import/request_body.py | 31 +++++-- pybreeze/utils/curl_import/request_codegen.py | 24 ++++-- .../utils/curl_import/script_templates.py | 19 +++-- pybreeze/utils/exception/exception_tags.py | 1 + pybreeze/utils/har_import/har_parser.py | 9 +- test/test_utils/test_curl_import.py | 10 +++ test/test_utils/test_curl_request_fidelity.py | 85 +++++++++++++++++++ test/test_utils/test_har_import.py | 7 ++ 12 files changed, 246 insertions(+), 33 deletions(-) create mode 100644 test/test_utils/test_curl_request_fidelity.py diff --git a/architecture_explore.md b/architecture_explore.md index ae1bb3a3..a311bc02 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -340,7 +340,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(530) 完整 curl 解析(bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | +| `curl_import/` | `curl_parser.py`(579) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | @@ -434,7 +434,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 97 個 `test_*.py`、1495 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 98 個 `test_*.py`、1513 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index e3200e42..4ea2276c 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1510,3 +1510,30 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_diagram_editing.py` - `architecture_explore.md` §7, §18 - **Open items**: the same sweep found that text typed on the canvas is not recorded for undo, and that undo blanks images the editor cannot reload; recorded in `progress.md`. + +## U-20260923-87 · 2026-09-23 · curl and HAR import: malformed URLs reported, -I, --oauth2-bearer, fragments, repeated -F · #fix #tools + +- **What**: the curl importer (`utils/curl_import/`) and the HAR importer produced the wrong request, or none, for common inputs. Found in the review sweep of `tools_gui/` and `diagram_editor/`. + - `curl 'http://[::1/api'` (or a port that is not a number) parsed, then `urlparse` in the code generator raised `ValueError` out of the tab. The tab kept the previous command's code, so Copy and Save then used the wrong script. A HAR entry with such a URL raised the same way while being listed: the list and "Generate all" kept the previous file's requests. + - `-I` / `--head` was skipped as a display flag, so the code sent GET. + - `--oauth2-bearer TOKEN` was consumed and dropped, losing the Authorization header. + - `https://x/a?b=1#frag` kept the fragment in the last query value (`b="1#frag"`). curl never sends a fragment. + - `-F f=@a.txt -F f=@b.txt` kept only the last file (a dict per field name), and so did a recorded multipart upload. +- **Fix**: + - `url_is_well_formed()` (`curl_parser.py`) is used by `parse_curl` (a malformed URL raises `CurlParseException` with the new `malformed_url_error`) and by `parse_har` (the entry is skipped and logged without its URL, which may carry a token). + - `-I` / `--head` sets `head_only`, and the method becomes HEAD unless `-X` names another. + - `--oauth2-bearer` is kept in `bearer_token` and applied at the end as `Authorization: Bearer …`, unless `-H` gave an Authorization header. + - The fragment is dropped before the query is split. + - `form_parts()` keeps every value of a repeated field as a list (`add_repeated_value`). `file_uploads()` lists the `(field, file)` pairs. The requests code and the APITestka call write `files=[(…), …]` when a field repeats, and a dict as before otherwise; `requests` sends both forms as multipart. +- **Tests**: + - `test_curl_import.py`: malformed URLs are a parse error; an IPv6 URL with a port is fine. + - `test_har_import.py`: an entry with a malformed URL is skipped. + - new `test_curl_request_fidelity.py`, 14 tests: HEAD (three spellings, and an explicit method wins), the bearer header (and an explicit header wins in either order), fragments, and repeated `-F` (the parts, the requests code, the APITestka call, a single field unchanged, a recorded multipart upload). +- **Result / numbers**: unit tests 1499 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/curl_parser.py`, `request_body.py`, `request_codegen.py`, `script_templates.py` + - `pybreeze/utils/har_import/har_parser.py` + - `pybreeze/utils/exception/exception_tags.py` + - `test/test_utils/test_curl_import.py`, `test_har_import.py`, `test_curl_request_fidelity.py` (new) + - `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 0532d19f..c5648641 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-87 | 2026-09-23 | curl and HAR import: malformed URLs reported, -I, --oauth2-bearer, fragments, repeated -F | #fix #tools | [2026-09](2026-09.md) | | U-20260923-86 | 2026-09-23 | A diagram file with an infinite or non-numeric font size opens | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-85 | 2026-09-23 | A tool's Save to file says when the file was not saved | #fix #tools | [2026-09](2026-09.md) | | U-20260923-84 | 2026-09-23 | Diff shows a change when only a final newline or a line ending differs | #fix #tools | [2026-09](2026-09.md) | @@ -167,4 +168,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 104 | +| [2026-09.md](2026-09.md) | 2026-09 | 105 | diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index 84395e2d..8a0e5de1 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -15,12 +15,13 @@ import sys from collections.abc import Callable from dataclasses import dataclass, field -from urllib.parse import parse_qsl, quote, urlencode +from urllib.parse import parse_qsl, quote, urlencode, urlsplit from pybreeze.utils.exception.exception_tags import ( empty_curl_command_error, invalid_http_method_error, malformed_curl_command_error, + malformed_url_error, not_a_curl_command_error, ) from pybreeze.utils.exception.exceptions import CurlParseException @@ -67,6 +68,9 @@ class CurlRequest: :param username: basic-auth user, or ``None`` :param password: basic-auth password, or ``None`` :param send_data_as_params: ``True`` when ``-G`` moves the body to the query + :param head_only: ``True`` when ``-I`` / ``--head`` asks for the headers only + :param bearer_token: the ``--oauth2-bearer`` token, or ``None``; sent as + the ``Authorization`` header unless ``-H`` gives one :param form_fields: multipart form fragments from ``-F`` / ``--form``, in curl's syntax: a value starting with ``@`` is a file to upload :param form_strings: multipart ``name=value`` fields taken literally, from @@ -85,6 +89,8 @@ class CurlRequest: username: str | None = None password: str | None = None send_data_as_params: bool = False + head_only: bool = False + bearer_token: str | None = None form_fields: list[str] = field(default_factory=list) form_strings: list[str] = field(default_factory=list) data_file_refs: list[str] = field(default_factory=list) @@ -143,16 +149,19 @@ def header_value(self, name: str) -> str | None: "-e": "referer", "--referer": "referer", "--url": "url", "-m": "timeout", "--max-time": "timeout", + "--oauth2-bearer": "oauth2_bearer", } # Value-less flags that still change behaviour. _GET_FLAGS = frozenset({"-G", "--get"}) +# -I fetches the headers only: a HEAD request, unless -X names another method +_HEAD_FLAGS = frozenset({"-I", "--head"}) # Value-less flags to accept and skip (they do not affect the generated request). _VALUELESS_FLAGS = frozenset({ "--compressed", "-L", "--location", "-k", "--insecure", "-s", "--silent", "-S", "--show-error", "-q", "--disable", - "-v", "--verbose", "-i", "--include", "-I", "--head", "-f", "--fail", + "-v", "--verbose", "-i", "--include", "-f", "--fail", "--fail-with-body", "-g", "--globoff", "-O", "--remote-name", "-J", "--remote-header-name", "-#", "--progress-bar", "-N", "--no-buffer", "-j", "--junk-session-cookies", "--no-keepalive", "--no-progress-meter", @@ -172,7 +181,7 @@ def header_value(self, name: str) -> str | None: "--cert-type", "--key-type", "--pass", "-T", "--upload-file", "--limit-rate", "-r", "--range", "-c", "--cookie-jar", "--resolve", "--interface", "--dns-servers", "--local-port", "--ciphers", "-y", "--speed-time", - "-Y", "--speed-limit", "--keepalive-time", "--oauth2-bearer", "--aws-sigv4", + "-Y", "--speed-limit", "--keepalive-time", "--aws-sigv4", "-C", "--continue-at", "-z", "--time-cond", "-D", "--dump-header", "-K", "--config", }) @@ -189,7 +198,7 @@ def _short_flags(*tables: object) -> frozenset[str]: # Short flags derived from the tables above so a bundled cluster like # ``-sXPOST`` can split into ``-s`` and ``-X`` + ``POST`` without a second list. _SHORT_VALUE_FLAGS = _short_flags(_VALUE_FLAGS, _IGNORED_VALUE_FLAGS) -_SHORT_VALUELESS_FLAGS = _short_flags(_VALUELESS_FLAGS, _GET_FLAGS) +_SHORT_VALUELESS_FLAGS = _short_flags(_VALUELESS_FLAGS, _GET_FLAGS, _HEAD_FLAGS) def _is_short_flag_cluster(token: str) -> bool: @@ -401,6 +410,11 @@ def _apply_user(request: CurlRequest, value: str) -> None: request.username, _separator, request.password = value.partition(":") +def _apply_oauth2_bearer(request: CurlRequest, value: str) -> None: + # Applied once every -H is in (_finalise_method): an explicit one wins + request.bearer_token = value + + def _set_url(request: CurlRequest, value: str) -> None: request.url = value @@ -421,6 +435,7 @@ def _set_url(request: CurlRequest, value: str) -> None: "url": _set_url, "timeout": _apply_timeout, "user": _apply_user, + "oauth2_bearer": _apply_oauth2_bearer, } @@ -449,6 +464,8 @@ def _consume_tokens(tokens: list[str], request: CurlRequest) -> None: index += 1 # consume and discard the value elif token in _GET_FLAGS: request.send_data_as_params = True + elif token in _HEAD_FLAGS: + request.head_only = True elif token in _VALUELESS_FLAGS: pass # a known valueless flag: nothing to do elif not token.startswith("-") and not request.url: @@ -458,7 +475,15 @@ def _consume_tokens(tokens: list[str], request: CurlRequest) -> None: def _finalise_method(request: CurlRequest) -> None: - """Infer the method and move the body to the query when ``-G`` was given.""" + """Settle what depends on the whole command. + + The method (``-I``, or POST for a body), the ``--oauth2-bearer`` header + unless ``-H`` set one, and the body moved to the query when ``-G`` was given. + """ + if request.head_only and request.method == _DEFAULT_METHOD: + request.method = "HEAD" + if request.bearer_token is not None: + set_default_header(request.headers, "Authorization", f"Bearer {request.bearer_token}") if request.method == _DEFAULT_METHOD and request.has_body and not request.send_data_as_params: request.method = _METHOD_WITH_BODY if request.send_data_as_params: @@ -498,6 +523,9 @@ def _split_url_query(request: CurlRequest) -> None: :attr:`CurlRequest.full_url` can still rebuild the original address. Values are URL-decoded, and a key given more than once keeps every value. """ + # The fragment is the browser's: curl never sends it, and left in, it + # became part of the last query value (?b=1#frag sent b="1#frag"). + request.url = request.url.partition("#")[0] base, separator, query = request.url.partition("?") if not separator: return @@ -506,13 +534,29 @@ def _split_url_query(request: CurlRequest) -> None: add_repeated_value(request.params, key, value) +def url_is_well_formed(url: str) -> bool: + """Whether *url* can be taken apart: ``urllib`` raises ``ValueError`` on an + unclosed IPv6 bracket (``http://[::1/api``) or a port that is not a number. + + Checked here, where the error can be reported: the code generators and + the HAR list call ``urlparse`` later, from a Qt slot that catches only + the parse errors. + """ + try: + _ = urlsplit(url).port + except ValueError: + return False + return True + + def parse_curl(command: str) -> CurlRequest: """Parse a ``curl`` command string into a :class:`CurlRequest`. :param command: the full command, e.g. ``curl -X POST https://api/x -d '...'`` :return: the structured request :raises CurlParseException: when the command is empty, is not a curl command, - or cannot be tokenised (for example, unbalanced quotes) + cannot be tokenised (for example, unbalanced quotes), or its URL is + malformed """ normalised = _normalise_command(command) if not normalised: @@ -528,5 +572,8 @@ def parse_curl(command: str) -> CurlRequest: _consume_tokens(_expand_short_flags(tokens[1:]), request) # The URL's own query first: curl appends -G data after it. _split_url_query(request) + if not url_is_well_formed(request.url): + pybreeze_logger.error(malformed_url_error) + raise CurlParseException(malformed_url_error) _finalise_method(request) return request diff --git a/pybreeze/utils/curl_import/request_body.py b/pybreeze/utils/curl_import/request_body.py index eb83aabf..b9bd5a92 100644 --- a/pybreeze/utils/curl_import/request_body.py +++ b/pybreeze/utils/curl_import/request_body.py @@ -8,7 +8,7 @@ import json -from pybreeze.utils.curl_import.curl_parser import CurlRequest +from pybreeze.utils.curl_import.curl_parser import CurlRequest, add_repeated_value # Header that carries the body's media type _CONTENT_TYPE_HEADER = "content-type" @@ -39,8 +39,9 @@ def body_kind(request: CurlRequest) -> BodyKind | None: return "data", request.body -# Multipart form split into plain fields and file uploads (field -> filename) -FormParts = tuple[dict[str, str], dict[str, str]] +# Multipart form split into plain fields and file uploads (field -> filename); +# a field given more than once maps to the list of its values +FormParts = tuple[dict[str, str | list[str]], dict[str, str | list[str]]] def form_parts(request: CurlRequest) -> FormParts: @@ -51,21 +52,33 @@ def form_parts(request: CurlRequest) -> FormParts: dropped). :param request: the parsed curl request + A field given more than once keeps every value, as a list: curl sends + each ``-F``, and a dict kept only the last (two ``-F f=@...`` uploaded one + file). + :return: ``(data_fields, file_fields)`` where ``file_fields`` maps a field name - to its filename + to its filename, or to the list of them """ - data_fields: dict[str, str] = {} - file_fields: dict[str, str] = {} + data_fields: dict[str, str | list[str]] = {} + file_fields: dict[str, str | list[str]] = {} for fragment in request.form_fields: key, separator, value = fragment.partition("=") if not separator: continue if value.startswith("@"): - file_fields[key] = value[1:].split(";", 1)[0] + add_repeated_value(file_fields, key, value[1:].split(";", 1)[0]) else: - data_fields[key] = value + add_repeated_value(data_fields, key, value) for fragment in request.form_strings: key, separator, value = fragment.partition("=") if separator: - data_fields[key] = value + add_repeated_value(data_fields, key, value) return data_fields, file_fields + + +def file_uploads(file_fields: dict[str, str | list[str]]) -> list[tuple[str, str]]: + """Every ``(field, filename)`` upload, a repeated field once per file.""" + return [ + (field, name) for field, names in file_fields.items() + for name in (names if isinstance(names, list) else [names]) + ] diff --git a/pybreeze/utils/curl_import/request_codegen.py b/pybreeze/utils/curl_import/request_codegen.py index 04ad0051..748aca28 100644 --- a/pybreeze/utils/curl_import/request_codegen.py +++ b/pybreeze/utils/curl_import/request_codegen.py @@ -11,7 +11,7 @@ import re from pybreeze.utils.curl_import.curl_parser import CurlRequest -from pybreeze.utils.curl_import.request_body import body_kind, form_parts +from pybreeze.utils.curl_import.request_body import body_kind, file_uploads, form_parts # Keyword argument passing the request body to ``requests.request``. _DATA_KWARG = "data=data" @@ -37,14 +37,20 @@ def _format_dict(name: str, mapping: dict[str, str | list[str]]) -> str | None: return "\n".join(lines) -def _format_files(file_fields: dict[str, str]) -> str: - """Render a ``files = { ... }`` block that opens each upload.""" - lines = ["files = {"] - lines.extend( - f' {python_string(field)}: open({python_string(filename)}, "rb"),' - for field, filename in file_fields.items() - ) - lines.append("}") +def _format_files(file_fields: dict[str, str | list[str]]) -> str: + """Render a ``files = ...`` block that opens each upload. + + A dict while every field is given once; a list of ``(field, file)`` pairs + when one repeats, since a dict cannot hold the same key twice. + """ + uploads = file_uploads(file_fields) + repeated = len(uploads) > len(file_fields) + lines = ["files = [" if repeated else "files = {"] + for field, filename in uploads: + opened = f'open({python_string(filename)}, "rb")' + lines.append(f" ({python_string(field)}, {opened})," + if repeated else f" {python_string(field)}: {opened},") + lines.append("]" if repeated else "}") return "\n".join(lines) diff --git a/pybreeze/utils/curl_import/script_templates.py b/pybreeze/utils/curl_import/script_templates.py index 45704c8e..df45f439 100644 --- a/pybreeze/utils/curl_import/script_templates.py +++ b/pybreeze/utils/curl_import/script_templates.py @@ -17,7 +17,7 @@ from urllib.parse import urlparse from pybreeze.utils.curl_import.curl_parser import CurlRequest -from pybreeze.utils.curl_import.request_body import body_kind, form_parts +from pybreeze.utils.curl_import.request_body import body_kind, file_uploads, form_parts from pybreeze.utils.curl_import.request_codegen import ( REQUESTS_IMPORT, data_from_file_expr, python_literal, request_statements, to_requests_code ) @@ -38,6 +38,18 @@ def _inline_json(value: object) -> str: return json.dumps(value, ensure_ascii=False) +def _inline_files(file_fields: dict[str, str | list[str]]) -> str: + """The ``files`` value, one line: a dict, or pairs when a field repeats.""" + uploads = file_uploads(file_fields) + if len(uploads) > len(file_fields): + pairs = ", ".join( + f"({_inline_json(field)}, open({_inline_json(name)}, \"rb\"))" for field, name in uploads) + return f"[{pairs}]" + entries = ", ".join( + f"{_inline_json(field)}: open({_inline_json(name)}, \"rb\")" for field, name in uploads) + return f"{{{entries}}}" + + def _apitestka_payload_lines(request: CurlRequest) -> list[str]: """Return the inline ``data=`` / ``files=`` / ``json=`` kwargs for the payload.""" if request.has_form: @@ -46,10 +58,7 @@ def _apitestka_payload_lines(request: CurlRequest) -> list[str]: if data_fields: lines.append(f" data={_inline_json(data_fields)},") if file_fields: - uploads = ", ".join( - f"{_inline_json(field)}: open({_inline_json(name)}, \"rb\")" - for field, name in file_fields.items()) - lines.append(f" files={{{uploads}}},") + lines.append(f" files={_inline_files(file_fields)},") return lines if request.data_file_refs: return [f" data={data_from_file_expr(request)},"] diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index 12deb8f3..898989bd 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -42,6 +42,7 @@ not_a_curl_command_error: str = "the command does not look like a curl command" malformed_curl_command_error: str = "can't parse the curl command: check quoting" no_url_in_curl_error: str = "no URL found in the curl command" +malformed_url_error: str = "the URL is malformed (for example an unclosed [ or a port that is not a number)" invalid_http_method_error: str = ( "not an HTTP method: a method is one word of letters, digits and !#$%&'*+.^_`|~-") diff --git a/pybreeze/utils/har_import/har_parser.py b/pybreeze/utils/har_import/har_parser.py index 9f630173..3fe6f003 100644 --- a/pybreeze/utils/har_import/har_parser.py +++ b/pybreeze/utils/har_import/har_parser.py @@ -14,7 +14,9 @@ from dataclasses import dataclass, field from urllib.parse import parse_qsl, urlparse -from pybreeze.utils.curl_import.curl_parser import CurlRequest, add_repeated_value, http_method +from pybreeze.utils.curl_import.curl_parser import ( + CurlRequest, add_repeated_value, http_method, url_is_well_formed, +) from pybreeze.utils.exception.exception_tags import ( empty_har_error, invalid_har_json_error, @@ -253,6 +255,11 @@ def parse_har(text: str) -> list[HarEntry]: raw_request = raw_entry.get("request") if not isinstance(raw_request, dict) or not raw_request.get("url"): continue + if not url_is_well_formed(str(raw_request["url"])): + # Skipped, not shown: listing or generating it raised from the + # tab. Not logged either -- a recorded URL may carry a token. + pybreeze_logger.info("HAR entry with a malformed URL skipped") + continue status, media_type = _response_details(raw_entry.get("response")) entries.append(HarEntry( request=_entry_request(raw_request), diff --git a/test/test_utils/test_curl_import.py b/test/test_utils/test_curl_import.py index 95b08651..ff827f06 100644 --- a/test/test_utils/test_curl_import.py +++ b/test/test_utils/test_curl_import.py @@ -138,6 +138,16 @@ def test_get_flag_moves_data_to_params(self): class TestParseCurlRobustness: + @pytest.mark.parametrize("url", ["http://[::1/api", "http://host:port/x"]) + def test_a_malformed_url_is_a_parse_error(self, url): + # It raised ValueError later, from the code generator, out of the tab, + # which kept showing the previous command's code. + with pytest.raises(CurlParseException): + parse_curl(f"curl '{url}'") + + def test_an_ipv6_url_with_a_port_is_fine(self): + assert parse_curl("curl 'http://[::1]:8080/a'").url == "http://[::1]:8080/a" + def test_line_continuations(self): command = "curl https://x \\\n -H 'Accept: application/json' \\\n -d 'a=1'" request = parse_curl(command) diff --git a/test/test_utils/test_curl_request_fidelity.py b/test/test_utils/test_curl_request_fidelity.py new file mode 100644 index 00000000..cef783a7 --- /dev/null +++ b/test/test_utils/test_curl_request_fidelity.py @@ -0,0 +1,85 @@ +"""The request a curl command imports as is the request curl would send.""" +from __future__ import annotations + +import pytest + +from pybreeze.utils.curl_import.curl_parser import parse_curl +from pybreeze.utils.curl_import.request_body import form_parts +from pybreeze.utils.curl_import.request_codegen import to_requests_code +from pybreeze.utils.curl_import.script_templates import apitestka_call_block +from pybreeze.utils.har_import.har_parser import parse_har + + +class TestHead: + @pytest.mark.parametrize("command", [ + "curl -I https://x", "curl --head https://x", "curl -sI https://x", + ]) + def test_it_is_a_head_request(self, command): + # -I was skipped as a display flag, and the code sent a GET. + assert parse_curl(command).method == "HEAD" + + def test_an_explicit_method_wins(self): + assert parse_curl("curl -I -X OPTIONS https://x").method == "OPTIONS" + + +class TestOauth2Bearer: + def test_it_becomes_the_authorization_header(self): + # The token was consumed and thrown away. + assert parse_curl("curl --oauth2-bearer T0K https://x").headers == { + "Authorization": "Bearer T0K"} + + @pytest.mark.parametrize("command", [ + "curl --oauth2-bearer T0K -H 'Authorization: Basic eg==' https://x", + "curl -H 'Authorization: Basic eg==' --oauth2-bearer T0K https://x", + ]) + def test_an_explicit_authorization_header_wins(self, command): + assert parse_curl(command).headers == {"Authorization": "Basic eg=="} + + +class TestFragment: + def test_it_is_not_sent(self): + request = parse_curl("curl 'https://x/a?b=1#frag'") + + # It became part of the last query value: b="1#frag". + assert request.params == {"b": "1"} + assert request.full_url == "https://x/a?b=1" + + def test_a_fragment_without_a_query_is_dropped_too(self): + assert parse_curl("curl 'https://x/a#top'").url == "https://x/a" + + +class TestRepeatedFormFields: + COMMAND = "curl -F f=@a.txt -F f=@b.txt -F k=1 -F k=2 https://x/up" + + def test_every_value_is_kept(self): + # A dict kept only the last: one file uploaded out of two. + assert form_parts(parse_curl(self.COMMAND)) == ( + {"k": ["1", "2"]}, {"f": ["a.txt", "b.txt"]}) + + def test_the_requests_code_uploads_both_files(self): + code = to_requests_code(parse_curl(self.COMMAND)) + + assert '("f", open("a.txt", "rb")),' in code + assert '("f", open("b.txt", "rb")),' in code + + def test_the_apitestka_call_uploads_both_files(self): + block = apitestka_call_block(parse_curl(self.COMMAND)) + + assert 'files=[("f", open("a.txt", "rb")), ("f", open("b.txt", "rb"))],' in block + + def test_a_field_given_once_is_written_as_before(self): + assert 'files={"f": open("a.txt", "rb")},' in apitestka_call_block( + parse_curl("curl -F f=@a.txt https://x/up")) + + def test_a_recorded_multipart_upload_keeps_both_files(self): + import json + + har = json.dumps({"log": {"entries": [{"request": { + "method": "POST", "url": "https://x/up", + "postData": {"mimeType": "multipart/form-data; boundary=z", "params": [ + {"name": "f", "fileName": "a.txt"}, {"name": "f", "fileName": "b.txt"}]}, + }}]}}) + + (entry,) = parse_har(har) + + assert form_parts(entry.request)[1] == {"f": ["a.txt", "b.txt"]} diff --git a/test/test_utils/test_har_import.py b/test/test_utils/test_har_import.py index c28c0bb7..1436acd0 100644 --- a/test/test_utils/test_har_import.py +++ b/test/test_utils/test_har_import.py @@ -96,6 +96,13 @@ def test_no_usable_entries(self): with pytest.raises(HarParseException): parse_har(_har()) + def test_an_entry_with_a_malformed_url_is_skipped(self): + # Listing it raised ValueError out of the tab, which kept showing the + # previous file's requests, and "Generate all" generated those. + entries = parse_har(_har(_entry(url="http://[::1/api"), _entry())) + + assert [entry.request.url for entry in entries] == ["https://api.example.com/v1/items"] + class TestHeadersAndCookies: def test_headers_are_collected(self): From 5589df25c1a83f80be5397b67dc7d303dedc8d8f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:50:35 +0800 Subject: [PATCH 114/312] Read tool input as entered instead of Qt's display text --- architecture_explore.md | 7 ++- docs/updates/2026-09.md | 16 +++++ docs/updates/README.md | 3 +- .../pybreeze_ui/tools_gui/curl_import_gui.py | 5 +- pybreeze/pybreeze_ui/tools_gui/diff_gui.py | 5 +- pybreeze/pybreeze_ui/tools_gui/exact_text.py | 19 ++++++ pybreeze/pybreeze_ui/tools_gui/hash_gui.py | 3 +- .../tools_gui/header_analyzer_gui.py | 3 +- .../pybreeze_ui/tools_gui/json_format_gui.py | 3 +- .../pybreeze_ui/tools_gui/jwt_decoder_gui.py | 3 +- .../pybreeze_ui/tools_gui/query_json_gui.py | 5 +- pybreeze/pybreeze_ui/tools_gui/regex_gui.py | 3 +- .../tools_gui/response_inspector_gui.py | 5 +- .../pybreeze_ui/tools_gui/url_builder_gui.py | 5 +- test/test_utils/test_exact_text.py | 60 +++++++++++++++++++ 15 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 pybreeze/pybreeze_ui/tools_gui/exact_text.py create mode 100644 test/test_utils/test_exact_text.py diff --git a/architecture_explore.md b/architecture_explore.md index a311bc02..37d9d8ad 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -206,7 +206,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 6. 工具分頁 `pybreeze_ui/tools_gui/`(13 個工具 widget + 2 個共用機制) +## 6. 工具分頁 `pybreeze_ui/tools_gui/`(13 個工具 widget + 3 個共用機制) 每個工具都是 `QWidget`,UI 極薄,真正邏輯全在 `pybreeze/utils/` 對應的純函式套件裡(所以測得動、也測了)。 @@ -226,9 +226,10 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `HeaderAnalyzerGUI` | `utils/header_tools/` | HTTP header 安全稽核(HSTS、CSP、CORS、Set-Cookie、banner…)| | `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT | -### 兩個橫向共用機制 +### 三個橫向共用機制 - **`tool_tabs.open_tool_tab()`** — 工具之間互相「轉交」:Response Inspector 把狀態碼丟給 HTTP Status、headers 丟給 Header Analyzer、JWT 丟給 JWT Decoder、JSON body 丟給 JSON Format;curl 匯入把 URL 丟給 URL Builder。開新分頁並自動聚焦。 +- **`exact_text.exact_text()`** — 讀工具輸入一律走它(`document().toRawText()`,區塊分隔換回 `\n`):`toPlainText()` 是顯示用的,會把不斷行空白(U+00A0)變成空白、U+2028 變成換行,Hash 算的是別的字串、Diff 看不出差別 - **`output_actions.OutputActions`** — 統一的「複製 / 在編輯器開啟 / 存檔」三顆按鈕,綁在工具的唯讀輸出 `QTextEdit` 上;副檔名與檔名可傳 callable 動態決定。存檔失敗(唯讀資料夾、被鎖住的檔案)會跳警告,說出檔名與原因 --- @@ -434,7 +435,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 98 個 `test_*.py`、1513 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 99 個 `test_*.py`、1517 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 4ea2276c..1f5a6ab4 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1537,3 +1537,19 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_curl_import.py`, `test_har_import.py`, `test_curl_request_fidelity.py` (new) - `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-88 · 2026-09-23 · Tools work on the text as entered, not Qt's display copy · #fix #tools + +- **What**: every tool read its input with `toPlainText()`, Qt's display copy of the text. It turns each non-breaking space (U+00A0) into a plain space and a line separator (U+2028) into a newline. Hash showed the SHA-256 of `"a b"` for `"a\u00a0b"`. Diff called `"price:\u00a0100"` and `"price: 100"` identical. A regex using `\xa0` never matched pasted text. JSON Format rewrote the contents of strings. Found in the review sweep of `tools_gui/` and `diagram_editor/`. +- **Fix**: new `tools_gui/exact_text.py`. `exact_text(edit)` returns `document().toRawText()`, with Qt's block separator (U+2029) turned back into `\n`. Every tool input uses it: curl import, Diff, Hash, Header Analyzer, JSON Format, JWT, Query/JSON, Regex, Response Inspector and URL Builder. +- **Tests**: new `test_exact_text.py`: + - both editor types give back every character; + - Hash digests the text as entered; + - Diff sees a non-breaking space. +- **Result / numbers**: unit tests 1503 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/tools_gui/exact_text.py` (new) + - `pybreeze/pybreeze_ui/tools_gui/{curl_import,diff,hash,header_analyzer,json_format,jwt_decoder,query_json,regex,response_inspector,url_builder}_gui.py` + - `test/test_utils/test_exact_text.py` (new) + - `architecture_explore.md` §6, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c5648641..d5314006 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-88 | 2026-09-23 | Tools work on the text as entered, not Qt's display copy | #fix #tools | [2026-09](2026-09.md) | | U-20260923-87 | 2026-09-23 | curl and HAR import: malformed URLs reported, -I, --oauth2-bearer, fragments, repeated -F | #fix #tools | [2026-09](2026-09.md) | | U-20260923-86 | 2026-09-23 | A diagram file with an infinite or non-numeric font size opens | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-85 | 2026-09-23 | A tool's Save to file says when the file was not saved | #fix #tools | [2026-09](2026-09.md) | @@ -168,4 +169,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 105 | +| [2026-09.md](2026-09.md) | 2026-09 | 106 | diff --git a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py index 8a743bc0..f9e487bb 100644 --- a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py @@ -14,6 +14,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.pybreeze_ui.tools_gui.tool_tabs import open_tool_tab @@ -99,7 +100,7 @@ def selected_target(self) -> str: def _on_target_changed(self, _index: int) -> None: """Regenerate when the target changes, if there is already input.""" - if self.input_edit.toPlainText().strip(): + if exact_text(self.input_edit).strip(): self.convert() def _clear_result(self) -> None: @@ -112,7 +113,7 @@ def _clear_result(self) -> None: def convert(self) -> None: """Parse the input command and show the template for the chosen target.""" word = language_wrapper.language_word_dict - command = self.input_edit.toPlainText().strip() + command = exact_text(self.input_edit).strip() if not command: self._clear_result() self.output_edit.setPlainText(word.get("curl_import_empty_hint")) diff --git a/pybreeze/pybreeze_ui/tools_gui/diff_gui.py b/pybreeze/pybreeze_ui/tools_gui/diff_gui.py index 1c9fd600..4ace037e 100644 --- a/pybreeze/pybreeze_ui/tools_gui/diff_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/diff_gui.py @@ -6,6 +6,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.diff_tools.text_diff import DiffSummary, diff_summary, unified_diff @@ -70,7 +71,7 @@ def __init__(self, main_window=None) -> None: def compare(self) -> None: """Compute and show the diff and summary of the two inputs.""" - left = self.left_edit.toPlainText() - right = self.right_edit.toPlainText() + left = exact_text(self.left_edit) + right = exact_text(self.right_edit) self.summary_label.setText(build_summary_line(diff_summary(left, right))) self.output_edit.setPlainText(unified_diff(left, right)) diff --git a/pybreeze/pybreeze_ui/tools_gui/exact_text.py b/pybreeze/pybreeze_ui/tools_gui/exact_text.py new file mode 100644 index 00000000..7e3bb2d1 --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/exact_text.py @@ -0,0 +1,19 @@ +"""Read a tool's input exactly as it was typed or pasted. + +``toPlainText()`` is for display: it turns every non-breaking space (U+00A0) +into a plain space and a line separator (U+2028) into a newline. A tool that +hashes, diffs, matches or formats the text then works on something else than +the user gave it -- the Hash tool showed the digest of a different string, and +Diff called two texts that differ only there identical. +""" +from __future__ import annotations + +from PySide6.QtWidgets import QPlainTextEdit, QTextEdit + +# How QTextDocument separates its blocks in the raw text; the user typed a newline +_BLOCK_SEPARATOR = "
" + + +def exact_text(edit: QTextEdit | QPlainTextEdit) -> str: + """The text in *edit*, every character as entered, lines ending in ``\\n``.""" + return edit.document().toRawText().replace(_BLOCK_SEPARATOR, "\n") diff --git a/pybreeze/pybreeze_ui/tools_gui/hash_gui.py b/pybreeze/pybreeze_ui/tools_gui/hash_gui.py index b55e9c19..1c1b8954 100644 --- a/pybreeze/pybreeze_ui/tools_gui/hash_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/hash_gui.py @@ -4,6 +4,7 @@ from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget from je_editor import language_wrapper +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.hash_tools.hash_text import hash_all @@ -54,4 +55,4 @@ def __init__(self, main_window=None) -> None: def compute(self) -> None: """Hash the entered text and show one digest per algorithm.""" - self.output_edit.setPlainText(build_hash_text(hash_all(self.input_edit.toPlainText()))) + self.output_edit.setPlainText(build_hash_text(hash_all(exact_text(self.input_edit)))) diff --git a/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py b/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py index 0fdd492d..7e5afd18 100644 --- a/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py @@ -10,6 +10,7 @@ from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget from je_editor import language_wrapper +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.pybreeze_ui.tools_gui.tool_tabs import open_tool_tab @@ -110,7 +111,7 @@ def _clear_analysis(self, message: str) -> None: def analyze(self) -> None: """Analyse the pasted headers and show the report.""" word = language_wrapper.language_word_dict - text = self.input_edit.toPlainText().strip() + text = exact_text(self.input_edit).strip() if not text: self._clear_analysis(word.get("header_analyzer_empty_hint")) return diff --git a/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py b/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py index 8dc164d1..539b47a0 100644 --- a/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py @@ -6,6 +6,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import ITEJsonException from pybreeze.utils.json_format.json_process import minify_json, reformat_json @@ -64,7 +65,7 @@ def __init__(self, main_window=None, initial_json: str | None = None) -> None: def _run(self, transform) -> None: """Apply a JSON transform, showing the result or a friendly error.""" word = language_wrapper.language_word_dict - text = self.input_edit.toPlainText().strip() + text = exact_text(self.input_edit).strip() if not text: self._valid_output = False self.output_edit.setPlainText(word.get("json_format_empty_hint")) diff --git a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py index 73799029..03c6a9f0 100644 --- a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py @@ -10,6 +10,7 @@ from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget from je_editor import language_wrapper +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import JwtDecodeException from pybreeze.utils.jwt_tools.jwt_decoder import ( @@ -84,7 +85,7 @@ def __init__(self, initial_token: str | None = None, main_window=None) -> None: def decode(self) -> None: """Decode the pasted token and show its header and payload.""" word = language_wrapper.language_word_dict - token = self.input_edit.toPlainText().strip() + token = exact_text(self.input_edit).strip() if not token: self._valid_output = False self.output_edit.setPlainText(word.get("jwt_decoder_empty_hint")) diff --git a/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py b/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py index c8b60cd2..bc25e662 100644 --- a/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py @@ -6,6 +6,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import QueryConvertException from pybreeze.utils.logging.logger import pybreeze_logger @@ -56,7 +57,7 @@ def __init__(self, main_window=None) -> None: def convert_to_json(self) -> None: """Convert the input query string to JSON.""" - text = self.input_edit.toPlainText().strip() + text = exact_text(self.input_edit).strip() if not text: self._valid_output = False self.output_edit.setPlainText( @@ -68,7 +69,7 @@ def convert_to_json(self) -> None: def convert_to_query(self) -> None: """Convert the input JSON to a query string.""" word = language_wrapper.language_word_dict - text = self.input_edit.toPlainText().strip() + text = exact_text(self.input_edit).strip() if not text: self._valid_output = False self.output_edit.setPlainText(word.get("query_json_empty_hint")) diff --git a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py index 1759b972..eefcd987 100644 --- a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py @@ -9,6 +9,7 @@ from je_editor import language_wrapper from pybreeze.pybreeze_ui.thread_keeper import let_run_out +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import RegexTesterException from pybreeze.utils.logging.logger import pybreeze_logger @@ -126,7 +127,7 @@ def test(self) -> None: self.test_button.setEnabled(False) self.output_edit.setPlainText(word.get("regex_running")) thread = RegexMatchThread( - self.pattern_edit.text(), self.text_edit.toPlainText(), self.selected_flags()) + self.pattern_edit.text(), exact_text(self.text_edit), self.selected_flags()) thread.matched.connect(self._show_matches) thread.failed.connect(self._show_error) thread.finished.connect(lambda: self.test_button.setEnabled(True)) diff --git a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py index 8dcf1327..9b0b7ca3 100644 --- a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py @@ -12,6 +12,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI from pybreeze.pybreeze_ui.tools_gui.json_format_gui import JsonFormatGUI @@ -137,7 +138,7 @@ def __init__(self, main_window=None) -> None: def analyze(self) -> None: """Analyse the pasted response, show the report, and enable cross-tool actions.""" word = language_wrapper.language_word_dict - text = self.input_edit.toPlainText().strip() + text = exact_text(self.input_edit).strip() if not text: self._analysis = None self._set_cross_tool_enabled(jwt=False, status=False, headers=False, body=False) @@ -192,7 +193,7 @@ def open_headers_in_analyzer(self) -> QWidget | None: self._main_window, HeaderAnalyzerGUI( main_window=self._main_window, - initial_headers=self.input_edit.toPlainText()), + initial_headers=exact_text(self.input_edit)), "extend_tools_menu_header_analyzer_tab_label") def open_body_in_json_format(self) -> QWidget | None: diff --git a/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py b/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py index f46f6143..4fe1aaf7 100644 --- a/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py @@ -6,6 +6,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import UrlConvertException from pybreeze.utils.logging.logger import pybreeze_logger @@ -62,7 +63,7 @@ def __init__(self, main_window=None, initial_url: str | None = None) -> None: def convert_to_json(self) -> None: """Parse the input URL into its JSON parts.""" word = language_wrapper.language_word_dict - text = self.input_edit.toPlainText().strip() + text = exact_text(self.input_edit).strip() if not text: self._valid_output = False self.output_edit.setPlainText(word.get("url_builder_empty_hint")) @@ -80,7 +81,7 @@ def convert_to_json(self) -> None: def convert_to_url(self) -> None: """Build a URL from the input JSON parts.""" word = language_wrapper.language_word_dict - text = self.input_edit.toPlainText().strip() + text = exact_text(self.input_edit).strip() if not text: self._valid_output = False self.output_edit.setPlainText(word.get("url_builder_empty_hint")) diff --git a/test/test_utils/test_exact_text.py b/test/test_utils/test_exact_text.py new file mode 100644 index 00000000..03cadfe9 --- /dev/null +++ b/test/test_utils/test_exact_text.py @@ -0,0 +1,60 @@ +"""The tools work on the text as it was entered, not on Qt's display copy of it.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import hashlib + +import pytest +from PySide6.QtWidgets import QApplication, QPlainTextEdit, QTextEdit + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text + +# A non-breaking space, and a line separator inside a line +_ENTERED = "price: 100\nnext
line" + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.mark.parametrize("editor", [QTextEdit, QPlainTextEdit]) +def test_every_character_comes_back(app, editor): + edit = editor() + edit.setPlainText(_ENTERED) + + # toPlainText() gives "price: 100\nnext\nline" + assert exact_text(edit) == _ENTERED + + +def test_the_hash_is_of_the_text_entered(app): + from pybreeze.pybreeze_ui.tools_gui.hash_gui import HashGUI + + tool = HashGUI() + tool.input_edit.setPlainText("a b") + + tool.compute() + + # It showed the digest of "a b". + assert hashlib.sha256("a b".encode()).hexdigest() in tool.output_edit.toPlainText() + tool.deleteLater() + + +def test_the_diff_sees_a_non_breaking_space(app): + from pybreeze.pybreeze_ui.tools_gui.diff_gui import DiffGUI + + tool = DiffGUI() + tool.left_edit.setPlainText("price: 100") + tool.right_edit.setPlainText("price: 100") + + tool.compare() + + # It called the two identical. + assert tool.output_edit.toPlainText() + tool.deleteLater() From 8cc39709ac64c2d4b88a977efe2fcd14fe246850 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 13:58:05 +0800 Subject: [PATCH 115/312] Make text typed on the diagram canvas one undo step and keep added images through undo --- architecture_explore.md | 8 +- docs/updates/2026-09.md | 22 ++++ docs/updates/README.md | 3 +- progress.md | 2 - .../diagram_editor/diagram_items.py | 21 +++- .../diagram_editor/diagram_scene.py | 23 ++-- test/test_utils/test_diagram_undo_fidelity.py | 104 ++++++++++++++++++ 7 files changed, 168 insertions(+), 15 deletions(-) create mode 100644 test/test_utils/test_diagram_undo_fidelity.py diff --git a/architecture_explore.md b/architecture_explore.md index 37d9d8ad..973b8f32 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -234,13 +234,13 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,731 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,759 行,最大子系統) | 檔案 | 職責 | |---|---| | `diagram_editor_widget.py` (624) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | -| `diagram_scene.py` (854) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點之上/之下。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | -| `diagram_items.py` (904) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範)。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級 | +| `diagram_scene.py` (863) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點之上/之下。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | +| `diagram_items.py` (923) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`add_image()` 把圖放進 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級 | | `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (434) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | | `diagram_view.py` (180) | `QGraphicsView`:滾輪縮放(有上下界;橫向滾輪不縮放)、中鍵平移、`drawBackground` 畫格線 | @@ -435,7 +435,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 99 個 `test_*.py`、1517 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 100 個 `test_*.py`、1522 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 1f5a6ab4..395a5096 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1553,3 +1553,25 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_exact_text.py` (new) - `architecture_explore.md` §6, §18 - **Open items**: none. + +## U-20260923-89 · 2026-09-23 · Diagram undo keeps text typed on the canvas and images it cannot reload · #done #diagram + +- **What**: `progress.md` #50 and #51, two ways the diagram editor's undo lost work. Found in the review sweep of `diagram_editor/`. + - Text typed on the canvas (double-click on a node, connection or image label, `_EditableLabel`) took no undo snapshot. Undo restores whole-scene snapshots, so after "move the node, type Hello", Ctrl+Z went back to before the move and Ctrl+Y to just after it; "Hello" was in neither snapshot and could not be recovered. + - Every undo rebuilds each image from its `source`, but `add_image` never put its pixmap in `_pixmap_cache`. An image picked through "All Files" with an extension the editor does not reload (`.tiff`) went blank on the first undo, and an image added from a URL was downloaded again (a failed download left it blank, logged at debug level only). +- **Fix**: + - `_EditableLabel` records the scene when a double-click starts editing. When the focus leaves, `DiagramScene.record_change("Edit Text", before)` pushes one undo step if anything changed. `end_undo` uses the same helper. The command skips its first redo, so nothing is rebuilt while the label is still in its own event handler. + - `add_image` caches the pixmap under its source. +- **Tests**: new `test_diagram_undo_fidelity.py`: + - typing is one undo step; + - move, type, undo, redo gives the typed text back; + - opening the editor without typing records nothing; + - undo keeps the picture of a `.tiff` and of a URL image, with no download; + - a mouse move is still one step. +- **Result / numbers**: unit tests 1508 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `diagram_scene.py` + - `test/test_utils/test_diagram_undo_fidelity.py` (new) + - `architecture_explore.md` §7, §18 + - `progress.md` (#50 and #51 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index d5314006..cac227ee 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-89 | 2026-09-23 | Diagram undo keeps text typed on the canvas and images it cannot reload | #done #diagram | [2026-09](2026-09.md) | | U-20260923-88 | 2026-09-23 | Tools work on the text as entered, not Qt's display copy | #fix #tools | [2026-09](2026-09.md) | | U-20260923-87 | 2026-09-23 | curl and HAR import: malformed URLs reported, -I, --oauth2-bearer, fragments, repeated -F | #fix #tools | [2026-09](2026-09.md) | | U-20260923-86 | 2026-09-23 | A diagram file with an infinite or non-numeric font size opens | #fix #diagram | [2026-09](2026-09.md) | @@ -169,4 +170,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 106 | +| [2026-09.md](2026-09.md) | 2026-09 | 107 | diff --git a/progress.md b/progress.md index d2679d14..2efd633d 100644 --- a/progress.md +++ b/progress.md @@ -10,5 +10,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#47** [BLOCKED] Closing the IDE closes JEditor's docked file editors (`editor_main/main_ui.py`, `_close_tool_tabs_and_docks`), and the published `je_editor` dock (`FullEditorWidget.closeEvent`) writes its buffer back on close whether edited or not, in UTF-8 with platform line endings, so exiting rewrites an LF file as CRLF. Fixed in JEditor (JEDITOR U-20260923-07: writes only when edited, in the file's own encoding and line ending); waits for the `je_editor` release that carries it, then bump the pin and add a PyBreeze test. - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? -- **#50** Diagram editor: text typed on the canvas (double-click editing, `_EditableLabel` in `diagram_editor/diagram_items.py`, nodes and connection labels) takes no undo snapshot, and undo restores whole-scene snapshots (`diagram_scene.py`, `undo_scope`), so the next Ctrl+Z / Ctrl+Y brings back the old text and the typed text cannot be recovered. Only the property panel's text box records text edits. -- **#51** Diagram editor: undo/redo rebuilds every image from its `source` (`diagram_scene.py`, image reload), but `add_image` never puts the pixmap in `_pixmap_cache`. An image added through the "All Files" filter with an extension outside the allowlist (e.g. `.tiff`) goes blank on the first undo, and an image added from a URL is downloaded again; if that download fails the image stays blank and the failure is only logged at debug level. diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index 0cfa7230..0fc46297 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -190,7 +190,15 @@ class NodeStyle: # --------------------------------------------------------------------------- class _EditableLabel(QGraphicsTextItem): - """Label that is read-only by default; double-click to edit, focus-out to commit.""" + """Label that is read-only by default; double-click to edit, focus-out to commit. + + The edit is one undo step, from the double-click to the focus leaving. It + used to be none: undo restores whole-scene snapshots, so the next undo or + redo brought the old text back and what was typed could not be recovered. + """ + + # The scene as it was when editing began, until the edit is committed + _before_edit: dict | None = None def focusOutEvent(self, event) -> None: self.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) @@ -200,9 +208,20 @@ def focusOutEvent(self, event) -> None: parent = self.parentItem() if parent is not None and hasattr(parent, "_center_label"): parent._center_label() + self._record_edit() super().focusOutEvent(event) + def _record_edit(self) -> None: + """Record the text typed since the double-click as one undo step.""" + before, self._before_edit = self._before_edit, None + scene = self.scene() + if before is not None and hasattr(scene, "record_change"): + scene.record_change("Edit Text", before) + def mouseDoubleClickEvent(self, event) -> None: + scene = self.scene() + if self._before_edit is None and hasattr(scene, "record_change"): + self._before_edit = scene.to_dict() self.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditorInteraction) self.setFocus(Qt.FocusReason.MouseFocusReason) super().mouseDoubleClickEvent(event) diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 3cbd9505..761c403e 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -183,16 +183,20 @@ def begin_undo(self, description: str) -> None: def end_undo(self) -> None: if self._pending_undo_snapshot is None: return - new = self._snapshot() - if new != self._pending_undo_snapshot: - cmd = DiagramSnapshotCommand( - self, self._pending_undo_desc or "Edit", - self._pending_undo_snapshot, new, - ) - self.undo_stack.push(cmd) + self.record_change(self._pending_undo_desc or "Edit", self._pending_undo_snapshot) self._pending_undo_snapshot = None self._pending_undo_desc = None + def record_change(self, description: str, before: dict) -> None: + """Put one undo step on the stack, from *before* to the scene as it is now. + + Nothing is recorded when nothing changed. The command skips its first + redo, so the scene is not rebuilt under the caller. + """ + after = self._snapshot() + if after != before: + self.undo_stack.push(DiagramSnapshotCommand(self, description, before, after)) + @contextmanager def undo_scope(self, description: str): """Take a snapshot, run the body, and record what it changed. @@ -634,6 +638,11 @@ def add_image(self, pixmap: QPixmap, source: str, pos: QPointF | None = None) -> pos = views[0].mapToScene(views[0].viewport().rect().center()) else: pos = QPointF(0, 0) + if source and not pixmap.isNull(): + # An undo rebuilds every image from its source: without this, one + # the editor cannot reload (a .tiff picked through "All Files") + # went blank, and one from a URL was downloaded again. + self._pixmap_cache[source] = pixmap with self.undo_scope("Add Image"): img = DiagramImage(x=pos.x() - w / 2, y=pos.y() - h / 2, w=w, h=h, source=source, pixmap=pixmap) self.addItem(img) diff --git a/test/test_utils/test_diagram_undo_fidelity.py b/test/test_utils/test_diagram_undo_fidelity.py new file mode 100644 index 00000000..2b55d162 --- /dev/null +++ b/test/test_utils/test_diagram_undo_fidelity.py @@ -0,0 +1,104 @@ +"""Undo in the diagram editor gives back what was there: typed text, and images.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QEvent, QPointF, Qt +from PySide6.QtGui import QColor, QFocusEvent, QPixmap +from PySide6.QtWidgets import QApplication, QGraphicsSceneMouseEvent + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _type_into(label, text: str) -> None: + """Double-click the label, replace its text, and click away.""" + label.mouseDoubleClickEvent(QGraphicsSceneMouseEvent(QEvent.Type.GraphicsSceneMouseDoubleClick)) + label.setPlainText(text) + label.focusOutEvent(QFocusEvent(QEvent.Type.FocusOut)) + + +class TestTextTypedOnTheCanvas: + def _scene_with_a_node(self): + scene = DiagramScene() + node = DiagramNode(x=0, y=0, w=100, h=60, text="Node") + scene.addItem(node) + return scene + + def test_it_is_one_undo_step(self, app): + scene = self._scene_with_a_node() + + _type_into(scene.get_all_nodes()[0].label, "Hello") + + assert scene.undo_stack.count() == 1 + scene.undo_stack.undo() + assert scene.get_all_nodes()[0].text() == "Node" + + def test_undo_then_redo_brings_it_back(self, app): + scene = self._scene_with_a_node() + with scene.undo_scope("Move"): + scene.get_all_nodes()[0].moveBy(10, 0) + _type_into(scene.get_all_nodes()[0].label, "Hello") + + scene.undo_stack.undo() + scene.undo_stack.redo() + + # Undo went back to before the move and redo to just after it: the + # typed text was in neither snapshot, and was gone for good. + assert scene.get_all_nodes()[0].text() == "Hello" + + def test_opening_the_editor_without_typing_records_nothing(self, app): + scene = self._scene_with_a_node() + + _type_into(scene.get_all_nodes()[0].label, "Node") + + assert scene.undo_stack.count() == 0 + + +class TestAnImageTheEditorCannotReload: + def test_undo_keeps_its_picture(self, app, monkeypatch): + scene = DiagramScene() + pixmap = QPixmap(20, 10) + pixmap.fill(QColor("red")) + downloads: list = [] + monkeypatch.setattr(scene, "_start_image_download", downloads.append) + # A .tiff picked through "All Files", and an image added from a URL + scene.add_image(pixmap, r"C:\scans\page.tiff", QPointF(0, 0)) + scene.add_image(pixmap, "https://img.example/logo.png", QPointF(100, 0)) + with scene.undo_scope("Move"): + scene.get_all_images()[0].moveBy(5, 0) + + scene.undo_stack.undo() + + # The first went blank (.tiff is not on the list the editor reloads), + # and the second was downloaded again. + assert all(not image._pix_item.pixmap().isNull() for image in scene.get_all_images()) + assert downloads == [] + + +def test_a_mouse_move_is_still_one_step(app): + scene = DiagramScene() + node = DiagramNode(x=0, y=0, w=100, h=60, text="Node") + scene.addItem(node) + node.setSelected(True) + press = QGraphicsSceneMouseEvent(QEvent.Type.GraphicsSceneMousePress) + press.setScenePos(QPointF(50, 30)) + press.setButton(Qt.MouseButton.LeftButton) + press.setButtons(Qt.MouseButton.LeftButton) + scene.mousePressEvent(press) + node.moveBy(20, 0) + + scene.end_undo() + + assert scene.undo_stack.count() == 1 From 0578419a294be3d4fe54b24ebb9d285df0adf323 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 14:53:33 +0800 Subject: [PATCH 116/312] Refuse a URL whose host the SSRF check and the HTTP client would read differently --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 16 ++++ docs/updates/README.md | 3 +- progress.md | 1 + pybreeze/utils/network/url_validation.py | 41 +++++++++ .../test_url_parser_differential.py | 88 +++++++++++++++++++ 6 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 test/test_utils/test_url_parser_differential.py diff --git a/architecture_explore.md b/architecture_explore.md index 973b8f32..3f7e038d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -339,7 +339,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `subprocess_util.py` | `utf8_subprocess_env()`(釘 `PYTHONIOENCODING`,解 Windows cp950 亂碼)、`no_window_creationflags()`(`CREATE_NO_WINDOW`,避免 GUI 程式彈出黑窗) | | `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | -| `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | +| `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | | `curl_import/` | `curl_parser.py`(579) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | @@ -435,7 +435,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 100 個 `test_*.py`、1522 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 101 個 `test_*.py`、1535 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 395a5096..e113d725 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1575,3 +1575,19 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §7, §18 - `progress.md` (#50 and #51 removed) - **Open items**: none. + +## U-20260923-90 · 2026-09-23 · SSRF check refuses a URL whose host urlparse and urllib3 read differently · #fix #security #network + +- **What**: `validate_url` read the host with `urlparse`; `requests` connects to the host `urllib3` reads. The two disagree over a backslash: `http://127.0.0.1\@example.com/` is `example.com` to `urlparse` (public, passed) and `127.0.0.1` to `urllib3`, which `requests` then connected to. Every caller (AI review client, diagram image download, prthinker, the request tools) was open to it; the reported path reached a loopback listener and read its reply. Found in the review sweep of `extend/process_executor/` and `utils/network/`. +- **Fix**: new `_check_one_reading(url)` in `utils/network/url_validation.py`, run first by `validate_url`: + - a backslash, whitespace or a control character (below 0x20, or 0x7F) is refused; + - otherwise the host `urllib3.util.parse_url` reads is compared with the one `urlparse` reads (brackets stripped, lower-cased, IDNA-encoded) and a mismatch is refused; + - the messages are generic ("URL cannot be parsed.", "URL names its host ambiguously."): the parsers' own messages quote the whole URL, which may carry a token. +- **Tests**: new `test_url_parser_differential.py` (13): six ambiguous URLs refused (backslash before `@`, with a port, the metadata address, newline, tab, NUL), five ordinary URLs still pass (query and fragment, userinfo, upper case, an IDN, a port), the parser error does not quote the URL, and end to end a loopback listener behind `127.0.0.1:port\@example.com` is never reached. +- **Result / numbers**: unit tests 1521 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/network/url_validation.py` + - `test/test_utils/test_url_parser_differential.py` (new) + - `architecture_explore.md` §12, §18 + - `progress.md` (#52 added) +- **Open items**: #52 (the address checked is not the address connected to: DNS rebinding). diff --git a/docs/updates/README.md b/docs/updates/README.md index cac227ee..c17b8a06 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-90 | 2026-09-23 | SSRF check refuses a URL whose host urlparse and urllib3 read differently | #fix #security #network | [2026-09](2026-09.md) | | U-20260923-89 | 2026-09-23 | Diagram undo keeps text typed on the canvas and images it cannot reload | #done #diagram | [2026-09](2026-09.md) | | U-20260923-88 | 2026-09-23 | Tools work on the text as entered, not Qt's display copy | #fix #tools | [2026-09](2026-09.md) | | U-20260923-87 | 2026-09-23 | curl and HAR import: malformed URLs reported, -I, --oauth2-bearer, fragments, repeated -F | #fix #tools | [2026-09](2026-09.md) | @@ -170,4 +171,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 107 | +| [2026-09.md](2026-09.md) | 2026-09 | 108 | diff --git a/progress.md b/progress.md index 2efd633d..c723002d 100644 --- a/progress.md +++ b/progress.md @@ -10,3 +10,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#47** [BLOCKED] Closing the IDE closes JEditor's docked file editors (`editor_main/main_ui.py`, `_close_tool_tabs_and_docks`), and the published `je_editor` dock (`FullEditorWidget.closeEvent`) writes its buffer back on close whether edited or not, in UTF-8 with platform line endings, so exiting rewrites an LF file as CRLF. Fixed in JEditor (JEDITOR U-20260923-07: writes only when edited, in the file's own encoding and line ending); waits for the `je_editor` release that carries it, then bump the pin and add a PyBreeze test. - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? +- **#52** The SSRF check resolves the host, then the request resolves it again (`utils/network/url_validation.py:130`, `validate_url`; the redirect re-check in `pybreeze_ui/diagram_editor/diagram_net_utils.py` likewise), so a name whose DNS answer changes between the two (DNS rebinding, short TTL) passes as public and connects to a private address. Needs the checked address pinned for the connection (a `requests` transport adapter that connects to the validated IP and keeps the name for TLS SNI and the Host header), used by every caller of `validate_url`. diff --git a/pybreeze/utils/network/url_validation.py b/pybreeze/utils/network/url_validation.py index 2dad2a99..a47f5b5a 100644 --- a/pybreeze/utils/network/url_validation.py +++ b/pybreeze/utils/network/url_validation.py @@ -4,6 +4,9 @@ import socket from urllib.parse import urlparse +from urllib3.exceptions import LocationParseError +from urllib3.util import parse_url + _ALLOWED_SCHEMES = frozenset({"http", "https"}) # RFC 6598 shared address space (Carrier-Grade NAT). Not covered by @@ -60,6 +63,40 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: return any(_is_blocked_ip(embedded) for embedded in _embedded_ipv4(ip)) +def _check_one_reading(url: str) -> None: + """Refuse a URL whose host ``urlparse`` and ``urllib3`` would read differently. + + The check below reads the host with ``urlparse``; ``requests`` connects to + the host ``urllib3`` reads. They disagree over a backslash: + ``http://127.0.0.1\\@example.com/`` is ``example.com`` to the first, which + passed, and ``127.0.0.1`` to the second, which was then connected to. + Whitespace and control characters are refused for the same reason, and the + two readings of the host are compared whatever the URL holds. + """ + if any(character == "\\" or character.isspace() or ord(character) < 0x20 or ord(character) == 0x7F + for character in url): + raise UnsafeURLError("URL contains a backslash, whitespace or a control character.") + # Not the parsers' messages: they quote the whole URL, which may hold a token + try: + connected_host = (parse_url(url).host or "").strip("[]").lower() + except LocationParseError: + raise UnsafeURLError("URL cannot be parsed.") from None + try: + checked_host = (urlparse(url).hostname or "").lower() + except ValueError: + raise UnsafeURLError("URL cannot be parsed.") from None + if _as_ascii(connected_host) != _as_ascii(checked_host): + raise UnsafeURLError("URL names its host ambiguously.") + + +def _as_ascii(host: str) -> str: + """*host* as it goes on the wire: urllib3 IDNA-encodes a Unicode name, urlparse does not.""" + try: + return host.encode("idna").decode("ascii") + except UnicodeError: + return host + + def validate_url(url: str) -> str: """Validate a user-supplied URL against SSRF rules. @@ -72,8 +109,12 @@ def validate_url(url: str) -> str: (IPv4-mapped, 6to4, Teredo, NAT64) that tunnel to a blocked IPv4 endpoint. + The host checked must be the host connected to, so a URL the parsers can + read two ways is refused before anything else: see ``_check_one_reading``. + Returns the original *url* on success; raises ``UnsafeURLError`` on failure. """ + _check_one_reading(url) parsed = urlparse(url) if parsed.scheme.lower() not in _ALLOWED_SCHEMES: diff --git a/test/test_utils/test_url_parser_differential.py b/test/test_utils/test_url_parser_differential.py new file mode 100644 index 00000000..80e0307a --- /dev/null +++ b/test/test_utils/test_url_parser_differential.py @@ -0,0 +1,88 @@ +"""The host validate_url checks is the host requests connects to. + +The check read the host with ``urlparse``; ``requests`` connects to the host +``urllib3`` reads. A URL the two read differently got a public host checked +and a private one connected to. +""" +from __future__ import annotations + +import socket +import threading + +import pytest +import requests + +from pybreeze.utils.network import url_validation +from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url + +_PUBLIC = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.215.14", 0))] + + +@pytest.fixture() +def public_dns(monkeypatch): + """Every name resolves to a public address, so only the parsing decides.""" + monkeypatch.setattr(url_validation.socket, "getaddrinfo", lambda *_a, **_k: _PUBLIC) + + +@pytest.mark.parametrize("url", [ + "http://127.0.0.1\\@example.com/", + "http://127.0.0.1:8080\\@example.com/", + "http://169.254.169.254\\@example.com/latest/meta-data/", + "http://exam\nple.com/", + "http://example.com\t/", + "http://example.com/\x00", +]) +def test_a_url_read_two_ways_is_refused(public_dns, url): + with pytest.raises(UnsafeURLError): + validate_url(url) + + +@pytest.mark.parametrize("url", [ + "https://example.com/path?q=1#frag", + "https://user:secret@example.com/", + "https://EXAMPLE.com/", + "https://bücher.example/", + "https://api.example.com:8443/v1", +]) +def test_an_ordinary_url_still_passes(public_dns, url): + assert validate_url(url) == url + + +def test_the_parser_error_does_not_quote_the_url(public_dns): + with pytest.raises(UnsafeURLError) as raised: + validate_url("http://[::1/api?token=sk-live-not-a-real-key") + + assert "sk-live" not in str(raised.value) + + +def test_the_loopback_listener_is_never_reached(public_dns): + """The reported attack, end to end: the check passed and requests got the secret.""" + served: list = [] + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + listener.settimeout(3) + port = listener.getsockname()[1] + url = f"http://127.0.0.1:{port}\\@example.com/" + + def serve() -> None: + try: + connection, _address = listener.accept() + except OSError: + return + with connection: + served.append(connection.recv(1024)) + connection.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nSECRET") + + server = threading.Thread(target=serve) + server.start() + try: + validate_url(url) + requests.get(url, timeout=(3, 3), allow_redirects=False) + except UnsafeURLError: + pass + finally: + listener.close() + server.join(5) + + assert served == [] From e244ffdafa6f6f9b060b927670441246be0fbc15 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 14:55:39 +0800 Subject: [PATCH 117/312] Report a run that cannot start in its window and give Python runs no stdin --- architecture_explore.md | 5 ++- docs/updates/2026-09.md | 17 +++++++ docs/updates/README.md | 3 +- .../process_executor/file_runner_process.py | 26 ++++++++--- .../python_task_process_manager.py | 29 +++++++++--- test/test_utils/test_run_output.py | 44 +++++++++++++++++++ 6 files changed, 108 insertions(+), 16 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 3f7e038d..9a44ede8 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -92,7 +92,7 @@ Template Method 定義的子行程生命週期: | 階段 | 做的事 | |---|---| | 解譯器解析 | `renew_path()` → 執行視窗帶著 IDE 選定的直譯器(`python_compiler`,由 `build_task_process()` 從主視窗抄過來)就用它;沒選才用 `find_venv_path()` 找 `venv/`、`.venv/`,交給 `check_and_choose_venv()`;找不到時**不拋例外**,直接把錯誤寫進執行視窗並回傳 `False` | -| 啟動 | `subprocess.Popen(args, shell=False, creationflags=CREATE_NO_WINDOW, env=PYTHONIOENCODING=...)` | +| 啟動 | `subprocess.Popen(args, shell=False, stdin=DEVNULL, creationflags=CREATE_NO_WINDOW, env=PYTHONIOENCODING=...)`;`stdin` 不接 IDE 的主控台(腳本 `input()` 立刻拿到 EOF);`Popen` 丟 `OSError`(直譯器不見、命令列超過 Windows 上限)時寫進執行視窗並顯示,不從選單拋出 | | 讀取 | 兩條 daemon Thread 各自經 `queue_pump.read_stream_into_queue()` 對 stdout / stderr `readline()`,原樣塞進 `Queue`(保留縮排、行尾與空行);**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | | 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則,交給 `CodeWindow.append_output()` | | 收尾 | `exit_program()`:join 執行緒(timeout 2s)→ drain queue(`max_messages=None` 一次抽乾)→ `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | @@ -147,6 +147,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `run_current_file_with()` 建好後同樣掛在執行視窗的 `runner` 上 - 有和 `TaskProcessManager` 相同語意的 `stop()` - 子行程的 `stdin` 是 `DEVNULL`:執行視窗沒有輸入欄,讀取要立刻拿到 EOF,不能卡在沒人寫的管線上 + - 啟動失敗(找不到指令、是資料夾、沒有執行權限、產物被鎖)都寫進執行視窗,編譯產物經 `_remove_binary()` 一併刪掉 ### 4.5 `queue_pump.py` 與 `CodeWindow.append_output()` @@ -435,7 +436,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 101 個 `test_*.py`、1535 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 101 個 `test_*.py`、1538 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index e113d725..cfb6b638 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1591,3 +1591,20 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `architecture_explore.md` §12, §18 - `progress.md` (#52 added) - **Open items**: #52 (the address checked is not the address connected to: DNS rebinding). + +## U-20260923-91 · 2026-09-23 · A run that cannot start says so in its window, and a Python run no longer reads the IDE's stdin · #fix #executor + +- **What**: three gaps in the two executors, found in the review sweep of `extend/process_executor/`. + - `TaskProcessManager._spawn_and_pump` did not catch `OSError` from `Popen`. An interpreter that had been removed, or a command line over Windows' limit (a large script run as `--execute_str`), raised out of the menu callback and left a run window nobody ever saw. + - `FileRunnerProcess._start_process` caught only `FileNotFoundError`. A compiler path that is a folder or not executable, or a compiled binary locked or blocked by antivirus, raised out of the menu, or out of the timer slot after a compile, and the window said nothing. The compiled binary was then left behind. + - The Python child inherited the IDE's stdin: a script calling `input()` blocked on the IDE's console and consumed what was typed there. The plugin runner already gave its children `DEVNULL`. +- **Fix**: + - `_spawn_and_pump` passes `stdin=subprocess.DEVNULL` and catches `OSError`: it logs, writes `[Error] could not start: ` on its own line in the window and shows it. + - `_start_process` also catches `OSError` and writes `[Error] Could not start : `. Both failure paths remove the compiled binary through the new `_remove_binary()`, which `_finish` now uses too. +- **Tests**: `test_run_output.py` +3: a missing interpreter is reported in the window, a script calling `input()` gets end of file, and a compiler that is a folder is reported. +- **Result / numbers**: unit tests 1524 passed, 14 skipped. Two child-IDE tests (`test_closing_the_ide`, `test_file_tree_rename`) timed out once while the machine was loaded and passed on their own. `ruff check` clean. +- **Files**: + - `pybreeze/extend/process_executor/python_task_process_manager.py`, `file_runner_process.py` + - `test/test_utils/test_run_output.py` + - `architecture_explore.md` §4.1, §4.4, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c17b8a06..f5721080 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-91 | 2026-09-23 | A run that cannot start says so in its window, and a Python run no longer reads the IDE's stdin | #fix #executor | [2026-09](2026-09.md) | | U-20260923-90 | 2026-09-23 | SSRF check refuses a URL whose host urlparse and urllib3 read differently | #fix #security #network | [2026-09](2026-09.md) | | U-20260923-89 | 2026-09-23 | Diagram undo keeps text typed on the canvas and images it cannot reload | #done #diagram | [2026-09](2026-09.md) | | U-20260923-88 | 2026-09-23 | Tools work on the text as entered, not Qt's display copy | #fix #tools | [2026-09](2026-09.md) | @@ -171,4 +172,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 108 | +| [2026-09.md](2026-09.md) | 2026-09 | 109 | diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index 9bb46ba2..686712ce 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -131,6 +131,15 @@ def _start_process(self, command: list[str], cleanup_binary: str | None = None, ) except FileNotFoundError: self.main_window.append_output(f"[Error] Command not found: {command[0]}\n", is_error=True) + self._remove_binary(cleanup_binary) + return + except OSError as error: + # Not executable, a folder, or a compiled binary locked or blocked + # by antivirus: this raised out of the menu, or out of the timer + # slot after a compile, and the window said nothing. + self.main_window.append_output( + f"[Error] Could not start {command[0]}: {error.strerror or error}\n", is_error=True) + self._remove_binary(cleanup_binary) return self._cleanup_binary = cleanup_binary @@ -206,14 +215,19 @@ def _finish(self) -> None: is_error=exit_code != 0, ) - # Clean up compiled binary - if self._cleanup_binary: - try: - os.remove(self._cleanup_binary) - except OSError as error: - pybreeze_logger.debug("Could not remove compiled binary %s: %s", self._cleanup_binary, error) + self._remove_binary(self._cleanup_binary) self.main_window.run_ended() + @staticmethod + def _remove_binary(binary: str | None) -> None: + """Delete a compiled binary once it has run, or could not be started.""" + if not binary: + return + try: + os.remove(binary) + except OSError as error: + pybreeze_logger.debug("Could not remove compiled binary %s: %s", binary, error) + def _drain_queues(self) -> None: """Drain all remaining messages from output/error queues to UI.""" pump_message_queue(self.output_queue, self.main_window.append_output, is_error=False, max_messages=None) diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 43786e86..c06eb750 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -132,13 +132,28 @@ def _spawn_and_pump( child_environment = utf8_subprocess_env(self.program_encoding) if environment: child_environment.update(environment) - self.process = subprocess.Popen( # nosec B603 # nosemgrep # noqa: S603 - args, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - creationflags=no_window_creationflags(), - env=child_environment, - ) + try: + self.process = subprocess.Popen( # nosec B603 # nosemgrep # noqa: S603 + args, + # Not the IDE's own stdin: a script calling input() blocked on + # the IDE's console and consumed what was typed there. It gets + # end of file, as FileRunnerProcess's children do. + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + creationflags=no_window_creationflags(), + env=child_environment, + ) + except OSError as error: + # An interpreter that is gone, or a command line over Windows' + # limit (a large script run as --execute_str): this raised out of + # the menu and left a run window no one would ever see. + pybreeze_logger.error("%s could not start: %r", package, error) + self.main_window.append_output( + f"[Error] {package} could not start: {error.strerror or error}\n", + is_error=True, own_line=True) + self.main_window.show() + return self.still_run_program = True self.read_program_output_from_thread = Thread( target=self.read_program_output_from_process, diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index bcd25815..a1f5899e 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -248,3 +248,47 @@ def no_python(_path): run_window = main_window.current_run_code_window[0] assert "No Python interpreter found" in run_window.code_result.toPlainText() + + +class TestAPythonRunThatCannotStart: + def test_a_missing_interpreter_is_reported_in_the_window(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + main_window = MainWindow(python_compiler=str(tmp_path / "gone" / "python.exe")) + + # It raised FileNotFoundError out of the menu, and the run window was + # never shown. + build_task_process(main_window).start_module_process("pip", ["--version"]) + + run_window = main_window.current_run_code_window[0] + assert "pip could not start" in run_window.code_result.toPlainText() + assert run_window.runner.process is None + + def test_a_script_that_reads_input_gets_end_of_file(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + (tmp_path / "ask.py").write_text("input('name? ')\n", encoding="utf-8") + main_window = MainWindow(python_compiler=sys.executable) + runner = build_task_process(main_window) + + # It read the IDE's own console, and waited there. + runner.start_module_process("ask", [], environment={"PYTHONPATH": str(tmp_path)}) + run_window = main_window.current_run_code_window[0] + _run_events_until(qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) + + assert "EOFError" in run_window.code_result.toPlainText() + + +class TestAFileRunThatCannotStart: + def test_a_compiler_that_is_a_folder_is_reported(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + runner = FileRunnerProcess(window) + + # PermissionError: only FileNotFoundError was caught. + runner.run_file({"name": "Odd", "compiler": str(tmp_path)}, str(tmp_path / "main.x")) + + assert "[Error] Could not start" in window.code_result.toPlainText() + assert runner.process is None From 407dde606059a057bc41f2c3292be44077356b4a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 15:07:27 +0800 Subject: [PATCH 118/312] End a run without joining its output readers on the UI thread --- architecture_explore.md | 5 +- docs/updates/2026-09.md | 15 +++ docs/updates/README.md | 3 +- .../process_executor/file_runner_process.py | 28 ++++-- .../python_task_process_manager.py | 34 +++++-- .../extend/process_executor/queue_pump.py | 41 ++++++++ test/test_utils/test_run_output.py | 96 +++++++++++++++++++ 7 files changed, 201 insertions(+), 21 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 9a44ede8..3118884c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -95,7 +95,7 @@ Template Method 定義的子行程生命週期: | 啟動 | `subprocess.Popen(args, shell=False, stdin=DEVNULL, creationflags=CREATE_NO_WINDOW, env=PYTHONIOENCODING=...)`;`stdin` 不接 IDE 的主控台(腳本 `input()` 立刻拿到 EOF);`Popen` 丟 `OSError`(直譯器不見、命令列超過 Windows 上限)時寫進執行視窗並顯示,不從選單拋出 | | 讀取 | 兩條 daemon Thread 各自經 `queue_pump.read_stream_into_queue()` 對 stdout / stderr `readline()`,原樣塞進 `Queue`(保留縮排、行尾與空行);**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | | 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則,交給 `CodeWindow.append_output()` | -| 收尾 | `exit_program()`:join 執行緒(timeout 2s)→ drain queue(`max_messages=None` 一次抽乾)→ `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | +| 收尾 | 子行程結束後,pump 照樣每 tick 抽 queue,直到兩條 reader 都讀到 EOF 或寬限(`ReaderGrace`,2 秒)用完,UI 執行緒不 join 任何執行緒。`exit_program()`:drain queue(`max_messages=None` 一次抽乾)→ reader 還活著(子行程開的行程還握著管線)就在視窗註明之後的輸出不會顯示 → `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | | 停止 | `stop()`:子行程還在跑就 `terminate()`(只有它本身,它再開的行程不管),之後照一般結束的路徑回報。經 `CodeWindow.stop_runner()` 呼叫;關閉 IDE 時 `PyBreezeMainWindow.closeEvent()` 對每個執行視窗都呼叫一次(經 `_close_guarded()`:一個視窗、分頁或 dock 關閉時丟例外只記錄,其餘照關,JEditor 自己的 `closeEvent` 一定會跑到) | 三種啟動介面: @@ -155,6 +155,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `read_stream_into_queue(stream, queue, buffer_size, encoding, keep_reading)` — reader 執行緒用。行**原樣**進 queue(縮排、行尾、空行都留著);空讀 = EOF 即停,管線被關掉的 `OSError` / `ValueError` 記 debug 後停。超過 `buffer_size` 的長行分段讀進來:用 incremental decoder 解碼(被切斷的多位元組字元接到下一段),段尾的 `\r` 留到下一段(`\r\n` 被切開時不會變成兩個換行);不認得的 encoding 退回 UTF-8 並記 warning - `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 +- `ReaderGrace` / `any_alive()` / `OUTPUT_STILL_HELD_NOTE` — 子行程結束後 reader 還能讀多久(`READER_GRACE_SECONDS = 2.0`,從結束後第一個 tick 起算)。管線要等最後一個握著它的行程結束才會 EOF,子行程開的行程沒轉向輸出時會一直握著;以前兩個執行器在 UI 執行緒上各 join 2 秒,IDE 卡 4 秒還是丟掉之後的輸出。現在由 pump 逐 tick 詢問,時間到就結束執行並在視窗註明 - `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。`\r\n` 與單獨的 `\r` 轉成換行;換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 --- @@ -436,7 +437,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 101 個 `test_*.py`、1538 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 101 個 `test_*.py`、1541 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index cfb6b638..98ce7763 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1608,3 +1608,18 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_run_output.py` - `architecture_explore.md` §4.1, §4.4, §18 - **Open items**: none. + +## U-20260923-92 · 2026-09-23 · A run whose started process keeps its output open no longer freezes the IDE when it ends · #fix #executor + +- **What**: when the child exited, both executors joined each reader thread on the UI thread for up to two seconds (`TaskProcessManager.exit_program`, `FileRunnerProcess._finish`). A pipe reaches end of file only when the last process holding it exits, so a run that starts a process without redirecting its output (a server, a browser driver, a `subprocess.Popen` left running) froze the IDE for four seconds at its end, measured at 4.0 s in the new test, and what that process wrote later was lost without a word. Found in the review sweep of `extend/process_executor/`. +- **Fix**: + - New in `queue_pump.py`: `ReaderGrace` (`READER_GRACE_SECONDS = 2.0`, counted from the first tick after the exit), `any_alive()` and `OUTPUT_STILL_HELD_NOTE`. + - After the exit the pump keeps draining tick by tick while a reader is alive and the grace is not over, then ends the run. Nothing waits on the UI thread; output that comes within the grace is shown before the exit line. + - A reader still alive at the end means a process the run started holds the output: the window says what it writes from then on is not shown. +- **Tests**: `test_run_output.py` +3: a Python run and a plugin run whose grandchild holds the pipes end with no event pass over one second (both failed on the old code at 4.0 s) and show the note; output a grandchild writes within the grace comes before the exit line, with no note. +- **Result / numbers**: executor tests 78 passed; unit tests 1527 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/extend/process_executor/queue_pump.py`, `python_task_process_manager.py`, `file_runner_process.py` + - `test/test_utils/test_run_output.py` + - `architecture_explore.md` §4.1, §4.5, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index f5721080..bbcb0c0e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-92 | 2026-09-23 | A run whose started process keeps its output open no longer freezes the IDE when it ends | #fix #executor | [2026-09](2026-09.md) | | U-20260923-91 | 2026-09-23 | A run that cannot start says so in its window, and a Python run no longer reads the IDE's stdin | #fix #executor | [2026-09](2026-09.md) | | U-20260923-90 | 2026-09-23 | SSRF check refuses a URL whose host urlparse and urllib3 read differently | #fix #security #network | [2026-09](2026-09.md) | | U-20260923-89 | 2026-09-23 | Diagram undo keeps text typed on the canvas and images it cannot reload | #done #diagram | [2026-09](2026-09.md) | @@ -172,4 +173,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 109 | +| [2026-09.md](2026-09.md) | 2026-09 | 110 | diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index 686712ce..685d86c2 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -18,7 +18,13 @@ from PySide6.QtCore import QTimer -from pybreeze.extend.process_executor.queue_pump import pump_message_queue, read_stream_into_queue +from pybreeze.extend.process_executor.queue_pump import ( + OUTPUT_STILL_HELD_NOTE, + ReaderGrace, + any_alive, + pump_message_queue, + read_stream_into_queue, +) from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.subprocess_util import no_window_creationflags, utf8_subprocess_env @@ -50,6 +56,7 @@ def __init__( # step starts the run from here), and when to give up on the child self._after_exit: Callable[[int], None] | None = None self._deadline: float | None = None + self._reader_grace = ReaderGrace() def run_file(self, run_config: dict, file_path: str) -> None: """ @@ -146,6 +153,7 @@ def _start_process(self, command: list[str], cleanup_binary: str | None = None, self._after_exit = after_exit self._deadline = None if time_limit is None else time.monotonic() + time_limit self.still_running = True + self._reader_grace.restart() self._stdout_thread = Thread(target=self._read_stdout, daemon=True) self._stdout_thread.start() @@ -177,7 +185,10 @@ def _pull_text(self) -> None: if self.process is not None: self.process.poll() if self.process.returncode is not None: - self._finish() + # Output still on its way is pumped on the next ticks, not + # waited for here: this is the UI thread + if not self._reader_grace.still_reading(self._stdout_thread, self._stderr_thread): + self._finish() elif self._deadline is not None and time.monotonic() > self._deadline: self._deadline = None self.main_window.append_output( @@ -190,16 +201,15 @@ 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 + # Not waited for: the pump gave the readers their grace. One still + # alive means a process the child started holds the output. + readers = (self._stdout_thread, self._stderr_thread) + self._stdout_thread = self._stderr_thread = None # Drain remaining output directly (not via _pull_text to avoid recursion) self._drain_queues() + if any_alive(*readers): + self.main_window.append_output(OUTPUT_STILL_HELD_NOTE, is_error=False, own_line=True) after_exit, self._after_exit = self._after_exit, None if self.process is not None: diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index c06eb750..0a6d8539 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -13,7 +13,13 @@ from je_editor import JEditorExecException from je_editor.utils.venv_check.check_venv import check_and_choose_venv -from pybreeze.extend.process_executor.queue_pump import pump_message_queue, read_stream_into_queue +from pybreeze.extend.process_executor.queue_pump import ( + OUTPUT_STILL_HELD_NOTE, + ReaderGrace, + any_alive, + pump_message_queue, + read_stream_into_queue, +) from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.subprocess_util import no_window_creationflags, utf8_subprocess_env @@ -59,6 +65,7 @@ def __init__( self.run_output_queue: Queue = Queue() self.run_error_queue: Queue = Queue() self.process: subprocess.Popen | None = None + self._reader_grace = ReaderGrace() self.task_done_trigger_function: Callable = task_done_trigger_function self.error_trigger_function: Callable = error_trigger_function @@ -155,6 +162,7 @@ def _spawn_and_pump( self.main_window.show() return self.still_run_program = True + self._reader_grace.restart() self.read_program_output_from_thread = Thread( target=self.read_program_output_from_process, daemon=True @@ -190,23 +198,31 @@ def pull_text(self): self.timer.stop() return if self.process.returncode is not None: + # Output still on its way is pumped on the next ticks, not waited + # for here: this is the UI thread + if self._reader_grace.still_reading( + self.read_program_output_from_thread, self.read_program_error_output_from_thread): + return if self.timer.isActive(): self.timer.stop() self.exit_program() elif self.still_run_program: self.process.poll() - # exit program change run flag to false and clean read thread and queue and process def exit_program(self): + """End the run: show what is left of its output, report the exit, run the done hook. + + Does not wait for the reader threads; the pump gave them their grace. + One still alive means a process the run started holds the output, and + the window says so. + """ 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 + readers = (self.read_program_output_from_thread, self.read_program_error_output_from_thread) + self.read_program_output_from_thread = None + self.read_program_error_output_from_thread = None self.drain_and_display_queue() + if any_alive(*readers): + self.main_window.append_output(OUTPUT_STILL_HELD_NOTE, own_line=True) if self.process is not None: self.process.terminate() self.main_window.append_output( diff --git a/pybreeze/extend/process_executor/queue_pump.py b/pybreeze/extend/process_executor/queue_pump.py index 0e70dcca..ed6f1b3c 100644 --- a/pybreeze/extend/process_executor/queue_pump.py +++ b/pybreeze/extend/process_executor/queue_pump.py @@ -10,6 +10,8 @@ import codecs import itertools import queue +import threading +import time from collections.abc import Callable from queue import Queue from typing import IO @@ -22,6 +24,13 @@ # thread from stalling when a process floods stdout. MAX_MESSAGES_PER_PUMP = 256 +# After the child exits, its readers get this long to reach end of file +READER_GRACE_SECONDS = 2.0 + +# Written when a process the run started still holds its output at the end +OUTPUT_STILL_HELD_NOTE = ( + "[A process started by this run still holds its output; what it writes from now on is not shown]\n") + def read_stream_into_queue( stream: IO, @@ -78,6 +87,38 @@ def _decoder_for(encoding: str) -> codecs.IncrementalDecoder: return codecs.getincrementaldecoder("utf-8")(errors="replace") +def any_alive(*readers: threading.Thread | None) -> bool: + """True when one of *readers* is a thread still running.""" + return any(reader is not None and reader.is_alive() for reader in readers) + + +class ReaderGrace: + """How long an exited child's reader threads still get before the run ends. + + A child's pipes reach end of file when the last process holding them + exits, and that is not always the child: a process it started without + redirecting its output keeps them open. The executors used to join each + reader on the UI thread for up to two seconds, so such a run froze the IDE + for four and still lost what came later. The pump now asks this, tick by + tick, and ends the run once the readers are done or the grace is over. + """ + + def __init__(self) -> None: + self._since: float | None = None + + def still_reading(self, *readers: threading.Thread | None) -> bool: + """True while a reader is alive and the grace, counted from the first ask, is not over.""" + if not any_alive(*readers): + return False + if self._since is None: + self._since = time.monotonic() + return time.monotonic() - self._since < READER_GRACE_SECONDS + + def restart(self) -> None: + """Count the next child's grace from its own exit.""" + self._since = None + + def pump_message_queue( q: Queue, append_fn: Callable[[str, bool], None], diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index a1f5899e..856c719b 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -292,3 +292,99 @@ def test_a_compiler_that_is_a_folder_is_reported(self, qt_app, tmp_path): assert "[Error] Could not start" in window.code_result.toPlainText() assert runner.process is None + + +def _script_leaving_a_process_on_the_pipes(folder, grandchild_code: str): + """A script that starts a process holding its output, prints that process's id, and exits.""" + script = folder / "leave.py" + script.write_text( + "import subprocess, sys\n" + f"child = subprocess.Popen([sys.executable, '-c', {grandchild_code!r}],\n" + " stdout=sys.stdout, stderr=sys.stderr)\n" + "print('grandchild', child.pid, flush=True)\n", + encoding="utf-8", + ) + return script + + +def _longest_event_pass(qt_app, finished) -> float: + """Process events until *finished*, returning the longest single pass in seconds.""" + deadline = time.monotonic() + _RUN_TIMEOUT_SECONDS + longest = 0.0 + while not finished(): + if time.monotonic() > deadline: + pytest.fail("the run did not end in time") + started = time.monotonic() + qt_app.processEvents() + longest = max(longest, time.monotonic() - started) + time.sleep(0.01) + return longest + + +def _stop_grandchild(text: str) -> None: + for line in text.splitlines(): + if line.startswith("grandchild "): + try: + os.kill(int(line.split()[1]), 9) + except OSError: + pass + + +# Long enough to outlast the run's grace many times over +_HOLDING = "import time; time.sleep(20)" + + +class TestAProcessTheRunStartedHoldsItsOutput: + """The run ends without waiting on the UI thread for pipes a grandchild keeps open.""" + + def test_a_python_run_ends_without_freezing_the_window(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + _script_leaving_a_process_on_the_pipes(tmp_path, _HOLDING) + main_window = MainWindow(python_compiler=sys.executable) + build_task_process(main_window).start_module_process( + "leave", [], environment={"PYTHONPATH": str(tmp_path)}) + run_window = main_window.current_run_code_window[0] + try: + # Each reader was joined for two seconds inside one timer tick + longest = _longest_event_pass( + qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) + text = run_window.code_result.toPlainText() + finally: + _stop_grandchild(run_window.code_result.toPlainText()) + + assert longest < 1.0 + assert "still holds its output" in text + + def test_a_plugin_run_ends_without_freezing_the_window(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + script = _script_leaving_a_process_on_the_pipes(tmp_path, _HOLDING) + window = CodeWindow() + runner = FileRunnerProcess(window) + runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + try: + longest = _longest_event_pass(qt_app, lambda: not runner.still_running) + text = window.code_result.toPlainText() + finally: + _stop_grandchild(window.code_result.toPlainText()) + + assert longest < 1.0 + assert "still holds its output" in text + assert "[Process exited with code 0]" in text + + def test_output_that_comes_within_the_grace_is_shown(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + _script_leaving_a_process_on_the_pipes( + tmp_path, "import time; time.sleep(0.3); print('late line', flush=True)") + main_window = MainWindow(python_compiler=sys.executable) + build_task_process(main_window).start_module_process( + "leave", [], environment={"PYTHONPATH": str(tmp_path)}) + run_window = main_window.current_run_code_window[0] + _run_events_until(qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) + + text = run_window.code_result.toPlainText() + assert text.index("late line") < text.index("Task exit with code") + assert "still holds its output" not in text From d09fdc67a8d8d82dac521ff4c4c1acdef266bbe4 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 15:28:18 +0800 Subject: [PATCH 119/312] Mail only the report a run wrote, and say in its window whether the mail went --- architecture.md | 3 +- architecture_explore.md | 6 +- docs/updates/2026-09.md | 20 ++++++ docs/updates/README.md | 3 +- .../mail_thunder_setting.py | 58 ++++++++++++--- .../process_executor_utils.py | 42 ++++++++++- test/test_utils/test_build_task_process.py | 68 +++++++++++++++++- test/test_utils/test_mail_report.py | 71 ++++++++++++++++++- 8 files changed, 248 insertions(+), 23 deletions(-) diff --git a/architecture.md b/architecture.md index 8ae342f3..e11b3901 100644 --- a/architecture.md +++ b/architecture.md @@ -88,7 +88,8 @@ Automation menu (automation_menu_factory.build_automation_menu) → call_() extend/process_executor// → build_process() (process_executor_utils.py) → CodeWindow + TaskProcessManager → python -m --execute_str | --execute_file → stdout/stderr reader threads → Queue → QTimer → pump_message_queue() → CodeWindow.append_output() - → optional send_after_test() (mail_thunder_extend; mails the report on a thread of its own) + → optional report_mail_hook() → send_after_test() (mail_thunder_extend; mails this run's report on a + thread of its own, never an earlier run's, and the run window says whether it went) ``` **Run a non-Python file through a plugin** diff --git a/architecture_explore.md b/architecture_explore.md index 3118884c..765d7008 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -113,7 +113,7 @@ Template Method 定義的子行程生命週期: | `build_process_from_file()` | 以檔案路徑執行單一檔案 | | `run_dir_files_with_package()` | 問使用者選資料夾(`_ask_for_action_files()`,對話框掛在主視窗上;資料夾裡沒有 `.json` 就明說),對每個 `.json` 開一個執行視窗批次跑 | | `open_run_window()` | 開一個執行視窗、掛進 `main_window.current_run_code_window`,並接上 `finished_and_closed`:使用者關掉一個已經跑完的執行視窗時主視窗就放掉它(以前這份清單只增不減,每次執行都留下一個視窗、一個執行器、兩個 queue 和一個 timer);執行中被關掉的視窗記下 `_closed_while_running`,等執行器在結束路徑尾端呼叫 `CodeWindow.run_ended()` 時才放掉(排到 timer 的 slot 回來之後才發,視窗是 timer 的 parent)。插件執行也走這裡 | -| `build_task_process()` | 共用建構:`open_run_window()` 並帶上主視窗選定的直譯器、決定要不要接 `send_after_test`。建好的 `TaskProcessManager` 掛在執行視窗的 `runner` 上,所以呼叫端可以不留參考。prthinker 審查也走這裡 | +| `build_task_process()` | 共用建構:`open_run_window()` 並帶上主視窗選定的直譯器、決定要不要接寄報告的 hook(`report_mail_hook(code_window)`:建立時記下子行程工作目錄裡 `default_name.html` 的絕對路徑與開始時間,比這次執行還舊的報告不寄;寄送結果經無 parent 的 `_MailNotice` 以 queued signal 回到執行視窗,寫出寄到了或沒寄的原因)。建好的 `TaskProcessManager` 掛在執行視窗的 `runner` 上,所以呼叫端可以不留參考。prthinker 審查也走這裡 | ### 4.3 各自動化模組(Strategy) @@ -403,7 +403,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 │ ▲ ├── daemon Thread: stdout readline ┤ ├── daemon Thread: stderr readline ┘ - ├── daemon Thread: pybreeze-report-mail(send_after_test → send_report,只寫 log) + ├── daemon Thread: pybreeze-report-mail(send_after_test → send_report,結果經 _MailNotice queued signal 寫回執行視窗) │ ├── QThread: SSHReaderThread ──Signal──► terminal widget ├── QThread: SshConnectThread ──Signal──► shell / file tree(host key 問題 BlockingQueued 回 UI) @@ -437,7 +437,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 101 個 `test_*.py`、1541 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 101 個 `test_*.py`、1553 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 98ce7763..71278665 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1623,3 +1623,23 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_run_output.py` - `architecture_explore.md` §4.1, §4.5, §18 - **Open items**: none. + +## U-20260923-93 · 2026-09-23 · Run and send: an earlier run's report is not mailed, and the run window says whether the mail went · #fix #executor #mail + +- **What**: the "and send" runs' done-hook (`send_after_test`) always mailed `default_name.html` from the IDE's working directory, found in the review sweep of `extend/process_executor/`: + - a run that failed before writing its report mailed the one an earlier run had left there, as if it were this run's; + - whether the mail went out, and why not (no `je_mail_thunder`, no report, no mail user, a failed login, an unreachable server), was only logged; the run window said nothing. +- **Fix**: + - `send_report(path, *, not_before=None)` returns None once the report is sent, else a short reason for the user (no path, address or server reply; those stay in the log). A report last written more than two seconds before `not_before` is not sent ("the run wrote no new default_name.html; the one there is from an earlier run"), and neither is a folder in its place. + - `send_after_test(..., not_before=, on_done=)` passes that answer to `on_done` on the mail thread. + - `build_task_process` makes the hook per run with the new `report_mail_hook(code_window)`: it takes the report's absolute path in the directory the child starts in and the start time. A parentless `_MailNotice` QObject carries the answer to the run window through a queued signal (checked to arrive on the main thread), which writes `[Mail] The test report was sent` or `[Mail] The test report was not sent: `. An answer after the window is gone is dropped. +- **Tests**: + - `test_mail_report.py` +9: an hour-old report is not sent, a fresh one is, a folder is not; the answer for sent, no user, failed login, failed send (the error kind only, not its text), no `je_mail_thunder`; `on_done` gets the answer. + - `test_build_task_process.py` +3: the window says it was sent (with the absolute report path and the start time asked for), says why not, and an answer after the window is deleted raises nothing on the mail thread. +- **Result / numbers**: unit tests 1539 passed, 14 skipped. `test_file_tree_rename` (a child IDE) timed out once while the machine was loaded and passed on its own. `ruff check` clean. +- **Files**: + - `pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py` + - `pybreeze/extend/process_executor/process_executor_utils.py` + - `test/test_utils/test_mail_report.py`, `test_build_task_process.py` + - `architecture.md` (main flow), `architecture_explore.md` §4.2, §16, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index bbcb0c0e..918ef0f9 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-93 | 2026-09-23 | Run and send: an earlier run's report is not mailed, and the run window says whether the mail went | #fix #executor #mail | [2026-09](2026-09.md) | | U-20260923-92 | 2026-09-23 | A run whose started process keeps its output open no longer freezes the IDE when it ends | #fix #executor | [2026-09](2026-09.md) | | U-20260923-91 | 2026-09-23 | A run that cannot start says so in its window, and a Python run no longer reads the IDE's stdin | #fix #executor | [2026-09](2026-09.md) | | U-20260923-90 | 2026-09-23 | SSRF check refuses a URL whose host urlparse and urllib3 read differently | #fix #security #network | [2026-09](2026-09.md) | @@ -173,4 +174,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 110 | +| [2026-09.md](2026-09.md) | 2026-09 | 111 | diff --git a/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py b/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py index 529ebc17..1b81da46 100644 --- a/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py +++ b/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py @@ -2,6 +2,7 @@ import os import threading +from collections.abc import Callable from pybreeze.utils.exception.exception_tags import send_html_exception_tag from pybreeze.utils.exception.exceptions import ITESendHtmlReportException @@ -9,8 +10,17 @@ DEFAULT_REPORT_PATH = "default_name.html" +# How much older than the run a report may look and still be its own: file +# systems keep modification times to a second or two +_MTIME_SLACK_SECONDS = 2.0 -def send_after_test(html_report_path: str | None = None) -> None: + +def send_after_test( + html_report_path: str | None = None, + *, + not_before: float | None = None, + on_done: Callable[[str | None], None] | None = None, +) -> None: """Mail a finished run's HTML report, on a thread of its own. This is a run's done-hook, called on the UI thread as the run ends. The @@ -19,35 +29,46 @@ def send_after_test(html_report_path: str | None = None) -> None: it. Whatever goes wrong is logged by that thread, never raised. :param html_report_path: the report to send; ``default_name.html`` when None + :param not_before: see ``send_report`` + :param on_done: called on the mail thread with ``send_report``'s answer """ - threading.Thread( - target=send_report, args=(html_report_path,), name="pybreeze-report-mail", daemon=True - ).start() + def send() -> None: + outcome = send_report(html_report_path, not_before=not_before) + if on_done is not None: + on_done(outcome) + + threading.Thread(target=send, name="pybreeze-report-mail", daemon=True).start() -def send_report(html_report_path: str | None = None) -> None: +def send_report(html_report_path: str | None = None, *, not_before: float | None = None) -> str | None: """Mail the HTML report to the configured mail user, and log what went wrong. Blocks for as long as the mail server takes; ``send_after_test`` runs it off the UI thread. :param html_report_path: the report to send; ``default_name.html`` when None + :param not_before: when the run started (``time.time()``). A report last + written before then is an earlier run's, left behind by a run that + wrote none, and is not sent. + :return: None once the report is sent, else why it was not, in words for + the run window (no path, address or server reply: those are logged) """ try: from je_mail_thunder import SMTPWrapper, get_mail_thunder_os_environ, read_output_content from je_mail_thunder.utils.exception.exceptions import MailThunderException except ImportError as error: pybreeze_logger.error("Cannot send the report without je_mail_thunder: %r", error) - return + return "je_mail_thunder is not installed" report_path = html_report_path if html_report_path is not None else DEFAULT_REPORT_PATH - if not os.path.isfile(report_path): - pybreeze_logger.error("Report file not found: %s", report_path) - return + problem = _report_problem(report_path, not_before) + if problem is not None: + pybreeze_logger.error("Report not sent (%s): %s", problem, report_path) + return problem user = _mail_user(read_output_content, get_mail_thunder_os_environ) if user is None: pybreeze_logger.error("Cannot determine mail user for sending report") - return + return "no mail user is set" try: with open(report_path, encoding="utf-8") as file: html_string = file.read() @@ -64,10 +85,27 @@ def send_report(html_report_path: str | None = None) -> None: mail_thunder_smtp.send_message(message) except ITESendHtmlReportException as error: pybreeze_logger.error("%r %s", error, send_html_exception_tag) + return "the mail server login failed" # OSError covers the socket and every smtplib error; ValueError a report # that is not UTF-8 except (OSError, ValueError, MailThunderException) as error: pybreeze_logger.error("Failed to send report: %r", error) + return f"sending failed ({type(error).__name__})" + return None + + +def _report_problem(report_path: str, not_before: float | None) -> str | None: + """Why *report_path* is not this run's report to send, or None when it is.""" + name = os.path.basename(report_path) + try: + written = os.stat(report_path) + except OSError: + return f"the run wrote no {name}" + if not os.path.isfile(report_path): + return f"{name} is not a file" + if not_before is not None and written.st_mtime < not_before - _MTIME_SLACK_SECONDS: + return f"the run wrote no new {name}; the one there is from an earlier run" + return None def _mail_user(read_output_content, get_mail_thunder_os_environ) -> str | None: diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index 3a32324b..94e5ed78 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -1,13 +1,17 @@ from __future__ import annotations import json +import os +import time +from collections.abc import Callable from typing import TYPE_CHECKING +from PySide6.QtCore import QObject, Signal from PySide6.QtWidgets import QFileDialog, QMessageBox from je_editor import EditorWidget, language_wrapper from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow -from pybreeze.extend.mail_thunder_extend.mail_thunder_setting import send_after_test +from pybreeze.extend.mail_thunder_extend.mail_thunder_setting import DEFAULT_REPORT_PATH, send_after_test 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 @@ -158,6 +162,40 @@ def forget_run_window(main_window: PyBreezeMainWindow, code_window: CodeWindow) main_window.current_run_code_window.remove(code_window) +class _MailNotice(QObject): + """Carries the mail thread's answer to the run window, on the UI thread.""" + + told = Signal(str, bool) + + def tell(self, reason: str | None) -> None: + """Called on the mail thread with ``send_report``'s answer.""" + if reason is None: + self.told.emit("[Mail] The test report was sent\n", False) + else: + self.told.emit(f"[Mail] The test report was not sent: {reason}\n", True) + + +def report_mail_hook(code_window: CodeWindow) -> Callable[[], None]: + """The done-hook that mails this run's report and says in *code_window* how that went. + + Made as the run starts, so it knows where the child writes its report (the + working directory it starts in) and when it started: a report older than + the run is an earlier run's, and is not sent. The mail used to go out, or + fail, with nothing but a log line to say so. + """ + report_path = os.path.abspath(DEFAULT_REPORT_PATH) + started = time.time() + # No parent: the mail thread may answer after the window is gone. Its + # queued connection to the window is dropped with the window. + notice = _MailNotice() + notice.told.connect(code_window.append_output) + + def mail_the_report() -> None: + send_after_test(report_path, not_before=started, on_done=notice.tell) + + return mail_the_report + + def build_task_process( main_window: PyBreezeMainWindow, send_mail: bool = False, @@ -176,7 +214,7 @@ def build_task_process( main_window.clear_code_result() code_window.runner = TaskProcessManager( code_window, - task_done_trigger_function=send_after_test if send_mail else None, + task_done_trigger_function=report_mail_hook(code_window) if send_mail else None, program_buffer_size=program_buffer, program_encoding=main_window.encoding, ) diff --git a/test/test_utils/test_build_task_process.py b/test/test_utils/test_build_task_process.py index 86709cd4..3c178d86 100644 --- a/test/test_utils/test_build_task_process.py +++ b/test/test_utils/test_build_task_process.py @@ -68,12 +68,74 @@ def test_the_run_window_is_kept_and_the_old_result_cleared(self, qt_app): assert main_window.cleared def test_the_report_mail_is_sent_only_when_asked(self, qt_app): - from pybreeze.extend.mail_thunder_extend.mail_thunder_setting import send_after_test from pybreeze.extend.process_executor.process_executor_utils import build_task_process assert build_task_process(MainWindow()).task_done_trigger_function is None - assert build_task_process( - MainWindow(), send_mail=True).task_done_trigger_function is send_after_test + assert callable(build_task_process(MainWindow(), send_mail=True).task_done_trigger_function) + + +class TestTheReportMailSaysHowItWent: + """The run window tells the user whether the report went out; it was only logged.""" + + @staticmethod + def _run_the_hook(qt_app, monkeypatch, answer): + import time + + from pybreeze.extend.mail_thunder_extend import mail_thunder_setting as mail + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + asked: list = [] + + def send_report(path, *, not_before=None): + asked.append((path, not_before)) + return answer + + monkeypatch.setattr(mail, "send_report", send_report) + before = time.time() + runner = build_task_process(MainWindow(), send_mail=True) + runner.task_done_trigger_function() + window = runner.main_window + deadline = time.monotonic() + 10 + while "[Mail]" not in window.code_result.toPlainText() and time.monotonic() < deadline: + qt_app.processEvents() + time.sleep(0.01) + return window.code_result.toPlainText(), asked, before + + def test_a_sent_report_is_reported(self, qt_app, monkeypatch): + text, asked, before = self._run_the_hook(qt_app, monkeypatch, None) + + assert "[Mail] The test report was sent" in text + ((path, not_before),) = asked + # The report the child writes in the directory it starts in, from this run on + assert os.path.isabs(path) and path.endswith("default_name.html") + assert not_before >= before + + def test_a_report_not_sent_says_why(self, qt_app, monkeypatch): + text, _asked, _before = self._run_the_hook(qt_app, monkeypatch, "no mail user is set") + + assert "[Mail] The test report was not sent: no mail user is set" in text + + def test_an_answer_after_the_window_is_gone_is_dropped(self, qt_app, monkeypatch): + import threading + + from pybreeze.extend.mail_thunder_extend import mail_thunder_setting as mail + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + from shiboken6 import delete + + release = threading.Event() + raised: list = [] + monkeypatch.setattr(threading, "excepthook", raised.append) + monkeypatch.setattr(mail, "send_report", lambda _path, *, not_before=None: release.wait(5) and None) + runner = build_task_process(MainWindow(), send_mail=True) + runner.task_done_trigger_function() + (mail_thread,) = [one for one in threading.enumerate() if one.name == "pybreeze-report-mail"] + delete(runner.main_window) + release.set() + + mail_thread.join(5) + qt_app.processEvents() + assert not mail_thread.is_alive() + assert raised == [] class TestRunningWithoutAScriptTab: diff --git a/test/test_utils/test_mail_report.py b/test/test_utils/test_mail_report.py index b8dd0923..1bbf6411 100644 --- a/test/test_utils/test_mail_report.py +++ b/test/test_utils/test_mail_report.py @@ -1,8 +1,10 @@ """Mailing a run's report: off the UI thread, and every failure logged rather than raised.""" from __future__ import annotations +import os import sys import threading +import time import types from unittest.mock import MagicMock @@ -90,7 +92,7 @@ def test_it_returns_before_the_mail_is_sent(self, monkeypatch): sending = threading.Event() release = threading.Event() - def slow_send(_path) -> None: + def slow_send(_path, *, not_before=None) -> None: sending.set() release.wait(5) @@ -132,10 +134,11 @@ def test_no_user_means_no_connection(self, mail_thunder, logger, report): assert "mail user" in _logged(logger) def test_a_missing_report_means_no_connection(self, mail_thunder, logger, tmp_path): - mail.send_report(str(tmp_path / "absent.html")) + reason = mail.send_report(str(tmp_path / "absent.html")) assert FakeSmtp.instances == [] - assert "not found" in _logged(logger) + assert reason == "the run wrote no absent.html" + assert reason in _logged(logger) def test_a_failed_login_is_logged_and_the_connection_closed(self, mail_thunder, logger, report): FakeSmtp.logs_in = False @@ -177,3 +180,65 @@ def test_without_je_mail_thunder_it_is_logged(self, monkeypatch, logger, report) mail.send_report(report) assert "je_mail_thunder" in _logged(logger) + + +class TestAReportFromAnEarlierRun: + """A run that wrote no report used to mail the one an earlier run left.""" + + def test_a_report_older_than_the_run_is_not_sent(self, mail_thunder, logger, report): + an_hour_ago = time.time() - 3600 + os.utime(report, (an_hour_ago, an_hour_ago)) + + reason = mail.send_report(report, not_before=time.time()) + + assert FakeSmtp.instances == [] + assert "earlier run" in reason + + def test_a_report_the_run_wrote_is_sent(self, mail_thunder, logger, report): + assert mail.send_report(report, not_before=time.time() - 1) is None + assert len(FakeSmtp.instances[0].sent) == 1 + + def test_a_folder_in_its_place_is_not_sent(self, mail_thunder, logger, tmp_path): + folder = tmp_path / "default_name.html" + folder.mkdir() + + assert mail.send_report(str(folder)) == "default_name.html is not a file" + + +class TestTheAnswer: + """What send_report says, for the run window: never a path, address or server reply.""" + + def test_sent(self, mail_thunder, logger, report): + assert mail.send_report(report) is None + + def test_no_user(self, mail_thunder, logger, report): + mail_thunder.read_output_content = lambda: {} + + assert mail.send_report(report) == "no mail user is set" + + def test_login_failed(self, mail_thunder, logger, report): + FakeSmtp.logs_in = False + + assert mail.send_report(report) == "the mail server login failed" + + def test_send_failed_names_only_the_error_kind(self, mail_thunder, logger, report): + FakeSmtp.send_error = ConnectionRefusedError("refused by mail.example.com for tester@example.com") + + reason = mail.send_report(report) + + assert reason == "sending failed (ConnectionRefusedError)" + + def test_without_je_mail_thunder(self, monkeypatch, logger, report): + monkeypatch.setitem(sys.modules, "je_mail_thunder", None) + + assert mail.send_report(report) == "je_mail_thunder is not installed" + + def test_the_answer_reaches_on_done(self, monkeypatch): + answered = threading.Event() + answers: list = [] + monkeypatch.setattr(mail, "send_report", lambda _path, *, not_before=None: "why") + + mail.send_after_test("report.html", on_done=lambda reason: (answers.append(reason), answered.set())) + + assert answered.wait(5) + assert answers == ["why"] From 3649cbd42894105e64c7e3af04db976246976bef Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 16:05:47 +0800 Subject: [PATCH 120/312] Connect requests to user URLs only to the address checked as they connect --- CLAUDE.md | 5 +- architecture.md | 5 +- architecture_explore.md | 9 +- docs/updates/2026-09.md | 23 ++ docs/updates/README.md | 3 +- progress.md | 1 - .../connect_gui/url/ai_code_review_gui.py | 13 +- .../diagram_editor/diagram_net_utils.py | 8 +- .../code_review/code_review_thread.py | 3 +- .../extend_ai_gui/skills/skills_send_gui.py | 12 +- pybreeze/utils/network/public_http.py | 137 ++++++++++++ pybreeze/utils/network/url_validation.py | 20 +- test/test_utils/test_ai_code_review_client.py | 29 ++- test/test_utils/test_cot_session_reuse.py | 2 +- ...st_http_goes_through_public_connections.py | 57 +++++ test/test_utils/test_public_http.py | 202 ++++++++++++++++++ test/test_utils/test_skills_request.py | 15 +- 17 files changed, 509 insertions(+), 35 deletions(-) create mode 100644 pybreeze/utils/network/public_http.py create mode 100644 test/test_utils/test_http_goes_through_public_connections.py create mode 100644 test/test_utils/test_public_http.py diff --git a/CLAUDE.md b/CLAUDE.md index 51e215cc..2a62ac1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ pybreeze/ ├── header_tools/ jwt_tools/ hash_tools/ timestamp_tools/ ├── regex_tools/ query_tools/ url_tools/ diff_tools/ ├── http_reference/ json_format/ response_inspector/ - ├── network/ # url_validation (SSRF), http_client (capped reads) + ├── network/ # url_validation (SSRF), public_http (pinned connections), http_client (capped reads) ├── exception/ # ITEException hierarchy ├── logging/ file_process/ app_dirs.py / subprocess_util.py └── manager/package_manager/ # PackageManager — holds syntax_check_list @@ -101,8 +101,9 @@ ruff check pybreeze/ # before committing non-trivia 2. Resolve the hostname and reject private / loopback / link-local / reserved IPs 3. Enforce timeouts (15 s downloads, 30 s API calls) and response size caps (20 MB binary) 4. `allow_redirects=False`, or re-validate every redirect target +5. Connect only to the address checked: send through `public_session()` (requests) or `PublicHTTPHandler` / `PublicHTTPSHandler` (urllib) from `utils/network/public_http.py`, which check again as they connect and connect to that address, so a name that resolves differently after validation (DNS rebinding) gets nowhere private. `test_http_goes_through_public_connections.py` fails on a direct `requests.*` or `urlopen` call -Reference implementations: `utils/network/url_validation.py` (`validate_url`), `utils/network/http_client.py` (`read_capped_text`), `diagram_editor/diagram_net_utils.py` (`safe_download_image`). Never pass a user URL to `urlopen()` / `requests.*` unvalidated, and never set `verify=False`. +Reference implementations: `utils/network/url_validation.py` (`validate_url`), `utils/network/public_http.py` (`public_session`), `utils/network/http_client.py` (`read_capped_text`), `diagram_editor/diagram_net_utils.py` (`safe_download_image`). Never pass a user URL to `urlopen()` / `requests.*` unvalidated, and never set `verify=False`. **SSH** — never `paramiko.AutoAddPolicy()` or `WarningPolicy()`. Use `apply_host_key_policy(client, parent_widget)` from `connect_gui/ssh/ssh_host_key_policy.py`: it shows the SHA256 fingerprint for confirmation on first connect and persists to `~/.pybreeze/ssh_known_hosts`. Every `connect()` passes `disabled_algorithms=SHA1_ALGORITHMS` (`connect_gui/ssh/ssh_connect_thread.py`): `requirements.txt` does not pin paramiko, and paramiko 4 still offers SHA-1 signatures and key exchanges (CVE-2026-44405). diff --git a/architecture.md b/architecture.md index e11b3901..501de2dc 100644 --- a/architecture.md +++ b/architecture.md @@ -36,7 +36,7 @@ their output reaches the UI through Queue + QTimer. | `pybreeze/extend/process_executor/` | Subprocess isolation layer: `TaskProcessManager`, `process_executor_utils.py`, `FileRunnerProcess`, `queue_pump.py`, one sub-package per automation package, plus `test_pioneer/` and `prthinker/` | | `pybreeze/extend/mail_thunder_extend/`, `prthinker_extend/` | Post-test email hook; prthinker settings and argument assembly (pure logic) | | `pybreeze/extend_multi_language/` | PyBreeze's English and Traditional Chinese strings, merged into JEditor's dictionaries | -| `pybreeze/utils/` | Pure logic, no Qt or JEditor (`test_utils_has_no_qt.py` guards it): request parsing and codegen, HTTP tools, `network/` SSRF validation and capped reads, exceptions, logging, `app_dirs.py`, `subprocess_util.py` | +| `pybreeze/utils/` | Pure logic, no Qt or JEditor (`test_utils_has_no_qt.py` guards it): request parsing and codegen, HTTP tools, `network/` SSRF validation, pinned connections and capped reads, exceptions, logging, `app_dirs.py`, `subprocess_util.py` | | `test/test_utils/` | Unit tests (pure logic and headless widgets). `test/unit_test/start_automation/` holds the launch tests | | `pyproject.toml`, `dev.toml` | Stable packaging (CI bumps and publishes it) and the unpublished dev packaging (keep its dependencies identical) | | `.github/workflows/` | `dev.yml`, `stable.yml` (unit tests on a Windows matrix, then SonarCloud) | @@ -194,7 +194,8 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() IDE stops every run still going (`CodeWindow.stop_runner()`): a child is a separate, console-less process that would otherwise outlive it unseen. - Every outbound request to a user URL passes SSRF validation (`utils/network/url_validation.py`) - with timeouts and size caps (§ Security › Network). + with timeouts and size caps, and connects through `utils/network/public_http.py`, which connects + only to the address it checks as it connects (§ Security › Network). - SSH uses the interactive host-key policy, never auto-add (§ Security › SSH). - Work that waits on a network — an AI review request, a diagram's image downloads, an SSH connect, an SFTP listing or transfer — runs on a diff --git a/architecture_explore.md b/architecture_explore.md index 765d7008..dffc2a6c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -236,7 +236,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,759 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,763 行,最大子系統) | 檔案 | 職責 | |---|---| @@ -247,9 +247,9 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `diagram_property_panel.py` (434) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | | `diagram_view.py` (180) | `QGraphicsView`:滾輪縮放(有上下界;橫向滾輪不縮放)、中鍵平移、`drawBackground` 畫格線 | | `diagram_commands.py` (28) | `DiagramSnapshotCommand(QUndoCommand)` — 快照式 undo,存變更前後完整場景狀態 | -| `diagram_net_utils.py` (104) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗、20 MB 大小上限、15 秒 timeout | +| `diagram_net_utils.py` (108) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗、`_OPENER` 用 `PublicHTTPHandler` / `PublicHTTPSHandler`(連線當下再檢查一次並只連到那個位址)、20 MB 大小上限、15 秒 timeout | -`diagram_net_utils` 是 CLAUDE.md 指定的網路安全參考實作,其他 HTTP 呼叫端則統一走 `utils/network/url_validation.py`。 +`diagram_net_utils` 是 CLAUDE.md 指定的網路安全參考實作,其他 HTTP 呼叫端則統一走 `utils/network/url_validation.py` 驗證、經 `utils/network/public_http.py` 的 `public_session()` 送出。 --- @@ -342,6 +342,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | +| `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 暫換成 `public_address()` 回傳的位址)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | | `curl_import/` | `curl_parser.py`(579) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | @@ -437,7 +438,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 101 個 `test_*.py`、1553 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 103 個 `test_*.py`、1564 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 71278665..891580cf 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1643,3 +1643,26 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_mail_report.py`, `test_build_task_process.py` - `architecture.md` (main flow), `architecture_explore.md` §4.2, §16, §18 - **Open items**: none. + +## U-20260923-94 · 2026-09-23 · Requests to user URLs connect only to the address checked as they connect (DNS rebinding) · #done #security #network + +- **What**: `progress.md` #52. `validate_url` resolved the host and checked every address, then the request resolved it again to connect. A name answering a public address the first time and a private one the second (DNS rebinding) passed and was connected to. Reproduced before the fix: a name answering `93.184.215.14` then `127.0.0.1` passed `validate_url`, and both a plain `requests.get` and the diagram editor's `urllib` opener read `SECRET` from a loopback listener. Callers: the AI code review client, CoT code review, Skills send and diagram image downloads. +- **Fix**: + - `url_validation.public_address(hostname)`: resolves the name, refuses it when any address is blocked, and returns the address to connect to. `validate_url` uses it. + - New `utils/network/public_http.py`. Its connections call `public_address` again as they connect and connect to the address it returns; the name stays the connection's host, so TLS checks the certificate against it (SNI) and the `Host` header carries it. + - For `requests`: `public_session()` mounts `PublicAddressAdapter`, whose pools use connections that swap urllib3's `_dns_host` for the checked address only while the socket opens. A refusal is a `NewConnectionError`, so callers get the `requests.ConnectionError` they already catch. + - For `urllib`: `PublicHTTPHandler` / `PublicHTTPSHandler` give `http.client` connections a `_create_connection` that connects to the checked address. A refusal is an `AddressNotPublicError` (`OSError`), so `urlopen` raises the `URLError` the canvas already catches. + - Through a proxy nothing is pinned: the proxy resolves, and it may well be on the local network. + - Callers: `skills_send_gui`, `code_review_thread` and `ai_code_review_gui` send through `public_session()` (the review client through `session.request` with the chosen method); `diagram_net_utils._OPENER` uses the two handlers. +- **Tests**: + - New `test_public_http.py` (9), with a fake DNS that answers the port asked for. An earlier fake answered port 0, which made the old code fail as well and would have proved nothing. For both `requests` and `urllib`: a rebinding name never reaches the loopback listener; a checked name is connected to under its own name (`Host` header, and the TLS ClientHello's server name); a proxy on the local network is still used. A rebinding image download fails with an `OSError`. + - New `test_http_goes_through_public_connections.py` (2): no module outside `public_http.py` calls `requests.get/post/.../Session` or `urlopen` directly, and the scan tells a call from generated code in a string. + - The Skills, AI review client and CoT tests hand in a stand-in session instead of patching `requests`; the review client's test now also asserts the method sent. +- **Result / numbers**: unit tests 1550 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/network/url_validation.py`, `public_http.py` (new) + - `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `extend_ai_gui/code_review/code_review_thread.py`, `connect_gui/url/ai_code_review_gui.py`, `diagram_editor/diagram_net_utils.py` + - `test/test_utils/test_public_http.py`, `test_http_goes_through_public_connections.py` (new), `test_skills_request.py`, `test_ai_code_review_client.py`, `test_cot_session_reuse.py` + - `CLAUDE.md` (tree, Security › Network), `architecture.md`, `architecture_explore.md` §7, §12, §18 + - `progress.md` (#52 removed) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 918ef0f9..1965f6ea 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-94 | 2026-09-23 | Requests to user URLs connect only to the address checked as they connect (DNS rebinding) | #done #security #network | [2026-09](2026-09.md) | | U-20260923-93 | 2026-09-23 | Run and send: an earlier run's report is not mailed, and the run window says whether the mail went | #fix #executor #mail | [2026-09](2026-09.md) | | U-20260923-92 | 2026-09-23 | A run whose started process keeps its output open no longer freezes the IDE when it ends | #fix #executor | [2026-09](2026-09.md) | | U-20260923-91 | 2026-09-23 | A run that cannot start says so in its window, and a Python run no longer reads the IDE's stdin | #fix #executor | [2026-09](2026-09.md) | @@ -174,4 +175,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 111 | +| [2026-09.md](2026-09.md) | 2026-09 | 112 | diff --git a/progress.md b/progress.md index c723002d..2efd633d 100644 --- a/progress.md +++ b/progress.md @@ -10,4 +10,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#47** [BLOCKED] Closing the IDE closes JEditor's docked file editors (`editor_main/main_ui.py`, `_close_tool_tabs_and_docks`), and the published `je_editor` dock (`FullEditorWidget.closeEvent`) writes its buffer back on close whether edited or not, in UTF-8 with platform line endings, so exiting rewrites an LF file as CRLF. Fixed in JEditor (JEDITOR U-20260923-07: writes only when edited, in the file's own encoding and line ending); waits for the `je_editor` release that carries it, then bump the pin and add a PyBreeze test. - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? -- **#52** The SSRF check resolves the host, then the request resolves it again (`utils/network/url_validation.py:130`, `validate_url`; the redirect re-check in `pybreeze_ui/diagram_editor/diagram_net_utils.py` likewise), so a name whose DNS answer changes between the two (DNS rebinding, short TTL) passes as public and connects to a private address. Needs the checked address pinned for the connection (a `requests` transport adapter that connects to the validated IP and keeps the name for TLS SNI and the Host header), used by every caller of `validate_url`. 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 d28b8a50..24ddce88 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 @@ -18,6 +18,7 @@ from pybreeze.utils.network.http_client import ( ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, succeeded, ) +from pybreeze.utils.network.public_http import public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -66,8 +67,9 @@ def __init__(self, method: str, url: str, code_text: str) -> None: def run(self) -> None: try: validate_url(self._url) - response = self._send() - body = read_capped_text(response) + with public_session() as session: + response = self._send(session) + body = read_capped_text(response) if succeeded(response): self.answered.emit(body) else: @@ -80,13 +82,12 @@ def run(self) -> None: pybreeze_logger.error("AI code review request failed: %s", type(error).__name__) self.failed.emit(str(error)) - def _send(self) -> requests.Response: + def _send(self, session: requests.Session) -> requests.Response: """Send the request with the chosen method, code in the body where there is one.""" - send = getattr(requests, self._method.lower()) options = {"timeout": (CONNECT_TIMEOUT, 30), "allow_redirects": False, "stream": True} if self._method in METHODS_WITH_A_BODY: - return send(self._url, data={"code": self._code_text}, **options) - return send(self._url, **options) + return session.request(self._method, self._url, data={"code": self._code_text}, **options) + return session.request(self._method, self._url, **options) def read_stats(path: str) -> tuple[int, int]: diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py index f34dc12a..037be7d0 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py @@ -2,7 +2,8 @@ Security measures: - Only ``http`` and ``https`` schemes are allowed (blocks ``file://``, ``ftp://``, etc.) - - Resolved IPs are checked against private/loopback ranges to prevent SSRF + - Resolved IPs are checked against private/loopback ranges to prevent SSRF, + again as each connection is made, which connects to the address checked - Downloads are capped at ``MAX_DOWNLOAD_BYTES`` to prevent memory exhaustion - Connection timeout is enforced """ @@ -10,6 +11,7 @@ from urllib.request import HTTPRedirectHandler, Request, build_opener +from pybreeze.utils.network.public_http import PublicHTTPHandler, PublicHTTPSHandler from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024 # 20 MB @@ -42,7 +44,9 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): return super().redirect_request(req, fp, code, msg, headers, newurl) -_OPENER = build_opener(_ValidatingRedirectHandler()) +# The HTTP handlers connect only to the address they check as they connect, +# so a name that resolves differently after validation gets nowhere private +_OPENER = build_opener(_ValidatingRedirectHandler(), PublicHTTPHandler(), PublicHTTPSHandler()) def _parse_content_length(raw: str | None) -> int | None: 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 35e8ce1c..f27453a0 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 @@ -12,6 +12,7 @@ from pybreeze.utils.network.http_client import ( ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, succeeded, truncate_for_display, ) +from pybreeze.utils.network.public_http import public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -33,7 +34,7 @@ def run(self): return # One session reuses a single TCP/TLS connection across all the # sequential per-template POSTs to the same endpoint. - session = requests.Session() + session = public_session() try: self._run_templates(session, self.code) finally: 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 50d5e979..63b80866 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 @@ -17,6 +17,7 @@ from pybreeze.utils.network.http_client import ( ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, succeeded, truncate_for_display, ) +from pybreeze.utils.network.public_http import public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -34,11 +35,12 @@ def __init__(self, api_url, code_text): def run(self): try: validate_url(self.api_url) - response = requests.post( - self.api_url, json={"code": self.code_text}, - timeout=(CONNECT_TIMEOUT, 30), allow_redirects=False, stream=True, - ) - body = read_capped_text(response) + with public_session() as session: + response = session.post( + self.api_url, json={"code": self.code_text}, + timeout=(CONNECT_TIMEOUT, 30), allow_redirects=False, stream=True, + ) + body = read_capped_text(response) if succeeded(response): self.answered.emit(body) return diff --git a/pybreeze/utils/network/public_http.py b/pybreeze/utils/network/public_http.py new file mode 100644 index 00000000..e8827f15 --- /dev/null +++ b/pybreeze/utils/network/public_http.py @@ -0,0 +1,137 @@ +"""HTTP connections that connect only to the address they have just checked. + +``validate_url`` resolves the host and checks every address, then the request +resolves it again to connect. A name whose answer changes between the two +(DNS rebinding: a short TTL, public the first time, private the second) passed +the check and reached the private address. The connections here resolve and +check once more as they connect, and connect to that address; the name stays +the connection's host, so TLS still checks the certificate against it (SNI) +and the ``Host`` header still carries it. + +Only direct connections are pinned. Through a proxy it is the proxy that +resolves and connects, and the connection made here is to the proxy, which may +well be on the local network. +""" +from __future__ import annotations + +import http.client +import socket +import urllib.request +from typing import Any + +import requests +from requests.adapters import HTTPAdapter +from urllib3.connection import HTTPConnection, HTTPSConnection +from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool +from urllib3.exceptions import NewConnectionError + +from pybreeze.utils.network.url_validation import UnsafeURLError, public_address + + +class AddressNotPublicError(OSError): + """The host resolved to a blocked address when the connection was made.""" + + +class _PinnedConnectionMixin: + """A urllib3 connection that opens its socket to the address ``public_address`` returns. + + urllib3 connects to ``_dns_host`` and takes ``host`` (TLS server name, + ``Host`` header) from it. It holds the checked address only while the + socket is opened, and the name again once it is. + """ + + _dns_host: str + + def _new_conn(self) -> socket.socket: + name = self._dns_host + try: + address = public_address(name) + except UnsafeURLError as error: + raise NewConnectionError(self, f"Refused: {error}") from None + self._dns_host = address + try: + return super()._new_conn() # type: ignore[misc] + finally: + self._dns_host = name + + +class _PinnedHTTPConnection(_PinnedConnectionMixin, HTTPConnection): + pass + + +class _PinnedHTTPSConnection(_PinnedConnectionMixin, HTTPSConnection): + pass + + +class _PinnedHTTPConnectionPool(HTTPConnectionPool): + ConnectionCls = _PinnedHTTPConnection + + +class _PinnedHTTPSConnectionPool(HTTPSConnectionPool): + ConnectionCls = _PinnedHTTPSConnection + + +class PublicAddressAdapter(HTTPAdapter): + """A ``requests`` adapter whose direct connections go only to checked public addresses.""" + + def init_poolmanager(self, *args: Any, **kwargs: Any) -> None: + super().init_poolmanager(*args, **kwargs) + # A new dict: the pool manager's default is urllib3's module-wide one + self.poolmanager.pool_classes_by_scheme = { + "http": _PinnedHTTPConnectionPool, + "https": _PinnedHTTPSConnectionPool, + } + + +def public_session() -> requests.Session: + """A ``requests.Session`` for user-supplied URLs; still pass each URL through ``validate_url``.""" + session = requests.Session() + adapter = PublicAddressAdapter() + session.mount("http://", adapter) + session.mount("https://", adapter) + return session + + +def _create_public_connection(address: tuple[str, int], *args: Any, **kwargs: Any) -> socket.socket: + """``socket.create_connection`` to the checked address of *address*'s host.""" + host, port = address + try: + checked = public_address(host) + except UnsafeURLError as error: + raise AddressNotPublicError(str(error)) from None + return socket.create_connection((checked, port), *args, **kwargs) + + +class _PinnedHTTPClientConnection(http.client.HTTPConnection): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._create_connection = _create_public_connection + + +class _PinnedHTTPSClientConnection(http.client.HTTPSConnection): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._create_connection = _create_public_connection + + +def _through_a_proxy(request: urllib.request.Request) -> bool: + """True when ``ProxyHandler`` pointed *request* at a proxy instead of its own host.""" + return request.host != urllib.request.Request(request.full_url).host + + +class PublicHTTPHandler(urllib.request.HTTPHandler): + """``urllib``'s HTTP handler, connecting directly only to checked public addresses.""" + + def http_open(self, req: urllib.request.Request) -> http.client.HTTPResponse: + if _through_a_proxy(req): + return super().http_open(req) + return self.do_open(_PinnedHTTPClientConnection, req) + + +class PublicHTTPSHandler(urllib.request.HTTPSHandler): + """``urllib``'s HTTPS handler, connecting directly only to checked public addresses.""" + + def https_open(self, req: urllib.request.Request) -> http.client.HTTPResponse: + if _through_a_proxy(req): + return super().https_open(req) + return self.do_open(_PinnedHTTPSClientConnection, req, context=self._context) diff --git a/pybreeze/utils/network/url_validation.py b/pybreeze/utils/network/url_validation.py index a47f5b5a..4cd58886 100644 --- a/pybreeze/utils/network/url_validation.py +++ b/pybreeze/utils/network/url_validation.py @@ -126,6 +126,21 @@ def validate_url(url: str) -> str: if not hostname: raise UnsafeURLError("URL has no hostname.") + public_address(hostname) + return url + + +def public_address(hostname: str) -> str: + """Resolve *hostname* and return the address to connect to, if every address it has is public. + + ``validate_url`` checks with this, and the connections in ``public_http`` + call it again as they connect and connect to the address it returns, so a + name that answers differently the second time (DNS rebinding) is checked + on the answer actually used. + + Raises ``UnsafeURLError`` when the name does not resolve or any of its + addresses is blocked. + """ try: infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) except (socket.gaierror, UnicodeError) as exc: @@ -133,6 +148,8 @@ def validate_url(url: str) -> str: # over 63 characters, or an empty one). Callers catch UnsafeURLError, so # anything else here would escape into a Qt slot. raise UnsafeURLError(f"Cannot resolve hostname '{hostname}': {exc}") from exc + if not infos: + raise UnsafeURLError(f"Cannot resolve hostname '{hostname}'.") for *_unused, sockaddr in infos: ip = ipaddress.ip_address(sockaddr[0]) @@ -140,5 +157,4 @@ def validate_url(url: str) -> str: raise UnsafeURLError( f"Access to non-public address {ip} is blocked." ) - - return url + return infos[0][4][0] diff --git a/test/test_utils/test_ai_code_review_client.py b/test/test_utils/test_ai_code_review_client.py index 2c556a2d..cab0cb05 100644 --- a/test/test_utils/test_ai_code_review_client.py +++ b/test/test_utils/test_ai_code_review_client.py @@ -77,6 +77,19 @@ def test_a_file_from_an_older_version_is_rewritten_as_fingerprints(self, client) assert all(looks_like_a_fingerprint(line) for line in text.split() if line) +class _FakeSession: + """The session a request goes through, handing every request to *request*.""" + + def __init__(self, request) -> None: + self.request = request + + def __enter__(self): + return self + + def __exit__(self, *_exc) -> None: + """Nothing to close.""" + + class TestTheRequestItself: """The request runs on its own thread; its two signals are what reaches the UI.""" @@ -84,21 +97,23 @@ def _run(self, monkeypatch, response=None, raises=None, method="GET"): from pybreeze.pybreeze_ui.connect_gui.url.ai_code_review_gui import ReviewRequestThread monkeypatch.setattr(ai_code_review_gui, "validate_url", lambda url: url) - if raises is None: - monkeypatch.setattr( - ai_code_review_gui.requests, method.lower(), lambda *a, **k: response) - monkeypatch.setattr(ai_code_review_gui, "read_capped_text", lambda resp: resp.text) - else: - def refuse(*_args, **_kwargs): + sent: list = [] + + def request(sent_method, *_args, **_kwargs): + sent.append(sent_method) + if raises is not None: raise raises + return response - monkeypatch.setattr(ai_code_review_gui.requests, method.lower(), refuse) + monkeypatch.setattr(ai_code_review_gui, "public_session", lambda: _FakeSession(request)) + monkeypatch.setattr(ai_code_review_gui, "read_capped_text", lambda resp: resp.text) thread = ReviewRequestThread(method, _A_URL, "print(1)") answered: list = [] failed: list = [] thread.answered.connect(answered.append) thread.failed.connect(failed.append) thread.run() + assert sent == [method] # sent with the method chosen in the panel return answered, failed def test_an_answer_reaches_the_panel(self, app, monkeypatch): diff --git a/test/test_utils/test_cot_session_reuse.py b/test/test_utils/test_cot_session_reuse.py index 5796498c..07428c99 100644 --- a/test/test_utils/test_cot_session_reuse.py +++ b/test/test_utils/test_cot_session_reuse.py @@ -154,7 +154,7 @@ def __init__(self): super().__init__() created["session"] = self - monkeypatch.setattr(mod.requests, "Session", _TrackedSession) + monkeypatch.setattr(mod, "public_session", _TrackedSession) # Make the work raise to prove the finally still closes the session. monkeypatch.setattr(SenderThread, "_run_templates", lambda self, session, code: (_ for _ in ()).throw(RuntimeError("boom"))) diff --git a/test/test_utils/test_http_goes_through_public_connections.py b/test/test_utils/test_http_goes_through_public_connections.py new file mode 100644 index 00000000..4ebfae76 --- /dev/null +++ b/test/test_utils/test_http_goes_through_public_connections.py @@ -0,0 +1,57 @@ +"""Every request the IDE sends goes through the connections that pin the checked address. + +``validate_url`` alone checks one DNS answer and the request connects on +another. ``public_http`` connects to the address it checks as it connects; a +call straight to ``requests`` or ``urlopen`` would skip that. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +import pybreeze + +_SOURCE = Path(pybreeze.__file__).parent +# Where the pinned session is built +_ALLOWED = {Path("utils/network/public_http.py")} +_REQUESTS_CALLS = {"get", "post", "put", "patch", "delete", "head", "options", "request", "Session", "session"} +_URLLIB_CALLS = {"urlopen"} + + +def _direct_calls(source: Path) -> list[str]: + found: list[str] = [] + for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"))): + if not isinstance(node, ast.Call): + continue + function = node.func + if isinstance(function, ast.Attribute) and isinstance(function.value, ast.Name): + if function.value.id == "requests" and function.attr in _REQUESTS_CALLS: + found.append(f"requests.{function.attr}") + elif function.attr in _URLLIB_CALLS: + found.append(function.attr) + elif isinstance(function, ast.Name) and function.id in _URLLIB_CALLS: + found.append(function.id) + return found + + +def test_no_module_sends_a_request_around_the_pinned_connections(): + offenders = { + str(source.relative_to(_SOURCE)): calls + for source in sorted(_SOURCE.rglob("*.py")) + if source.relative_to(_SOURCE) not in _ALLOWED and (calls := _direct_calls(source)) + } + + assert offenders == {} + + +def test_the_scan_finds_a_direct_call(tmp_path): + sample = tmp_path / "sample.py" + sample.write_text( + "import requests\nfrom urllib.request import urlopen\n" + "requests.post('https://x')\nurlopen('https://x')\n" + "code = 'requests.request(\"GET\", url)'\n", + encoding="utf-8", + ) + + # The string is generated code, not a call + assert _direct_calls(sample) == ["requests.post", "urlopen"] diff --git a/test/test_utils/test_public_http.py b/test/test_utils/test_public_http.py new file mode 100644 index 00000000..6399781e --- /dev/null +++ b/test/test_utils/test_public_http.py @@ -0,0 +1,202 @@ +"""The address a request connects to is the address that was checked. + +``validate_url`` resolved the name and the request resolved it again. A name +that answered a public address the first time and a private one the second +(DNS rebinding) passed the check and reached the private address. +""" +from __future__ import annotations + +import socket +import ssl +import threading +import urllib.error +import urllib.request + +import pytest +import requests + +from pybreeze.pybreeze_ui.diagram_editor import diagram_net_utils +from pybreeze.utils.network import url_validation +from pybreeze.utils.network.public_http import ( + PublicHTTPHandler, + PublicHTTPSHandler, + public_session, +) +from pybreeze.utils.network.url_validation import validate_url + +_PUBLIC_IP = "93.184.215.14" +_real_getaddrinfo = socket.getaddrinfo + + +def _answer(ip: str, port) -> list: + # The port asked for: a connection made from this answer goes to it + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, int(port or 0)))] + + +@pytest.fixture() +def dns(monkeypatch): + """Names ending in ``.test`` answer from a table; ``rebind.test`` public first, then loopback.""" + rebind_answers = [_PUBLIC_IP] + table = {"service.test": "127.0.0.1"} + + def getaddrinfo(host, port=None, *args, **kwargs): + if host == "rebind.test": + return _answer(rebind_answers.pop(0) if rebind_answers else "127.0.0.1", port) + if host in table: + return _answer(table[host], port) + return _real_getaddrinfo(host, port, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", getaddrinfo) + + +@pytest.fixture() +def loopback_allowed(monkeypatch): + """Let the checks pass loopback, so a local server can stand in for a public one.""" + monkeypatch.setattr(url_validation, "_is_blocked_ip", lambda _ip: False) + + +def _complete(data: bytes) -> bool: + """True once *data* holds a request's headers, or a whole TLS record (the ClientHello).""" + if data.startswith(b"\x16") and len(data) >= 5: + return len(data) >= 5 + int.from_bytes(data[3:5], "big") + return b"\r\n\r\n" in data + + +class _Listener: + """A loopback server that records the first request it gets, and what it answers.""" + + def __init__(self, reply: bytes = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"): + self.received: list[bytes] = [] + self._reply = reply + self._socket = socket.socket() + self._socket.bind(("127.0.0.1", 0)) + self._socket.listen(1) + self._socket.settimeout(5) + self.port = self._socket.getsockname()[1] + self._thread = threading.Thread(target=self._serve, daemon=True) + self._thread.start() + + def _serve(self) -> None: + try: + connection, _address = self._socket.accept() + except OSError: + return + with connection: + connection.settimeout(5) + data = b"" + try: + while not _complete(data): + chunk = connection.recv(4096) + if not chunk: + break + data += chunk + except OSError: + pass + self.received.append(data) + if not data.startswith(b"\x16"): + connection.sendall(self._reply) + + def close(self) -> None: + self._socket.close() + self._thread.join(5) + + +def _session() -> requests.Session: + """``public_session()``, ignoring any proxy this machine has set.""" + session = public_session() + session.trust_env = False + return session + + +@pytest.fixture() +def listener(): + server = _Listener() + yield server + server.close() + + +class TestRequestsSession: + def test_a_name_that_rebinds_to_loopback_is_not_reached(self, dns, listener): + url = f"http://rebind.test:{listener.port}/" + validate_url(url) # the first answer is public + + with _session() as session, pytest.raises(requests.ConnectionError) as raised: + session.get(url, timeout=(3, 3), allow_redirects=False) + + assert "non-public address 127.0.0.1" in str(raised.value) + listener.close() + assert listener.received == [] + + def test_it_connects_to_the_checked_address_under_the_name(self, dns, loopback_allowed, listener): + with _session() as session: + response = session.get(f"http://service.test:{listener.port}/path", timeout=(3, 3)) + + assert response.text == "ok" + request = listener.received[0] + assert request.startswith(b"GET /path HTTP/1.1\r\n") + assert f"Host: service.test:{listener.port}\r\n".encode() in request + + def test_tls_names_the_host_not_the_address(self, dns, loopback_allowed, listener): + with _session() as session, pytest.raises(requests.RequestException): + session.get(f"https://service.test:{listener.port}/", timeout=(3, 3)) + + listener.close() + # The ClientHello's server name + assert b"service.test" in listener.received[0] + + def test_a_proxy_on_the_local_network_is_still_used(self, dns, listener): + proxy = f"http://127.0.0.1:{listener.port}" + + with _session() as session: + response = session.get( + "http://rebind.test/", proxies={"http": proxy}, timeout=(3, 3)) + + assert response.text == "ok" + assert listener.received[0].startswith(b"GET http://rebind.test/ HTTP/1.1\r\n") + + +class TestUrllibOpener: + """The opener the diagram editor downloads images with.""" + + def test_a_name_that_rebinds_to_loopback_is_not_reached(self, dns, listener): + url = f"http://rebind.test:{listener.port}/image.png" + validate_url(url) + + with pytest.raises(urllib.error.URLError) as raised: + diagram_net_utils._OPENER.open(url, timeout=3) + + assert "non-public address 127.0.0.1" in str(raised.value) + listener.close() + assert listener.received == [] + + def test_a_rebinding_image_fails_as_a_download_error_the_canvas_catches(self, dns, listener): + with pytest.raises(OSError): + diagram_net_utils.safe_download_image(f"http://rebind.test:{listener.port}/image.png") + + listener.close() + assert listener.received == [] + + def test_it_connects_to_the_checked_address_under_the_name(self, dns, loopback_allowed, listener): + with diagram_net_utils._OPENER.open(f"http://service.test:{listener.port}/i.png", timeout=3) as reply: + assert reply.read() == b"ok" + + assert f"Host: service.test:{listener.port}\r\n".encode() in listener.received[0] + + def test_tls_names_the_host_not_the_address(self, dns, loopback_allowed, listener): + opener = urllib.request.build_opener(PublicHTTPHandler(), PublicHTTPSHandler()) + + with pytest.raises((urllib.error.URLError, ssl.SSLError, ConnectionError)): + opener.open(f"https://service.test:{listener.port}/", timeout=3) + + listener.close() + assert b"service.test" in listener.received[0] + + def test_a_proxy_on_the_local_network_is_still_used(self, dns, listener): + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({"http": f"http://127.0.0.1:{listener.port}"}), + PublicHTTPHandler(), PublicHTTPSHandler()) + + with opener.open("http://rebind.test/i.png", timeout=3) as reply: + assert reply.read() == b"ok" + + assert listener.received[0].startswith(b"GET http://rebind.test/i.png HTTP/1.1\r\n") diff --git a/test/test_utils/test_skills_request.py b/test/test_utils/test_skills_request.py index 67eec392..f4afd089 100644 --- a/test/test_utils/test_skills_request.py +++ b/test/test_utils/test_skills_request.py @@ -40,6 +40,19 @@ def close(self) -> None: """Nothing to close.""" +class FakeSession: + """The session a request is sent through, answering every post with *post*.""" + + def __init__(self, post) -> None: + self.post = post + + def __enter__(self): + return self + + def __exit__(self, *_exc) -> None: + """Nothing to close.""" + + def _run(monkeypatch, answer) -> tuple[list[str], list[str]]: """Run one request against *answer* (a response, or an exception to raise).""" monkeypatch.setattr(skills_send_gui, "validate_url", lambda url: url) @@ -49,7 +62,7 @@ def post(*_args, **_kwargs): raise answer return answer - monkeypatch.setattr(skills_send_gui.requests, "post", post) + monkeypatch.setattr(skills_send_gui, "public_session", lambda: FakeSession(post)) answered: list[str] = [] errors: list[str] = [] thread = RequestThread(_URL, "print(1)") From 4a11c4af592a7aede619eb973532bdc16edcd7cb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 16:23:05 +0800 Subject: [PATCH 121/312] Create automation projects in the open folder and ask before replacing their templates --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 17 +++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 4 + .../extend_traditional_chinese.py | 4 + .../api_testka_menu/build_api_testka_menu.py | 2 +- .../build_autocontrol_menu.py | 2 +- .../build_automation_file_menu.py | 2 +- .../automation_menu_factory.py | 53 ++++++- .../build_load_density_menu.py | 2 +- .../build_mail_thunder_menu.py | 2 +- .../web_runner_menu/build_webrunner_menu.py | 2 +- .../test_automation_menu_factory.py | 2 +- test/test_utils/test_create_project.py | 134 ++++++++++++++++++ 14 files changed, 217 insertions(+), 16 deletions(-) create mode 100644 test/test_utils/test_create_project.py diff --git a/architecture_explore.md b/architecture_explore.md index dffc2a6c..bab22311 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -168,7 +168,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) `build_automation_menu(ui, spec)` 依一份 `AutomationMenu` 描述組出標準自動化子選單:`Run` 子選單(`RunAction` 列表)/ `Help`(`HelpLink` 列表,文件+GitHub,開內嵌瀏覽器分頁)/ `Project`(建立範本目錄)/ GUI 分頁,每一段各由一個小函式建(`_add_run_menu` 等),沒有項目的段落不建。三個描述都是 frozen dataclass。六個自動化模組全部靠它,`build_*_menu.py` 只剩一份 `AutomationMenu(...)`。每個 QAction 都以它所在的選單為 parent,由 Qt 持有;AutoControl 額外的 `Record` 子選單也一樣;它的停止錄製不論前面是哪個分頁都會停,把動作以 AutoControl 執行器讀的 JSON 插在編輯分頁的游標處(沒有編輯分頁就放剪貼簿),沒錄到東西就告知。 -`safe_create_project(import_name)` 回傳延遲 import 的 closure,模組沒裝時只記 log 不炸選單。 +`safe_create_project(ui, import_name)` 回傳延遲 import 的 closure:專案建在 IDE 開著的資料夾(`working_dir`,沒開就用行程的工作目錄);套件的資料夾(`create_project_dir` 的 `parent_name` 預設值)已存在時先問(預設否),因為各套件一律覆寫範本檔;模組沒裝、寫入失敗都跳警告並記 log,成功時說出建在哪裡。 | 選單 | 文件 | GUI 分頁 | |---|---|---| @@ -438,7 +438,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 103 個 `test_*.py`、1564 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 104 個 `test_*.py`、1570 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 891580cf..2913e867 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1666,3 +1666,20 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `CLAUDE.md` (tree, Security › Network), `architecture.md`, `architecture_explore.md` §7, §12, §18 - `progress.md` (#52 removed) - **Open items**: none. + +## U-20260923-95 · 2026-09-23 · Create Project writes into the folder open in the IDE and asks before it replaces a template · #fix #menu + +- **What**: the Project › Create entry of six automation menus (APITestka, AutoControl, FileAutomation, LoadDensity, MailThunder, WebRunner) called `package.create_project_dir()` with no arguments and caught only `ImportError`. Found in the review sweep of `pybreeze_ui/menu/`. + - The files went to the process's working directory, not the folder open in the IDE, and nothing said where. + - Every package rewrites its `keyword/*.json` and `executor/*.py` whether they exist or not: a second click wiped the user's edited templates without a word (reproduced with `je_web_runner`: an edited `keyword1.json` was back to the template). + - A write that failed (a read-only folder) raised out of the menu slot, and success was silent too. + - TestPioneer's template entry had already been fixed for the same three things. +- **Fix**: `safe_create_project(ui, import_name)` in `automation_menu_factory.py` creates the project under `ui.working_dir` (the process's working directory when no folder is open). When the package's folder already exists (`_project_folder_name`: the `parent_name` default of its `create_project_dir`) it asks first, default No. A failure or a missing package is shown in a warning, and success names the folder. New keys `create_project_exists`, `create_project_failed`, `create_project_not_installed`, `create_project_created` in both languages. The six menu builders pass their main window. +- **Tests**: new `test_create_project.py` (6): into the open folder and not the process's directory; an edited template kept on No and replaced on Yes; a failed write reported, not raised; a missing package reported; the working directory when no folder is open. `test_automation_menu_factory.py`'s check that every entry names an installed package reads the new call form. +- **Result / numbers**: menu, main window and startup tests 217 passed; language parity passed. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py` and the six `build_*_menu.py` that use it + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_create_project.py` (new), `test_automation_menu_factory.py` + - `architecture_explore.md` §5.1, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 1965f6ea..dbbb6701 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-95 | 2026-09-23 | Create Project writes into the folder open in the IDE and asks before it replaces a template | #fix #menu | [2026-09](2026-09.md) | | U-20260923-94 | 2026-09-23 | Requests to user URLs connect only to the address checked as they connect (DNS rebinding) | #done #security #network | [2026-09](2026-09.md) | | U-20260923-93 | 2026-09-23 | Run and send: an earlier run's report is not mailed, and the run window says whether the mail went | #fix #executor #mail | [2026-09](2026-09.md) | | U-20260923-92 | 2026-09-23 | A run whose started process keeps its output open no longer freezes the IDE when it ends | #fix #executor | [2026-09](2026-09.md) | @@ -175,4 +176,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 112 | +| [2026-09.md](2026-09.md) | 2026-09 | 113 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 10125639..0b0819bc 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -16,6 +16,10 @@ "run_label": "Run", "help_label": "HELP", "project_label": "Project", + "create_project_exists": "{path} already exists. Replace its template files and lose your changes to them?", + "create_project_failed": "The project could not be created at {path}: {error}", + "create_project_not_installed": "{package} is not installed: {error}", + "create_project_created": "Project created: {path}", # Tab tools menu "tab_menu_jupyterlab_tab_name": "JupyterLab", # APITestka Menu diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 188c40b0..8fec4bd4 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -16,6 +16,10 @@ "run_label": "運行", "help_label": "幫助", "project_label": "專案", + "create_project_exists": "{path} 已經存在。要用新的範本檔取代,並捨棄你對它們的修改嗎?", + "create_project_failed": "無法在 {path} 建立專案:{error}", + "create_project_not_installed": "沒有安裝 {package}:{error}", + "create_project_created": "已建立專案:{path}", # Tab tools menu "tab_menu_jupyterlab_tab_name": "JupyterLab", # APITestka Menu 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 5360e927..7bdaea0f 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 @@ -35,7 +35,7 @@ def set_apitestka_menu(ui_we_want_to_set: PyBreezeMainWindow): HelpLink("https://github.com/Integration-Automation/APITestka", "apitestka_github_label", "apitestka_github_tab_label"), ), - create_project=safe_create_project("je_api_testka"), + create_project=safe_create_project(ui_we_want_to_set, "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 96d6f7a5..ca5e91dc 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 @@ -41,7 +41,7 @@ def set_autocontrol_menu(ui_we_want_to_set: PyBreezeMainWindow): HelpLink("https://github.com/Integration-Automation/AutoControlGUI", "autocontrol_github_label", "autocontrol_github_tab_label"), ), - create_project=safe_create_project("je_auto_control"), + create_project=safe_create_project(ui_we_want_to_set, "je_auto_control"), create_project_label_key="autocontrol_create_project_label", gui_widget_class=AutoControlGUIWidget, gui_label="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 e9500f29..f0343e46 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 @@ -34,6 +34,6 @@ def set_automation_file_menu(ui_we_want_to_set: PyBreezeMainWindow): HelpLink("https://github.com/Integration-Automation/FileAutomation", "file_automation_github_label", "file_automation_github_tab_label"), ), - create_project=safe_create_project("automation_file"), + create_project=safe_create_project(ui_we_want_to_set, "automation_file"), create_project_label_key="file_automation_create_project_label", )) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py index 1d350b4c..dd3e2bd0 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py @@ -1,11 +1,13 @@ from __future__ import annotations import importlib +import inspect from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Callable from PySide6.QtGui import QAction -from PySide6.QtWidgets import QMenu, QWidget +from PySide6.QtWidgets import QMenu, QMessageBox, QWidget from je_editor import language_wrapper from pybreeze.pybreeze_ui.menu.menu_utils import open_web_browser @@ -110,12 +112,51 @@ def _add_gui_action( menu.addAction(action) -def safe_create_project(import_name: str) -> Callable: - """Create a safe project creation function that handles ImportError.""" - def _create(): +def safe_create_project(ui: PyBreezeMainWindow, import_name: str) -> Callable[[], None]: + """The Create Project action for *import_name*: its template folder in the IDE's working directory. + + The packages write their template files whether they exist or not, so a + second click wiped a template the user had edited, with no word; they also + wrote into the process's working directory, not the folder open in the + IDE, and said nothing about where. This asks before replacing an existing + folder and reports what happened, as TestPioneer's template entry does. + """ + def _create() -> None: + word = language_wrapper.language_word_dict + title = word.get("project_label") try: package = importlib.import_module(import_name) # nosec # nosemgrep # plugin registry uses a curated whitelist of automation packages (build_process) - package.create_project_dir() except ImportError as error: - pybreeze_logger.error(f"Failed to import {import_name}: {error}") + pybreeze_logger.error("Failed to import %s: %r", import_name, error) + QMessageBox.warning( + ui, title, word.get("create_project_not_installed").format(package=import_name, error=error)) + return + project = Path(getattr(ui, "working_dir", None) or Path.cwd()) + target = project / _project_folder_name(package.create_project_dir) + if target.exists() and not _may_replace(ui, title, target): + return + try: + package.create_project_dir(project_path=str(project)) + except Exception as error: # noqa: BLE001 — each package raises its own exception types for a write it could not make; it is logged and reported + pybreeze_logger.error("%s project not created in %s: %r", import_name, project, error) + QMessageBox.warning(ui, title, word.get("create_project_failed").format(path=target, error=error)) + return + QMessageBox.information(ui, title, word.get("create_project_created").format(path=target)) return _create + + +def _project_folder_name(create_project_dir: Callable) -> str: + """The folder *create_project_dir* makes by default: its ``parent_name`` parameter's default.""" + parameter = inspect.signature(create_project_dir).parameters.get("parent_name") + if parameter is None or not isinstance(parameter.default, str): + return "" + return parameter.default + + +def _may_replace(ui: PyBreezeMainWindow, title: str, target: Path) -> bool: + """Ask whether the template files under *target* may be written over.""" + reply = QMessageBox.question( + ui, title, language_wrapper.language_word_dict.get("create_project_exists").format(path=target), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) + return reply == QMessageBox.StandardButton.Yes 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 edeb3b3d..5bcf05c9 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 @@ -36,7 +36,7 @@ def set_load_density_menu(ui_we_want_to_set: PyBreezeMainWindow): HelpLink("https://github.com/Integration-Automation/LoadDensity", "load_density_github_label", "load_density_github_tab_label"), ), - create_project=safe_create_project("je_load_density"), + create_project=safe_create_project(ui_we_want_to_set, "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 3ba6aa92..908f6594 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 @@ -25,6 +25,6 @@ def set_mail_thunder_menu(ui_we_want_to_set: PyBreezeMainWindow): HelpLink("https://github.com/Integration-Automation/MailThunder", "mail_thunder_github_label", "mail_thunder_github_tab_label"), ), - create_project=safe_create_project("je_mail_thunder"), + create_project=safe_create_project(ui_we_want_to_set, "je_mail_thunder"), create_project_label_key="mail_thunder_create_project_label", )) 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 fbf9bcd6..c1665bf6 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 @@ -34,6 +34,6 @@ def set_web_runner_menu(ui_we_want_to_set: PyBreezeMainWindow): HelpLink("https://github.com/Integration-Automation/WebRunner", "web_runner_github_label", "web_runner_github_tab_label"), ), - create_project=safe_create_project("je_web_runner"), + create_project=safe_create_project(ui_we_want_to_set, "je_web_runner"), create_project_label_key="web_runner_create_project_label", )) diff --git a/test/test_utils/test_automation_menu_factory.py b/test/test_utils/test_automation_menu_factory.py index 69e59e80..5db1c790 100644 --- a/test/test_utils/test_automation_menu_factory.py +++ b/test/test_utils/test_automation_menu_factory.py @@ -120,7 +120,7 @@ def test_every_create_project_names_a_package_that_exists(): menus = Path(__file__).resolve().parents[2] / "pybreeze" / "pybreeze_ui" / "menu" / "automation_menu" names = [name for path in menus.rglob("*.py") - for name in re.findall(r'safe_create_project\("([^"]+)"\)', path.read_text(encoding="utf-8"))] + for name in re.findall(r'safe_create_project\(\w+, "([^"]+)"\)', path.read_text(encoding="utf-8"))] assert names, "no create-project entries found" assert [name for name in names if importlib.util.find_spec(name) is None] == [] diff --git a/test/test_utils/test_create_project.py b/test/test_utils/test_create_project.py new file mode 100644 index 00000000..d2b88b55 --- /dev/null +++ b/test/test_utils/test_create_project.py @@ -0,0 +1,134 @@ +"""Create Project: into the folder open in the IDE, asking before it writes over a template. + +The packages rewrite every template file whenever they run. The entry called +them with no path, so the files went to the process's working directory; a +second click wiped the user's edits without a word, and a failure to write +raised out of the menu slot. +""" +from __future__ import annotations + +import os +import sys +import types + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox, QWidget + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import safe_create_project + +_PACKAGE = "fake_automation_package" + + +@pytest.fixture(scope="module", autouse=True) +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def package(monkeypatch): + """A package whose create_project_dir writes one template file, as the real ones do.""" + fake = types.ModuleType(_PACKAGE) + fake.fail_with = None + + def create_project_dir(project_path: str | None = None, parent_name: str = "FakeProject") -> None: + if fake.fail_with is not None: + raise fake.fail_with + folder = os.path.join(project_path or os.getcwd(), parent_name, "keyword") + os.makedirs(folder, exist_ok=True) + with open(os.path.join(folder, "keyword1.json"), "w", encoding="utf-8") as file: + file.write("template") + + fake.create_project_dir = create_project_dir + monkeypatch.setitem(sys.modules, _PACKAGE, fake) + return fake + + +@pytest.fixture() +def dialogs(monkeypatch): + """Record every message box; ``answer`` is what a question gets.""" + shown: dict = {"question": [], "warning": [], "information": [], "answer": QMessageBox.StandardButton.No} + + def question(_parent, _title, text, *_args): + shown["question"].append(text) + return shown["answer"] + + monkeypatch.setattr(QMessageBox, "question", staticmethod(question)) + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda _p, _t, text: shown["warning"].append(text))) + monkeypatch.setattr( + QMessageBox, "information", staticmethod(lambda _p, _t, text: shown["information"].append(text))) + return shown + + +def _window(working_dir) -> QWidget: + window = QWidget() + window.working_dir = str(working_dir) + return window + + +def test_the_project_goes_into_the_folder_open_in_the_ide(package, dialogs, tmp_path, monkeypatch): + elsewhere = tmp_path / "process_cwd" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + opened = tmp_path / "opened" + opened.mkdir() + + safe_create_project(_window(opened), _PACKAGE)() + + assert (opened / "FakeProject" / "keyword" / "keyword1.json").is_file() + assert not (elsewhere / "FakeProject").exists() + assert dialogs["question"] == [] + assert str(opened / "FakeProject") in dialogs["information"][0] + + +def test_an_edited_template_is_kept_unless_the_user_agrees(package, dialogs, tmp_path): + edited = tmp_path / "FakeProject" / "keyword" / "keyword1.json" + edited.parent.mkdir(parents=True) + edited.write_text("my edits", encoding="utf-8") + + safe_create_project(_window(tmp_path), _PACKAGE)() + + assert edited.read_text(encoding="utf-8") == "my edits" + assert len(dialogs["question"]) == 1 + assert dialogs["information"] == [] + + +def test_the_user_can_agree_to_replace_it(package, dialogs, tmp_path): + edited = tmp_path / "FakeProject" / "keyword" / "keyword1.json" + edited.parent.mkdir(parents=True) + edited.write_text("my edits", encoding="utf-8") + dialogs["answer"] = QMessageBox.StandardButton.Yes + + safe_create_project(_window(tmp_path), _PACKAGE)() + + assert edited.read_text(encoding="utf-8") == "template" + + +def test_a_write_that_fails_is_reported_not_raised(package, dialogs, tmp_path): + package.fail_with = PermissionError(13, "Access is denied") + + safe_create_project(_window(tmp_path), _PACKAGE)() + + assert "Access is denied" in dialogs["warning"][0] + assert dialogs["information"] == [] + + +def test_a_package_that_is_not_installed_is_reported(dialogs, tmp_path, monkeypatch): + monkeypatch.setitem(sys.modules, _PACKAGE, None) # makes the import fail + + safe_create_project(_window(tmp_path), _PACKAGE)() + + assert _PACKAGE in dialogs["warning"][0] + + +def test_without_a_folder_open_it_uses_the_working_directory(package, dialogs, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + window = QWidget() + + safe_create_project(window, _PACKAGE)() + + assert (tmp_path / "FakeProject" / "keyword" / "keyword1.json").is_file() From 9888fc88ebcaee99fcad1bb4afb9122db93b1498 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 16:25:14 +0800 Subject: [PATCH 122/312] Describe a failed AI request without showing its URL --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 15 +++ docs/updates/README.md | 3 +- .../connect_gui/url/ai_code_review_gui.py | 4 +- .../code_review/code_review_thread.py | 5 +- .../extend_ai_gui/skills/skills_send_gui.py | 5 +- pybreeze/utils/network/http_client.py | 31 +++++ test/test_utils/test_cot_session_reuse.py | 2 +- test/test_utils/test_request_error_text.py | 114 ++++++++++++++++++ test/test_utils/test_skills_request.py | 2 +- 10 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 test/test_utils/test_request_error_text.py diff --git a/architecture_explore.md b/architecture_explore.md index bab22311..ca95b6ad 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -343,7 +343,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 暫換成 `public_address()` 回傳的位址)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | -| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | +| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | | `curl_import/` | `curl_parser.py`(579) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | @@ -438,7 +438,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 104 個 `test_*.py`、1570 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 105 個 `test_*.py`、1581 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 2913e867..983600de 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1683,3 +1683,18 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_create_project.py` (new), `test_automation_menu_factory.py` - `architecture_explore.md` §5.1, §18 - **Open items**: none. + +## U-20260923-96 · 2026-09-23 · A failed AI request is described in its panel without the URL and any token in it · #fix #security #ai + +- **What**: the Skills panel (`skills_send_gui`), each CoT review step (`code_review_thread._ask`, also kept in the panel's answers) and the AI code review client (`ai_code_review_gui.ReviewRequestThread`) showed a failed request as `str(error)`. A `requests` error quotes the URL, path and query included (`... Max retries exceeded with url: /v1/review?api_key=...`), and CLAUDE.md treats API URLs as credentials. The log lines already left the URL out; the panels displayed it. Found in the review sweep of `extend_ai_gui/` (the review client was found alongside it). +- **Fix**: new `describe_request_error(error)` in `utils/network/http_client.py` says what went wrong: timed out, the secure connection could not be made, could not connect, too many redirects, the URL is not valid (each with the error's type name), else the type name alone. The size cap's and the SSRF check's own messages name no path or query and are kept. The three panels use it. +- **Tests**: + - New `test_request_error_text.py` (11): each kind of failure is described with neither the token nor the host; the size cap and SSRF messages are kept; the Skills panel, the review client and a CoT step show no token for an error quoting the URL. + - Two tests that asserted the raw error text in the panel now assert the description: `test_skills_request.py` ("could not connect to the server") and `test_cot_session_reuse.py` (the type name). +- **Result / numbers**: AI panel tests 63 passed; 1581 tests collected in 105 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/network/http_client.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `extend_ai_gui/code_review/code_review_thread.py`, `connect_gui/url/ai_code_review_gui.py` + - `test/test_utils/test_request_error_text.py` (new), `test_skills_request.py`, `test_cot_session_reuse.py` + - `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index dbbb6701..462570a1 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-96 | 2026-09-23 | A failed AI request is described in its panel without the URL and any token in it | #fix #security #ai | [2026-09](2026-09.md) | | U-20260923-95 | 2026-09-23 | Create Project writes into the folder open in the IDE and asks before it replaces a template | #fix #menu | [2026-09](2026-09.md) | | U-20260923-94 | 2026-09-23 | Requests to user URLs connect only to the address checked as they connect (DNS rebinding) | #done #security #network | [2026-09](2026-09.md) | | U-20260923-93 | 2026-09-23 | Run and send: an earlier run's report is not mailed, and the run window says whether the mail went | #fix #executor #mail | [2026-09](2026-09.md) | @@ -176,4 +177,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 113 | +| [2026-09.md](2026-09.md) | 2026-09 | 114 | 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 24ddce88..4bd975c0 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 @@ -16,7 +16,7 @@ from pybreeze.utils.hash_tools.hash_text import hash_text from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( - ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, succeeded, + ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, describe_request_error, succeeded, ) from pybreeze.utils.network.public_http import public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -80,7 +80,7 @@ def run(self) -> None: except (requests.RequestException, ResponseTooLargeError, UnsafeURLError) as error: # Not %r: a requests error carries the whole URL, which may hold a token. pybreeze_logger.error("AI code review request failed: %s", type(error).__name__) - self.failed.emit(str(error)) + self.failed.emit(describe_request_error(error)) def _send(self, session: requests.Session) -> requests.Response: """Send the request with the chosen method, code in the body where there is one.""" 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 f27453a0..00c1fd6a 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 @@ -10,7 +10,8 @@ ) from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( - ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, succeeded, truncate_for_display, + ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, describe_request_error, succeeded, + truncate_for_display, ) from pybreeze.utils.network.public_http import public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -75,7 +76,7 @@ def _ask(self, session: requests.Session, file: str, prompt: str) -> tuple[str, pybreeze_logger.error( "CoT code review send failed for %s: %s", file, type(error).__name__) word = language_wrapper.language_word_dict - return f"{word.get('cot_gui_error_sending')} {file} {error}", False + return f"{word.get('cot_gui_error_sending')} {file} {describe_request_error(error)}", False if not succeeded(resp): # An error page or an unfollowed redirect is not an answer: counted # as one, it was quoted into every later step of the chain. 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 63b80866..642e18c4 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 @@ -15,7 +15,8 @@ from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( - ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, succeeded, truncate_for_display, + ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, describe_request_error, succeeded, + truncate_for_display, ) from pybreeze.utils.network.public_http import public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -51,7 +52,7 @@ def run(self): except (requests.RequestException, ResponseTooLargeError, UnsafeURLError) as e: # Not %r: a requests error carries the whole URL, which may hold a token. pybreeze_logger.error("Skills send request failed: %s", type(e).__name__) - self.error.emit(language_wrapper.language_word_dict.get("skills_exception").format(error=str(e))) + self.error.emit(language_wrapper.language_word_dict.get("skills_exception").format(error=describe_request_error(e))) def describe_failed_status(response, body: str) -> tuple[bool, str]: diff --git a/pybreeze/utils/network/http_client.py b/pybreeze/utils/network/http_client.py index 803e05c2..6d39716b 100644 --- a/pybreeze/utils/network/http_client.py +++ b/pybreeze/utils/network/http_client.py @@ -10,6 +10,8 @@ import requests +from pybreeze.utils.network.url_validation import UnsafeURLError + DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024 # 16 MB DISPLAY_TRUNCATE_CHARS = 2000 _CHUNK_SIZE = 65536 @@ -61,6 +63,35 @@ def read_capped_text( return body.decode(default_encoding, "replace") +# What a failed request is called for the user, most specific first: a connect +# timeout is both a Timeout and a ConnectionError +_REQUEST_FAILURES: tuple[tuple[type[Exception], str], ...] = ( + (requests.Timeout, "the request timed out"), + (requests.exceptions.SSLError, "the secure connection could not be made"), + (requests.ConnectionError, "could not connect to the server"), + (requests.TooManyRedirects, "too many redirects"), + (requests.exceptions.InvalidURL, "the URL is not valid"), + (requests.exceptions.MissingSchema, "the URL is not valid"), + (requests.exceptions.InvalidSchema, "the URL is not valid"), +) + + +def describe_request_error(error: Exception) -> str: + """Say what went wrong with a request, for the user, without its URL. + + A ``requests`` error's text quotes the URL, path and query included, and + an API URL may carry a token: shown as it was, the panels displayed it. + The size cap's and the SSRF check's own messages name no path or query + and are kept. + """ + if isinstance(error, (ResponseTooLargeError, UnsafeURLError)): + return str(error) + for kind, reason in _REQUEST_FAILURES: + if isinstance(error, kind): + return f"{reason} ({type(error).__name__})" + return type(error).__name__ + + def succeeded(response) -> bool: """Whether *response* answered with a 2xx status. diff --git a/test/test_utils/test_cot_session_reuse.py b/test/test_utils/test_cot_session_reuse.py index 07428c99..67584b65 100644 --- a/test/test_utils/test_cot_session_reuse.py +++ b/test/test_utils/test_cot_session_reuse.py @@ -101,7 +101,7 @@ def test_a_failed_step_is_shown_but_never_quoted_back(): thread._run_templates(session, "print('x')") # The failure still reaches the user ... - assert "endpoint down" in received["linter.md"] + assert "RequestException" in received["linter.md"] # what failed, never the URL # ... every later step still runs ... assert len(session.post_calls) == len(COT_TEMPLATE_FILES) # ... and none of them carries the failure text into the model. diff --git a/test/test_utils/test_request_error_text.py b/test/test_utils/test_request_error_text.py new file mode 100644 index 00000000..c6654c9a --- /dev/null +++ b/test/test_utils/test_request_error_text.py @@ -0,0 +1,114 @@ +"""A failed request is described to the user without its URL. + +A ``requests`` error quotes the URL, path and query included, and an API URL +may carry a token. The logs already left it out; the panels showed it. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +import requests + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.utils.network.http_client import ResponseTooLargeError, describe_request_error +from pybreeze.utils.network.url_validation import UnsafeURLError + +_SECRET = "sk-live-not-a-real-key" +_URL = f"https://api.example/v1/review?api_key={_SECRET}" +_QUOTING_THE_URL = f"HTTPSConnectionPool(host='api.example', port=443): Max retries exceeded with url: {_URL}" + + +@pytest.fixture(scope="module", autouse=True) +def app(): + from PySide6.QtWidgets import QApplication + + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class _Refusing: + """A session whose every request raises *error*.""" + + def __init__(self, error: Exception) -> None: + self._error = error + + def __enter__(self): + return self + + def __exit__(self, *_exc) -> None: + """Nothing to close.""" + + def close(self) -> None: + """Nothing to close.""" + + def post(self, *_args, **_kwargs): + raise self._error + + def request(self, *_args, **_kwargs): + raise self._error + + +class TestDescribeRequestError: + @pytest.mark.parametrize(("error", "words"), [ + (requests.ConnectTimeout(_QUOTING_THE_URL), "timed out"), + (requests.ReadTimeout(_QUOTING_THE_URL), "timed out"), + (requests.exceptions.SSLError(_QUOTING_THE_URL), "secure connection"), + (requests.ConnectionError(_QUOTING_THE_URL), "could not connect"), + (requests.TooManyRedirects(_QUOTING_THE_URL), "too many redirects"), + (requests.exceptions.InvalidURL(_QUOTING_THE_URL), "not valid"), + (requests.RequestException(_QUOTING_THE_URL), "RequestException"), + ]) + def test_it_says_what_happened_and_not_where(self, error, words): + text = describe_request_error(error) + + assert words in text + assert _SECRET not in text and "api.example" not in text + + def test_the_size_cap_and_the_ssrf_check_keep_their_own_words(self): + assert describe_request_error(ResponseTooLargeError("too big")) == "too big" + assert describe_request_error(UnsafeURLError("blocked")) == "blocked" + + +def test_the_skills_panel_does_not_show_the_url(monkeypatch): + from pybreeze.pybreeze_ui.extend_ai_gui.skills import skills_send_gui + + monkeypatch.setattr(skills_send_gui, "validate_url", lambda url: url) + monkeypatch.setattr( + skills_send_gui, "public_session", lambda: _Refusing(requests.ConnectionError(_QUOTING_THE_URL))) + errors: list = [] + thread = skills_send_gui.RequestThread(_URL, "print(1)") + thread.error.connect(errors.append) + + thread.run() + + assert errors and all(_SECRET not in error for error in errors) + + +def test_the_review_client_does_not_show_the_url(monkeypatch): + from pybreeze.pybreeze_ui.connect_gui.url import ai_code_review_gui + + monkeypatch.setattr(ai_code_review_gui, "validate_url", lambda url: url) + monkeypatch.setattr( + ai_code_review_gui, "public_session", lambda: _Refusing(requests.ConnectionError(_QUOTING_THE_URL))) + failed: list = [] + thread = ai_code_review_gui.ReviewRequestThread("POST", _URL, "print(1)") + thread.failed.connect(failed.append) + + thread.run() + + assert failed and all(_SECRET not in message for message in failed) + + +def test_the_cot_review_does_not_show_the_url(): + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread + + thread = SenderThread(files=["linter.md"], code="print(1)", url=_URL) + + reply, answered = thread._ask(_Refusing(requests.ConnectionError(_QUOTING_THE_URL)), "linter.md", "prompt") + + assert not answered + assert _SECRET not in reply and "could not connect" in reply diff --git a/test/test_utils/test_skills_request.py b/test/test_utils/test_skills_request.py index f4afd089..1c5ac61b 100644 --- a/test/test_utils/test_skills_request.py +++ b/test/test_utils/test_skills_request.py @@ -105,7 +105,7 @@ def test_a_failed_request_is_an_error(self, monkeypatch): answered, errors = _run(monkeypatch, requests.ConnectionError("refused")) assert answered == [] - assert "refused" in errors[0] + assert "could not connect to the server" in errors[0] def test_a_failed_request_does_not_log_the_url(monkeypatch): From ae0347a68bbcb059d0eb5863ea6a7b307a2e3197 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 16:27:58 +0800 Subject: [PATCH 123/312] Keep an edited Skills prompt across a template switch and refuse to send one without its code --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 18 +++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 2 + .../extend_traditional_chinese.py | 2 + .../extend_ai_gui/skills/skills_send_gui.py | 36 +++++- test/test_utils/test_skills_panel.py | 120 ++++++++++++++++++ 7 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 test/test_utils/test_skills_panel.py diff --git a/architecture_explore.md b/architecture_explore.md index ca95b6ad..df164d4b 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -270,7 +270,7 @@ extend_ai_gui/ │ ├── prompt_file_io.py 共用存檔(先寫 `.saving` 再 `os.replace()`;失敗跳警告對話框,不顯示路徑) │ ├── cot_code_review_prompt_templates/ 8 個模板常數+global_rule │ └── skills_prompt_templates/ 2 個模板常數 -└── skills/skills_send_gui.py 單次 prompt 發送(RequestThread:回答走 `answered`,不再蓋掉 QThread 自己的 `finished`;關閉時交給 `thread_keeper.let_run_out()`) +└── skills/skills_send_gui.py 單次 prompt 發送(RequestThread:回答走 `answered`,不再蓋掉 QThread 自己的 `finished`;關閉時交給 `thread_keeper.let_run_out()`;換模板前編輯區有修改就先問;prompt 還留著 `{code_diff}` 就不送出) ``` **CoT 審查鏈**(`cot_chain.py` 定義接線,`code_review_thread.py` 執行)八個步驟: @@ -438,7 +438,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 105 個 `test_*.py`、1581 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 106 個 `test_*.py`、1586 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 983600de..ec6faa56 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1698,3 +1698,21 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_request_error_text.py` (new), `test_skills_request.py`, `test_cot_session_reuse.py` - `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-97 · 2026-09-23 · Skills panel: switching the template keeps an edited prompt unless the user agrees, and a prompt without its code is not sent · #fix #ai + +- **What**: two ways the Skills panel (`skills_send_gui.SkillsSendGUI`) lost or wasted what the user did. Found in the review sweep of `extend_ai_gui/`. + - The edit area is the only input: the user pastes code into the template in place of `{code_diff}`. Changing the template selector replaced the edit area at once, pasted code and all; the prompt editor already asks before this. + - Pressing Send on a template as loaded posted the literal `{code_diff}`, and the endpoint was asked to review no code. +- **Fix**: + - `load_selected_prompt` asks before replacing an edited prompt (`skills_switch_over_edits`, default No). On No the selector goes back to the template in the edit area, with its signals blocked. A freshly loaded template is marked unedited. + - `send_prompt` refuses a prompt still holding `CODE_PLACEHOLDER` (`{code_diff}`) and says where the code goes (`skills_code_missing`). + - New keys in both languages. +- **Tests**: new `test_skills_panel.py` (5): an untouched prompt switches without a question; an edited one is kept on No (with the selector back on its template) and replaced on Yes, after which the next switch does not ask; a template with `{code_diff}` in it starts no request; one with the code in place does. +- **Result / numbers**: AI panel, prompt store and language parity tests 65 passed; 1586 tests in 106 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_skills_panel.py` (new) + - `architecture_explore.md` §8, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 462570a1..c55b7c0e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-97 | 2026-09-23 | Skills panel: switching the template keeps an edited prompt unless the user agrees, and a prompt without its code is not sent | #fix #ai | [2026-09](2026-09.md) | | U-20260923-96 | 2026-09-23 | A failed AI request is described in its panel without the URL and any token in it | #fix #security #ai | [2026-09](2026-09.md) | | U-20260923-95 | 2026-09-23 | Create Project writes into the folder open in the IDE and asks before it replaces a template | #fix #menu | [2026-09](2026-09.md) | | U-20260923-94 | 2026-09-23 | Requests to user URLs connect only to the address checked as they connect (DNS rebinding) | #done #security #network | [2026-09](2026-09.md) | @@ -177,4 +178,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 114 | +| [2026-09.md](2026-09.md) | 2026-09 | 115 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 0b0819bc..cadefdd9 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -347,6 +347,8 @@ "skills_response_label": "Response:", "skills_missing_input": "Please enter API URL and Prompt", "skills_generating": "Generating...", + "skills_switch_over_edits": "The prompt has been edited. Replace it with {name} and lose the edits?", + "skills_code_missing": "Put the code in place of {code_diff} in the prompt before sending.", # JupyterLab GUI "jupyterlab_init": "Initializing...", "jupyterlab_downloading": "Downloading...", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 8fec4bd4..aacb81d1 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -346,6 +346,8 @@ "skills_response_label": "回傳結果:", "skills_missing_input": "請輸入 API URL 和 Prompt", "skills_generating": "產生中...", + "skills_switch_over_edits": "Prompt 已經修改過。要換成 {name},並捨棄這些修改嗎?", + "skills_code_missing": "送出前,請把程式碼放在 prompt 裡 {code_diff} 的位置。", # JupyterLab GUI "jupyterlab_init": "初始化中...", "jupyterlab_downloading": "下載中...", 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 642e18c4..73f0e4c1 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 @@ -3,7 +3,7 @@ import requests from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QLineEdit, - QTextEdit, QPushButton, QLabel, QComboBox + QTextEdit, QPushButton, QLabel, QComboBox, QMessageBox ) from PySide6.QtCore import QThread, Signal from je_editor import language_wrapper @@ -22,6 +22,10 @@ from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url +# Where a skill template wants the code; the user puts it there before sending +CODE_PLACEHOLDER = "{code_diff}" + + class RequestThread(QThread): # Not "finished": that is QThread's own end-of-thread signal, and hiding it # left nothing to re-enable the send button when run() ended some other way. @@ -112,6 +116,9 @@ def __init__(self): self.setLayout(layout) self.thread = None # 保存執行緒 + # 編輯區裡是哪個模板:換模板被拒時選單要回到這裡 + # The template in the edit area: where the selector goes back to when a switch is refused + self._shown_template = self.prompt_select.currentText() # 開啟時就把選到的那個模板載進來,選單才不是擺著好看 # Load the selected template on open, so the selector does something self.load_selected_prompt(self.prompt_select.currentText()) @@ -130,7 +137,29 @@ def load_selected_prompt(self, name: str) -> None: built_in = SKILLS_TEMPLATE_RELATION.get(name) if built_in is None: return + if self.prompt_input.document().isModified() and not self._may_replace_edits(name): + # 選單回到編輯區裡的模板,不再觸發一次 + # Put the selector back on the template being edited, without coming here again + self.prompt_select.blockSignals(True) + self.prompt_select.setCurrentText(self._shown_template) + self.prompt_select.blockSignals(False) + return self.prompt_input.setPlainText(load_prompt(name, built_in)) + self.prompt_input.document().setModified(False) + self._shown_template = name + + def _may_replace_edits(self, name: str) -> bool: + """ + 編輯區有修改時,問使用者能不能換掉;以前一換模板,貼上的程式碼就沒了 + Ask whether the edited prompt may be replaced: switching templates used + to throw away the code the user had pasted into it. + """ + reply = QMessageBox.question( + self, language_wrapper.language_word_dict.get("skills_prompt_select_label"), + language_wrapper.language_word_dict.get("skills_switch_over_edits").format(name=name), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) + return reply == QMessageBox.StandardButton.Yes def send_prompt(self): # Ignore re-submits while a request is in flight: reassigning self.thread @@ -145,6 +174,11 @@ def send_prompt(self): if not api_url or not prompt_text: self.response_output.setPlainText(language_wrapper.language_word_dict.get("skills_missing_input")) return + if CODE_PLACEHOLDER in prompt_text: + # 模板原封不動送出,端點收到的是一份沒有程式碼的審查請求 + # Sent as it is, the template asked for a review of no code at all + self.response_output.setPlainText(language_wrapper.language_word_dict.get("skills_code_missing")) + return # 顯示「產生中」 self.response_output.setPlainText(language_wrapper.language_word_dict.get("skills_generating")) diff --git a/test/test_utils/test_skills_panel.py b/test/test_utils/test_skills_panel.py new file mode 100644 index 00000000..8d976493 --- /dev/null +++ b/test/test_utils/test_skills_panel.py @@ -0,0 +1,120 @@ +"""The Skills panel keeps what the user put in the prompt, and sends only a prompt with code in it. + +Switching the template replaced the edit area without a word, taking the code +the user had pasted with it; and a template sent as it was asked the endpoint +to review ``{code_diff}``. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.extend_ai_gui import prompt_store +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import SKILLS_TEMPLATE_FILES +from pybreeze.pybreeze_ui.extend_ai_gui.skills import skills_send_gui +from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import SkillsSendGUI + +_PASTED = "please look at this:\ndef f():\n return 1\n" + + +@pytest.fixture(scope="module", autouse=True) +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def panel(tmp_path, monkeypatch): + # The built-in prompts, never the user's own + monkeypatch.setattr(prompt_store, "pybreeze_data_dir", lambda: tmp_path) + widget = SkillsSendGUI() + yield widget + widget.deleteLater() + + +@pytest.fixture() +def answer(monkeypatch): + """What the replace question gets, and how often it was asked.""" + state = {"reply": QMessageBox.StandardButton.No, "asked": 0} + + def question(*_args): + state["asked"] += 1 + return state["reply"] + + monkeypatch.setattr(QMessageBox, "question", staticmethod(question)) + return state + + +def _type(panel, text: str) -> None: + """Put *text* in the prompt as typing or pasting does, marking it edited.""" + panel.prompt_input.selectAll() + panel.prompt_input.insertPlainText(text) + + +class TestSwitchingTheTemplate: + def test_an_untouched_prompt_is_replaced_without_asking(self, panel, answer): + panel.prompt_select.setCurrentText(SKILLS_TEMPLATE_FILES[1]) + + assert answer["asked"] == 0 + assert panel.prompt_input.toPlainText() != "" + + def test_an_edited_prompt_is_kept_when_the_user_says_no(self, panel, answer): + _type(panel, _PASTED) + + panel.prompt_select.setCurrentText(SKILLS_TEMPLATE_FILES[1]) + + assert answer["asked"] == 1 + assert panel.prompt_input.toPlainText() == _PASTED + # The selector names what is in the edit area + assert panel.prompt_select.currentText() == SKILLS_TEMPLATE_FILES[0] + + def test_an_edited_prompt_is_replaced_when_the_user_says_yes(self, panel, answer): + _type(panel, _PASTED) + answer["reply"] = QMessageBox.StandardButton.Yes + + panel.prompt_select.setCurrentText(SKILLS_TEMPLATE_FILES[1]) + + assert panel.prompt_input.toPlainText() != _PASTED + # A fresh template is not an edit: the next switch does not ask + panel.prompt_select.setCurrentText(SKILLS_TEMPLATE_FILES[0]) + assert answer["asked"] == 1 + + +class TestSending: + def test_a_template_with_no_code_in_it_is_not_sent(self, panel, monkeypatch): + started: list = [] + monkeypatch.setattr(skills_send_gui, "RequestThread", lambda *args: started.append(args)) + panel.api_url_input.setText("https://skills.example/api") + + panel.send_prompt() # the template as loaded, {code_diff} and all + + assert started == [] + assert "{code_diff}" in panel.response_output.toPlainText() + + def test_a_prompt_with_the_code_in_place_is_sent(self, panel, monkeypatch): + sent: list = [] + + class Thread: + def __init__(self, url, text) -> None: + sent.append(text) + self.answered = self.error = self.finished = self + + def connect(self, _slot) -> None: + """Nothing to connect to.""" + + def start(self) -> None: + """Nothing to run.""" + + monkeypatch.setattr(skills_send_gui, "RequestThread", Thread) + panel.api_url_input.setText("https://skills.example/api") + _type(panel, panel.prompt_input.toPlainText().replace("{code_diff}", _PASTED)) + + panel.send_prompt() + + assert len(sent) == 1 and _PASTED.strip() in sent[0] From f07bae4227c4e8c61dac492adf31bb28b7d77570 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 16:31:13 +0800 Subject: [PATCH 124/312] Read prompts without creating the data folder, and fall back when the lookup fails --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 12 ++++++++ docs/updates/README.md | 3 +- .../pybreeze_ui/extend_ai_gui/prompt_store.py | 16 +++++++--- test/test_utils/test_prompt_store.py | 30 ++++++++++++++++++- test/test_utils/test_skills_panel.py | 2 +- 6 files changed, 58 insertions(+), 9 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index df164d4b..9f5d564c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -258,7 +258,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ``` extend_ai_gui/ ├── ai_gui_global_variable.py 模板檔名清單 + 檔名→內建模板內容對照表 -├── prompt_store.py 編輯過的 prompt 檔案解析(~/.pybreeze/prompts/;`read_prompt_file()` 讀 utf-8-sig,非 UTF-8 視同讀不到、退回內建) +├── prompt_store.py 編輯過的 prompt 檔案解析(~/.pybreeze/prompts/;`read_prompt_file()` 讀 utf-8-sig,非 UTF-8 視同讀不到、退回內建;讀取時什麼都不建立(`pybreeze_data_path()`),查檔丟 `OSError` 也退回內建) ├── code_review/ │ ├── cot_chain.py 接線表(純邏輯,無 Qt):哪步引用哪步 │ ├── code_review_thread.py SenderThread(QThread):跑八步審查鏈 @@ -438,7 +438,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 106 個 `test_*.py`、1586 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 106 個 `test_*.py`、1589 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ec6faa56..b1c465a7 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1716,3 +1716,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_skills_panel.py` (new) - `architecture_explore.md` §8, §18 - **Open items**: none. + +## U-20260923-98 · 2026-09-23 · Reading a prompt creates nothing and cannot stop a review on a folder it may not look into · #fix #ai + +- **What**: `prompt_store.prompt_dir()` went through `pybreeze_data_dir()`, which creates `~/.pybreeze`, although its docstring says reading a prompt creates nothing. The CoT review reads each step's prompt on its worker thread, and two things raised out of `SenderThread.run` there, ending the review at its first step with nothing in the panel: a file where `~/.pybreeze` should be (`FileExistsError`), and `Path.is_file()` on a prompt folder the user may not look into (`PermissionError` on Windows). Found in the review sweep of `extend_ai_gui/`. +- **Fix**: `prompt_dir()` uses `pybreeze_data_path()`, which creates nothing; saving a prompt already makes the folder it writes into. `load_prompt` falls back to the built-in prompt, with a log line, when looking the file up raises `OSError`. +- **Tests**: `test_prompt_store.py` +3: looking at a prompt leaves no `~/.pybreeze`; a file in its place gives the built-in; a folder that refuses the lookup gives the built-in. Its fixture and `test_skills_panel.py`'s now point `pybreeze_data_path` at a temporary folder. +- **Result / numbers**: prompt store, Skills, CoT and AI panel lifecycle tests 87 passed. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py` + - `test/test_utils/test_prompt_store.py`, `test_skills_panel.py` + - `architecture_explore.md` §8, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c55b7c0e..10a57973 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-98 | 2026-09-23 | Reading a prompt creates nothing and cannot stop a review on a folder it may not look into | #fix #ai | [2026-09](2026-09.md) | | U-20260923-97 | 2026-09-23 | Skills panel: switching the template keeps an edited prompt unless the user agrees, and a prompt without its code is not sent | #fix #ai | [2026-09](2026-09.md) | | U-20260923-96 | 2026-09-23 | A failed AI request is described in its panel without the URL and any token in it | #fix #security #ai | [2026-09](2026-09.md) | | U-20260923-95 | 2026-09-23 | Create Project writes into the folder open in the IDE and asks before it replaces a template | #fix #menu | [2026-09](2026-09.md) | @@ -178,4 +179,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 115 | +| [2026-09.md](2026-09.md) | 2026-09 | 116 | diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py index 32653491..edfad59e 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py @@ -13,7 +13,7 @@ from pathlib import Path -from pybreeze.utils.app_dirs import pybreeze_data_dir +from pybreeze.utils.app_dirs import pybreeze_data_path from pybreeze.utils.logging.logger import pybreeze_logger _PROMPT_DIR_NAME = "prompts" @@ -24,9 +24,11 @@ def prompt_dir() -> Path: Reading and naming a prompt must not create anything: opening the editor to look at a built-in prompt should leave no directory behind. ``save_prompt_text`` - makes the directory when there is finally something to put in it. + makes the directory when there is finally something to put in it. Not even + ``~/.pybreeze``: making it here raised on a review's worker thread when a + file stood in its place, and the review stopped without a word. """ - return pybreeze_data_dir() / _PROMPT_DIR_NAME + return pybreeze_data_path() / _PROMPT_DIR_NAME def prompt_path(name: str) -> Path: @@ -46,7 +48,13 @@ def load_prompt(name: str, default: str) -> str: :return: the prompt text to use """ path = prompt_path(name) - if not path.is_file(): + try: + exists = path.is_file() + except OSError as error: + # A folder the user may not look into raises instead of answering + pybreeze_logger.error("Prompt %s could not be looked up: %r", name, error) + return default + if not exists: return default edited = read_prompt_file(path) if edited is None: diff --git a/test/test_utils/test_prompt_store.py b/test/test_utils/test_prompt_store.py index 0ec00bc7..a23600f2 100644 --- a/test/test_utils/test_prompt_store.py +++ b/test/test_utils/test_prompt_store.py @@ -22,7 +22,7 @@ @pytest.fixture() def prompts(tmp_path, monkeypatch): """Point the prompt directory at a temporary one, never the real home.""" - monkeypatch.setattr(prompt_store, "pybreeze_data_dir", lambda: tmp_path) + monkeypatch.setattr(prompt_store, "pybreeze_data_path", lambda: tmp_path) return tmp_path / "prompts" @@ -37,6 +37,34 @@ def test_looking_at_a_prompt_creates_no_directory(self, prompts): load_prompt("linter.md", "built-in") assert not prompt_dir().exists() + def test_looking_at_a_prompt_does_not_create_the_data_directory_either(self, tmp_path, monkeypatch): + data = tmp_path / "home" / ".pybreeze" + monkeypatch.setattr(prompt_store, "pybreeze_data_path", lambda: data) + + assert load_prompt("linter.md", "built-in") == "built-in" + assert not data.exists() + + def test_a_file_where_the_data_directory_should_be_gives_the_built_in(self, tmp_path, monkeypatch): + # Making the directory raised FileExistsError on the review's worker + # thread, and the review stopped with nothing in the panel. + data = tmp_path / ".pybreeze" + data.write_text("not a folder", encoding="utf-8") + monkeypatch.setattr(prompt_store, "pybreeze_data_path", lambda: data) + + assert load_prompt("linter.md", "built-in") == "built-in" + + def test_a_prompt_folder_that_cannot_be_looked_into_gives_the_built_in(self, prompts, monkeypatch): + looked_up = prompt_store.Path.is_file + + def refuse(path) -> bool: + if path.name == "linter.md": + raise PermissionError(13, "Access is denied") + return looked_up(path) + + monkeypatch.setattr(prompt_store.Path, "is_file", refuse) + + assert load_prompt("linter.md", "built-in") == "built-in" + def test_saving_creates_the_directory(self, prompts, tmp_path): from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import ( save_prompt_text diff --git a/test/test_utils/test_skills_panel.py b/test/test_utils/test_skills_panel.py index 8d976493..e9b28f16 100644 --- a/test/test_utils/test_skills_panel.py +++ b/test/test_utils/test_skills_panel.py @@ -32,7 +32,7 @@ def app(): @pytest.fixture() def panel(tmp_path, monkeypatch): # The built-in prompts, never the user's own - monkeypatch.setattr(prompt_store, "pybreeze_data_dir", lambda: tmp_path) + monkeypatch.setattr(prompt_store, "pybreeze_data_path", lambda: tmp_path) widget = SkillsSendGUI() yield widget widget.deleteLater() From d896746069ac02143aac71d315e77e04ff1479fb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 16:31:45 +0800 Subject: [PATCH 125/312] Match plugin suffixes as file suffixes and give each plugin one run entry --- architecture_explore.md | 6 +-- docs/updates/2026-09.md | 16 ++++++++ docs/updates/README.md | 3 +- .../menu/plugin_menu/build_plugin_menu.py | 22 ++++------- .../menu/plugin_menu/build_run_with_menu.py | 23 ++++++++++- test/test_utils/test_plugin_menu.py | 39 +++++++++++++++++-- 6 files changed, 85 insertions(+), 24 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 9f5d564c..b5f90596 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -196,8 +196,8 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.4 插件選單 -- **`build_plugin_menu.py`** — 讀 `je_editor.plugins.get_all_plugin_metadata()`,每個插件一個子選單(About + 每個副檔名一個 Run 動作,動作直接呼叫 `run_current_file_with()`);另有「Plugin Browser」分頁入口 -- **`build_run_with_menu.py`** — 讀 `get_all_plugin_run_configs()`,在 Run 選單下加「Run with…」。`run_current_file_with()` 先經 `save_current_file_for_run()` 存檔(已有檔名的分頁照 JEditor 自己存檔的方式寫:`write_file_with_encoding()` 用分頁的編碼與行尾,寫成功後才 `mark_ignore_next_file_change()` 與 `mark_saved()`;存檔失敗跳警告、不執行;沒檔名的走 JEditor 的另存新檔),再驗副檔名、交給 `FileRunnerProcess`。Plugins 選單的 Run 動作也走這一條 +- **`build_plugin_menu.py`** — 讀 `je_editor.plugins.get_all_plugin_metadata()`,每個插件一個子選單(About + 一個 Run 動作,多個副檔名時一併列在標籤裡,動作直接呼叫 `run_current_file_with()`);另有「Plugin Browser」分頁入口 +- **`build_run_with_menu.py`** — 讀 `get_all_plugin_run_configs()`,在 Run 選單下加「Run with…」。`run_config_suffixes()` 把插件登記的副檔名正規化成 `Path.suffix` 的樣子(小寫、一個前導點;JEditor 原樣保存,`.R`、`r` 以前永遠比對不上)。`run_current_file_with()` 先經 `save_current_file_for_run()` 存檔(已有檔名的分頁照 JEditor 自己存檔的方式寫:`write_file_with_encoding()` 用分頁的編碼與行尾,寫成功後才 `mark_ignore_next_file_change()` 與 `mark_saved()`;存檔失敗跳警告、不執行;沒檔名的走 JEditor 的另存新檔),再驗副檔名、交給 `FileRunnerProcess`。Plugins 選單的 Run 動作也走這一條 ### 5.5 安裝選單 @@ -438,7 +438,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 106 個 `test_*.py`、1589 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 106 個 `test_*.py`、1596 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index b1c465a7..f44f2146 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1728,3 +1728,19 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_prompt_store.py`, `test_skills_panel.py` - `architecture_explore.md` §8, §18 - **Open items**: none. + +## U-20260923-99 · 2026-09-23 · Plugin runs match suffixes registered in any case or without the dot, and the Plugins menu has one run entry per plugin · #fix #plugin + +- **What**: two faults in the plugin run entries, found in the review sweep of `pybreeze_ui/menu/`. + - `run_current_file_with` lower-cased the file's suffix but compared it with the suffixes as the plugin registered them, and JEditor keeps those as given. A plugin registering `(".R",)` or `("r",)` always got the suffix mismatch dialog, after the file had been saved. + - The Plugins menu gave a plugin with several suffixes one "Run with X (.suffix)" entry per suffix, and every one ran the same config: the file's own suffix decides what runs. +- **Fix**: + - New `run_config_suffixes(run_config)` in `build_run_with_menu.py`: lower case, one leading dot, blanks and non-strings dropped, a bare string taken as one suffix, duplicates removed. Used by the match, the Run With labels and the Plugins menu. + - The Plugins menu has one run entry per plugin, listing its suffixes when there are several. +- **Tests**: `test_plugin_menu.py` +7: six normalisation cases, and a `.r` file runs under a plugin registering `.R`. The test that expected one entry per suffix now expects one entry listing them. +- **Result / numbers**: plugin menu and JEditor contract tests 67 passed; 1596 tests in 106 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `build_plugin_menu.py` + - `test/test_utils/test_plugin_menu.py` + - `architecture_explore.md` §5, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 10a57973..608514ae 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-99 | 2026-09-23 | Plugin runs match suffixes registered in any case or without the dot, and the Plugins menu has one run entry per plugin | #fix #plugin | [2026-09](2026-09.md) | | U-20260923-98 | 2026-09-23 | Reading a prompt creates nothing and cannot stop a review on a folder it may not look into | #fix #ai | [2026-09](2026-09.md) | | U-20260923-97 | 2026-09-23 | Skills panel: switching the template keeps an edited prompt unless the user agrees, and a prompt without its code is not sent | #fix #ai | [2026-09](2026-09.md) | | U-20260923-96 | 2026-09-23 | A failed AI request is described in its panel without the URL and any token in it | #fix #security #ai | [2026-09](2026-09.md) | @@ -179,4 +180,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 116 | +| [2026-09.md](2026-09.md) | 2026-09 | 117 | diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py index 939f6194..e9ca9359 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py @@ -8,7 +8,7 @@ from je_editor import get_all_plugin_metadata, language_wrapper from je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget import PluginBrowserWidget -from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import run_current_file_with +from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import run_config_suffixes, run_current_file_with if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -18,8 +18,9 @@ def set_plugin_menu(ui_we_want_to_set: PyBreezeMainWindow) -> None: """ 建立插件選單,顯示所有已載入插件的名稱、版本、作者。 Build Plugin menu showing all loaded plugins with name, version, author. - 同一語言若支援多種副檔名,以子選單呈現。 - If a language plugin supports multiple suffixes, show them in a submenu. + 有執行設定的插件是一個子選單:About 和一個「Run with」項目,支援多種副檔名時一併列出。 + A plugin with a run config gets a submenu: About, and one Run with entry + that lists the suffixes when there are several. """ metadata_list = get_all_plugin_metadata() if not metadata_list: @@ -59,7 +60,7 @@ def _add_plugin_entry(ui_we_want_to_set: PyBreezeMainWindow, meta: dict) -> None ui_we_want_to_set.plugin_menu.addAction(about_action) return - suffixes = run_config.get("suffixes", ()) + suffixes = run_config_suffixes(run_config) config_name = run_config.get("name", plugin_name) sub_menu = ui_we_want_to_set.plugin_menu.addMenu(config_name) @@ -73,16 +74,9 @@ def _add_plugin_entry(ui_we_want_to_set: PyBreezeMainWindow, meta: dict) -> None sub_menu.addAction(about_action) sub_menu.addSeparator() - if len(suffixes) > 1: - # 多種副檔名:每個副檔名一個執行動作 - # Multiple suffixes: one run action per suffix - for suffix in suffixes: - _add_run_action(ui_we_want_to_set, sub_menu, run_config, - label_name=f"{config_name} ({suffix})") - else: - # 單一副檔名:一個執行動作 - # Single suffix: one run action - _add_run_action(ui_we_want_to_set, sub_menu, run_config, label_name=config_name) + # One entry: every one ran the same config, whatever suffix its label named + label_name = f"{config_name} ({', '.join(suffixes)})" if len(suffixes) > 1 else config_name + _add_run_action(ui_we_want_to_set, sub_menu, run_config, label_name=label_name) def _add_run_action(ui_we_want_to_set: PyBreezeMainWindow, parent_menu, diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index 5a5acc18..bef5ce3e 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -27,6 +27,25 @@ from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow +def run_config_suffixes(run_config: dict) -> tuple[str, ...]: + """The suffixes a plugin's run config accepts, as ``Path.suffix`` gives them: lower case, with the dot. + + JEditor keeps what the plugin registered as it was, so ``".R"`` or ``"r"`` + never matched a file's suffix and the run was always refused. + """ + suffixes = run_config.get("suffixes", ()) + if isinstance(suffixes, str): + suffixes = (suffixes,) + normalized: list[str] = [] + for suffix in suffixes: + if not isinstance(suffix, str) or not suffix.strip(". "): + continue + one = "." + suffix.strip().lstrip(".").lower() + if one not in normalized: + normalized.append(one) + return tuple(normalized) + + def save_current_file_for_run(main_window: PyBreezeMainWindow) -> str | None: """Save the current editor tab and return its path, or None when there is nothing to run. @@ -82,7 +101,7 @@ def run_current_file_with(main_window: PyBreezeMainWindow, run_config: dict) -> # Check suffix match suffix = Path(file_path).suffix.lower() - supported = run_config.get("suffixes", ()) + supported = run_config_suffixes(run_config) if supported and suffix not in supported: msg = QMessageBox(main_window) msg.setWindowTitle(language_wrapper.language_word_dict.get("run_with_menu_label")) @@ -122,7 +141,7 @@ def set_run_with_menu(ui_we_want_to_set: PyBreezeMainWindow) -> None: for config in configs: name = config.get("name", "Unknown") - suffixes = ", ".join(config.get("suffixes", ())) + suffixes = ", ".join(run_config_suffixes(config)) label = f"{name} ({suffixes})" if suffixes else name action = QAction(label, ui_we_want_to_set.run_with_menu) diff --git a/test/test_utils/test_plugin_menu.py b/test/test_utils/test_plugin_menu.py index 9535c2e4..dbf2c904 100644 --- a/test/test_utils/test_plugin_menu.py +++ b/test/test_utils/test_plugin_menu.py @@ -20,7 +20,7 @@ from pybreeze.pybreeze_ui.menu.plugin_menu import build_run_with_menu as run_with from pybreeze.pybreeze_ui.menu.plugin_menu.build_plugin_menu import set_plugin_menu from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import ( - run_current_file_with, save_current_file_for_run, set_run_with_menu + run_config_suffixes, run_current_file_with, save_current_file_for_run, set_run_with_menu ) GO_CONFIG = {"name": "Go", "compiler": "go", "args": ("run",), "suffixes": (".go",)} @@ -111,6 +111,35 @@ def test_a_config_without_suffixes_is_labelled_by_name_alone( assert labels(window.run_with_menu) == ["Bare"] +class TestTheSuffixesAPluginRegistered: + @pytest.mark.parametrize(("registered", "expected"), [ + ((".go",), (".go",)), + ((".R",), (".r",)), + (("r",), (".r",)), + ((" .Py ", "py"), (".py",)), + (".rs", (".rs",)), + (("", ".", None, 3), ()), + ]) + def test_they_read_as_a_file_suffix_does(self, registered, expected): + assert run_config_suffixes({"suffixes": registered}) == expected + + def test_a_file_matching_a_suffix_registered_in_capitals_runs(self, window, tmp_path, monkeypatch): + script = tmp_path / "analysis.r" + script.touch() + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) + refused: list = [] + monkeypatch.setattr(run_with.QMessageBox, "exec", lambda self: refused.append(self.text())) + started: list = [] + monkeypatch.setattr( + run_with, "FileRunnerProcess", + lambda **kwargs: type("R", (), {"run_file": lambda *a: started.append(a)})()) + + run_current_file_with(window, {"name": "R", "compiler": "Rscript", "suffixes": (".R",)}) + + assert refused == [] + assert started + + class TestFindingTheFileToRun: def test_a_non_editor_tab_offers_no_file(self, window): window.tab_widget.addTab(QWidget(), "not an editor") @@ -209,7 +238,9 @@ def test_a_plugin_with_a_run_config_gets_a_submenu(self, window, monkeypatch): # About, a separator (empty text), then one run action for the suffix assert [text for text in submenus[0] if text] == ["About", "Run with Go"] - def test_multiple_suffixes_get_one_run_action_each(self, window, monkeypatch): + def test_multiple_suffixes_share_one_run_action_that_lists_them(self, window, monkeypatch): + # There was one entry per suffix, and every one ran the same config: + # the file's own suffix decides what runs, not the entry picked. monkeypatch.setattr( plugin_menu, "get_all_plugin_metadata", lambda: [{"name": "C++", "version": "1.0", "author": "someone", @@ -217,8 +248,8 @@ def test_multiple_suffixes_get_one_run_action_each(self, window, monkeypatch): set_plugin_menu(window) submenu = submenu_action_texts(window.plugin_menu)[0] runs = [text for text in submenu if "(" in text] - assert len(runs) == 2 - assert ".cpp" in runs[0] and ".hpp" in runs[1] + assert len(runs) == 1 + assert ".cpp, .hpp" in runs[0] def test_the_browser_entry_comes_first(self, window, monkeypatch): monkeypatch.setattr( From e92984d488b0a6cc57cceb0f5e0312df557d04b8 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 16:31:56 +0800 Subject: [PATCH 126/312] Record the undeclared requests and urllib3 dependencies in progress --- progress.md | 1 + 1 file changed, 1 insertion(+) diff --git a/progress.md b/progress.md index 2efd633d..64913121 100644 --- a/progress.md +++ b/progress.md @@ -10,3 +10,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#47** [BLOCKED] Closing the IDE closes JEditor's docked file editors (`editor_main/main_ui.py`, `_close_tool_tabs_and_docks`), and the published `je_editor` dock (`FullEditorWidget.closeEvent`) writes its buffer back on close whether edited or not, in UTF-8 with platform line endings, so exiting rewrites an LF file as CRLF. Fixed in JEditor (JEDITOR U-20260923-07: writes only when edited, in the file's own encoding and line ending); waits for the `je_editor` release that carries it, then bump the pin and add a PyBreeze test. - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? +- **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? From cc4354c544a4d40c8b186ffa8169c6544e90af56 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 16:38:22 +0800 Subject: [PATCH 127/312] Report deeply nested JSON in the URL and query tools and build URLs from null and bad parts correctly --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 19 ++++++ docs/updates/README.md | 3 +- pybreeze/utils/query_tools/query_convert.py | 11 ++-- pybreeze/utils/url_tools/url_convert.py | 71 ++++++++++++++++----- test/test_utils/test_query_convert.py | 9 +++ test/test_utils/test_url_convert.py | 39 +++++++++++ 7 files changed, 132 insertions(+), 22 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index b5f90596..fd766764 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -438,7 +438,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 106 個 `test_*.py`、1596 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 106 個 `test_*.py`、1611 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index f44f2146..b3355148 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1744,3 +1744,22 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_plugin_menu.py` - `architecture_explore.md` §5, §18 - **Open items**: none. + +## U-20260923-100 · 2026-09-23 · URL Builder and Query JSON: deeply nested JSON reported, null parts empty, ports checked, an empty port accepted · #fix #tools + +- **What**: four faults in `utils/url_tools/url_convert.py` and `utils/query_tools/query_convert.py`, all reproduced. Found in the review sweep of `utils/`. + - `json_to_url` and `json_to_query` caught only `ValueError` from `json.loads`. JSON nested deeper than the parser goes (`"[" * 100000`) raised `RecursionError`, and the URL Builder and Query JSON tabs catch only their own exceptions, so it escaped the slot. + - `build_url` wrote parts with `str()`: `{"path": null, "fragment": null}` built `http://h/None#None`, and a query value `null` built `a=None` (the query tool writes `a=`). A query value that was an object was flattened to its keys (`b=x`), and a port `"abc"` built `http://h:abc`. + - `url_to_json("http://example.com:/p")` refused the URL as having a port out of range. RFC 3986 allows the colon with an empty port. +- **Fix**: + - Both catch `(ValueError, RecursionError)`. + - `build_url`: a null or missing part is empty (`_part`). Query values go through the query tool's `coerce_scalar` (was `_coerce_scalar`): null is empty, booleans `true`/`false`, an object or a nested list refused as a `UrlConvertException`. The port (`_port_text`) must be a whole number from 0 to 65535, given as a number or as digits. + - `_has_port_text` counts a port only when text follows the colon. +- **Not changed**: a URL taken apart and rebuilt can still change its query's spelling (a malformed escape such as `%zz` comes back as `%25zz`, invalid UTF-8 as U+FFFD, `/` and `;` percent-encoded, a bare `flag` as `flag=`). The tool shows decoded values for editing, so keeping the raw spelling would take a different design. +- **Tests**: `test_url_convert.py` +14 (the empty port; null parts; null and boolean query values; an object value refused; five bad ports refused; four good or absent ports build; nested JSON reported) and `test_query_convert.py` +1 (nested JSON reported). +- **Result / numbers**: URL and query tests 64 passed; 1611 tests in 106 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/url_tools/url_convert.py`, `pybreeze/utils/query_tools/query_convert.py` + - `test/test_utils/test_url_convert.py`, `test_query_convert.py` + - `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 608514ae..e2d3c57b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-100 | 2026-09-23 | URL Builder and Query JSON: deeply nested JSON reported, null parts empty, ports checked, an empty port accepted | #fix #tools | [2026-09](2026-09.md) | | U-20260923-99 | 2026-09-23 | Plugin runs match suffixes registered in any case or without the dot, and the Plugins menu has one run entry per plugin | #fix #plugin | [2026-09](2026-09.md) | | U-20260923-98 | 2026-09-23 | Reading a prompt creates nothing and cannot stop a review on a folder it may not look into | #fix #ai | [2026-09](2026-09.md) | | U-20260923-97 | 2026-09-23 | Skills panel: switching the template keeps an edited prompt unless the user agrees, and a prompt without its code is not sent | #fix #ai | [2026-09](2026-09.md) | @@ -180,4 +181,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 117 | +| [2026-09.md](2026-09.md) | 2026-09 | 118 | diff --git a/pybreeze/utils/query_tools/query_convert.py b/pybreeze/utils/query_tools/query_convert.py index 750f8798..f91013c8 100644 --- a/pybreeze/utils/query_tools/query_convert.py +++ b/pybreeze/utils/query_tools/query_convert.py @@ -55,7 +55,7 @@ def query_to_json(query: str) -> str: return json.dumps(query_to_dict(query), indent=4, ensure_ascii=False, sort_keys=True) -def _coerce_scalar(value: object) -> str: +def coerce_scalar(value: object) -> str: """Render a scalar JSON value as the string a query string would carry. ``null`` is an empty value (``a=``), not Python's ``None``; an object or a @@ -85,8 +85,9 @@ def json_to_query(json_text: str) -> str: """ try: parsed = json.loads(json_text) - # json.JSONDecodeError derives from ValueError. - except ValueError as error: + # json.JSONDecodeError derives from ValueError; RecursionError is JSON + # nested deeper than the parser goes, which escaped the tab's slot + except (ValueError, RecursionError) as error: pybreeze_logger.error(invalid_json_for_query_error) raise QueryConvertException(invalid_json_for_query_error) from error if not isinstance(parsed, dict): @@ -96,7 +97,7 @@ def json_to_query(json_text: str) -> str: pairs: list[tuple[str, str]] = [] for key, value in parsed.items(): if isinstance(value, list): - pairs.extend((key, _coerce_scalar(item)) for item in value) + pairs.extend((key, coerce_scalar(item)) for item in value) else: - pairs.append((key, _coerce_scalar(value))) + pairs.append((key, coerce_scalar(value))) return urlencode(pairs) diff --git a/pybreeze/utils/url_tools/url_convert.py b/pybreeze/utils/url_tools/url_convert.py index 57f8a8ee..b3487030 100644 --- a/pybreeze/utils/url_tools/url_convert.py +++ b/pybreeze/utils/url_tools/url_convert.py @@ -15,9 +15,11 @@ unreadable_url_error, url_port_out_of_range_error, ) -from pybreeze.utils.exception.exceptions import UrlConvertException +from pybreeze.utils.exception.exceptions import QueryConvertException, UrlConvertException from pybreeze.utils.logging.logger import pybreeze_logger -from pybreeze.utils.query_tools.query_convert import query_to_dict +from pybreeze.utils.query_tools.query_convert import coerce_scalar, query_to_dict + +_MAX_PORT = 65535 def _safe_port(split: SplitResult) -> int | None: @@ -75,9 +77,13 @@ def url_to_json(url: str) -> str: def _has_port_text(url: str) -> bool: - """Whether *url*'s authority names a port at all, readable or not.""" + """Whether *url*'s authority names a port at all, readable or not. + + ``http://example.com:/`` names none: RFC 3986 allows the colon with an + empty port, and it was refused as a port out of range. + """ host_and_port = urlsplit(url.strip()).netloc.rpartition("@")[2] - return bool(host_and_port.rpartition("]")[2].partition(":")[1]) + return bool(host_and_port.rpartition("]")[2].partition(":")[2]) def _build_netloc(components: dict, host: str) -> str: @@ -85,24 +91,48 @@ def _build_netloc(components: dict, host: str) -> str: if ":" in host and not host.startswith("["): host = f"[{host}]" # bracket an IPv6 literal so the port stays separable netloc = host - port = components.get("port") - if port is not None and port != "": + port = _port_text(components.get("port")) + if port: netloc = f"{netloc}:{port}" username = components.get("username") if username is not None: password = components.get("password") - credentials = username if password is None else f"{username}:{password}" + credentials = str(username) if password is None else f"{username}:{password}" netloc = f"{credentials}@{netloc}" return netloc +def _port_text(port: object) -> str: + """*port* as the URL writes it: empty for none, else a whole number from 0 to 65535. + + :raises UrlConvertException: for anything else; ``"abc"`` went into the URL as ``h:abc`` + """ + if port is None or port == "": + return "" + if isinstance(port, str) and port.strip().isdigit(): + port = int(port) + if isinstance(port, bool) or not isinstance(port, int) or not 0 <= port <= _MAX_PORT: + pybreeze_logger.error(url_port_out_of_range_error) + raise UrlConvertException(url_port_out_of_range_error) + return str(port) + + def _build_query(query: object) -> str: """Render the query component from a dict of pairs (or a raw string).""" if not query: return "" if isinstance(query, dict): - # doseq: a list value is a key that repeats, one pair per value. - return urlencode(query, doseq=True) + # A list value is a key that repeats, one pair per value. Each value as + # the query tool writes it: null is empty, not "None", and an object + # has no query form. + pairs: list[tuple[str, str]] = [] + try: + for key, value in query.items(): + values = value if isinstance(value, list) else [value] + pairs.extend((str(key), coerce_scalar(item)) for item in values) + except QueryConvertException as error: + raise UrlConvertException(str(error)) from error + return urlencode(pairs) return str(query) @@ -113,26 +143,37 @@ def build_url(components: dict) -> str: :param components: URL parts as produced by :func:`parse_url` :return: the assembled URL + :raises UrlConvertException: for a port that is not a whole number from 0 + to 65535, or a query value that is an object or a list inside a list """ - scheme = str(components.get("scheme", "")) - host = str(components.get("host", "")) + scheme = _part(components, "scheme") + host = _part(components, "host") netloc = _build_netloc(components, host) - path = str(components.get("path", "")) + path = _part(components, "path") query = _build_query(components.get("query")) - fragment = str(components.get("fragment", "")) + fragment = _part(components, "fragment") return urlunsplit((scheme, netloc, path, query, fragment)) +def _part(components: dict, name: str) -> str: + """The URL part *name* as text: a missing or null part is empty, not ``None``.""" + value = components.get(name) + return "" if value is None else str(value) + + def json_to_url(json_text: str) -> str: """Build a URL from a JSON object of URL parts. :param json_text: a JSON object as produced by :func:`url_to_json` :return: the assembled URL - :raises UrlConvertException: when the input is not valid JSON or not an object + :raises UrlConvertException: when the input is not valid JSON or not an + object, or holds a part :func:`build_url` refuses """ try: components = json.loads(json_text) - except ValueError as error: + # RecursionError: JSON nested deeper than the parser goes, which escaped + # the tab's slot + except (ValueError, RecursionError) as error: pybreeze_logger.error(invalid_json_for_url_error) raise UrlConvertException(invalid_json_for_url_error) from error if not isinstance(components, dict): diff --git a/test/test_utils/test_query_convert.py b/test/test_utils/test_query_convert.py index 7aa5ff38..e263ea27 100644 --- a/test/test_utils/test_query_convert.py +++ b/test/test_utils/test_query_convert.py @@ -90,3 +90,12 @@ def test_an_object_or_a_nested_list_is_refused(self, text): with pytest.raises(QueryConvertException): json_to_query(text) + + +def test_json_nested_too_deep_is_reported_not_raised_out_of_the_tab(): + from pybreeze.utils.exception.exceptions import QueryConvertException + from pybreeze.utils.query_tools.query_convert import json_to_query + + # json.loads raises RecursionError, which the tab did not catch + with pytest.raises(QueryConvertException): + json_to_query("[" * 100000) diff --git a/test/test_utils/test_url_convert.py b/test/test_utils/test_url_convert.py index c7d7ecfe..e6a8868f 100644 --- a/test/test_utils/test_url_convert.py +++ b/test/test_utils/test_url_convert.py @@ -134,3 +134,42 @@ def test_urls_without_a_bad_port_still_convert(self, url): from pybreeze.utils.url_tools.url_convert import url_to_json assert json.loads(url_to_json(url))["scheme"] == "http" + + +class TestAnEmptyPort: + def test_the_colon_alone_names_no_port(self): + # RFC 3986 allows it; it was refused as a port out of range + assert json.loads(url_to_json("http://example.com:/p"))["port"] is None + + +class TestPartsThatAreNotText: + def test_null_parts_are_empty_not_none(self): + url = json_to_url('{"scheme": "http", "host": "h", "path": null, "fragment": null}') + + assert url == "http://h" + + def test_a_null_query_value_is_empty_as_in_the_query_tool(self): + assert json_to_url('{"scheme": "http", "host": "h", "query": {"a": null, "b": true}}') == "http://h?a=&b=true" + + def test_an_object_as_a_query_value_is_refused_not_flattened(self): + # {"x": 1} went into the URL as its keys: b=x + with pytest.raises(UrlConvertException): + json_to_url('{"scheme": "http", "host": "h", "query": {"b": {"x": 1}}}') + + @pytest.mark.parametrize("port", ['"abc"', "70000", "-1", "true", "8.5"]) + def test_a_port_that_is_no_port_is_refused(self, port): + # "abc" went into the URL as h:abc + with pytest.raises(UrlConvertException): + json_to_url(f'{{"scheme": "http", "host": "h", "port": {port}}}') + + @pytest.mark.parametrize(("port", "expected"), [("8080", "http://h:8080"), ('"8080"', "http://h:8080"), + ("null", "http://h"), ('""', "http://h")]) + def test_a_port_given_as_text_or_left_out_still_builds(self, port, expected): + assert json_to_url(f'{{"scheme": "http", "host": "h", "port": {port}}}') == expected + + +class TestJsonNestedTooDeep: + def test_it_is_reported_not_raised_out_of_the_tab(self): + # json.loads raises RecursionError, which the tab did not catch + with pytest.raises(UrlConvertException): + json_to_url("[" * 100000) From 43a83f9f44db1fc8410e7dccf1846c50c899060e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 16:48:08 +0800 Subject: [PATCH 128/312] Save prthinker settings in one step for their owner only, read them without creating anything, and say when saving fails --- CLAUDE.md | 4 +- architecture_explore.md | 7 +-- docs/updates/2026-09.md | 27 ++++++++++ docs/updates/README.md | 3 +- .../prthinker_extend/prthinker_setting.py | 18 ++++--- .../extend_multi_language/extend_english.py | 1 + .../extend_traditional_chinese.py | 1 + .../dialog/prthinker_setting_dialog.py | 8 ++- pybreeze/utils/app_dirs.py | 8 ++- pybreeze/utils/file_process/replace_file.py | 47 ++++++++++++++++ test/test_utils/test_app_dirs.py | 16 ++++++ test/test_utils/test_prthinker_setting.py | 49 +++++++++++++++-- .../test_prthinker_setting_dialog.py | 8 ++- test/test_utils/test_replace_file.py | 53 +++++++++++++++++++ 14 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 pybreeze/utils/file_process/replace_file.py create mode 100644 test/test_utils/test_replace_file.py diff --git a/CLAUDE.md b/CLAUDE.md index 2a62ac1d..848d2392 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,11 +111,11 @@ Reference implementations: `utils/network/url_validation.py` (`validate_url`), ` **JupyterLab** — the embedded server is localhost-only; the empty `--ServerApp.token`/`password` and `--ServerApp.disable_check_xsrf=True` are safe *only* because of that. Never change `--ServerApp.ip` to an externally reachable address, and never set `--ServerApp.allow_origin`: a loopback bind does not stop a browser, and with the origin open any page the user visits can drive a tokenless server. The view loads from the same origin and needs nothing relaxed. The server outlives its launcher thread, so its tab stops it on close whatever the thread's state. -**File I/O** — dialog-chosen paths are trusted; paths loaded from saved data (`.diagram.json`) are not: check `is_file()` and an extension allowlist, or run URLs through SSRF validation. Use `pathlib`, never string concatenation. Write to `~/.pybreeze/` via `app_dirs.pybreeze_data_dir()` with `encoding="utf-8"`. Resolve symlinks with `Path.resolve(strict=True)` and verify the result stays in bounds. +**File I/O** — dialog-chosen paths are trusted; paths loaded from saved data (`.diagram.json`) are not: check `is_file()` and an extension allowlist, or run URLs through SSRF validation. Use `pathlib`, never string concatenation. Write to `~/.pybreeze/` via `app_dirs.pybreeze_data_dir()` with `encoding="utf-8"`; read through `pybreeze_data_path()`, which creates nothing. Replace a file the user would lose through `utils/file_process/replace_file.replace_text` (written beside it, then moved into place), never an in-place `write_text`. Resolve symlinks with `Path.resolve(strict=True)` and verify the result stays in bounds. **Qt** — `QGraphicsTextItem` text interaction must not be on by default (double-click to edit). Plugin loading takes only `.py` files, skipping `_`/`.` prefixes. `QWebEngineView.setUrl()` only for localhost or user-confirmed URLs; never `setHtml()` with unsanitised content. -**Secrets** — SSH passwords and passphrases stay in memory for the session only. Password fields use `QLineEdit.EchoMode.Password`. +**Secrets** — SSH passwords and passphrases stay in memory for the session only. A secret that must persist (the prthinker keys and token) is written with `replace_text(..., private=True)` under the `0700` data folder. Password fields use `QLineEdit.EchoMode.Password`. **Dependencies** — pin exact versions in `requirements.txt` / `dev_requirements.txt`. Review any new dependency's maintenance and CVE history; prefer stdlib over a single-function package. diff --git a/architecture_explore.md b/architecture_explore.md index fd766764..3365e7e3 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -337,7 +337,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | 套件 | 內容 | |---|---| -| `app_dirs.py` | `pybreeze_data_dir()` → `~/.pybreeze`,所有持久化資料的單一位置 | +| `app_dirs.py` | `pybreeze_data_dir()` → `~/.pybreeze`,所有持久化資料的單一位置,建立時為 `0700`(`DATA_DIR_MODE`);`pybreeze_data_path()` 只給路徑、不建立 | | `subprocess_util.py` | `utf8_subprocess_env()`(釘 `PYTHONIOENCODING`,解 Windows cp950 亂碼)、`no_window_creationflags()`(`CREATE_NO_WINDOW`,避免 GUI 程式彈出黑窗) | | `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | @@ -349,6 +349,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | +| `file_process/replace_file.py` | `replace_text(path, text, private=False)`:寫到旁邊的 `<名稱>.saving` 再 `os.replace` 過去,失敗時原檔不動、半成品刪掉;`private` 以 `0600` 建立(存金鑰的檔案用) | | `manager/package_manager/` | `PackageManager`(單例 `package_manager`)持有 `syntax_check_list` | **分層原則**:`utils/` 不 import Qt 或 JEditor,由 `test_utils_has_no_qt.py` 守著,所以全部是不需要視窗的純邏輯測試。 @@ -365,7 +366,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 純邏輯、無 Qt,值得單獨一節,因為它示範了本專案處理祕密的方式: -- 設定存 `~/.pybreeze/prthinker_setting.json` +- 設定存 `~/.pybreeze/prthinker_setting.json`(經 `replace_text(..., private=True)` 整檔替換,只有擁有者讀得到;讀取時什麼都不建立,存檔失敗時對話框會說) - `environment_for()` 把設定轉成 `PRTHINKER_*` 環境變數交給子行程,**命令列只留「這次要審什麼」** — API key 不會出現在工作管理員或執行紀錄 - `SECRET_SETTINGS` 四個欄位在 `loggable()` 中一律縮成 `(set)` / 空字串 - 模型名稱依後端交給不同變數(`MODEL_ENVIRONMENT`):`remote` / `local` 是 `PRTHINKER_MODEL_NAME`,其他後端是各自的 `PRTHINKER__MODEL`。prthinker 只有 local 讀 `PRTHINKER_MODEL_NAME`(remote 由伺服器決定模型,只拿來標示) @@ -438,7 +439,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 106 個 `test_*.py`、1611 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1619 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index b3355148..5cdb4768 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1763,3 +1763,30 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_url_convert.py`, `test_query_convert.py` - `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-101 · 2026-09-23 · prthinker settings: a failed save keeps the stored keys, the file is its owner's only, reading creates nothing, and a failure is shown · #fix #security #prthinker + +- **What**: four faults in the prthinker settings (`extend/prthinker_extend/prthinker_setting.py`, `pybreeze_ui/dialog/prthinker_setting_dialog.py`), found in the review sweep of `editor_main/`, `dialog/` and prthinker. + - `save_setting` wrote with `Path.write_text`, which empties the file first. A full disk or a crash part-way left `prthinker_setting.json` empty or cut short; the next read fell back to the defaults, every API key and token was gone, and the next Save stored the defaults. + - The file, holding the remote, OpenAI and Anthropic keys and the forge token, was created with the default umask: readable by every local user on a shared POSIX machine (`0644`), as was `~/.pybreeze` (`0755`). + - `setting_path()` went through `pybreeze_data_dir()`, which creates `~/.pybreeze`, outside any `try`. With a file in its place (reproduced) or a read-only home, "Review current file", "Review PR", "Settings" and "Install prthinker" all raised out of their slots with no message. The same bug class as U-20260923-98 in the prompt store. + - When Save failed the dialog stayed open with no reason given; it was only logged. +- **Fix**: + - New `utils/file_process/replace_file.replace_text(path, text, *, private=False)`: writes `.saving` beside the file (created `0600` when `private`, and removing a leftover from an earlier failure first), then `os.replace`s it over the file. On failure the file is unchanged, the partial one is removed and the `OSError` raised. + - `save_setting` makes the folder (`DATA_DIR_MODE`, `0700`) and writes through `replace_text(..., private=True)`. + - `setting_path()` uses `pybreeze_data_path()`, and `load_setting` treats an `OSError` from the lookup as nothing stored. + - `app_dirs.pybreeze_data_dir()` creates `~/.pybreeze` as `0700`. An existing folder is left as it is, and Windows keeps the profile's own access rules. + - The dialog warns when Save fails (`prthinker_setting_save_failed`, both languages), naming the file and saying the stored settings are kept. +- **Tests**: + - `test_prthinker_setting.py` +3: a save failing at the replace keeps the stored key and leaves no partial file; the file is opened with mode `0600`; a file where the folder should be reads as defaults and fails to save without raising. The failure test now fails at `replace_text` instead of patching `Path.write_text`. + - New `test_replace_file.py` (4, one POSIX-only): written; a failure keeps the old file with no partial one; a leftover partial file is replaced; a private file is `0600`. + - `test_app_dirs.py` +1 (made `0700`), and the dialog's failed-save test now also checks the warning. + - The fixtures point `pybreeze_data_path` at a temporary folder. +- **Result / numbers**: prthinker, app dirs, install menu and language parity tests 96 passed; 1619 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/file_process/replace_file.py` (new), `pybreeze/utils/app_dirs.py` + - `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_replace_file.py` (new), `test_prthinker_setting.py`, `test_prthinker_setting_dialog.py`, `test_app_dirs.py` + - `CLAUDE.md` (File I/O, Secrets), `architecture_explore.md` §12, §14, §17, §18 +- **Open items**: the prompt editors and the diagram editor each keep their own copy of the same write-beside-and-replace steps; moving them onto `replace_text` is a refactor for its own stage. diff --git a/docs/updates/README.md b/docs/updates/README.md index e2d3c57b..ee5f794e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-101 | 2026-09-23 | prthinker settings: a failed save keeps the stored keys, the file is its owner's only, reading creates nothing, and a failure is shown | #fix #security #prthinker | [2026-09](2026-09.md) | | U-20260923-100 | 2026-09-23 | URL Builder and Query JSON: deeply nested JSON reported, null parts empty, ports checked, an empty port accepted | #fix #tools | [2026-09](2026-09.md) | | U-20260923-99 | 2026-09-23 | Plugin runs match suffixes registered in any case or without the dot, and the Plugins menu has one run entry per plugin | #fix #plugin | [2026-09](2026-09.md) | | U-20260923-98 | 2026-09-23 | Reading a prompt creates nothing and cannot stop a review on a folder it may not look into | #fix #ai | [2026-09](2026-09.md) | @@ -181,4 +182,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 118 | +| [2026-09.md](2026-09.md) | 2026-09 | 119 | diff --git a/pybreeze/extend/prthinker_extend/prthinker_setting.py b/pybreeze/extend/prthinker_extend/prthinker_setting.py index 1670bf3c..3162eab6 100644 --- a/pybreeze/extend/prthinker_extend/prthinker_setting.py +++ b/pybreeze/extend/prthinker_extend/prthinker_setting.py @@ -23,7 +23,8 @@ from pathlib import Path from typing import Dict, List -from pybreeze.utils.app_dirs import pybreeze_data_dir +from pybreeze.utils.app_dirs import DATA_DIR_MODE, pybreeze_data_path +from pybreeze.utils.file_process.replace_file import replace_text from pybreeze.utils.logging.logger import pybreeze_logger # 設定檔名 / The settings file's name @@ -133,7 +134,7 @@ def setting_path() -> Path: :return: 設定檔路徑 / the settings file's path """ - return pybreeze_data_dir() / SETTING_FILE_NAME + return pybreeze_data_path() / SETTING_FILE_NAME def load_setting() -> Dict[str, str]: @@ -149,9 +150,11 @@ def load_setting() -> Dict[str, str]: """ setting = dict(DEFAULT_SETTING) path = setting_path() - if not path.is_file(): - return setting try: + # Reading creates nothing: a data folder that could not be made (a + # file in its place) raised out of every prthinker menu entry + if not path.is_file(): + return setting stored = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError) as error: pybreeze_logger.error("prthinker settings could not be read: %r", error) @@ -181,8 +184,11 @@ def save_setting(setting: Dict[str, str]) -> bool: """ to_store = {key: setting.get(key, "") for key in DEFAULT_SETTING} try: - setting_path().write_text( - json.dumps(to_store, indent=4, ensure_ascii=False), encoding="utf-8") + path = setting_path() + path.parent.mkdir(mode=DATA_DIR_MODE, parents=True, exist_ok=True) + # Replaced in one step, readable by its owner only: written in place, a + # failure part-way emptied the file and every key and token in it + replace_text(path, json.dumps(to_store, indent=4, ensure_ascii=False), private=True) except OSError as error: pybreeze_logger.error("prthinker settings could not be saved: %r", error) return False diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index cadefdd9..e33ffd6a 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -144,6 +144,7 @@ "prthinker_setting_extra_arguments_label": "Extra command-line arguments", "prthinker_setting_source_path_label": "prthinker source folder", "prthinker_setting_stored_at_label": "Stored at:", + "prthinker_setting_save_failed": "The settings could not be saved to {path}. What was there before is kept.", # Prompt editors — where the edited prompts override the built-in ones from "prompt_editor_stored_at_label": "Prompt files (these override the built-in prompts):", "prthinker_choose_source_path_label": "Choose the prthinker source folder", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index aacb81d1..f3e6772f 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -144,6 +144,7 @@ "prthinker_setting_extra_arguments_label": "額外的命令列參數", "prthinker_setting_source_path_label": "prthinker 原始碼資料夾", "prthinker_setting_stored_at_label": "設定檔位置:", + "prthinker_setting_save_failed": "設定無法存到 {path},原本的設定保持不變。", # Prompt 編輯器 —— 編輯過的 prompt 會覆寫內建版本 "prompt_editor_stored_at_label": "Prompt 檔案位置(會覆寫內建 prompt):", "prthinker_choose_source_path_label": "選擇 prthinker 原始碼資料夾", diff --git a/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py b/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py index fe46fd3a..677545c2 100644 --- a/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py +++ b/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py @@ -14,7 +14,7 @@ from typing import Dict from PySide6.QtWidgets import ( - QComboBox, QDialog, QDialogButtonBox, QFormLayout, QLabel, QLineEdit, + QComboBox, QDialog, QDialogButtonBox, QFormLayout, QLabel, QLineEdit, QMessageBox, QVBoxLayout ) from je_editor import language_wrapper @@ -117,3 +117,9 @@ def save(self) -> None: self.setting.update(self.values()) if save_setting(self.setting): self.accept() + return + # 以前只寫進 log,視窗不關也不說原因 + # It used to go to the log only: the window stayed open with no reason given + QMessageBox.warning( + self, self.word_dict.get("prthinker_setting_dialog_title"), + self.word_dict.get("prthinker_setting_save_failed").format(path=setting_path())) diff --git a/pybreeze/utils/app_dirs.py b/pybreeze/utils/app_dirs.py index 9c7cec2a..48873478 100644 --- a/pybreeze/utils/app_dirs.py +++ b/pybreeze/utils/app_dirs.py @@ -4,6 +4,8 @@ from pathlib import Path _DATA_DIR_NAME = ".pybreeze" +# Its owner only: it holds SSH known hosts, prompts, and the prthinker keys and tokens +DATA_DIR_MODE = 0o700 def pybreeze_data_path() -> Path: @@ -19,8 +21,10 @@ def pybreeze_data_dir() -> Path: A single home-based location (``~/.pybreeze``) keeps persisted data — SSH known hosts, AI-review stats — stable regardless of the directory the IDE - was launched from. + was launched from. Made readable by its owner only (``0700`` on POSIX; + Windows keeps the profile's own access rules); an existing one is left as + it is. """ data_dir = pybreeze_data_path() - data_dir.mkdir(parents=True, exist_ok=True) + data_dir.mkdir(mode=DATA_DIR_MODE, parents=True, exist_ok=True) return data_dir diff --git a/pybreeze/utils/file_process/replace_file.py b/pybreeze/utils/file_process/replace_file.py new file mode 100644 index 00000000..0deabb54 --- /dev/null +++ b/pybreeze/utils/file_process/replace_file.py @@ -0,0 +1,47 @@ +"""Write a file so that a failure part-way leaves the old one as it was. + +Opening a file for writing empties it first. A full disk, a crash or a file +held open by something else then left it empty or cut short: the settings, the +prompt or the diagram it held were gone, not just the new save. +""" +from __future__ import annotations + +import contextlib +import os +from pathlib import Path + +# Owner read and write only, for a file that holds secrets; others as the umask allows +_PRIVATE_MODE = 0o600 +_SHARED_MODE = 0o666 + + +def replace_text(path: Path, text: str, *, private: bool = False) -> None: + """Write *text* to *path* in UTF-8, replacing the file in one step. + + The text goes to ``.saving`` beside it, which then takes the file's + place (``os.replace``). Line endings are the platform's, as with + ``Path.write_text``. + + :param path: the file to write; its folder must exist + :param text: what it will hold + :param private: create the file readable by its owner only (``0600`` on + POSIX; Windows keeps the profile's own access rules). For a file that + holds keys or tokens, which ``write_text`` left readable by every + local user. + :raises OSError: when it cannot be written; *path* is then unchanged and + the partial file removed + """ + beside = path.with_name(path.name + ".saving") + try: + # A leftover from an earlier failure would keep its own permissions + with contextlib.suppress(FileNotFoundError): + beside.unlink() + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) + descriptor = os.open(beside, flags, _PRIVATE_MODE if private else _SHARED_MODE) + with os.fdopen(descriptor, "w", encoding="utf-8") as file: + file.write(text) + os.replace(beside, path) + except OSError: + with contextlib.suppress(OSError): + beside.unlink() + raise diff --git a/test/test_utils/test_app_dirs.py b/test/test_utils/test_app_dirs.py index c5f66cb0..0a051917 100644 --- a/test/test_utils/test_app_dirs.py +++ b/test/test_utils/test_app_dirs.py @@ -18,3 +18,19 @@ def test_idempotent_when_already_exists(self, tmp_path, monkeypatch): second = pybreeze_data_dir() assert first == second assert second.is_dir() + + def test_it_is_made_for_its_owner_only(self, tmp_path, monkeypatch): + # It holds the SSH known hosts and the prthinker keys and tokens + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + modes: list = [] + made = Path.mkdir + + def record(path, mode=0o777, parents=False, exist_ok=False): + modes.append(mode) + made(path, mode=mode, parents=parents, exist_ok=exist_ok) + + monkeypatch.setattr(Path, "mkdir", record) + + pybreeze_data_dir() + + assert modes == [0o700] diff --git a/test/test_utils/test_prthinker_setting.py b/test/test_utils/test_prthinker_setting.py index 8c185e36..ff6d9b15 100644 --- a/test/test_utils/test_prthinker_setting.py +++ b/test/test_utils/test_prthinker_setting.py @@ -8,17 +8,17 @@ from pybreeze.extend.prthinker_extend import prthinker_setting from pybreeze.extend.prthinker_extend.prthinker_setting import ( - BACKENDS, DEFAULT_SETTING, INSTALL_EXTRAS, MODEL_ENVIRONMENT, RAG_MODES, SECRET_SETTINGS, + BACKENDS, DEFAULT_SETTING, INSTALL_EXTRAS, MODEL_ENVIRONMENT, RAG_MODES, SECRET_SETTINGS, SETTING_FILE_NAME, SETTING_ENVIRONMENT, environment_for, extra_arguments, install_target, load_setting, loggable, - review_file_arguments, review_pr_arguments, save_setting, split_arguments + review_file_arguments, review_pr_arguments, save_setting, setting_path, split_arguments ) @pytest.fixture() def data_dir(tmp_path, monkeypatch): """Keep every test's settings file inside its own temporary directory.""" - monkeypatch.setattr(prthinker_setting, "pybreeze_data_dir", lambda: tmp_path) + monkeypatch.setattr(prthinker_setting, "pybreeze_data_path", lambda: tmp_path) return tmp_path @@ -62,8 +62,47 @@ def test_a_broken_file_does_not_stop_the_feature(self, data_dir): def test_saving_reports_failure_instead_of_raising(self, data_dir, monkeypatch): def refuse(*_args, **_kwargs): raise OSError("read-only") - monkeypatch.setattr( - prthinker_setting.Path, "write_text", refuse, raising=False) + monkeypatch.setattr(prthinker_setting, "replace_text", refuse) + assert save_setting(DEFAULT_SETTING) is False + + def test_a_save_that_fails_part_way_keeps_the_stored_keys(self, data_dir, monkeypatch): + # Written in place, the file was emptied first: a full disk lost every + # key and token in it, and the next save stored the defaults + from pybreeze.utils.file_process import replace_file + + save_setting({**DEFAULT_SETTING, "openai_api_key": "kept-key"}) + + def disk_full(*_args, **_kwargs): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(replace_file.os, "replace", disk_full) + + assert save_setting({**DEFAULT_SETTING, "openai_api_key": "new-key"}) is False + assert load_setting()["openai_api_key"] == "kept-key" + assert [path.name for path in setting_path().parent.iterdir()] == [SETTING_FILE_NAME] + + def test_the_file_is_created_for_its_owner_only(self, data_dir, monkeypatch): + from pybreeze.utils.file_process import replace_file + + modes: list = [] + opened = replace_file.os.open + + def record(path, flags, mode=0o777): + modes.append(mode) + return opened(path, flags, mode) + + monkeypatch.setattr(replace_file.os, "open", record) + + assert save_setting(DEFAULT_SETTING) is True + assert modes == [0o600] + + def test_reading_creates_nothing_and_a_file_for_the_folder_reads_as_defaults(self, tmp_path, monkeypatch): + # Making the folder raised FileExistsError out of every prthinker entry + blocked = tmp_path / ".pybreeze" + blocked.write_text("not a folder", encoding="utf-8") + monkeypatch.setattr(prthinker_setting, "pybreeze_data_path", lambda: blocked) + + assert load_setting() == DEFAULT_SETTING assert save_setting(DEFAULT_SETTING) is False diff --git a/test/test_utils/test_prthinker_setting_dialog.py b/test/test_utils/test_prthinker_setting_dialog.py index b710682c..de1f590c 100644 --- a/test/test_utils/test_prthinker_setting_dialog.py +++ b/test/test_utils/test_prthinker_setting_dialog.py @@ -7,7 +7,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QLineEdit +from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QLineEdit, QMessageBox from pybreeze.extend.prthinker_extend import prthinker_setting from pybreeze.extend.prthinker_extend.prthinker_setting import ( @@ -30,7 +30,7 @@ def app(): @pytest.fixture() def data_dir(tmp_path, monkeypatch): """Point the settings file at a temporary directory, never the real home.""" - monkeypatch.setattr(prthinker_setting, "pybreeze_data_dir", lambda: tmp_path) + monkeypatch.setattr(prthinker_setting, "pybreeze_data_path", lambda: tmp_path) return tmp_path @@ -120,8 +120,12 @@ def test_a_failed_save_leaves_the_window_open(self, dialog, monkeypatch): # the user needs the form still in front of them to retry or copy from. monkeypatch.setattr( prthinker_setting_dialog, "save_setting", lambda _setting: False) + warned: list = [] + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda _p, _t, text: warned.append(text))) dialog.save() assert dialog.result() != QDialog.DialogCode.Accepted + # It said so: the reason used to go to the log only + assert warned and str(prthinker_setting.setting_path()) in warned[0] def test_a_field_left_untouched_keeps_its_stored_value(self, app, data_dir): save_setting({**DEFAULT_SETTING, "model_name": "kept"}) diff --git a/test/test_utils/test_replace_file.py b/test/test_utils/test_replace_file.py new file mode 100644 index 00000000..7b1a6db5 --- /dev/null +++ b/test/test_utils/test_replace_file.py @@ -0,0 +1,53 @@ +"""A file written through replace_text is either the old one or the new one, never half.""" +from __future__ import annotations + +import os +import sys + +import pytest + +from pybreeze.utils.file_process import replace_file +from pybreeze.utils.file_process.replace_file import replace_text + + +def test_the_text_is_written(tmp_path): + target = tmp_path / "settings.json" + + replace_text(target, "new text\n") + + assert target.read_text(encoding="utf-8") == "new text\n" + + +def test_a_failure_part_way_leaves_the_old_file_and_no_partial_one(tmp_path, monkeypatch): + target = tmp_path / "settings.json" + target.write_text("old text", encoding="utf-8") + + def disk_full(*_args, **_kwargs): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(replace_file.os, "replace", disk_full) + + with pytest.raises(OSError): + replace_text(target, "new text") + + assert target.read_text(encoding="utf-8") == "old text" + assert [path.name for path in tmp_path.iterdir()] == ["settings.json"] + + +def test_a_partial_file_left_by_an_earlier_failure_is_replaced(tmp_path): + target = tmp_path / "settings.json" + (tmp_path / "settings.json.saving").write_text("half of an old save", encoding="utf-8") + + replace_text(target, "new text") + + assert target.read_text(encoding="utf-8") == "new text" + assert not (tmp_path / "settings.json.saving").exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits") +def test_a_private_file_is_readable_by_its_owner_only(tmp_path): + target = tmp_path / "keys.json" + + replace_text(target, "{}", private=True) + + assert os.stat(target).st_mode & 0o777 == 0o600 From e1bd8c458534643736ec06da3bc3819d606c9992 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 16:49:46 +0800 Subject: [PATCH 129/312] Refactor the prompt and diagram saves onto the shared replace_text --- architecture_explore.md | 6 +++--- docs/updates/2026-09.md | 12 ++++++++++++ docs/updates/README.md | 3 ++- .../diagram_editor/diagram_editor_widget.py | 7 ++----- .../extend_ai_gui/prompt_edit_gui/prompt_file_io.py | 13 +++++-------- test/test_utils/test_diagram_editor_widget.py | 3 ++- test/test_utils/test_prompt_store.py | 4 ++-- 7 files changed, 28 insertions(+), 20 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 3365e7e3..cae11cda 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -236,11 +236,11 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,763 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,760 行,最大子系統) | 檔案 | 職責 | |---|---| -| `diagram_editor_widget.py` (624) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔先寫 `.saving` 再 `os.replace()` 換上去,存檔失敗不會毀掉上一份 | +| `diagram_editor_widget.py` (621) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔經 `replace_text()` 先寫 `.saving` 再換上去,存檔失敗不會毀掉上一份 | | `diagram_scene.py` (863) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點之上/之下。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | | `diagram_items.py` (923) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`add_image()` 把圖放進 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級 | | `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | @@ -267,7 +267,7 @@ extend_ai_gui/ │ ├── prompt_editor_widget.py 共用編輯器(QFileSystemWatcher 熱更新,watcher 以編輯器為 parent、關閉時停止監看;有未存編輯時,外部改動、「重新載入」和切換模板都先問;還沒有檔案的模板只用 placeholder 說明,存檔不會把說明寫進去) │ ├── cot_prompt_editor_widget.py 8 個 CoT 模板的檔案清單+語言鍵 │ ├── skills_prompt_editor_widget.py 2 個 Skill 模板的檔案清單+語言鍵 -│ ├── prompt_file_io.py 共用存檔(先寫 `.saving` 再 `os.replace()`;失敗跳警告對話框,不顯示路徑) +│ ├── prompt_file_io.py 共用存檔(經 `replace_text()`:先寫 `.saving` 再換上去;失敗跳警告對話框,不顯示路徑) │ ├── cot_code_review_prompt_templates/ 8 個模板常數+global_rule │ └── skills_prompt_templates/ 2 個模板常數 └── skills/skills_send_gui.py 單次 prompt 發送(RequestThread:回答走 `answered`,不再蓋掉 QThread 自己的 `finished`;關閉時交給 `thread_keeper.let_run_out()`;換模板前編輯區有修改就先問;prompt 還留著 `{code_diff}` 就不送出) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 5cdb4768..cda6eeef 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1790,3 +1790,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_replace_file.py` (new), `test_prthinker_setting.py`, `test_prthinker_setting_dialog.py`, `test_app_dirs.py` - `CLAUDE.md` (File I/O, Secrets), `architecture_explore.md` §12, §14, §17, §18 - **Open items**: the prompt editors and the diagram editor each keep their own copy of the same write-beside-and-replace steps; moving them onto `replace_text` is a refactor for its own stage. + +## U-20260923-102 · 2026-09-23 · Prompt and diagram saves use the shared replace_text (refactor) · #refactor #tools + +- **What**: the open item of U-20260923-101. `prompt_file_io.save_prompt_text` and `DiagramEditorWidget._write_json` each kept their own copy of "write `.saving`, `os.replace` it over the file, remove it on failure". No behaviour change. +- **Change**: both call `utils/file_process/replace_file.replace_text`; their own `os` imports and cleanup lines are gone. The error handling around them (a warning dialog, the log line, a diagram that cannot be serialised) is unchanged. +- **Tests**: the two tests that make the replace fail (`test_prompt_store.py`, `test_diagram_editor_widget.py`) now inject the failure where `os.replace` is called, in `replace_file`; their assertions are unchanged (the last good file kept, nothing left beside it). Prompt, diagram and replace tests 126 passed, 1 skipped (POSIX-only). +- **Result / numbers**: `ruff check` clean. `diagram_editor/` 3,760 lines. +- **Files**: + - `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py` + - `test/test_utils/test_prompt_store.py`, `test_diagram_editor_widget.py` + - `architecture_explore.md` §7, §8 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index ee5f794e..3d657788 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-102 | 2026-09-23 | Prompt and diagram saves use the shared replace_text (refactor) | #refactor #tools | [2026-09](2026-09.md) | | U-20260923-101 | 2026-09-23 | prthinker settings: a failed save keeps the stored keys, the file is its owner's only, reading creates nothing, and a failure is shown | #fix #security #prthinker | [2026-09](2026-09.md) | | U-20260923-100 | 2026-09-23 | URL Builder and Query JSON: deeply nested JSON reported, null parts empty, ports checked, an empty port accepted | #fix #tools | [2026-09](2026-09.md) | | U-20260923-99 | 2026-09-23 | Plugin runs match suffixes registered in any case or without the dot, and the Plugins menu has one run entry per plugin | #fix #plugin | [2026-09](2026-09.md) | @@ -182,4 +183,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 119 | +| [2026-09.md](2026-09.md) | 2026-09 | 120 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 0a503d31..58a84ffd 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import os from pathlib import Path from PySide6.QtCore import QMarginsF, QRectF, QSizeF, Qt @@ -31,6 +30,7 @@ from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene, ImageDownloadThread, ToolMode from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView from pybreeze.pybreeze_ui.thread_keeper import let_run_out +from pybreeze.utils.file_process.replace_file import replace_text from pybreeze.utils.logging.logger import pybreeze_logger @@ -472,14 +472,11 @@ def _write_json(self, path: Path) -> None: step, so a failure part-way through (a full disk, a file being read by something else) costs the new save, never the last good one. """ - beside = path.with_name(path.name + ".saving") try: data = self._scene.to_dict() - beside.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - os.replace(beside, path) + replace_text(path, json.dumps(data, indent=2, ensure_ascii=False)) except (OSError, TypeError, ValueError) as e: pybreeze_logger.error(f"Save diagram failed: {e}") - beside.unlink(missing_ok=True) QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), str(e)) # ------------------------------------------------------------------ diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py index 12d8111f..7636b272 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py @@ -6,11 +6,11 @@ """ from __future__ import annotations -import os from pathlib import Path from PySide6.QtWidgets import QMessageBox, QWidget +from pybreeze.utils.file_process.replace_file import replace_text from pybreeze.utils.logging.logger import pybreeze_logger @@ -25,15 +25,12 @@ def save_prompt_text(parent: QWidget, path: str, content: str, error_title: str) # only looks at the built-in prompts leaves nothing behind. target = Path(path) target.parent.mkdir(parents=True, exist_ok=True) - # Written beside the file and moved into place in one step: opening the - # file itself for writing truncates it first, so a failure part-way left - # the prompt empty, and the review silently fell back to the built-in. - beside = target.with_name(target.name + ".saving") - beside.write_text(content, encoding="utf-8") - os.replace(beside, target) + # Replaced in one step: opening the file itself for writing truncates it + # first, so a failure part-way left the prompt empty, and the review + # silently fell back to the built-in. + replace_text(target, content) return True except OSError as error: pybreeze_logger.error("Prompt file write failed for %s: %r", path, error) - Path(path).with_name(Path(path).name + ".saving").unlink(missing_ok=True) QMessageBox.warning(parent, error_title, error.strerror or type(error).__name__) return False diff --git a/test/test_utils/test_diagram_editor_widget.py b/test/test_utils/test_diagram_editor_widget.py index 61282e9f..f44957c0 100644 --- a/test/test_utils/test_diagram_editor_widget.py +++ b/test/test_utils/test_diagram_editor_widget.py @@ -38,6 +38,7 @@ def editor(app): class TestSaving: def test_a_save_that_fails_leaves_the_last_good_file(self, editor, tmp_path, monkeypatch): from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + from pybreeze.utils.file_process import replace_file target = tmp_path / "keep.diagram.json" target.write_text(json.dumps(_A_DIAGRAM), encoding="utf-8") @@ -49,7 +50,7 @@ def test_a_save_that_fails_leaves_the_last_good_file(self, editor, tmp_path, mon def refuse(*_args, **_kwargs): raise OSError("no room on the disk") - monkeypatch.setattr(diagram_editor_widget.os, "replace", refuse) + monkeypatch.setattr(replace_file.os, "replace", refuse) editor._write_json(target) assert json.loads(target.read_text(encoding="utf-8")) == _A_DIAGRAM diff --git a/test/test_utils/test_prompt_store.py b/test/test_utils/test_prompt_store.py index a23600f2..0a9b313d 100644 --- a/test/test_utils/test_prompt_store.py +++ b/test/test_utils/test_prompt_store.py @@ -321,7 +321,7 @@ def test_with_nothing_unsaved_it_reloads_without_asking(self, prompts, monkeypat class TestSavingAPrompt: def test_a_save_that_fails_leaves_the_last_good_file(self, prompts, monkeypatch): - from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui import prompt_file_io + from pybreeze.utils.file_process import replace_file write(prompts, "linter.md", "the last good prompt") editor = _cot_editor(monkeypatch) @@ -331,7 +331,7 @@ def test_a_save_that_fails_leaves_the_last_good_file(self, prompts, monkeypatch) def refuse(*_args): raise OSError(28, "No space left on device") - monkeypatch.setattr(prompt_file_io.os, "replace", refuse) + monkeypatch.setattr(replace_file.os, "replace", refuse) editor.save_file() assert (prompts / "linter.md").read_text(encoding="utf-8") == "the last good prompt" From be8609a224014e795fd54d235263702be2e5ec71 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 16:52:51 +0800 Subject: [PATCH 130/312] Format and minify JSON without changing its numbers or characters, and report a repeated key --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 22 +++++ docs/updates/README.md | 3 +- pybreeze/utils/exception/exception_tags.py | 1 + pybreeze/utils/json_format/json_process.py | 104 ++++++++++++++++++--- test/test_utils/test_json_process.py | 57 +++++++++-- 6 files changed, 168 insertions(+), 23 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index cae11cda..85a5e51c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -347,7 +347,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `curl_import/` | `curl_parser.py`(579) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | -| `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | +| `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | | `file_process/replace_file.py` | `replace_text(path, text, private=False)`:寫到旁邊的 `<名稱>.saving` 再 `os.replace` 過去,失敗時原檔不動、半成品刪掉;`private` 以 `0600` 建立(存金鑰的檔案用) | | `manager/package_manager/` | `PackageManager`(單例 `package_manager`)持有 `syntax_check_list` | @@ -439,7 +439,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1619 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1633 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index cda6eeef..d1db2b80 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1802,3 +1802,25 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_prompt_store.py`, `test_diagram_editor_widget.py` - `architecture_explore.md` §7, §8 - **Open items**: none. + +## U-20260923-103 · 2026-09-23 · JSON Format and Minify keep numbers and characters as written, and report a repeated key · #fix #tools + +- **What**: the JSON Format tool (`utils/json_format/json_process.py`, `reformat_json` and `minify_json`) changed what the JSON said. Found in the review sweep of `utils/`; each case reproduced. + - Every number went through `float`/`int`. `1e400` came back as `Infinity`, which is not JSON. `12345678901234567890123.0` came back as `1.2345678901234568e+22` and `0.10000000000000000555` as `0.1`. An integer past Python's 4300-digit limit was refused as invalid. + - A key given twice in one object kept only its last value, without a word. + - Every non-ASCII character came back as a `\u` escape (`"中文"` as `"\u4e2d\u6587"`). + - `NaN` and `Infinity`, which are not JSON, were accepted. +- **Fix**: + - A number is parsed to a placeholder carrying a random marker (`_Numbers`), and after serialising one regex pass puts each number's own text back (200,000 numbers in 0.24 s). A string in the input cannot be mistaken for a placeholder. + - `object_pairs_hook` refuses a repeated key with `json_duplicate_key_error` naming it. `parse_constant` refuses `NaN` and `Infinity`. + - `ensure_ascii=False`. Format still indents by four and sorts keys; Minify keeps key order. + - `reformat_json`'s error adds the reason after `cant_reformat_json_error`. +- **Tests**: `test_json_process.py`: + - +14: seven number spellings kept by both; characters kept; a string shaped like a placeholder stays a string; a repeated key reported by both, while the same key in two objects is fine; `NaN`, `Infinity`, `-Infinity` refused. + - The 5000-digit integer test now expects the number back digit for digit instead of an error. +- **Result / numbers**: JSON and fuzz tests 53 passed; 1633 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/json_format/json_process.py`, `pybreeze/utils/exception/exception_tags.py` + - `test/test_utils/test_json_process.py` + - `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 3d657788..d3ca7d07 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-103 | 2026-09-23 | JSON Format and Minify keep numbers and characters as written, and report a repeated key | #fix #tools | [2026-09](2026-09.md) | | U-20260923-102 | 2026-09-23 | Prompt and diagram saves use the shared replace_text (refactor) | #refactor #tools | [2026-09](2026-09.md) | | U-20260923-101 | 2026-09-23 | prthinker settings: a failed save keeps the stored keys, the file is its owner's only, reading creates nothing, and a failure is shown | #fix #security #prthinker | [2026-09](2026-09.md) | | U-20260923-100 | 2026-09-23 | URL Builder and Query JSON: deeply nested JSON reported, null parts empty, ports checked, an empty port accepted | #fix #tools | [2026-09](2026-09.md) | @@ -183,4 +184,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 120 | +| [2026-09.md](2026-09.md) | 2026-09 | 121 | diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index 898989bd..e37311b2 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -32,6 +32,7 @@ # json exception cant_reformat_json_error: str = "can't reformat JSON: is the type correct?" wrong_json_data_error: str = "can't parse JSON" +json_duplicate_key_error: str = "the JSON gives the key {key!r} twice in one object" # XML cant_read_xml_error: str = "can't read XML" diff --git a/pybreeze/utils/json_format/json_process.py b/pybreeze/utils/json_format/json_process.py index be912aaf..97c16f7a 100644 --- a/pybreeze/utils/json_format/json_process.py +++ b/pybreeze/utils/json_format/json_process.py @@ -1,38 +1,110 @@ +"""Format and minify JSON without changing what it says. + +Round-tripped through ``float``, a number came back different (``1e400`` as +``Infinity``, which is not JSON; ``12345678901234567890123.0`` as +``1.2345678901234568e+22``), a key repeated in one object kept only its last +value, and every non-ASCII character came back as a ``\\u`` escape. +""" from __future__ import annotations +import re +import secrets 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 json_duplicate_key_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: +class _DuplicateKey(ValueError): + """A key given twice in one object.""" + + +class _Numbers: + """Keeps each number's own text while the JSON around it is re-laid out. + + A number is parsed to a placeholder string and put back, as written, once + the rest is serialised. The placeholder carries a random marker, so no + string in the input can be mistaken for one. + """ + + def __init__(self) -> None: + self._marker = secrets.token_hex(8) + self._texts: list[str] = [] + + def hold(self, text: str) -> str: + """The placeholder for the number written as *text*.""" + self._texts.append(text) + return f"\x00{self._marker}:{len(self._texts) - 1}\x00" + + def restore(self, serialised: str) -> str: + """*serialised* with every placeholder replaced by its number's text.""" + # How dumps writes a placeholder: quoted, the NUL escaped + placeholder = re.compile(rf'"\\u0000{self._marker}:(\d+)\\u0000"') + return placeholder.sub(lambda match: self._texts[int(match.group(1))], serialised) + + +def _refuse_constant(name: str) -> None: + """``NaN`` and ``Infinity`` are Python's extensions, not JSON.""" + raise ValueError(f"{name} is not JSON") + + +def _unique_pairs(pairs: list[tuple[str, object]]) -> dict: + """An object's members as a dict, refusing a key given twice.""" + members: dict = {} + for key, value in pairs: + if key in members: + raise _DuplicateKey(key) + members[key] = value + return members + + +def _load(json_string: str, numbers: _Numbers) -> object: + """Parse *json_string*, numbers held as their text; raise ``ITEJsonException`` when it is not JSON.""" try: - return dumps(loads(json_string), indent=4, sort_keys=True, **kwargs) + return loads( + json_string, parse_float=numbers.hold, parse_int=numbers.hold, + parse_constant=_refuse_constant, object_pairs_hook=_unique_pairs) + except _DuplicateKey as error: + message = json_duplicate_key_error.format(key=error.args[0]) + pybreeze_logger.error(message) + raise ITEJsonException(message) from error except (ValueError, RecursionError) as error: - # Wrap in the project exception so reformat_json's caller sees a single, - # documented ITEJsonException type rather than a raw JSONDecodeError -- - # a ValueError, as is the one for an integer longer than Python's - # 4300-digit conversion limit -- or a RecursionError, which the json - # module raises on input nested deeper than the interpreter's - # recursion limit. + # A JSONDecodeError is a ValueError. RecursionError: input nested + # deeper than the interpreter's recursion limit. pybreeze_logger.error(wrong_json_data_error) raise ITEJsonException(wrong_json_data_error) from error - except TypeError: + + +def _process_json(json_string: str, **kwargs) -> str: + if not isinstance(json_string, str): + # Not text to parse: shown as JSON as it is try: return dumps(json_string, indent=4, sort_keys=True, **kwargs) except TypeError as err: raise ITEJsonException(wrong_json_data_error) from err + numbers = _Numbers() + value = _load(json_string, numbers) + kwargs.setdefault("ensure_ascii", False) + try: + return numbers.restore(dumps(value, indent=4, sort_keys=True, **kwargs)) + except (TypeError, ValueError, RecursionError) as error: + raise ITEJsonException(wrong_json_data_error) from error def reformat_json(json_string: str, **kwargs) -> str: + """Return *json_string* indented by four, keys sorted, numbers and characters as written. + + :raises ITEJsonException: when it is not JSON, repeats a key in one object, + or uses ``NaN`` or ``Infinity`` + """ try: return _process_json(json_string, **kwargs) except ITEJsonException as err: - raise ITEJsonException(cant_reformat_json_error) from err + raise ITEJsonException(f"{cant_reformat_json_error} ({err})") from err # Compact separators for minified JSON: no spaces after ',' or ':'. @@ -42,12 +114,16 @@ def reformat_json(json_string: str, **kwargs) -> str: def minify_json(json_string: str) -> str: """Return *json_string* re-serialised with no insignificant whitespace. + Numbers and characters stay as written, and key order is kept. + :param json_string: the JSON text to compact :return: the minified JSON on a single line - :raises ITEJsonException: when the input is not valid JSON + :raises ITEJsonException: when the input is not valid JSON, repeats a key + in one object, or uses ``NaN`` or ``Infinity`` """ + numbers = _Numbers() + value = _load(json_string, numbers) try: - return dumps(loads(json_string), separators=_MINIFY_SEPARATORS) - except (ValueError, RecursionError) as error: - pybreeze_logger.error(wrong_json_data_error) + return numbers.restore(dumps(value, separators=_MINIFY_SEPARATORS, ensure_ascii=False)) + except RecursionError as error: raise ITEJsonException(wrong_json_data_error) from error diff --git a/test/test_utils/test_json_process.py b/test/test_utils/test_json_process.py index bbfa9a80..3e2ffdee 100644 --- a/test/test_utils/test_json_process.py +++ b/test/test_utils/test_json_process.py @@ -80,11 +80,56 @@ def test_minify_reports_it_as_bad_json(self): minify_json(self._DEEP) -def test_an_integer_past_the_conversion_limit_is_a_json_error(): +def test_an_integer_past_the_conversion_limit_comes_back_as_written(): + # Converted to int, it raised ValueError past Python's 4300-digit limit; + # it is valid JSON, and the number is now kept as its own text from pybreeze.utils.json_format.json_process import minify_json - huge = '{"n": ' + "9" * 5000 + "}" - with pytest.raises(ITEJsonException): - reformat_json(huge) - with pytest.raises(ITEJsonException): - minify_json(huge) + digits = "9" * 5000 + assert minify_json('{"n": ' + digits + "}") == '{"n":' + digits + "}" + assert digits in reformat_json('{"n": ' + digits + "}") + + +class TestTheDataIsNotChanged: + """Format and Minify re-lay the JSON out; what it says stays the same.""" + + @pytest.mark.parametrize("number", [ + "1e400", "12345678901234567890123.0", "0.10000000000000000555", "1.50", "2E3", "-0.0", "1e-400"]) + def test_a_number_is_written_as_it_was(self, number): + from pybreeze.utils.json_format.json_process import minify_json + + # float() made 1e400 Infinity, which is not JSON, and rounded the rest + assert minify_json(f"[{number}]") == f"[{number}]" + assert f" {number}\n" in reformat_json(f"[{number}]") + + def test_characters_are_written_as_they_were(self): + from pybreeze.utils.json_format.json_process import minify_json + + assert minify_json('{"n": "中文"}') == '{"n":"中文"}' + assert '"中文"' in reformat_json('{"n": "中文"}') + + def test_a_string_that_looks_like_a_placeholder_stays_a_string(self): + from pybreeze.utils.json_format.json_process import minify_json + + assert minify_json('["\\u0000abc:0\\u0000", 5]') == '["\\u0000abc:0\\u0000",5]' + + def test_a_key_given_twice_is_reported_not_dropped(self): + from pybreeze.utils.json_format.json_process import minify_json + + with pytest.raises(ITEJsonException) as raised: + minify_json('{"a": 1, "a": 2}') + assert "'a'" in str(raised.value) + with pytest.raises(ITEJsonException): + reformat_json('{"a": 1, "a": 2}') + + def test_the_same_key_in_two_objects_is_fine(self): + from pybreeze.utils.json_format.json_process import minify_json + + assert minify_json('[{"a": 1}, {"a": 2}]') == '[{"a":1},{"a":2}]' + + @pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"]) + def test_nan_and_infinity_are_not_json(self, constant): + from pybreeze.utils.json_format.json_process import minify_json + + with pytest.raises(ITEJsonException): + minify_json(f"[{constant}]") From 001e260fb79070618d615a1f6c4599e53465fe2d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 16:55:09 +0800 Subject: [PATCH 131/312] Read quoted max-age, folded header lines, header names in any case and HTTP/2 status in the header tools --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 20 ++++++++++ docs/updates/README.md | 3 +- .../utils/header_tools/header_analyzer.py | 12 +++++- .../response_inspector/response_analyzer.py | 38 +++++++++++++++++-- test/test_utils/test_header_analyzer.py | 21 ++++++++++ test/test_utils/test_response_analyzer.py | 22 +++++++++++ 7 files changed, 110 insertions(+), 8 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 85a5e51c..f929ae2e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -439,7 +439,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1633 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1639 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index d1db2b80..694c3c69 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1824,3 +1824,23 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_json_process.py` - `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-104 · 2026-09-23 · Header Analyzer and Response Inspector read quoted max-age, folded lines, header names in any case and HTTP/2 :status · #fix #tools + +- **What**: header parsing in `utils/header_tools/header_analyzer.py` and `utils/response_inspector/response_analyzer.py` departed from HTTP. Found in the review sweep of `utils/`; each case reproduced. + - `Strict-Transport-Security: max-age="31536000"` (RFC 6797 allows the quotes) read as max-age 0 and was reported as `hsts_weak_max_age`. + - A folded continuation line (obs-fold, RFC 9112 §5.2) was dropped by the analyzer, so a CSP directive written on it (`script-src 'unsafe-inline'`) went unchecked. In the inspector it ended the headers: the value was cut, and the rest, body included, stopped reading as JSON. + - The inspector grouped repeated headers by exact spelling: `Set-Cookie` and `set-cookie` were two entries instead of one list. + - An HTTP/2 `:status: 200` line, as browsers' developer tools copy it, ended the headers, and everything after it was read as the body. +- **Fix**: + - `_MAX_AGE_RE` accepts an optional quote around the digits. + - A line starting with a space or a tab (`FOLDED_LINE_START`) joins the value above with one space, in both parsers. + - The inspector keeps a header under the spelling first seen (`_same_name`). + - Pseudo-headers are skipped, and `:status` gives the status code when there is no status line. +- **Tests**: `test_header_analyzer.py` +3 (quoted max-age; a folded CSP line checked and joined; a tab folds) and `test_response_analyzer.py` +3 (a folded line with the body still JSON; names in two cases in one list; `:status` giving the status with the headers and body intact). +- **Result / numbers**: header and response tests 68 passed; 1639 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/header_tools/header_analyzer.py`, `pybreeze/utils/response_inspector/response_analyzer.py` + - `test/test_utils/test_header_analyzer.py`, `test_response_analyzer.py` + - `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index d3ca7d07..d3850b01 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-104 | 2026-09-23 | Header Analyzer and Response Inspector read quoted max-age, folded lines, header names in any case and HTTP/2 :status | #fix #tools | [2026-09](2026-09.md) | | U-20260923-103 | 2026-09-23 | JSON Format and Minify keep numbers and characters as written, and report a repeated key | #fix #tools | [2026-09](2026-09.md) | | U-20260923-102 | 2026-09-23 | Prompt and diagram saves use the shared replace_text (refactor) | #refactor #tools | [2026-09](2026-09.md) | | U-20260923-101 | 2026-09-23 | prthinker settings: a failed save keeps the stored keys, the file is its owner's only, reading creates nothing, and a failure is shown | #fix #security #prthinker | [2026-09](2026-09.md) | @@ -184,4 +185,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 121 | +| [2026-09.md](2026-09.md) | 2026-09 | 122 | diff --git a/pybreeze/utils/header_tools/header_analyzer.py b/pybreeze/utils/header_tools/header_analyzer.py index cb1d8f22..e49f6112 100644 --- a/pybreeze/utils/header_tools/header_analyzer.py +++ b/pybreeze/utils/header_tools/header_analyzer.py @@ -25,8 +25,10 @@ HEADER_LINE_RE = re.compile(r"^([A-Za-z0-9!#$%&'*+.^_`|~-]+):[ \t]?(.*)$") # Matches a leading response status line, e.g. "HTTP/1.1 200 OK" _STATUS_LINE_RE = re.compile(r"^\s*HTTP/\d(?:\.\d)?\s+\d{3}\b") -# Matches the max-age directive of an HSTS policy -_MAX_AGE_RE = re.compile(r"max-age\s*=\s*(\d+)", re.IGNORECASE) +# Matches the max-age directive of an HSTS policy, whose value RFC 6797 lets be quoted +_MAX_AGE_RE = re.compile(r'max-age\s*=\s*"?(\d+)"?', re.IGNORECASE) +# The start of a line that continues the header above it (obs-fold, RFC 9112 5.2) +FOLDED_LINE_START = (" ", "\t") # Header names referenced from more than one table below _HSTS_HEADER = "strict-transport-security" @@ -136,6 +138,12 @@ def parse_headers(text: str) -> list[HeaderField]: if fields: break # the blank line between the headers and the body continue + if line.startswith(FOLDED_LINE_START) and fields: + # A folded continuation: part of the value above, joined by one + # space. Dropped, a CSP directive written on it went unchecked. + last = fields[-1] + fields[-1] = HeaderField(name=last.name, value=f"{last.value} {line.strip()}".strip()) + continue match = HEADER_LINE_RE.match(line) if match is not None: fields.append(HeaderField(name=match.group(1), value=match.group(2).strip())) diff --git a/pybreeze/utils/response_inspector/response_analyzer.py b/pybreeze/utils/response_inspector/response_analyzer.py index ea52ea7a..1da43875 100644 --- a/pybreeze/utils/response_inspector/response_analyzer.py +++ b/pybreeze/utils/response_inspector/response_analyzer.py @@ -15,13 +15,15 @@ from dataclasses import dataclass, field from pybreeze.utils.curl_import.curl_parser import add_repeated_value -from pybreeze.utils.header_tools.header_analyzer import HEADER_LINE_RE +from pybreeze.utils.header_tools.header_analyzer import FOLDED_LINE_START, HEADER_LINE_RE from pybreeze.utils.http_reference.status_codes import StatusInfo, lookup from pybreeze.utils.jwt_tools.jwt_decoder import DecodedJwt, decode_jwt, find_tokens from pybreeze.utils.exception.exceptions import JwtDecodeException # Matches the response status line, e.g. "HTTP/1.1 200 OK" _STATUS_LINE_RE = re.compile(r"^HTTP/\d(?:\.\d)?\s+(\d{3})\b") +# An HTTP/2 or HTTP/3 pseudo-header as browsers' tools copy it, e.g. ":status: 200" +_PSEUDO_HEADER_RE = re.compile(r"^:([a-z]+):[ \t]?(.*)$") @dataclass @@ -76,21 +78,49 @@ def _parse_head_and_body(text: str) -> tuple[int | None, dict[str, str], str]: index = 1 headers: dict[str, str | list[str]] = {} + last_name: str | None = None while index < len(lines): line = lines[index] if line.strip() == "": index += 1 break - match = HEADER_LINE_RE.match(line) - if match is None: + if line.startswith(FOLDED_LINE_START) and last_name is not None: + # A folded continuation of the header above; it ended the headers + # and began the body, which then no longer read as JSON + _continue_value(headers, last_name, line.strip()) + elif (pseudo := _PSEUDO_HEADER_RE.match(line)) is not None: + # It ended the headers, and the rest was read as the body + if pseudo.group(1) == "status" and status_code is None and pseudo.group(2).strip().isdigit(): + status_code = int(pseudo.group(2).strip()) + elif (match := HEADER_LINE_RE.match(line)) is not None: + last_name = _same_name(headers, match.group(1)) + add_repeated_value(headers, last_name, match.group(2).strip()) + else: break - add_repeated_value(headers, match.group(1), match.group(2).strip()) index += 1 body = "\n".join(lines[index:]).strip() return status_code, headers, body +def _same_name(headers: dict[str, str | list[str]], name: str) -> str: + """The spelling *name* is already kept under, header names being case-insensitive. + + ``Set-Cookie`` and ``set-cookie`` were two entries instead of one list. + """ + lowered = name.lower() + return next((known for known in headers if known.lower() == lowered), name) + + +def _continue_value(headers: dict[str, str | list[str]], name: str, more: str) -> None: + """Join *more* to the last value kept under *name*, with one space.""" + value = headers[name] + if isinstance(value, list): + value[-1] = f"{value[-1]} {more}".strip() + else: + headers[name] = f"{value} {more}".strip() + + def _pretty_json(body: str) -> str | None: """Return *body* pretty-printed if it is JSON, else ``None`` (key order kept).""" try: diff --git a/test/test_utils/test_header_analyzer.py b/test/test_utils/test_header_analyzer.py index e78e2839..5ea4fff2 100644 --- a/test/test_utils/test_header_analyzer.py +++ b/test/test_utils/test_header_analyzer.py @@ -193,3 +193,24 @@ def test_text_without_headers_yields_no_fields(self): def test_a_max_age_too_long_for_int_is_long_enough(): assert "hsts_weak_max_age" not in _codes( "Strict-Transport-Security: max-age=" + "9" * 5000) + + +class TestWhatHeadersActuallySay: + def test_a_quoted_max_age_is_read(self): + # RFC 6797 lets the value be quoted; it read as 0 and was called weak + text = 'Strict-Transport-Security: max-age="31536000"; includeSubDomains' + + assert "hsts_weak_max_age" not in _codes(text) + + def test_a_folded_line_is_part_of_the_value_above(self): + # Dropped, the directive written on it was never checked + text = "Content-Security-Policy: default-src 'self';\n script-src 'unsafe-inline'" + + assert "csp_unsafe_directive" in _codes(text) + (field,) = parse_headers(text) + assert field.value == "default-src 'self'; script-src 'unsafe-inline'" + + def test_a_tab_folds_too(self): + (field,) = parse_headers("X-Long: a\n\tb") + + assert field.value == "a b" diff --git a/test/test_utils/test_response_analyzer.py b/test/test_utils/test_response_analyzer.py index 406936f3..2db92043 100644 --- a/test/test_utils/test_response_analyzer.py +++ b/test/test_utils/test_response_analyzer.py @@ -133,3 +133,25 @@ def test_a_header_sent_twice_keeps_both_values(): "HTTP/1.1 200 OK\nSet-Cookie: a=1\nSet-Cookie: b=2\nContent-Type: text/plain\n\nok") assert analysis.headers == {"Set-Cookie": ["a=1", "b=2"], "Content-Type": "text/plain"} + + +class TestHeadersAsHttpDefinesThem: + def test_a_folded_line_continues_the_header_and_the_body_is_still_json(self): + # It ended the headers: the value was cut and the body no longer JSON + analysis = analyze_response('HTTP/1.1 200 OK\nX-Long: a\n b\n\n{"a": 1}') + + assert analysis.headers["X-Long"] == "a b" + assert analysis.is_json_body + + def test_names_differing_only_in_case_are_one_header(self): + analysis = analyze_response("HTTP/1.1 200 OK\nSet-Cookie: a=1\nset-cookie: b=2\n\n") + + assert analysis.headers == {"Set-Cookie": ["a=1", "b=2"]} + + def test_an_http2_status_pseudo_header_gives_the_status_and_keeps_the_headers(self): + # It ended the headers, and everything after it was read as the body + analysis = analyze_response(':status: 404\ncontent-type: application/json\n\n{"a": 1}') + + assert analysis.status is not None and analysis.status.code == 404 + assert analysis.headers == {"content-type": "application/json"} + assert analysis.is_json_body From addfb4bd241f12d5bf26eab584505f5ded1ace20 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 17:06:37 +0800 Subject: [PATCH 132/312] Move docked editors and the tab's watch and language state with a renamed file --- architecture.md | 5 +- architecture_explore.md | 4 +- docs/updates/2026-09.md | 21 +++++++ docs/updates/README.md | 3 +- .../editor_main/file_tree_context_menu.py | 60 ++++++++++++++++++- .../test_utils/test_file_tree_context_menu.py | 33 ++++++++-- test/test_utils/test_file_tree_rename.py | 60 +++++++++++++++++++ test/test_utils/test_jeditor_contract.py | 20 +++++++ 8 files changed, 196 insertions(+), 10 deletions(-) diff --git a/architecture.md b/architecture.md index 501de2dc..d558591c 100644 --- a/architecture.md +++ b/architecture.md @@ -136,6 +136,7 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() | `PluginBrowserWidget` | `pyside_ui.main_ui.plugin_browser.plugin_browser_widget` | `menu/plugin_menu/build_plugin_menu.py` | | `DestroyDock` | `pyside_ui.main_ui.dock.destroy_dock` | `menu/tools/tools_menu.py`, `editor_main/main_ui.py` | | `init_new_auto_save_thread`, `auto_save_manager_dict`, `file_is_open_manager_dict` | `pyside_ui.code.auto_save.auto_save_manager` | `editor_main/file_tree_context_menu.py` (a rename restarts the tab's auto-save on the new path) | + | `FullEditorWidget` | `pyside_ui.main_ui.editor.editor_widget_dock` | `editor_main/file_tree_context_menu.py` (a rename re-points a docked editor's `current_file`) | | `check_and_choose_venv` | `utils.venv_check.check_venv` | `extend/process_executor/python_task_process_manager.py` | | `choose_file_get_save_file_path` | `pyside_ui.dialog.file_dialog.save_file_dialog` | `menu/plugin_menu/build_run_with_menu.py` | | `write_file_with_encoding` | `utils.file.save.save_file` | `menu/plugin_menu/build_run_with_menu.py` | @@ -143,7 +144,9 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() | `actually_color_dict` | `pyside_ui.main_ui.save_settings.user_color_setting_file` | `show_code_window/code_window.py`, `automation_menu/auto_control_menu/build_autocontrol_menu.py` | PyBreeze also relies on `EditorWidget`'s `current_file`, `code_edit`, `file_encoding`, - `line_ending`, `mark_ignore_next_file_change()` and `mark_saved()`, and on `language_wrapper`'s + `line_ending`, `mark_ignore_next_file_change()` and `mark_saved()`, on its private `_file_watcher` + and `_ignore_next_change` and on `CodeEditor`'s `reset_highlighter()`, `load_git_baseline()` and + `start_language_server()` (a rename moves the tab the way `open_an_file` does), and on `language_wrapper`'s `choose_language_dict` serving English and Traditional Chinese from the exported dict objects themselves. Having je_editor export the names in the table is workspace X-17. It merges its strings by mutating JEditor's `english_word_dict` and `traditional_chinese_word_dict` diff --git a/architecture_explore.md b/architecture_explore.md index f929ae2e..561cb787 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -73,7 +73,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE - `add_menu_to_menubar()` — 建構全部選單(見 §5) - `syntax_extend_package()` — 註冊 `.json` / `.yml` 自動化關鍵字高亮 - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁(`_add_extend_tabs()`:每一個分頁各自建,建不起來的只記 log,不會讓整個 IDE 起不來) - - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開 + - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器;Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) 3. `apply_stylesheet()` 套 qt_material 主題(預設 `dark_amber.xml`) 4. `showMaximized()` → `startup_setting()` → `app.exec()` @@ -439,7 +439,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1639 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1643 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 694c3c69..cad9b6d3 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1844,3 +1844,24 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_header_analyzer.py`, `test_response_analyzer.py` - `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-105 · 2026-09-23 · A file-tree rename moves a docked editor and the tab's watch, highlighter, git baseline and language server with the file · #fix #editor + +- **What**: two things a rename in the file tree (`editor_main/file_tree_context_menu.py`, `_action_rename`) left on the old name. Found in the review sweep of `editor_main/`. + - A file open in JEditor's Dock Editor (`FullEditorWidget`). It has no auto-save: it writes its buffer when it closes, and only if its file still exists. After a rename it wrote nothing, and every edit made in it was lost (reproduced). + - The tab's watch for changes made outside the IDE (`EditorWidget._file_watcher`). A change to the renamed file raised no question, and the tab's auto-save wrote over it. The highlighter, git baseline and language server also stayed those of the old name, so renaming `notes.txt` to `notes.py` kept plain-text highlighting. A reproducing test showed the watch still on `a.txt` after renaming it to `b.py`. +- **Fix**: + - `_dock_editors_under` finds the docked editors on the file or under the folder (`findChildren(FullEditorWidget)`), and a successful rename re-points their `current_file`. + - `_stop_auto_save` also stops the watch (`_unwatch`), before the file moves: once it is gone, Windows keeps the old name in the watcher. + - `_start_auto_save` watches the new path, clears a leftover `_ignore_next_change`, and reloads the highlighter, git baseline and language server, as JEditor's `open_an_file` does. +- **Tests**: + - `test_file_tree_rename.py` +1, a started IDE: the tab watches only the new name and is not ignoring changes; a docked editor on the file follows it and saves its edits there on close; one on another file is left alone. + - `test_file_tree_context_menu.py`: the tab-follows test also checks the watch and the three reloads. Its stand-in editor gains a watcher and a code edit recording reloads, and the stand-in window is a `QWidget`. + - A `FullEditorWidget` built in the pytest process makes the interpreter exit with code 127 at shutdown (its editor's workers; the IDE ends with `os._exit`), so docked editors are tested only in a started IDE. + - `test_jeditor_contract.py` +3: `FullEditorWidget` listed, and the private members the rename relies on pinned. +- **Result / numbers**: file tree, rename and contract tests 78 passed; 1643 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py` + - `test/test_utils/test_file_tree_rename.py`, `test_file_tree_context_menu.py`, `test_jeditor_contract.py` + - `architecture.md` §6 (the JEditor internals table and prose), `architecture_explore.md` §3, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index d3850b01..a576758b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-105 | 2026-09-23 | A file-tree rename moves a docked editor and the tab's watch, highlighter, git baseline and language server with the file | #fix #editor | [2026-09](2026-09.md) | | U-20260923-104 | 2026-09-23 | Header Analyzer and Response Inspector read quoted max-age, folded lines, header names in any case and HTTP/2 :status | #fix #tools | [2026-09](2026-09.md) | | U-20260923-103 | 2026-09-23 | JSON Format and Minify keep numbers and characters as written, and report a repeated key | #fix #tools | [2026-09](2026-09.md) | | U-20260923-102 | 2026-09-23 | Prompt and diagram saves use the shared replace_text (refactor) | #refactor #tools | [2026-09](2026-09.md) | @@ -185,4 +186,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 122 | +| [2026-09.md](2026-09.md) | 2026-09 | 123 | diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index 98645eea..d0164488 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -17,6 +17,7 @@ from je_editor.pyside_ui.code.auto_save.auto_save_manager import ( auto_save_manager_dict, file_is_open_manager_dict, init_new_auto_save_thread, ) +from je_editor.pyside_ui.main_ui.editor.editor_widget_dock import FullEditorWidget from pybreeze.utils.logging.logger import pybreeze_logger @@ -193,6 +194,11 @@ def _action_new_folder(tree_view: QTreeView, path: Path | None) -> None: _perform_file_op(tree_view, lambda: new_path.mkdir(parents=True)) +def _is_at_or_under(file_path: Path, path: Path) -> bool: + """Whether *file_path* is *path* or lies under it.""" + return file_path == path or path in file_path.parents + + def _editors_under(main_window, path: Path) -> list[tuple[EditorWidget, Path]]: """The editor tabs open on *path*, or on any file under it, with their files.""" found = [] @@ -201,13 +207,52 @@ def _editors_under(main_window, path: Path) -> list[tuple[EditorWidget, Path]]: if not isinstance(widget, EditorWidget) or widget.current_file is None: continue file_path = Path(widget.current_file) - if file_path == path or path in file_path.parents: + if _is_at_or_under(file_path, path): found.append((widget, file_path)) return found +def _dock_editors_under(main_window, path: Path) -> list[tuple[FullEditorWidget, Path]]: + """The docked editors (JEditor's Dock Editor) open on *path* or under it, with their files. + + A docked editor has no auto-save: it writes its buffer back when it closes, + and only if its file still exists. Left on the old name after a rename, it + wrote nothing, and every edit made in it was lost. + """ + found = [] + for editor in main_window.findChildren(FullEditorWidget): + if editor.current_file and _is_at_or_under(Path(editor.current_file), path): + found.append((editor, Path(editor.current_file))) + return found + + +def _unwatch(editor: EditorWidget) -> None: + """Stop the tab watching its file for changes made outside the IDE. + + Done before the file moves: once it is gone, Windows does not let go of + the old name. + """ + watcher = editor._file_watcher # noqa: SLF001 — JEditor's own watcher, handled as open_an_file handles it (test_jeditor_contract.py) + watched = watcher.files() + if watched: + watcher.removePaths(watched) + + +def _watch(editor: EditorWidget, file_path: Path) -> None: + """Watch *file_path* for changes made outside the IDE, as JEditor's ``open_an_file`` does. + + Left on the old name, a change made to the renamed file outside the IDE + raised no question, and the tab's auto-save wrote over it. + """ + _unwatch(editor) + editor._file_watcher.addPath(str(file_path)) # noqa: SLF001 — see _unwatch + # A save to the old name may have left it set, and it would swallow the + # first real change to the new one + editor._ignore_next_change = False # noqa: SLF001 — see _unwatch + + def _stop_auto_save(editor: EditorWidget) -> None: - """Stop the tab's auto-save and forget the path it was registered under. + """Stop the tab's auto-save and its watch, and forget the path it was registered under. JEditor's save thread loops for as long as the path it *started* with is a file, writing to whatever ``file`` holds; after a rename it would write the @@ -217,6 +262,7 @@ def _stop_auto_save(editor: EditorWidget) -> None: if editor.code_save_thread is not None: editor.code_save_thread.still_run = False editor.code_save_thread = None + _unwatch(editor) old = str(editor.current_file) auto_save_manager_dict.pop(old, None) file_is_open_manager_dict.pop(str(Path(old)), None) @@ -229,6 +275,12 @@ def _start_auto_save(editor: EditorWidget, file_path: Path) -> None: # Sets current_file, carries the tab's encoding and line ending, and starts # the thread, as JEditor does when it opens a file. init_new_auto_save_thread(str(file_path), editor) + _watch(editor, file_path) + # As when JEditor opens a file: a new suffix may be another language, and + # the git baseline and the language server go by the path + editor.code_edit.reset_highlighter() + editor.code_edit.load_git_baseline() + editor.code_edit.start_language_server() editor.rename_self_tab() @@ -267,12 +319,16 @@ def _action_rename(tree_view: QTreeView, main_window, path: Path | None) -> None # Every tab open on the file, or on a file under the folder, follows it. Their # auto-save stops first, so none writes to the old path mid-rename. moving = _editors_under(main_window, path) + docked = _dock_editors_under(main_window, path) for editor, _old in moving: _stop_auto_save(editor) renamed = _perform_file_op(tree_view, lambda: path.rename(target)) for editor, old in moving: now = target / old.relative_to(path) if renamed else old _start_auto_save(editor, now) + if renamed: + for dock_editor, old in docked: + dock_editor.current_file = str(target / old.relative_to(path)) def _action_delete(tree_view: QTreeView, main_window, path: Path | None) -> None: diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py index 73cc281f..44354961 100644 --- a/test/test_utils/test_file_tree_context_menu.py +++ b/test/test_utils/test_file_tree_context_menu.py @@ -12,7 +12,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtCore import QPoint, Qt +from PySide6.QtCore import QFileSystemWatcher, QPoint, Qt from PySide6.QtWidgets import ( QApplication, QFileSystemModel, QMessageBox, QTabWidget, QTreeView, QWidget ) @@ -70,6 +70,23 @@ def warnings(monkeypatch): return shown +class FakeCodeEdit: + """The editing area, recording what a rename reloads.""" + + def __init__(self, path: str) -> None: + self.current_file = path + self.reloaded: list[str] = [] + + def reset_highlighter(self) -> None: + self.reloaded.append("highlighter") + + def load_git_baseline(self) -> None: + self.reloaded.append("git baseline") + + def start_language_server(self) -> None: + self.reloaded.append("language server") + + class FakeEditor(QWidget): """Stands in for an EditorWidget holding one open file. @@ -80,7 +97,10 @@ class FakeEditor(QWidget): def __init__(self, path: str) -> None: super().__init__() self.current_file = path - self.code_edit = type("Edit", (), {"current_file": path})() + self.code_edit = FakeCodeEdit(path) + # What JEditor's EditorWidget watches its file with + self._file_watcher = QFileSystemWatcher([path], self) + self._ignore_next_change = False self.renamed = False self.closed = False self.code_save_thread = None @@ -93,10 +113,11 @@ def close(self) -> bool: return super().close() -class FakeWindow: - """A main window with just the tab widget the actions reach for.""" +class FakeWindow(QWidget): + """A main window with just the tab widget the actions reach for; docked editors are its children.""" def __init__(self) -> None: + super().__init__() self.tab_widget = QTabWidget() @@ -244,6 +265,10 @@ def test_an_open_tab_follows_the_rename(self, tree, tmp_path, monkeypatch): assert editor.current_file == str(tmp_path / "renamed.py") assert editor.code_edit.current_file == str(tmp_path / "renamed.py") assert editor.renamed + # As when JEditor opens a file: it watched the old name, and kept the + # old name's highlighter, git baseline and language server + assert [Path(one) for one in editor._file_watcher.files()] == [tmp_path / "renamed.py"] + assert editor.code_edit.reloaded == ["highlighter", "git baseline", "language server"] class TestDeleting: diff --git a/test/test_utils/test_file_tree_rename.py b/test/test_utils/test_file_tree_rename.py index 4d54f1f6..355325f8 100644 --- a/test/test_utils/test_file_tree_rename.py +++ b/test/test_utils/test_file_tree_rename.py @@ -62,3 +62,63 @@ def test_a_file_in_a_renamed_folder_follows_it(tmp_path): assert seen["tab_file"] assert seen["saved_to_new"], "the file under the new folder never got the edit" assert not seen["old_came_back"], "auto-save recreated the old folder" + + +_RENAME_WITH_A_DOCK_AND_A_WATCH = """ +from pathlib import Path +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QTreeView +from je_editor.pyside_ui.main_ui.dock.destroy_dock import DestroyDock +from je_editor.pyside_ui.main_ui.editor.editor_widget_dock import FullEditorWidget +from pybreeze.pybreeze_ui.editor_main import file_tree_context_menu as menu + +here = Path.cwd() +original = here / "a.txt" +original.write_text("original\\n", encoding="utf-8") +window.go_to_new_tab(original) +editor = window.tab_widget.currentWidget() +dock = DestroyDock() +docked = FullEditorWidget(str(original)) +dock.setWidget(docked) +window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock) +other = here / "other.txt" +other.write_text("other", encoding="utf-8") +other_dock = DestroyDock() +docked_elsewhere = FullEditorWidget(str(other)) +other_dock.setWidget(docked_elsewhere) +window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, other_dock) +docked.code_edit.setPlainText("edited in the dock\\n") + +menu.QInputDialog.getText = staticmethod(lambda *args, **kwargs: ("b.py", True)) +menu._action_rename(QTreeView(), window, original) +renamed = here / "b.py" + +watched = [str(Path(one)) for one in editor._file_watcher.files()] +docked_file = docked.current_file +docked.close() + +result = { + "watched": watched == [str(renamed)], + "watched_raw": watched, "renamed_raw": str(renamed), + "ignoring": editor._ignore_next_change, + "docked_file": str(Path(docked_file)) == str(renamed), + "dock_saved": renamed.read_text(encoding="utf-8") == "edited in the dock\\n", + "old_came_back": original.exists(), + "other_left_alone": docked_elsewhere.current_file == str(other), +} +""" + + +def test_a_rename_moves_the_watch_and_the_docked_editor_too(tmp_path): + seen = run_started_window(tmp_path, _RENAME_WITH_A_DOCK_AND_A_WATCH) + + # The watch stayed on the old name: a change made outside the IDE raised + # no question, and the tab's auto-save wrote over it + assert seen["watched"], (seen["watched_raw"], seen["renamed_raw"]) + assert not seen["ignoring"] + # The docked editor stayed on the old name and, closing, wrote nothing + assert seen["docked_file"] + assert seen["dock_saved"], "the docked editor's edits were lost" + assert not seen["old_came_back"] + assert seen["other_left_alone"] + diff --git a/test/test_utils/test_jeditor_contract.py b/test/test_utils/test_jeditor_contract.py index 0a6658a1..65816938 100644 --- a/test/test_utils/test_jeditor_contract.py +++ b/test/test_utils/test_jeditor_contract.py @@ -30,6 +30,8 @@ "editor_main/file_tree_context_menu.py"), ("je_editor.pyside_ui.code.auto_save.auto_save_manager", "file_is_open_manager_dict", "editor_main/file_tree_context_menu.py"), + ("je_editor.pyside_ui.main_ui.editor.editor_widget_dock", "FullEditorWidget", + "editor_main/file_tree_context_menu.py"), ("je_editor.utils.venv_check.check_venv", "check_and_choose_venv", "extend/process_executor/python_task_process_manager.py"), ("je_editor.pyside_ui.dialog.file_dialog.save_file_dialog", "choose_file_get_save_file_path", @@ -137,6 +139,24 @@ def test_auto_save_can_be_stopped_and_started_again_on_another_path(self): source = inspect.getsource(thread) assert "self.still_run" in source and "self.file" in source + def test_a_rename_can_move_what_an_editor_tab_follows_its_file_with(self): + # The file tree moves the tab's watcher, as open_an_file does, and + # reloads what goes by the path + from je_editor import EditorWidget + + source = inspect.getsource(EditorWidget) + assert "self._file_watcher = QFileSystemWatcher" in source + assert "self._ignore_next_change" in source + code_editor = _internal("je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext", "CodeEditor") + for method in ("reset_highlighter", "load_git_baseline", "start_language_server"): + assert callable(getattr(code_editor, method, None)), method + + def test_a_docked_editor_writes_to_the_file_it_names_when_it_closes(self): + # A rename re-points current_file; the dock saves nothing else + docked = _internal("je_editor.pyside_ui.main_ui.editor.editor_widget_dock", "FullEditorWidget") + assert _parameters(docked.__init__) == ["current_file"] + assert "self.current_file" in inspect.getsource(docked.closeEvent) + def test_the_language_wrapper_has_what_pybreeze_reads(self): from je_editor import language_wrapper From ad2283a03392ad418baf652c7634399cbf3962df Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 17:07:50 +0800 Subject: [PATCH 133/312] Refuse prthinker extra arguments with an open quote when the settings are saved --- architecture_explore.md | 4 ++-- docs/updates/2026-09.md | 15 +++++++++++++ docs/updates/README.md | 3 ++- .../prthinker_extend/prthinker_setting.py | 21 +++++++++++++++---- .../extend_multi_language/extend_english.py | 1 + .../extend_traditional_chinese.py | 1 + .../dialog/prthinker_setting_dialog.py | 15 +++++++++++-- .../test_prthinker_setting_dialog.py | 20 ++++++++++++++++++ 8 files changed, 71 insertions(+), 9 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 561cb787..9f06ed38 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -366,7 +366,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 純邏輯、無 Qt,值得單獨一節,因為它示範了本專案處理祕密的方式: -- 設定存 `~/.pybreeze/prthinker_setting.json`(經 `replace_text(..., private=True)` 整檔替換,只有擁有者讀得到;讀取時什麼都不建立,存檔失敗時對話框會說) +- 設定存 `~/.pybreeze/prthinker_setting.json`(經 `replace_text(..., private=True)` 整檔替換,只有擁有者讀得到;讀取時什麼都不建立,存檔失敗時對話框會說;「額外參數」斷不了詞(引號沒關)就不存,`read_extra_arguments()` 與執行時用同一套斷詞) - `environment_for()` 把設定轉成 `PRTHINKER_*` 環境變數交給子行程,**命令列只留「這次要審什麼」** — API key 不會出現在工作管理員或執行紀錄 - `SECRET_SETTINGS` 四個欄位在 `loggable()` 中一律縮成 `(set)` / 空字串 - 模型名稱依後端交給不同變數(`MODEL_ENVIRONMENT`):`remote` / `local` 是 `PRTHINKER_MODEL_NAME`,其他後端是各自的 `PRTHINKER__MODEL`。prthinker 只有 local 讀 `PRTHINKER_MODEL_NAME`(remote 由伺服器決定模型,只拿來標示) @@ -439,7 +439,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1643 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1645 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index cad9b6d3..e86cdfaf 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1865,3 +1865,18 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_file_tree_rename.py`, `test_file_tree_context_menu.py`, `test_jeditor_contract.py` - `architecture.md` §6 (the JEditor internals table and prose), `architecture_explore.md` §3, §18 - **Open items**: none. + +## U-20260923-106 · 2026-09-23 · prthinker settings refuse extra arguments with an open quote at Save instead of dropping them at run time · #fix #prthinker + +- **What**: an "extra arguments" value with a quote left open (`--rules "only these`) was saved as typed. At run time `extra_arguments` could not split it, logged that, and dropped the whole list, so the review ran without arguments the user thought it had. A limiting flag among them was lost with nothing on screen. Found in the review sweep of `dialog/` and prthinker. +- **Fix**: + - New `read_extra_arguments(text)` in `prthinker_setting.py` splits the field as the run does and raises `ValueError` when it cannot. `extra_arguments` uses it and keeps its run-time fallback for a hand-edited file. + - The dialog's Save checks the field first. If it cannot be split, it warns (`prthinker_setting_bad_extra_arguments`, both languages), stays open and writes nothing. +- **Tests**: `test_prthinker_setting_dialog.py` +2: an open quote is warned about, the window stays open and no file is written; a quoted argument is saved as typed. +- **Result / numbers**: prthinker and language parity tests 89 passed; 1645 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_prthinker_setting_dialog.py` + - `architecture_explore.md` §14, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index a576758b..3b21a1e5 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-106 | 2026-09-23 | prthinker settings refuse extra arguments with an open quote at Save instead of dropping them at run time | #fix #prthinker | [2026-09](2026-09.md) | | U-20260923-105 | 2026-09-23 | A file-tree rename moves a docked editor and the tab's watch, highlighter, git baseline and language server with the file | #fix #editor | [2026-09](2026-09.md) | | U-20260923-104 | 2026-09-23 | Header Analyzer and Response Inspector read quoted max-age, folded lines, header names in any case and HTTP/2 :status | #fix #tools | [2026-09](2026-09.md) | | U-20260923-103 | 2026-09-23 | JSON Format and Minify keep numbers and characters as written, and report a repeated key | #fix #tools | [2026-09](2026-09.md) | @@ -186,4 +187,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 123 | +| [2026-09.md](2026-09.md) | 2026-09 | 124 | diff --git a/pybreeze/extend/prthinker_extend/prthinker_setting.py b/pybreeze/extend/prthinker_extend/prthinker_setting.py index 3162eab6..bf270cc5 100644 --- a/pybreeze/extend/prthinker_extend/prthinker_setting.py +++ b/pybreeze/extend/prthinker_extend/prthinker_setting.py @@ -247,16 +247,29 @@ def extra_arguments(setting: Dict[str, str]) -> List[str]: :param setting: 目前的設定 / the settings in use :return: 參數 / the arguments """ - text = setting.get("extra_arguments", "").strip() - if not text: - return [] try: - return split_arguments(text, backslash_escapes=os.sep != "\\") + return read_extra_arguments(setting.get("extra_arguments", "")) except ValueError as error: pybreeze_logger.error("prthinker extra arguments could not be read: %r", error) return [] +def read_extra_arguments(text: str) -> List[str]: + """ + 把「額外參數」欄位斷成參數,斷不了就丟 ValueError;設定視窗存檔前用它先檢查 + Split the extra-arguments field into arguments, raising ValueError when it + cannot be; the settings dialog checks with it before saving. + + :param text: 欄位內容 / what the field holds + :return: 參數 / the arguments + :raises ValueError: 引號沒有關上時 / when a quote is left open + """ + text = text.strip() + if not text: + return [] + return split_arguments(text, backslash_escapes=os.sep != "\\") + + def split_arguments(text: str, *, backslash_escapes: bool) -> List[str]: """ 以命令列的規則斷詞 diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index e33ffd6a..57dcabc5 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -144,6 +144,7 @@ "prthinker_setting_extra_arguments_label": "Extra command-line arguments", "prthinker_setting_source_path_label": "prthinker source folder", "prthinker_setting_stored_at_label": "Stored at:", + "prthinker_setting_bad_extra_arguments": "The extra arguments cannot be read: a quote is left open. Close it or remove it, then save.", "prthinker_setting_save_failed": "The settings could not be saved to {path}. What was there before is kept.", # Prompt editors — where the edited prompts override the built-in ones from "prompt_editor_stored_at_label": "Prompt files (these override the built-in prompts):", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index f3e6772f..011ac6f5 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -144,6 +144,7 @@ "prthinker_setting_extra_arguments_label": "額外的命令列參數", "prthinker_setting_source_path_label": "prthinker 原始碼資料夾", "prthinker_setting_stored_at_label": "設定檔位置:", + "prthinker_setting_bad_extra_arguments": "額外參數無法解讀:有引號沒有關上。請補上或刪掉後再存檔。", "prthinker_setting_save_failed": "設定無法存到 {path},原本的設定保持不變。", # Prompt 編輯器 —— 編輯過的 prompt 會覆寫內建版本 "prompt_editor_stored_at_label": "Prompt 檔案位置(會覆寫內建 prompt):", diff --git a/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py b/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py index 677545c2..adf40c98 100644 --- a/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py +++ b/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py @@ -20,7 +20,7 @@ from je_editor import language_wrapper from pybreeze.extend.prthinker_extend.prthinker_setting import ( - BACKENDS, PLATFORMS, RAG_MODES, load_setting, save_setting, setting_path + BACKENDS, PLATFORMS, RAG_MODES, load_setting, read_extra_arguments, save_setting, setting_path ) # 以圓點顯示的欄位 / The fields shown as dots @@ -114,7 +114,18 @@ def values(self) -> Dict[str, str]: def save(self) -> None: """存檔並關閉;存不起來就留在視窗上 / Store and close, or stay open if it cannot be stored.""" - self.setting.update(self.values()) + values = self.values() + try: + read_extra_arguments(values.get("extra_arguments", "")) + except ValueError: + # 以前照存,執行時整串被丟掉,審查少了使用者以為有加上的參數 + # It used to be stored, then dropped whole at run time: a review ran + # without the arguments the user thought it had + QMessageBox.warning( + self, self.word_dict.get("prthinker_setting_dialog_title"), + self.word_dict.get("prthinker_setting_bad_extra_arguments")) + return + self.setting.update(values) if save_setting(self.setting): self.accept() return diff --git a/test/test_utils/test_prthinker_setting_dialog.py b/test/test_utils/test_prthinker_setting_dialog.py index de1f590c..2bfb7e03 100644 --- a/test/test_utils/test_prthinker_setting_dialog.py +++ b/test/test_utils/test_prthinker_setting_dialog.py @@ -127,6 +127,26 @@ def test_a_failed_save_leaves_the_window_open(self, dialog, monkeypatch): # It said so: the reason used to go to the log only assert warned and str(prthinker_setting.setting_path()) in warned[0] + def test_extra_arguments_with_an_open_quote_are_not_saved(self, dialog, data_dir, monkeypatch): + # Saved, they were dropped whole at run time, and the review ran + # without the arguments the user thought it had + warned: list = [] + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda _p, _t, text: warned.append(text))) + dialog.editors["extra_arguments"].setText('--rules "only these') + + dialog.save() + + assert warned + assert dialog.result() != QDialog.DialogCode.Accepted + assert not (data_dir / SETTING_FILE_NAME).exists() + + def test_readable_extra_arguments_are_saved(self, dialog, data_dir): + dialog.editors["extra_arguments"].setText('--rules "only these"') + + dialog.save() + + assert stored(data_dir)["extra_arguments"] == '--rules "only these"' + def test_a_field_left_untouched_keeps_its_stored_value(self, app, data_dir): save_setting({**DEFAULT_SETTING, "model_name": "kept"}) made = PRThinkerSettingDialog() From 7dc61cd0b0abc1b7f839a9d0fc2e697e64f66eb7 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 18:15:49 +0800 Subject: [PATCH 134/312] Record the release pipeline's stored token and tag-pinned actions in progress --- progress.md | 1 + 1 file changed, 1 insertion(+) diff --git a/progress.md b/progress.md index 64913121..c5338f66 100644 --- a/progress.md +++ b/progress.md @@ -11,3 +11,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#47** [BLOCKED] Closing the IDE closes JEditor's docked file editors (`editor_main/main_ui.py`, `_close_tool_tabs_and_docks`), and the published `je_editor` dock (`FullEditorWidget.closeEvent`) writes its buffer back on close whether edited or not, in UTF-8 with platform line endings, so exiting rewrites an LF file as CRLF. Fixed in JEditor (JEDITOR U-20260923-07: writes only when edited, in the file's own encoding and line ending); waits for the `je_editor` release that carries it, then bump the pin and add a PyBreeze test. - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? +- **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? From 318164b543c21c0c8ff32987a38d0d62d0359545 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 18:16:07 +0800 Subject: [PATCH 135/312] Drop the stale test module count from README and say AI requests connect only to the checked address --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2bdce1f8..d4124365 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ Password or private-key authentication, an interactive shell with ANSI handling ![AI code review client](images/ai_code_review.png) -*Shown in the pre-send state.* Send a selection to an LLM endpoint, then accept or reject the suggestion — the tally is kept in `~/.pybreeze/response_stats.txt`. The URL is SSRF-validated, redirects are not followed, and the response body is size-capped before it reaches the panel. +*Shown in the pre-send state.* Send a selection to an LLM endpoint, then accept or reject the suggestion — the tally is kept in `~/.pybreeze/response_stats.txt`. The URL is SSRF-validated, the connection goes only to the address that was checked, redirects are not followed, and the response body is size-capped before it reaches the panel. ### Chain-of-Thought Code Review (prthinker) @@ -426,7 +426,7 @@ python -m pip install -r dev_requirements.txt python -m pytest test/test_utils/ -v --tb=short ``` -- **Unit tests** — `test/test_utils/`, 67 modules covering the pure-logic layer (curl and HAR parsing, header analysis, SSRF validation, JWT, hashing, timestamps, diffing) plus headless Qt widget tests via `QT_QPA_PLATFORM=offscreen`, with Hypothesis property tests over the parsers +- **Unit tests** — `test/test_utils/`, covering the pure-logic layer (curl and HAR parsing, header analysis, SSRF validation, JWT, hashing, timestamps, diffing) plus headless Qt widget tests via `QT_QPA_PLATFORM=offscreen`, with Hypothesis property tests over the parsers - **Startup tests** — `test/unit_test/start_automation/` launches the IDE in debug mode and verifies it comes up and exits cleanly - **CI** — GitHub Actions on Windows across Python 3.10 – 3.14, on every push and PR plus a nightly run - **Static analysis** — SonarCloud, Codacy and Bandit From 6a23a0621a3254b40fe1e45cb5d0ad0f59ef3acb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 18:25:39 +0800 Subject: [PATCH 136/312] Copy and save tool output as generated, and report regex patterns and HAR files that cannot be read --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 18 ++++++++++ docs/updates/README.md | 3 +- .../pybreeze_ui/tools_gui/output_actions.py | 9 ++--- pybreeze/utils/har_import/har_parser.py | 4 ++- pybreeze/utils/regex_tools/regex_tester.py | 5 ++- test/test_utils/test_har_import.py | 9 +++++ test/test_utils/test_output_actions.py | 34 +++++++++++++++++++ test/test_utils/test_regex_tester.py | 18 ++++++++++ 9 files changed, 95 insertions(+), 9 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 9f06ed38..ab9557b2 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -232,7 +232,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - **`tool_tabs.open_tool_tab()`** — 工具之間互相「轉交」:Response Inspector 把狀態碼丟給 HTTP Status、headers 丟給 Header Analyzer、JWT 丟給 JWT Decoder、JSON body 丟給 JSON Format;curl 匯入把 URL 丟給 URL Builder。開新分頁並自動聚焦。 - **`exact_text.exact_text()`** — 讀工具輸入一律走它(`document().toRawText()`,區塊分隔換回 `\n`):`toPlainText()` 是顯示用的,會把不斷行空白(U+00A0)變成空白、U+2028 變成換行,Hash 算的是別的字串、Diff 看不出差別 -- **`output_actions.OutputActions`** — 統一的「複製 / 在編輯器開啟 / 存檔」三顆按鈕,綁在工具的唯讀輸出 `QTextEdit` 上;副檔名與檔名可傳 callable 動態決定。存檔失敗(唯讀資料夾、被鎖住的檔案)會跳警告,說出檔名與原因 +- **`output_actions.OutputActions`** — 統一的「複製 / 在編輯器開啟 / 存檔」三顆按鈕,綁在工具的唯讀輸出 `QTextEdit` 上,輸出也經 `exact_text()` 讀(不讓 U+00A0、U+2028 被改掉);副檔名與檔名可傳 callable 動態決定。存檔失敗(唯讀資料夾、被鎖住的檔案)會跳警告,說出檔名與原因 --- @@ -439,7 +439,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1645 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1650 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index e86cdfaf..c54e9b1c 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1880,3 +1880,21 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_prthinker_setting_dialog.py` - `architecture_explore.md` §14, §18 - **Open items**: none. + +## U-20260923-107 · 2026-09-23 · Tool output is copied and saved as generated, and Regex and HAR report patterns and files they cannot take · #fix #tools + +- **What**: three faults in the tool tabs, found in the review sweep of `tools_gui/`; each reproduced. + - `OutputActions` (Copy, Open in editor, Save to file on every tool) read the output with `toPlainText()`, which turns U+00A0 into a space and U+2028 into a newline. U-20260923-88 fixed the input side only. A cURL body holding either one was generated right, but copied and saved changed: the saved `request.py` no longer parsed ("unterminated string literal"). + - `regex_tester.compile_pattern` caught only `re.error`. `a{4294967296}` (`OverflowError`) and groups nested 5000 deep (`RecursionError`) escaped the worker's `run`: the button came back, but the output stayed on "Running the pattern..." with no error shown. + - `har_parser` caught only `ValueError` from `json.loads`. A `.har` of deeply nested JSON raised `RecursionError` out of the HAR tab's Open and Load slots. +- **Fix**: `OutputActions` reads through `exact_text()`. `compile_pattern` also turns `OverflowError` and `RecursionError` into `RegexTesterException`, and the HAR loader turns `RecursionError` into `HarParseException`. +- **Tests**: + - `test_output_actions.py` +2: copy and save keep U+00A0 and U+2028, and the saved script parses. + - `test_regex_tester.py` +2: the huge repeat and the deep nesting are reported. + - `test_har_import.py` +1: deep nesting is reported. +- **Result / numbers**: output, regex, HAR and exact-text tests 126 passed; 1650 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/tools_gui/output_actions.py`, `pybreeze/utils/regex_tools/regex_tester.py`, `pybreeze/utils/har_import/har_parser.py` + - `test/test_utils/test_output_actions.py`, `test_regex_tester.py`, `test_har_import.py` + - `architecture_explore.md` §6, §18 +- **Open items**: the rest of the same sweep's findings, taken in the stages that follow. diff --git a/docs/updates/README.md b/docs/updates/README.md index 3b21a1e5..a9502330 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-107 | 2026-09-23 | Tool output is copied and saved as generated, and Regex and HAR report patterns and files they cannot take | #fix #tools | [2026-09](2026-09.md) | | U-20260923-106 | 2026-09-23 | prthinker settings refuse extra arguments with an open quote at Save instead of dropping them at run time | #fix #prthinker | [2026-09](2026-09.md) | | U-20260923-105 | 2026-09-23 | A file-tree rename moves a docked editor and the tab's watch, highlighter, git baseline and language server with the file | #fix #editor | [2026-09](2026-09.md) | | U-20260923-104 | 2026-09-23 | Header Analyzer and Response Inspector read quoted max-age, folded lines, header names in any case and HTTP/2 :status | #fix #tools | [2026-09](2026-09.md) | @@ -187,4 +188,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 124 | +| [2026-09.md](2026-09.md) | 2026-09 | 125 | diff --git a/pybreeze/pybreeze_ui/tools_gui/output_actions.py b/pybreeze/pybreeze_ui/tools_gui/output_actions.py index 90598287..e9588510 100644 --- a/pybreeze/pybreeze_ui/tools_gui/output_actions.py +++ b/pybreeze/pybreeze_ui/tools_gui/output_actions.py @@ -17,6 +17,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.utils.logging.logger import pybreeze_logger # A value that is either fixed or computed on demand (e.g. depends on a selector) @@ -71,13 +72,13 @@ def button_row(self) -> QHBoxLayout: def _has_output(self) -> bool: """Whether the output has real content the actions should act on.""" - if not self._output.toPlainText().strip(): + if not exact_text(self._output).strip(): return False return self._is_valid() if self._is_valid is not None else True def copy(self) -> None: """Copy the output to the clipboard, if there is any.""" - text = self._output.toPlainText() + text = exact_text(self._output) clipboard = QApplication.clipboard() if text and clipboard is not None: clipboard.setText(text) @@ -92,7 +93,7 @@ def open_in_editor(self) -> QWidget | None: return None from je_editor import EditorWidget editor = EditorWidget(self._main_window) - editor.code_edit.setPlainText(self._output.toPlainText()) + editor.code_edit.setPlainText(exact_text(self._output)) tab_widget.addTab( editor, language_wrapper.language_word_dict.get("output_actions_editor_tab_label")) tab_widget.setCurrentWidget(editor) @@ -124,7 +125,7 @@ def save_to_file(self) -> str | None: if not path: return None try: - Path(path).write_text(self._output.toPlainText(), encoding="utf-8") + Path(path).write_text(exact_text(self._output), encoding="utf-8") except OSError as error: pybreeze_logger.error("output_actions.py save failed: %r", error) # It used to go to the log only, and the user took it as saved. diff --git a/pybreeze/utils/har_import/har_parser.py b/pybreeze/utils/har_import/har_parser.py index 3fe6f003..6c8d1c94 100644 --- a/pybreeze/utils/har_import/har_parser.py +++ b/pybreeze/utils/har_import/har_parser.py @@ -231,7 +231,9 @@ def _load_entries(text: str) -> list[dict]: raise HarParseException(empty_har_error) try: document = json.loads(text) - except ValueError as error: + # RecursionError: JSON nested deeper than the parser goes, which escaped + # the tab's slot + except (ValueError, RecursionError) as error: pybreeze_logger.error(invalid_har_json_error) raise HarParseException(invalid_har_json_error) from error log = document.get("log") if isinstance(document, dict) else None diff --git a/pybreeze/utils/regex_tools/regex_tester.py b/pybreeze/utils/regex_tools/regex_tester.py index 74a906fb..648c357a 100644 --- a/pybreeze/utils/regex_tools/regex_tester.py +++ b/pybreeze/utils/regex_tools/regex_tester.py @@ -86,7 +86,10 @@ def compile_pattern(pattern: str, flag_names: list[str] | set[str] | None = None raise RegexTesterException(empty_regex_pattern_error) try: return re.compile(pattern, build_flags(flag_names or [])) - except re.error as error: + # OverflowError: a repeat count past what re takes (a{4294967296}); + # RecursionError: groups nested deeper than the compiler goes. Either + # escaped the worker, and the tab stayed on "Running the pattern..." + except (re.error, OverflowError, RecursionError) as error: message = invalid_regex_pattern_error.format(detail=str(error)) pybreeze_logger.error(message) raise RegexTesterException(message) from error diff --git a/test/test_utils/test_har_import.py b/test/test_utils/test_har_import.py index 1436acd0..64fd1544 100644 --- a/test/test_utils/test_har_import.py +++ b/test/test_utils/test_har_import.py @@ -358,3 +358,12 @@ def test_a_recorded_name_the_url_lacks_keeps_all_its_values(self): {"name": "tag", "value": "x"}, {"name": "tag", "value": "y"}])))[0] assert entry.request.params == {"q": "a", "tag": ["x", "y"]} + + +def test_json_nested_too_deep_is_reported_not_raised_out_of_the_tab(): + from pybreeze.utils.exception.exceptions import HarParseException + from pybreeze.utils.har_import.har_parser import parse_har + + # json.loads raises RecursionError, which the tab did not catch + with pytest.raises(HarParseException): + parse_har("[" * 100000) diff --git a/test/test_utils/test_output_actions.py b/test/test_utils/test_output_actions.py index ed4e3d3e..e5f87e71 100644 --- a/test/test_utils/test_output_actions.py +++ b/test/test_utils/test_output_actions.py @@ -160,3 +160,37 @@ def test_callable(self, app): app, basename=lambda: "dyn", extension=lambda: "py") assert actions.suggested_filename() == "dyn.py" parent.deleteLater() + + +class TestTheTextIsWhatWasGenerated: + """Copy, Save and Open in editor read the output as written, not Qt's display copy.""" + + # A no-break space and a line separator: toPlainText() turned them into a + # space and a newline, and a saved script no longer parsed + _GENERATED = 'data = "x=1\u00a0y\u2028z"\n' + + def test_copy_keeps_every_character(self, app): + parent, output, actions = _make(app) + output.setPlainText(self._GENERATED) + + actions.copy() + + assert QApplication.clipboard().text() == self._GENERATED + parent.deleteLater() + + def test_a_saved_file_keeps_every_character(self, app, tmp_path): + import ast + + parent, output, actions = _make(app, basename="request", extension="py") + output.setPlainText(self._GENERATED) + target = tmp_path / "request.py" + with patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", + return_value=(str(target), "Python (*.py)"), + ): + actions.save_to_file() + + saved = target.read_text(encoding="utf-8") + assert saved == self._GENERATED + ast.parse(saved) + parent.deleteLater() diff --git a/test/test_utils/test_regex_tester.py b/test/test_utils/test_regex_tester.py index 13f23d7f..453a41d4 100644 --- a/test/test_utils/test_regex_tester.py +++ b/test/test_utils/test_regex_tester.py @@ -124,3 +124,21 @@ def test_catastrophic_backtracking_is_stopped(self): find_matches_bounded("(a+)+$", "a" * 40 + "b", timeout_seconds=2.0) assert time.monotonic() - started < 10 + + +class TestPatternsTheCompilerCannotTake: + """They escaped the worker, and the tab stayed on "Running the pattern...".""" + + def test_a_repeat_count_too_large_is_reported(self): + from pybreeze.utils.exception.exceptions import RegexTesterException + from pybreeze.utils.regex_tools.regex_tester import compile_pattern + + with pytest.raises(RegexTesterException): + compile_pattern("a{4294967296}") + + def test_groups_nested_too_deep_are_reported(self): + from pybreeze.utils.exception.exceptions import RegexTesterException + from pybreeze.utils.regex_tools.regex_tester import compile_pattern + + with pytest.raises(RegexTesterException): + compile_pattern("(" * 5000 + "a" + ")" * 5000) From 6fa42f4bbc60d085c2deecfe7e58ff2c106b2727 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 18:31:46 +0800 Subject: [PATCH 137/312] Send imported forms as multipart without a copied Content-Type, and refuse uploads in the JSON action --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 24 +++++++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 1 + .../extend_traditional_chinese.py | 1 + .../pybreeze_ui/tools_gui/har_import_gui.py | 13 +++- pybreeze/utils/curl_import/request_body.py | 40 ++++++++++++ pybreeze/utils/curl_import/request_codegen.py | 47 +++++++------- .../utils/curl_import/script_templates.py | 65 +++++++++++-------- pybreeze/utils/exception/exception_tags.py | 3 + test/test_utils/test_curl_request_fidelity.py | 59 ++++++++++++++++- test/test_utils/test_har_import_gui.py | 15 +++++ test/test_utils/test_script_templates.py | 35 +++++++--- 13 files changed, 242 insertions(+), 68 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index ab9557b2..8b9189b0 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -344,7 +344,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 暫換成 `public_address()` 回傳的位址)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(579) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(272) 產 APITestka/LoadDensity/pytest 模板 | +| `curl_import/` | `curl_parser.py`(579) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案或 `@file` body 就丟 `CurlParseException`;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯 | @@ -439,7 +439,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1650 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1657 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index c54e9b1c..aa8f664c 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1898,3 +1898,27 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_output_actions.py`, `test_regex_tester.py`, `test_har_import.py` - `architecture_explore.md` §6, §18 - **Open items**: the rest of the same sweep's findings, taken in the stages that follow. + +## U-20260923-108 · 2026-09-23 · cURL and HAR import send a form as multipart without the copied Content-Type, and the JSON action refuses what it cannot carry · #fix #tools + +- **What**: the generated code sent a form differently from curl. Found in the review sweep of `tools_gui/`; each case reproduced. + - curl sends every `-F` form as `multipart/form-data`. Text fields were generated as `data=`, so a text-only form (`curl -F name=x -F 'note=a b'`) went out URL-encoded (`name=x¬e=a+b`). This hit the requests, pytest and APITestka Python targets, and HAR multipart entries without uploads. + - A copied `Content-Type: multipart/form-data` header (the Swagger form without a boundary, or a HAR entry with the browser's boundary) was kept in `headers=`. `requests` does not replace a header it is given, so the body went out under a boundary it did not use, and the server could not read it. + - The APITestka JSON action target kept a form's text fields as `data`, and left a file upload or a body read with `-d @file` out without a word: the action sent a different request. +- **Fix**: + - `request_body.form_entries()` lists every field; the generators put all of them in `files=`, a text field as `(None, text)` and an upload as its opened file. The result is a dict, or pairs when a name repeats (`form_value_expr`, `form_has_repeats`, shared by `request_codegen` and `script_templates`). + - `request_body.sent_headers()` leaves a multipart `Content-Type` out of a form's headers. + - The JSON action writes text fields as `"files": {name: [null, text]}`, which `requests` reads as `(None, text)`. It raises `CurlParseException` (`action_cannot_read_files_error`: choose a Python target) for an upload or a `@file` body. + - The HAR tab now catches that and shows `har_import_generate_error`, where it raised out of its slot. +- **Tests**: + - `test_curl_request_fidelity.py` +4, each running the generated code against a stand-in `requests` and preparing what it would send: a text-only form goes out multipart; a copied multipart header does not hide the boundary while other headers stay; a JSON body keeps its type; a recorded HAR form leaves the browser's boundary out. Three of them fail on the old code. + - `test_script_templates.py` +2: the JSON action refuses an upload and a `@file` body. + - `test_har_import_gui.py` +1: the refusal is shown. + - Four tests that asserted the old `data=` form now assert the multipart one. +- **Result / numbers**: cURL, HAR and template tests 316 passed; 1657 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/request_body.py`, `request_codegen.py`, `script_templates.py`, `pybreeze/utils/exception/exception_tags.py` + - `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_curl_request_fidelity.py`, `test_script_templates.py`, `test_har_import_gui.py` + - `architecture_explore.md` §12, §18 +- **Open items**: the same sweep's `-d @file` / `--data-binary @file` reading, `-b file`, `-G` with `@file`, the Diff tab's UI-thread work and the re-encoded query string follow in later stages. diff --git a/docs/updates/README.md b/docs/updates/README.md index a9502330..e031b374 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-108 | 2026-09-23 | cURL and HAR import send a form as multipart without the copied Content-Type, and the JSON action refuses what it cannot carry | #fix #tools | [2026-09](2026-09.md) | | U-20260923-107 | 2026-09-23 | Tool output is copied and saved as generated, and Regex and HAR report patterns and files they cannot take | #fix #tools | [2026-09](2026-09.md) | | U-20260923-106 | 2026-09-23 | prthinker settings refuse extra arguments with an open quote at Save instead of dropping them at run time | #fix #prthinker | [2026-09](2026-09.md) | | U-20260923-105 | 2026-09-23 | A file-tree rename moves a docked editor and the tab's watch, highlighter, git baseline and language server with the file | #fix #editor | [2026-09](2026-09.md) | @@ -188,4 +189,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 125 | +| [2026-09.md](2026-09.md) | 2026-09 | 126 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 57dcabc5..b1eec6fa 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -424,6 +424,7 @@ "har_import_empty_hint": "Open a .har file saved from your browser's dev tools (Network → Save all as HAR).", "har_import_error": "Could not read the HAR export: {error}", + "har_import_generate_error": "Could not write the script: {error}", "har_import_not_utf8": "it is not UTF-8 text", "har_import_read_error": "Could not open the file: {error}", # Shared output actions (copy / open in editor / save to file) diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 011ac6f5..31df2d66 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -398,6 +398,7 @@ "har_import_empty_hint": "請開啟從瀏覽器開發者工具存出的 .har 檔(Network → Save all as HAR)。", "har_import_error": "無法讀取這份 HAR 匯出檔:{error}", + "har_import_generate_error": "無法產生腳本:{error}", "har_import_not_utf8": "不是 UTF-8 文字檔", "har_import_read_error": "無法開啟檔案:{error}", # 共用輸出動作(複製 / 開成分頁 / 存檔) diff --git a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py index d6466193..5dc6a645 100644 --- a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py @@ -18,7 +18,7 @@ from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.curl_import.script_templates import TEMPLATE_TARGETS -from pybreeze.utils.exception.exceptions import HarParseException +from pybreeze.utils.exception.exceptions import CurlParseException, HarParseException from pybreeze.utils.har_import.har_codegen import generate_har_script from pybreeze.utils.har_import.har_parser import HarEntry, api_entries, parse_har, summarize from pybreeze.utils.logging.logger import pybreeze_logger @@ -179,8 +179,15 @@ def _generate(self, entries: list[HarEntry], empty_hint_key: str) -> None: self._generated_code = None self.output_edit.setPlainText(word.get(empty_hint_key)) return - code = generate_har_script( - self.selected_target(), [entry.request for entry in entries]) + try: + code = generate_har_script( + self.selected_target(), [entry.request for entry in entries]) + except CurlParseException as error: + # A target that cannot carry a recorded file upload says so + pybreeze_logger.info("har_import_gui.py generate failed: %r", error) + self._generated_code = None + self.output_edit.setPlainText(word.get("har_import_generate_error").format(error=str(error))) + return self._generated_code = code self.output_edit.setPlainText(code) diff --git a/pybreeze/utils/curl_import/request_body.py b/pybreeze/utils/curl_import/request_body.py index b9bd5a92..9d9dc5dd 100644 --- a/pybreeze/utils/curl_import/request_body.py +++ b/pybreeze/utils/curl_import/request_body.py @@ -82,3 +82,43 @@ def file_uploads(file_fields: dict[str, str | list[str]]) -> list[tuple[str, str (field, name) for field, names in file_fields.items() for name in (names if isinstance(names, list) else [names]) ] + + +# One multipart field as sent: its name, whether it uploads a file, and the +# file name or the text +FormEntry = tuple[str, bool, str] + +# The media type every -F form is sent as +_MULTIPART_MEDIA_TYPE = "multipart/" + + +def form_entries(request: CurlRequest) -> list[FormEntry]: + """Every ``-F`` field of *request*, uploads and text alike, each repeat kept. + + curl sends a form given with ``-F`` as ``multipart/form-data`` whether or + not it uploads a file. Generated as ``data=`` when it held only text, + ``requests`` sent it URL-encoded instead: the generators now put every + field in ``files=``, a text field as ``(None, text)``. + """ + data_fields, file_fields = form_parts(request) + entries: list[FormEntry] = [] + for fields, is_file in ((data_fields, False), (file_fields, True)): + for name, values in fields.items(): + entries.extend((name, is_file, value) for value in (values if isinstance(values, list) else [values])) + return entries + + +def sent_headers(request: CurlRequest) -> dict[str, str]: + """The headers to generate for *request*. + + A form's ``Content-Type: multipart/...`` is left out: ``requests`` writes + its own with the boundary it chose, and does not replace one given + explicitly, so the copied header (without a boundary, or with the browser's) + left the server unable to read the body. + """ + if not request.has_form: + return dict(request.headers) + return { + name: value for name, value in request.headers.items() + if not (name.lower() == _CONTENT_TYPE_HEADER and value.lower().lstrip().startswith(_MULTIPART_MEDIA_TYPE)) + } diff --git a/pybreeze/utils/curl_import/request_codegen.py b/pybreeze/utils/curl_import/request_codegen.py index 748aca28..77b27c74 100644 --- a/pybreeze/utils/curl_import/request_codegen.py +++ b/pybreeze/utils/curl_import/request_codegen.py @@ -11,7 +11,7 @@ import re from pybreeze.utils.curl_import.curl_parser import CurlRequest -from pybreeze.utils.curl_import.request_body import body_kind, file_uploads, form_parts +from pybreeze.utils.curl_import.request_body import FormEntry, body_kind, form_entries, sent_headers # Keyword argument passing the request body to ``requests.request``. _DATA_KWARG = "data=data" @@ -37,19 +37,29 @@ def _format_dict(name: str, mapping: dict[str, str | list[str]]) -> str | None: return "\n".join(lines) -def _format_files(file_fields: dict[str, str | list[str]]) -> str: - """Render a ``files = ...`` block that opens each upload. +def form_value_expr(entry: FormEntry) -> str: + """The Python expression ``files=`` holds for one form field: the opened file, or ``(None, text)``.""" + _name, is_file, value = entry + return f'open({python_string(value)}, "rb")' if is_file else f"(None, {python_string(value)})" - A dict while every field is given once; a list of ``(field, file)`` pairs + +def form_has_repeats(entries: list[FormEntry]) -> bool: + """Whether a field name repeats, so ``files`` must be a list of pairs rather than a dict.""" + names = [name for name, _is_file, _value in entries] + return len(set(names)) < len(names) + + +def _format_form(entries: list[FormEntry]) -> str: + """Render a ``files = ...`` block holding every form field, sent as multipart. + + A dict while every field is given once; a list of ``(field, value)`` pairs when one repeats, since a dict cannot hold the same key twice. """ - uploads = file_uploads(file_fields) - repeated = len(uploads) > len(file_fields) + repeated = form_has_repeats(entries) lines = ["files = [" if repeated else "files = {"] - for field, filename in uploads: - opened = f'open({python_string(filename)}, "rb")' - lines.append(f" ({python_string(field)}, {opened})," - if repeated else f" {python_string(field)}: {opened},") + for entry in entries: + name, value = python_string(entry[0]), form_value_expr(entry) + lines.append(f" ({name}, {value})," if repeated else f" {name}: {value},") lines.append("]" if repeated else "}") return "\n".join(lines) @@ -134,17 +144,8 @@ def payload_python_parts(request: CurlRequest) -> tuple[list[str], list[str]]: :return: ``(assignment_lines, keyword_arguments)`` """ if request.has_form: - data_fields, file_fields = form_parts(request) - sections: list[str] = [] - kwargs: list[str] = [] - data_block = _format_dict("data", data_fields) - if data_block is not None: - sections.append(data_block) - kwargs.append(_DATA_KWARG) - if file_fields: - sections.append(_format_files(file_fields)) - kwargs.append("files=files") - return sections, kwargs + entries = form_entries(request) + return ([_format_form(entries)], ["files=files"]) if entries else ([], []) if request.data_file_refs: return [f"data = {data_from_file_expr(request)}"], [_DATA_KWARG] @@ -160,7 +161,7 @@ def payload_python_parts(request: CurlRequest) -> tuple[list[str], list[str]]: def _call_keyword_arguments(request: CurlRequest, payload_kwargs: list[str]) -> list[str]: """Build the keyword arguments passed to ``requests.request``.""" arguments = ["url"] - if request.headers: + if sent_headers(request): arguments.append("headers=headers") if request.params: arguments.append("params=params") @@ -187,7 +188,7 @@ def request_statements(request: CurlRequest) -> list[str]: payload_sections, payload_kwargs = payload_python_parts(request) statements: list[str] = [f"url = {python_string(request.url)}"] - headers_block = _format_dict("headers", request.headers) + headers_block = _format_dict("headers", sent_headers(request)) if headers_block is not None: statements.append(headers_block) params_block = _format_dict("params", request.params) diff --git a/pybreeze/utils/curl_import/script_templates.py b/pybreeze/utils/curl_import/script_templates.py index df45f439..34fdf7a7 100644 --- a/pybreeze/utils/curl_import/script_templates.py +++ b/pybreeze/utils/curl_import/script_templates.py @@ -17,9 +17,13 @@ from urllib.parse import urlparse from pybreeze.utils.curl_import.curl_parser import CurlRequest -from pybreeze.utils.curl_import.request_body import body_kind, file_uploads, form_parts +from pybreeze.utils.curl_import.request_body import body_kind, form_entries, sent_headers +from pybreeze.utils.exception.exception_tags import action_cannot_read_files_error +from pybreeze.utils.exception.exceptions import CurlParseException +from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.curl_import.request_codegen import ( - REQUESTS_IMPORT, data_from_file_expr, python_literal, request_statements, to_requests_code + REQUESTS_IMPORT, data_from_file_expr, form_has_repeats, form_value_expr, python_literal, + python_string, request_statements, to_requests_code, ) # APITestka action command that performs an HTTP request @@ -38,28 +42,20 @@ def _inline_json(value: object) -> str: return json.dumps(value, ensure_ascii=False) -def _inline_files(file_fields: dict[str, str | list[str]]) -> str: - """The ``files`` value, one line: a dict, or pairs when a field repeats.""" - uploads = file_uploads(file_fields) - if len(uploads) > len(file_fields): - pairs = ", ".join( - f"({_inline_json(field)}, open({_inline_json(name)}, \"rb\"))" for field, name in uploads) - return f"[{pairs}]" - entries = ", ".join( - f"{_inline_json(field)}: open({_inline_json(name)}, \"rb\")" for field, name in uploads) - return f"{{{entries}}}" +def _inline_form(request: CurlRequest) -> str: + """The ``files`` value, one line, every form field in it: a dict, or pairs when a field repeats.""" + entries = form_entries(request) + values = [(python_string(entry[0]), form_value_expr(entry)) for entry in entries] + if form_has_repeats(entries): + return "[" + ", ".join(f"({name}, {value})" for name, value in values) + "]" + return "{" + ", ".join(f"{name}: {value}" for name, value in values) + "}" def _apitestka_payload_lines(request: CurlRequest) -> list[str]: """Return the inline ``data=`` / ``files=`` / ``json=`` kwargs for the payload.""" if request.has_form: - data_fields, file_fields = form_parts(request) - lines = [] - if data_fields: - lines.append(f" data={_inline_json(data_fields)},") - if file_fields: - lines.append(f" files={_inline_files(file_fields)},") - return lines + # Every field in files=, so the form is sent as multipart, as curl sends it + return [f" files={_inline_form(request)},"] if form_entries(request) else [] if request.data_file_refs: return [f" data={data_from_file_expr(request)},"] kind = body_kind(request) @@ -83,8 +79,8 @@ def apitestka_call_block(request: CurlRequest) -> str: f" {_inline_json(request.method)},", f" test_url={_inline_json(request.url)},", ] - if request.headers: - lines.append(f" headers={_inline_json(request.headers)},") + if sent_headers(request): + lines.append(f" headers={_inline_json(sent_headers(request))},") if request.params: lines.append(f" params={_inline_json(request.params)},") if request.cookies: @@ -155,8 +151,8 @@ def to_loaddensity_python(request: CurlRequest) -> str: def _apitestka_action_params(request: CurlRequest) -> dict: """Build the parameter dict for an ``AT_test_api_method`` action.""" params: dict = {"http_method": request.method, "test_url": request.url} - if request.headers: - params["headers"] = request.headers + if sent_headers(request): + params["headers"] = sent_headers(request) if request.params: params["params"] = request.params if request.cookies: @@ -170,14 +166,27 @@ def _apitestka_action_params(request: CurlRequest) -> dict: def _apply_action_payload(request: CurlRequest, params: dict) -> None: """Add the body / form payload to an action's parameter dict. - JSON cannot carry file handles, so multipart file uploads are not represented - here; only the plain form fields are. Use a Python target for file uploads. + A form's text fields go in ``files`` as ``[null, text]``, which ``requests`` + reads as ``(None, text)``, so the form is sent as multipart, as curl sends + it (``data`` sent it URL-encoded). + + :raises CurlParseException: for a file upload or a body read from a file. + JSON cannot open a file, and the action was generated without them, as + if the request had none. """ if request.has_form: - data_fields, _file_fields = form_parts(request) - if data_fields: - params["data"] = data_fields + entries = form_entries(request) + if any(is_file for _name, is_file, _value in entries): + pybreeze_logger.error(action_cannot_read_files_error) + raise CurlParseException(action_cannot_read_files_error) + if form_has_repeats(entries): + params["files"] = [[name, [None, value]] for name, _is_file, value in entries] + elif entries: + params["files"] = {name: [None, value] for name, _is_file, value in entries} return + if request.data_file_refs: + pybreeze_logger.error(action_cannot_read_files_error) + raise CurlParseException(action_cannot_read_files_error) kind = body_kind(request) if kind is not None: params[kind[0]] = kind[1] diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index e37311b2..29773e06 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -43,6 +43,9 @@ not_a_curl_command_error: str = "the command does not look like a curl command" malformed_curl_command_error: str = "can't parse the curl command: check quoting" no_url_in_curl_error: str = "no URL found in the curl command" +action_cannot_read_files_error: str = ( + "an APITestka JSON action cannot upload a file or read a body from one: choose a Python target" +) malformed_url_error: str = "the URL is malformed (for example an unclosed [ or a port that is not a number)" invalid_http_method_error: str = ( "not an HTTP method: a method is one word of letters, digits and !#$%&'*+.^_`|~-") diff --git a/test/test_utils/test_curl_request_fidelity.py b/test/test_utils/test_curl_request_fidelity.py index cef783a7..0ae75df0 100644 --- a/test/test_utils/test_curl_request_fidelity.py +++ b/test/test_utils/test_curl_request_fidelity.py @@ -65,7 +65,8 @@ def test_the_requests_code_uploads_both_files(self): def test_the_apitestka_call_uploads_both_files(self): block = apitestka_call_block(parse_curl(self.COMMAND)) - assert 'files=[("f", open("a.txt", "rb")), ("f", open("b.txt", "rb"))],' in block + assert '("f", open("a.txt", "rb")), ("f", open("b.txt", "rb"))' in block + assert '("k", (None, "1")), ("k", (None, "2"))' in block def test_a_field_given_once_is_written_as_before(self): assert 'files={"f": open("a.txt", "rb")},' in apitestka_call_block( @@ -83,3 +84,59 @@ def test_a_recorded_multipart_upload_keeps_both_files(self): (entry,) = parse_har(har) assert form_parts(entry.request)[1] == {"f": ["a.txt", "b.txt"]} + + +def _what_requests_sends(code: str): + """Run generated ``requests`` code with the network cut out, and prepare what it would send.""" + import requests + + sent: list = [] + + def capture(method, url, **kwargs): + sent.append(requests.Request(method, url, **kwargs).prepare()) + return type("Response", (), {"status_code": 200, "text": ""})() + + namespace = {"requests": type("R", (), {"request": staticmethod(capture)})} + exec(compile(code.replace("import requests\n", ""), "", "exec"), namespace) # noqa: S102 — our own generated code, run against a stand-in + return sent[0] + + +class TestAFormIsSentAsMultipart: + """curl sends every -F form as multipart/form-data, text-only or not.""" + + def test_a_text_only_form(self): + # It was data=: requests sent it URL-encoded, name=x¬e=a+b + prepared = _what_requests_sends(to_requests_code(parse_curl("curl -F name=x -F 'note=a b' https://h/api"))) + + assert prepared.headers["Content-Type"].startswith("multipart/form-data; boundary=") + assert b'name="note"\r\n\r\na b\r\n' in prepared.body + + def test_a_copied_multipart_header_does_not_hide_the_boundary(self): + # Kept, it went out with no boundary, and requests does not replace a header it is given + command = "curl -H 'Content-Type: multipart/form-data' -H 'X-Kept: 1' -F name=x https://h/api" + prepared = _what_requests_sends(to_requests_code(parse_curl(command))) + + assert prepared.headers["Content-Type"].startswith("multipart/form-data; boundary=") + assert prepared.headers["X-Kept"] == "1" + + def test_a_json_body_keeps_its_content_type(self): + command = "curl -H 'Content-Type: application/json' -d '{\"a\": 1}' https://h/api" + prepared = _what_requests_sends(to_requests_code(parse_curl(command))) + + assert prepared.headers["Content-Type"] == "application/json" + + def test_a_recorded_multipart_form_leaves_the_browsers_boundary_out(self): + import json + + har = json.dumps({"log": {"entries": [{"request": { + "method": "POST", "url": "https://h/api", + "headers": [{"name": "Content-Type", "value": "multipart/form-data; boundary=----WebKitFormBoundaryX"}], + "postData": {"mimeType": "multipart/form-data; boundary=----WebKitFormBoundaryX", + "params": [{"name": "title", "value": "hi"}]}, + }}]}}) + (entry,) = parse_har(har) + + prepared = _what_requests_sends(to_requests_code(entry.request)) + + assert "WebKitFormBoundaryX" not in prepared.headers["Content-Type"] + assert prepared.headers["Content-Type"].split("boundary=")[1].encode() in prepared.body diff --git a/test/test_utils/test_har_import_gui.py b/test/test_utils/test_har_import_gui.py index 3e5f6ab7..29f0af53 100644 --- a/test/test_utils/test_har_import_gui.py +++ b/test/test_utils/test_har_import_gui.py @@ -204,3 +204,18 @@ def test_the_message_does_not_show_the_path(self, widget, tmp_path): shown = widget.output_edit.toPlainText() assert shown assert "private-folder-name" not in shown + + +def test_a_target_that_cannot_carry_an_upload_says_so(widget): + # The APITestka JSON action cannot open a file: it raised out of the slot + har = json.dumps({"log": {"entries": [{"request": { + "method": "POST", "url": "https://api.example.com/upload", "headers": [], + "postData": {"mimeType": "multipart/form-data", + "params": [{"name": "photo", "fileName": "a.jpg"}]}, + }}]}}) + widget.load_text(har) + widget.target_select.setCurrentIndex(widget.target_select.findData("apitestka_action")) + + widget.generate_all() + + assert "choose a Python target" in widget.output_edit.toPlainText() diff --git a/test/test_utils/test_script_templates.py b/test/test_utils/test_script_templates.py index 6f30edd1..70b4bc77 100644 --- a/test/test_utils/test_script_templates.py +++ b/test/test_utils/test_script_templates.py @@ -3,6 +3,8 @@ import json +import pytest + from pybreeze.utils.curl_import.curl_parser import parse_curl from pybreeze.utils.curl_import.request_body import body_kind, form_parts from pybreeze.utils.curl_import.request_codegen import to_requests_code @@ -59,10 +61,12 @@ def test_mixed(self): class TestRequestsCodeForm: - def test_form_uses_data_and_files(self): + def test_every_form_field_goes_in_files(self): + # data= made requests send a text-only form URL-encoded; curl sends multipart code = to_requests_code(parse_curl("curl -F 'name=x' -F 'photo=@a.jpg' https://up")) - assert "data=data" in code + assert "data=data" not in code assert "files=files" in code + assert '"name": (None, "x"),' in code assert 'open("a.jpg", "rb")' in code def test_form_code_is_valid_python(self): @@ -137,10 +141,10 @@ def test_is_valid_python(self): ) compile(to_apitestka_python(parse_curl(command)), "", "exec") - def test_form_data_and_files(self): + def test_form_fields_and_files_go_in_files(self): code = to_apitestka_python(parse_curl("curl -F 'name=x' -F 'photo=@a.jpg' https://up")) - assert "data=" in code - assert "files=" in code + assert "data=" not in code + assert '"name": (None, "x")' in code assert 'open("a.jpg", "rb")' in code compile(code, "", "exec") @@ -178,11 +182,22 @@ def test_get_flag_params(self): action = json.loads(to_apitestka_action_json(parse_curl("curl -G https://x -d 'a=1'"))) assert action[0][1]["params"] == {"a": "1"} - def test_form_data_fields_included(self): - action = json.loads( - to_apitestka_action_json(parse_curl("curl -F 'name=x' -F 'photo=@a.jpg' https://up"))) - # Plain form fields go under "data"; file uploads are omitted (no file handles in JSON). - assert action[0][1]["data"] == {"name": "x"} + def test_form_text_fields_are_sent_as_multipart(self): + action = json.loads(to_apitestka_action_json(parse_curl("curl -F 'name=x' -F 'a=b' https://up"))) + # [null, text] is requests' (None, text): a multipart field, as curl sends it + assert action[0][1]["files"] == {"name": [None, "x"], "a": [None, "b"]} + assert "data" not in action[0][1] + + @pytest.mark.parametrize("command", [ + "curl -F 'name=x' -F 'photo=@a.jpg' https://up", + "curl -d @body.json -H 'Content-Type: application/json' https://up", + ]) + def test_a_file_it_cannot_open_is_refused_not_left_out(self, command): + # The upload, or the body read from a file, was left out without a word + from pybreeze.utils.exception.exceptions import CurlParseException + + with pytest.raises(CurlParseException): + to_apitestka_action_json(parse_curl(command)) class TestLoadDensityPython: From 87f77eaf79ab8fd7da2a8ca40cc376e4e29e420f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 18:35:25 +0800 Subject: [PATCH 138/312] Read curl @file bodies as bytes the way curl sends them and stop sending a cookie file's name as a cookie --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 20 +++++++++ docs/updates/README.md | 3 +- pybreeze/utils/curl_import/curl_parser.py | 24 +++++++++-- pybreeze/utils/curl_import/request_codegen.py | 31 +++++++++----- .../utils/curl_import/script_templates.py | 6 +-- pybreeze/utils/exception/exception_tags.py | 5 ++- test/test_utils/test_curl_import.py | 8 ++-- test/test_utils/test_curl_request_fidelity.py | 42 +++++++++++++++++++ test/test_utils/test_script_templates.py | 5 ++- 10 files changed, 123 insertions(+), 25 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 8b9189b0..aa9c8483 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -344,7 +344,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 暫換成 `public_address()` 回傳的位址)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(579) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案或 `@file` body 就丟 `CurlParseException`;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | +| `curl_import/` | `curl_parser.py`(597) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯 | @@ -439,7 +439,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1657 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1662 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index aa8f664c..b942606f 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1922,3 +1922,23 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_curl_request_fidelity.py`, `test_script_templates.py`, `test_har_import_gui.py` - `architecture_explore.md` §12, §18 - **Open items**: the same sweep's `-d @file` / `--data-binary @file` reading, `-b file`, `-G` with `@file`, the Diff tab's UI-thread work and the re-encoded query string follow in later stages. + +## U-20260923-109 · 2026-09-23 · cURL import reads @file bodies as curl sends them and stops sending a -b cookie file's name as a cookie · #fix #tools + +- **What**: four `@file` and `-b` mistranslations, found in the review sweep of `tools_gui/`; each reproduced. + - A body read from a file (`-d @f`, `--data-binary @f`) was generated as `open(f, encoding="utf-8").read()`. A binary file (`--data-binary @photo.jpg`) made the script raise `UnicodeDecodeError`, text mode rewrote CRLF on Windows, and a `-d` file kept the carriage returns and newlines curl strips. + - `curl -b cookies.txt` names a cookie file curl reads; it was sent as the header `Cookie: cookies.txt`. + - `curl -G -d @query.txt` sent the file as a GET body, not as the query string. +- **Fix**: + - `CurlRequest.binary_data_files` marks the files given with `--data-binary`. `data_from_file_expr` reads every file as bytes, `--data-binary` exactly and `-d` without `\r` and `\n`, inline data `.encode()`d, joined by `b"&"`. + - `-b ` goes to `CurlRequest.cookie_files`. The Python targets write a comment saying the script does not read it (`cookie_file_notes`); the APITestka JSON action refuses it, as it refuses uploads. + - `-G` with a body file raises `CurlParseException` (`get_with_file_body_error`): the query is the file's content, which the script cannot know. +- **Tests**: + - `test_curl_request_fidelity.py` +5, running the generated code against a stand-in `requests`: a binary file is sent byte for byte; a `-d` file loses its line breaks, joined after the inline part; `-G` with a file is refused; the cookie file is a comment, not a header; the JSON action refuses it. + - Three tests asserting the old UTF-8 reading and the `Cookie` header now assert the new behaviour. +- **Result / numbers**: cURL, HAR, template and fuzz tests 341 passed; 1662 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/curl_parser.py`, `request_codegen.py`, `script_templates.py`, `pybreeze/utils/exception/exception_tags.py` + - `test/test_utils/test_curl_request_fidelity.py`, `test_curl_import.py`, `test_script_templates.py` + - `architecture_explore.md` §12, §18 +- **Open items**: the Diff tab's UI-thread work and the re-encoded query string, from the same sweep. diff --git a/docs/updates/README.md b/docs/updates/README.md index e031b374..090e5398 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-109 | 2026-09-23 | cURL import reads @file bodies as curl sends them and stops sending a -b cookie file's name as a cookie | #fix #tools | [2026-09](2026-09.md) | | U-20260923-108 | 2026-09-23 | cURL and HAR import send a form as multipart without the copied Content-Type, and the JSON action refuses what it cannot carry | #fix #tools | [2026-09](2026-09.md) | | U-20260923-107 | 2026-09-23 | Tool output is copied and saved as generated, and Regex and HAR report patterns and files they cannot take | #fix #tools | [2026-09](2026-09.md) | | U-20260923-106 | 2026-09-23 | prthinker settings refuse extra arguments with an open quote at Save instead of dropping them at run time | #fix #prthinker | [2026-09](2026-09.md) | @@ -189,4 +190,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 126 | +| [2026-09.md](2026-09.md) | 2026-09 | 127 | diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index 8a0e5de1..9eacf746 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -19,6 +19,7 @@ from pybreeze.utils.exception.exception_tags import ( empty_curl_command_error, + get_with_file_body_error, invalid_http_method_error, malformed_curl_command_error, malformed_url_error, @@ -76,6 +77,9 @@ class CurlRequest: :param form_strings: multipart ``name=value`` fields taken literally, from ``--form-string`` or a recorded text field; ``@`` means nothing there :param data_file_refs: filenames whose content forms the body (``-d @file``) + :param binary_data_files: those of them given with ``--data-binary``, sent + byte for byte; curl drops carriage returns and newlines from the others + :param cookie_files: files ``-b`` names, which curl reads cookies from :param timeout: request timeout in seconds from ``--max-time`` / ``-m``, or ``None`` when the command sets none :param cookies: cookies parsed from ``-b`` / ``--cookie`` name=value pairs @@ -94,6 +98,8 @@ class CurlRequest: form_fields: list[str] = field(default_factory=list) form_strings: list[str] = field(default_factory=list) data_file_refs: list[str] = field(default_factory=list) + binary_data_files: set[str] = field(default_factory=set) + cookie_files: list[str] = field(default_factory=list) timeout: str | None = None cookies: dict[str, str] = field(default_factory=dict) @@ -139,7 +145,7 @@ def header_value(self, name: str) -> str | None: # ``-d`` and friends honour curl's ``@file`` syntax; ``--data-raw`` / # ``--data-urlencode`` are always literal (a leading ``@`` is data, not a file). "-d": "data_file", "--data": "data_file", - "--data-ascii": "data_file", "--data-binary": "data_file", + "--data-ascii": "data_file", "--data-binary": "data_binary", "--data-raw": "data", "--data-urlencode": "data_urlencode", "--json": "json_flag", "-F": "form", "--form": "form", "--form-string": "form_string", @@ -377,14 +383,22 @@ def _apply_data_or_file(request: CurlRequest, value: str) -> None: request.data_parts.append(value) +def _apply_binary_data(request: CurlRequest, value: str) -> None: + """Record a ``--data-binary`` value; its ``@file`` is sent byte for byte.""" + _apply_data_or_file(request, value) + if value.startswith("@"): + request.binary_data_files.add(value[1:]) + + def _apply_cookie(request: CurlRequest, value: str) -> None: """Parse a ``-b`` cookie string into ``name=value`` pairs. ``curl -b 'a=1; b=2'`` yields inline cookies; a value with no ``=`` is a - cookie *file* curl would read, which we keep as a ``Cookie`` header instead. + cookie *file* curl reads. It was sent as the header ``Cookie: cookies.txt``; + it is kept in ``cookie_files`` for the generators to say so. """ if "=" not in value: - set_default_header(request.headers, "Cookie", value) + request.cookie_files.append(value) return for segment in value.split(";"): name, separator, cookie_value = segment.strip().partition("=") @@ -426,6 +440,7 @@ def _set_url(request: CurlRequest, value: str) -> None: "data": lambda request, value: request.data_parts.append(value), "data_urlencode": lambda request, value: request.data_parts.append(_urlencode_data_part(value)), "data_file": _apply_data_or_file, + "data_binary": _apply_binary_data, "json_flag": _apply_json_flag, "form": lambda request, value: request.form_fields.append(value), "form_string": lambda request, value: request.form_strings.append(value), @@ -486,6 +501,9 @@ def _finalise_method(request: CurlRequest) -> None: set_default_header(request.headers, "Authorization", f"Bearer {request.bearer_token}") if request.method == _DEFAULT_METHOD and request.has_body and not request.send_data_as_params: request.method = _METHOD_WITH_BODY + if request.send_data_as_params and request.data_file_refs: + # The file's content is the query, and it is not known until the script runs + raise CurlParseException(get_with_file_body_error) if request.send_data_as_params: # With -G, curl appends the data to the URL exactly as given, joined by # '&': each fragment is query text already, so it is split on '&' and diff --git a/pybreeze/utils/curl_import/request_codegen.py b/pybreeze/utils/curl_import/request_codegen.py index 77b27c74..1b19b2f0 100644 --- a/pybreeze/utils/curl_import/request_codegen.py +++ b/pybreeze/utils/curl_import/request_codegen.py @@ -67,20 +67,31 @@ def _format_form(entries: list[FormEntry]) -> str: def data_from_file_expr(request: CurlRequest) -> str: """Build a Python expression reading the body from its ``@file`` reference(s). - curl joins several data pieces with ``&``; inline data is emitted as a string - literal and each file as ``open(...).read()``. + curl joins several data pieces with ``&`` and sends bytes, as it reads them: + a ``--data-binary`` file exactly, a ``-d`` file without its carriage + returns and newlines. The file was read as UTF-8 text, so a binary one + raised ``UnicodeDecodeError`` and a ``-d`` file kept its line breaks. :param request: the parsed curl request (must have ``data_file_refs``) - :return: a Python expression, e.g. ``open("body.json", encoding="utf-8").read()`` + :return: a Python expression, e.g. ``open("body.bin", "rb").read()`` """ pieces: list[str] = [] if request.data_parts: - pieces.append(python_string(request.body)) - pieces.extend( - f'open({python_string(name)}, encoding="utf-8").read()' - for name in request.data_file_refs - ) - return ' + "&" + '.join(pieces) + pieces.append(f"{python_string(request.body)}.encode()") + for name in request.data_file_refs: + read = f'open({python_string(name)}, "rb").read()' + pieces.append(read if name in request.binary_data_files + else f'{read}.replace(b"\\r", b"").replace(b"\\n", b"")') + return ' + b"&" + '.join(pieces) + + +def cookie_file_notes(request: CurlRequest) -> list[str]: + """Comment lines saying which cookie files ``-b`` named, which the script does not read. + + They were sent as the header ``Cookie: cookies.txt``. + """ + return [f"# curl read cookies from {python_string(name)} (-b); add them to cookies= to send them" + for name in request.cookie_files] def python_string(text: str) -> str: @@ -186,7 +197,7 @@ def request_statements(request: CurlRequest) -> list[str]: :return: the statement blocks in order """ payload_sections, payload_kwargs = payload_python_parts(request) - statements: list[str] = [f"url = {python_string(request.url)}"] + statements: list[str] = cookie_file_notes(request) + [f"url = {python_string(request.url)}"] headers_block = _format_dict("headers", sent_headers(request)) if headers_block is not None: diff --git a/pybreeze/utils/curl_import/script_templates.py b/pybreeze/utils/curl_import/script_templates.py index 34fdf7a7..487eaf5f 100644 --- a/pybreeze/utils/curl_import/script_templates.py +++ b/pybreeze/utils/curl_import/script_templates.py @@ -22,7 +22,7 @@ from pybreeze.utils.exception.exceptions import CurlParseException from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.curl_import.request_codegen import ( - REQUESTS_IMPORT, data_from_file_expr, form_has_repeats, form_value_expr, python_literal, + REQUESTS_IMPORT, cookie_file_notes, data_from_file_expr, form_has_repeats, form_value_expr, python_literal, python_string, request_statements, to_requests_code, ) @@ -74,7 +74,7 @@ def apitestka_call_block(request: CurlRequest) -> str: :param request: the parsed request :return: the call statement, as one block """ - lines = [ + lines = cookie_file_notes(request) + [ "response = test_api_method_requests(", f" {_inline_json(request.method)},", f" test_url={_inline_json(request.url)},", @@ -184,7 +184,7 @@ def _apply_action_payload(request: CurlRequest, params: dict) -> None: elif entries: params["files"] = {name: [None, value] for name, _is_file, value in entries} return - if request.data_file_refs: + if request.data_file_refs or request.cookie_files: pybreeze_logger.error(action_cannot_read_files_error) raise CurlParseException(action_cannot_read_files_error) kind = body_kind(request) diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index 29773e06..83a780c5 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -43,8 +43,11 @@ not_a_curl_command_error: str = "the command does not look like a curl command" malformed_curl_command_error: str = "can't parse the curl command: check quoting" no_url_in_curl_error: str = "no URL found in the curl command" +get_with_file_body_error: str = ( + "-G sends the data as the query string, which a generated script cannot read from a file: give it inline" +) action_cannot_read_files_error: str = ( - "an APITestka JSON action cannot upload a file or read a body from one: choose a Python target" + "an APITestka JSON action cannot upload a file or read a body or cookies from one: choose a Python target" ) malformed_url_error: str = "the URL is malformed (for example an unclosed [ or a port that is not a number)" invalid_http_method_error: str = ( diff --git a/test/test_utils/test_curl_import.py b/test/test_utils/test_curl_import.py index ff827f06..73ea4d20 100644 --- a/test/test_utils/test_curl_import.py +++ b/test/test_utils/test_curl_import.py @@ -377,11 +377,13 @@ def test_long_cookie_flag(self): request = parse_curl("curl --cookie 'a=1' https://x") assert request.cookies == {"a": "1"} - def test_cookie_file_falls_back_to_header(self): - # A bare token with no '=' is a cookie file curl would read, not pairs. + def test_a_cookie_file_is_kept_as_a_file_not_sent_as_a_cookie(self): + # A bare token with no '=' is a cookie file curl reads. It was sent as + # the header "Cookie: cookies.txt". request = parse_curl("curl -b cookies.txt https://x") assert request.cookies == {} - assert request.headers["Cookie"] == "cookies.txt" + assert "Cookie" not in request.headers + assert request.cookie_files == ["cookies.txt"] def test_no_cookies_by_default(self): assert parse_curl("curl https://x").cookies == {} diff --git a/test/test_utils/test_curl_request_fidelity.py b/test/test_utils/test_curl_request_fidelity.py index 0ae75df0..5071a59a 100644 --- a/test/test_utils/test_curl_request_fidelity.py +++ b/test/test_utils/test_curl_request_fidelity.py @@ -140,3 +140,45 @@ def test_a_recorded_multipart_form_leaves_the_browsers_boundary_out(self): assert "WebKitFormBoundaryX" not in prepared.headers["Content-Type"] assert prepared.headers["Content-Type"].split("boundary=")[1].encode() in prepared.body + + +class TestABodyReadFromAFile: + """curl sends a file's bytes: --data-binary as they are, -d without CR and LF.""" + + def _sent_body(self, command: str, folder, monkeypatch) -> bytes: + monkeypatch.chdir(folder) + return _what_requests_sends(to_requests_code(parse_curl(command))).body + + def test_a_binary_file_is_sent_byte_for_byte(self, tmp_path, monkeypatch): + # It was read as UTF-8 text, and the script raised UnicodeDecodeError + (tmp_path / "photo.jpg").write_bytes(b"\xff\xfe\x00\r\n1") + + assert self._sent_body("curl --data-binary @photo.jpg https://h/up", tmp_path, monkeypatch) == ( + b"\xff\xfe\x00\r\n1") + + def test_a_data_file_loses_its_line_breaks_as_curl_sends_it(self, tmp_path, monkeypatch): + (tmp_path / "body.txt").write_bytes(b"a=1\r\nb=2\n") + + assert self._sent_body("curl -d x=0 -d @body.txt https://h/up", tmp_path, monkeypatch) == b"x=0&a=1b=2" + + def test_get_with_a_body_file_is_refused(self): + # -G makes the file's content the query, which the script cannot know + from pybreeze.utils.exception.exceptions import CurlParseException + + with pytest.raises(CurlParseException): + parse_curl("curl -G -d @query.txt https://h/find") + + +class TestACookieFile: + def test_the_script_says_it_does_not_read_it(self): + code = to_requests_code(parse_curl("curl -b cookies.txt https://h/")) + + assert '# curl read cookies from "cookies.txt" (-b)' in code + assert "Cookie" not in code.split("# curl read cookies")[1].split("\n", 1)[1] + + def test_the_json_action_refuses_it(self): + from pybreeze.utils.curl_import.script_templates import to_apitestka_action_json + from pybreeze.utils.exception.exceptions import CurlParseException + + with pytest.raises(CurlParseException): + to_apitestka_action_json(parse_curl("curl -b cookies.txt https://h/")) diff --git a/test/test_utils/test_script_templates.py b/test/test_utils/test_script_templates.py index 70b4bc77..ef004982 100644 --- a/test/test_utils/test_script_templates.py +++ b/test/test_utils/test_script_templates.py @@ -85,8 +85,9 @@ def test_json_flag_uses_json_kwarg(self): assert "json_body = {" in code def test_data_file_reads_file(self): + # As curl does for -d: the bytes, without carriage returns and newlines code = to_requests_code(parse_curl("curl -d @body.json https://x")) - assert 'data = open("body.json", encoding="utf-8").read()' in code + assert 'data = open("body.json", "rb").read().replace(b"\\r", b"").replace(b"\\n", b"")' in code assert "data=data" in code def test_data_file_code_is_valid_python(self): @@ -94,7 +95,7 @@ def test_data_file_code_is_valid_python(self): def test_apitestka_python_data_file(self): code = to_apitestka_python(parse_curl("curl -d @body.json https://x")) - assert 'open("body.json", encoding="utf-8").read()' in code + assert 'open("body.json", "rb").read()' in code compile(code, "", "exec") From ed1e621c7506e3d5f414f561ca54bd2ec1a57777 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 18:40:55 +0800 Subject: [PATCH 139/312] Compare texts once and off the UI thread in the Diff tab --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 18 ++++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 1 + .../extend_traditional_chinese.py | 1 + pybreeze/pybreeze_ui/tools_gui/diff_gui.py | 49 ++++++++-- pybreeze/utils/diff_tools/text_diff.py | 96 ++++++++++++++----- test/test_utils/test_diff_gui.py | 60 +++++++++++- test/test_utils/test_exact_text.py | 2 + test/test_utils/test_text_diff.py | 23 +++++ 10 files changed, 221 insertions(+), 36 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index aa9c8483..d957278d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -223,7 +223,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | | `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組。pattern 在另一個行程裡跑(`find_matches_bounded()`,spawn,5 秒後結束掉),分頁用 `RegexMatchThread` 等它:`re` 開始比對後就停不下來,災難性回溯只有整個行程能停 | | `HttpStatusGUI` | `utils/http_reference/` | 狀態碼參考,可依碼前綴或描述搜尋 | -| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計(統計取自 unified diff 同一套 `SequenceMatcher` 的 opcodes,和畫面上的 diff 一致;以前用 `ndiff`,逐行再比字元,3000 行全改要一分多鐘)。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | +| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計,`compare_texts()` 只比對一次、兩者共用同一個 `SequenceMatcher`(diff 照 `difflib.unified_diff` 的格式從它的 grouped opcodes 寫出),在 `DiffThread` 上算、不佔 UI 執行緒(4 萬行要四秒多),比對中按鈕停用、關閉時交給 `let_run_out()`。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | | `JsonFormatGUI` | `utils/json_format/` | 美化 / 壓縮 / 驗證 | | `HeaderAnalyzerGUI` | `utils/header_tools/` | HTTP header 安全稽核(HSTS、CSP、CORS、Set-Cookie、banner…)| | `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT | @@ -439,7 +439,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1662 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1665 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index b942606f..a8877b56 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1942,3 +1942,21 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_curl_request_fidelity.py`, `test_curl_import.py`, `test_script_templates.py` - `architecture_explore.md` §12, §18 - **Open items**: the Diff tab's UI-thread work and the re-encoded query string, from the same sweep. + +## U-20260923-110 · 2026-09-23 · The Diff tab matches the lines once and off the UI thread · #fix #tools #performance + +- **What**: `DiffGUI.compare` ran `diff_summary` and then `unified_diff` on the UI thread, and each built its own `SequenceMatcher` over every line. Measured here on two pretty-printed JSON arrays of 40,000 lines with a tenth of the values changed: 4.1 s for the counts plus 4.9 s for the diff, the IDE frozen throughout. The review sweep of `tools_gui/` measured 10.6 s at 50,000 lines. +- **Fix**: + - New `text_diff.compare_texts()` matches the lines once and returns a `Comparison` (the `DiffSummary` and the diff text). The unified diff is written from that matcher's grouped opcodes exactly as `difflib.unified_diff` writes it (`_unified_lines`, `_unified_range`); checked against `difflib` on 3,000 random cases. `unified_diff` and `diff_summary` stay, as wrappers. + - `DiffGUI` compares on a `DiffThread`: "Comparing…" (`diff_comparing`, both languages) until the result arrives, the button off meanwhile, a second Compare ignored while one runs, and a comparison still running on close left to `let_run_out`. +- **Tests**: + - `test_text_diff.py` +1: a Hypothesis property that `compare_texts` writes `difflib.unified_diff`'s diff. + - `test_diff_gui.py` +2: with 20,000-line texts, `compare()` returns in under half a second and the counts arrive after; a second Compare while one runs is ignored. + - The existing tab tests and `test_exact_text.py`'s diff test now wait for the result. +- **Result / numbers**: diff and exact-text tests 27 passed, text diff 17; 1665 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/diff_tools/text_diff.py`, `pybreeze/pybreeze_ui/tools_gui/diff_gui.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_text_diff.py`, `test_diff_gui.py`, `test_exact_text.py` + - `architecture_explore.md` §6, §16, §18 +- **Open items**: the query string re-encoded by the cURL and HAR import, from the same sweep. diff --git a/docs/updates/README.md b/docs/updates/README.md index 090e5398..ac9193b3 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-110 | 2026-09-23 | The Diff tab matches the lines once and off the UI thread | #fix #tools #performance | [2026-09](2026-09.md) | | U-20260923-109 | 2026-09-23 | cURL import reads @file bodies as curl sends them and stops sending a -b cookie file's name as a cookie | #fix #tools | [2026-09](2026-09.md) | | U-20260923-108 | 2026-09-23 | cURL and HAR import send a form as multipart without the copied Content-Type, and the JSON action refuses what it cannot carry | #fix #tools | [2026-09](2026-09.md) | | U-20260923-107 | 2026-09-23 | Tool output is copied and saved as generated, and Regex and HAR report patterns and files they cannot take | #fix #tools | [2026-09](2026-09.md) | @@ -190,4 +191,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 127 | +| [2026-09.md](2026-09.md) | 2026-09 | 128 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index b1eec6fa..d7dd33b6 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -541,6 +541,7 @@ "diff_right_label": "Actual:", "diff_compare_button": "Compare", "diff_identical": "The two texts are identical.", + "diff_comparing": "Comparing…", "diff_summary": "{added} line(s) added, {removed} line(s) removed.", # JSON Format — Menu "extend_tools_menu_json_format_tab_action": "JSON Format Tab", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 31df2d66..fd3de545 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -515,6 +515,7 @@ "diff_right_label": "實際:", "diff_compare_button": "比較", "diff_identical": "兩段文字完全相同。", + "diff_comparing": "比對中…", "diff_summary": "新增 {added} 行,移除 {removed} 行。", # JSON 格式化 — 選單 "extend_tools_menu_json_format_tab_action": "JSON 格式化分頁", diff --git a/pybreeze/pybreeze_ui/tools_gui/diff_gui.py b/pybreeze/pybreeze_ui/tools_gui/diff_gui.py index 4ace037e..96ed572a 100644 --- a/pybreeze/pybreeze_ui/tools_gui/diff_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/diff_gui.py @@ -1,6 +1,7 @@ """A tool tab that shows a unified diff between two pieces of text.""" from __future__ import annotations +from PySide6.QtCore import QThread, Signal from PySide6.QtWidgets import ( QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget ) @@ -8,7 +9,8 @@ from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions -from pybreeze.utils.diff_tools.text_diff import DiffSummary, diff_summary, unified_diff +from pybreeze.pybreeze_ui.thread_keeper import let_run_out +from pybreeze.utils.diff_tools.text_diff import Comparison, DiffSummary, compare_texts def build_summary_line(summary: DiffSummary) -> str: @@ -23,6 +25,20 @@ def build_summary_line(summary: DiffSummary) -> str: return word.get("diff_summary").format(added=summary.added, removed=summary.removed) +class DiffThread(QThread): + """One comparison, worked out off the UI thread.""" + + compared = Signal(object) + + def __init__(self, left: str, right: str) -> None: + super().__init__() + self._left = left + self._right = right + + def run(self) -> None: + self.compared.emit(compare_texts(self._left, self._right)) + + class DiffGUI(QWidget): """Paste two texts and see how they differ.""" @@ -68,10 +84,31 @@ def __init__(self, main_window=None) -> None: layout.addWidget(self.output_edit) layout.addLayout(self.actions.button_row()) self.setLayout(layout) + self._compare_thread: DiffThread | None = None def compare(self) -> None: - """Compute and show the diff and summary of the two inputs.""" - left = exact_text(self.left_edit) - right = exact_text(self.right_edit) - self.summary_label.setText(build_summary_line(diff_summary(left, right))) - self.output_edit.setPlainText(unified_diff(left, right)) + """Compare the two inputs; the diff and the summary arrive when it is done. + + Matching the lines of two large texts takes seconds (a 40,000-line JSON + response, over four), and it ran on the UI thread, twice, freezing the IDE. + """ + if self._compare_thread is not None and self._compare_thread.isRunning(): + return + self.compare_button.setEnabled(False) + self.summary_label.setText(language_wrapper.language_word_dict.get("diff_comparing")) + thread = DiffThread(exact_text(self.left_edit), exact_text(self.right_edit)) + thread.compared.connect(self._show) + thread.finished.connect(lambda: self.compare_button.setEnabled(True)) + self._compare_thread = thread + thread.start() + + def _show(self, comparison: Comparison) -> None: + self.summary_label.setText(build_summary_line(comparison.summary)) + self.output_edit.setPlainText(comparison.diff) + + def closeEvent(self, event) -> None: + """Let a comparison still going run out, cut off from this tab.""" + thread = self._compare_thread + if thread is not None and thread.isRunning(): + let_run_out(thread, thread.compared) + super().closeEvent(event) diff --git a/pybreeze/utils/diff_tools/text_diff.py b/pybreeze/utils/diff_tools/text_diff.py index 01c8fbdf..c12f1dde 100644 --- a/pybreeze/utils/diff_tools/text_diff.py +++ b/pybreeze/utils/diff_tools/text_diff.py @@ -62,6 +62,75 @@ def _shown(diff_line: str) -> str: return f"{content} (line ends with {ending!r})" +@dataclass(frozen=True) +class Comparison: + """How two texts differ: the counts and the unified diff, from one line matching. + + :param summary: the counts + :param diff: the unified diff text (empty when the texts are identical) + """ + + summary: DiffSummary + diff: str + + +def _unified_range(start: int, stop: int) -> str: + """A hunk header's ``start,length``, as ``diff -u`` and ``difflib.unified_diff`` write it.""" + length = stop - start + if length == 1: + return str(start + 1) + return f"{start if length == 0 else start + 1},{length}" + + +def _unified_lines( + matcher: difflib.SequenceMatcher, left_lines: list[str], right_lines: list[str], + labels: tuple[str, str]) -> list[str]: + """The unified diff lines of *matcher*'s match, as ``difflib.unified_diff`` writes them.""" + lines: list[str] = [] + for group in matcher.get_grouped_opcodes(_CONTEXT_LINES): + if not lines: + lines += [f"--- {labels[0]}", f"+++ {labels[1]}"] + first, last = group[0], group[-1] + lines.append(f"@@ -{_unified_range(first[1], last[2])} +{_unified_range(first[3], last[4])} @@") + for tag, left_start, left_end, right_start, right_end in group: + if tag == "equal": + lines += [f" {line}" for line in left_lines[left_start:left_end]] + continue + if tag in ("replace", "delete"): + lines += [f"-{line}" for line in left_lines[left_start:left_end]] + if tag in ("replace", "insert"): + lines += [f"+{line}" for line in right_lines[right_start:right_end]] + return lines + + +def compare_texts( + left: str, right: str, + left_label: str = _LEFT_LABEL, right_label: str = _RIGHT_LABEL) -> Comparison: + """Compare *left* and *right* once, for both the counts and the unified diff. + + Matching the lines is the slow part: the counts and the diff each did it + on their own, which doubled a wait of several seconds on large inputs. + + :param left: the first (expected) text + :param right: the second (actual) text + :param left_label: header label for the first text + :param right_label: header label for the second text + :return: the counts and the diff + """ + left_lines, right_lines = _line_lists(left, right) + matcher = difflib.SequenceMatcher(None, left_lines, right_lines) + added = 0 + removed = 0 + for tag, left_start, left_end, right_start, right_end in matcher.get_opcodes(): + if tag in ("replace", "delete"): + removed += left_end - left_start + if tag in ("replace", "insert"): + added += right_end - right_start + diff = "\n".join( + _shown(line) for line in _unified_lines(matcher, left_lines, right_lines, (left_label, right_label))) + return Comparison(DiffSummary(added=added, removed=removed, is_equal=left == right), diff) + + def unified_diff( left: str, right: str, left_label: str = _LEFT_LABEL, right_label: str = _RIGHT_LABEL) -> str: @@ -73,37 +142,18 @@ def unified_diff( :param right_label: header label for the second text :return: the unified diff text (empty when the inputs are identical) """ - left_lines, right_lines = _line_lists(left, right) - diff_lines = difflib.unified_diff( - left_lines, - right_lines, - fromfile=left_label, - tofile=right_label, - lineterm="", - n=_CONTEXT_LINES, - ) - return "\n".join(_shown(line) for line in diff_lines) + return compare_texts(left, right, left_label, right_label).diff def diff_summary(left: str, right: str) -> DiffSummary: """Summarise how *left* and *right* differ, line by line. - The counts come from the same line matching ``unified_diff`` uses, so they + The counts come from the same line matching as the unified diff, so they agree with the diff shown beside them. ``difflib.ndiff`` also compared the - characters inside every changed line, which the counts never needed: two - 3000-line texts with every line changed took over a minute, on the UI - thread. + characters inside every changed line, which the counts never needed. :param left: the first (expected) text :param right: the second (actual) text :return: the counts of added and removed lines and whether they are equal """ - matcher = difflib.SequenceMatcher(None, *_line_lists(left, right)) - added = 0 - removed = 0 - for tag, left_start, left_end, right_start, right_end in matcher.get_opcodes(): - if tag in ("replace", "delete"): - removed += left_end - left_start - if tag in ("replace", "insert"): - added += right_end - right_start - return DiffSummary(added=added, removed=removed, is_equal=left == right) + return compare_texts(left, right).summary diff --git a/test/test_utils/test_diff_gui.py b/test/test_utils/test_diff_gui.py index 8c2b8e49..42df3fe3 100644 --- a/test/test_utils/test_diff_gui.py +++ b/test/test_utils/test_diff_gui.py @@ -27,11 +27,25 @@ def widget(app): gui.deleteLater() +def _compare(widget, *, start: bool = True) -> None: + """Compare (unless *start* is false), and wait for the result: it is worked out off the UI thread.""" + import time + + if start: + widget.compare() + deadline = time.monotonic() + 120 + while widget._compare_thread.isRunning() and time.monotonic() < deadline: + QApplication.processEvents() + time.sleep(0.01) + widget._compare_thread.wait(1000) + QApplication.processEvents() + + class TestDiffGUI: def test_shows_diff(self, widget): widget.left_edit.setPlainText("a\nb") widget.right_edit.setPlainText("a\nc") - widget.compare() + _compare(widget) output = widget.output_edit.toPlainText() assert "-b" in output assert "+c" in output @@ -39,21 +53,21 @@ def test_shows_diff(self, widget): def test_identical_summary(self, widget): widget.left_edit.setPlainText("same") widget.right_edit.setPlainText("same") - widget.compare() + _compare(widget) assert widget.summary_label.text() != "" assert widget.output_edit.toPlainText() == "" def test_summary_counts(self, widget): widget.left_edit.setPlainText("a") widget.right_edit.setPlainText("a\nb") - widget.compare() + _compare(widget) # The summary mentions one added line. assert "1" in widget.summary_label.text() def test_copy_output(self, app, widget): widget.left_edit.setPlainText("a") widget.right_edit.setPlainText("b") - widget.compare() + _compare(widget) widget.actions.copy() assert QApplication.clipboard().text() != "" @@ -62,3 +76,41 @@ def test_build_summary_line_identical(self, app): from pybreeze.utils.diff_tools.text_diff import DiffSummary text = build_summary_line(DiffSummary(added=0, removed=0, is_equal=True)) assert text != "" + + +class TestALargeComparison: + def test_the_window_keeps_answering_while_it_is_compared(self, widget): + # Large texts took seconds to compare (40,000 lines over four), on the UI thread + import time + + rows = [f' {{"id": {i}, "value": {i * 7 % 13}}},' for i in range(20000)] + widget.left_edit.setPlainText("\n".join(rows)) + rows[::10] = [row.replace("value", "VALUE") for row in rows[::10]] + widget.right_edit.setPlainText("\n".join(rows)) + + started = time.monotonic() + widget.compare() + returned = time.monotonic() - started + _compare(widget, start=False) + + assert returned < 0.5 + assert "2000 line(s) added" in widget.summary_label.text() + + def test_a_second_compare_while_one_runs_is_ignored(self, widget, monkeypatch): + import threading + + from pybreeze.pybreeze_ui.tools_gui import diff_gui + + release = threading.Event() + real = diff_gui.compare_texts + monkeypatch.setattr(diff_gui, "compare_texts", lambda left, right: release.wait(10) and real(left, right)) + widget.left_edit.setPlainText("a") + widget.right_edit.setPlainText("b") + widget.compare() + first = widget._compare_thread + widget.compare() + + assert widget._compare_thread is first + release.set() + _compare(widget, start=False) + assert "+b" in widget.output_edit.toPlainText() diff --git a/test/test_utils/test_exact_text.py b/test/test_utils/test_exact_text.py index 03cadfe9..14117eab 100644 --- a/test/test_utils/test_exact_text.py +++ b/test/test_utils/test_exact_text.py @@ -54,6 +54,8 @@ def test_the_diff_sees_a_non_breaking_space(app): tool.right_edit.setPlainText("price: 100") tool.compare() + tool._compare_thread.wait(10000) + QApplication.processEvents() # It called the two identical. assert tool.output_edit.toPlainText() diff --git a/test/test_utils/test_text_diff.py b/test/test_utils/test_text_diff.py index 5005d775..47572e1b 100644 --- a/test/test_utils/test_text_diff.py +++ b/test/test_utils/test_text_diff.py @@ -1,6 +1,9 @@ """Tests for the text diff utility.""" from __future__ import annotations +from hypothesis import given, settings +from hypothesis import strategies as st + from pybreeze.utils.diff_tools.text_diff import diff_summary, unified_diff @@ -106,3 +109,23 @@ def test_a_different_line_ending_is_named(self): def test_other_changes_are_counted_as_before(self): # A line added after a last line without a newline is one added line. assert diff_summary("a", "a\nb").added == 1 + + +_LINES = st.lists(st.sampled_from(["a", "b", "c", "", "d e"]), max_size=25) + + +@settings(max_examples=300, deadline=None) +@given(left=_LINES, right=_LINES) +def test_the_diff_is_the_one_difflib_writes(left, right): + # One line matching now serves the counts and the diff; the diff must stay + # exactly difflib.unified_diff's + import difflib + + from pybreeze.utils.diff_tools.text_diff import _line_lists, _shown, compare_texts + + left_text, right_text = "\n".join(left), "\n".join(right) + left_lines, right_lines = _line_lists(left_text, right_text) + expected = "\n".join(_shown(line) for line in difflib.unified_diff( + left_lines, right_lines, "expected", "actual", lineterm="", n=3)) + + assert compare_texts(left_text, right_text).diff == expected From 3030cb989ae215891c254f95df8285eb7c9e3a61 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 18:41:14 +0800 Subject: [PATCH 140/312] List every worker QThread in the architecture map's threading model --- architecture_explore.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/architecture_explore.md b/architecture_explore.md index d957278d..72b3ece8 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -412,10 +412,14 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ├── QThread: SftpListThread / SftpTransferThread ──Signal──► file tree ├── QThread: SenderThread (CoT) ──Signal──► review UI ├── QThread: RequestThread(Skills)──Signal──► result UI + ├── QThread: ReviewRequestThread ──Signal──► AI review client panel + ├── QThread: ImageDownloadThread ──Signal──► diagram canvas + ├── QThread: RegexMatchThread ──Signal──► Regex tab(pattern 在另一個行程跑) + ├── QThread: DiffThread ──Signal──► Diff tab └── QThread: JupyterLauncherThread──Signal──► QWebEngineView ``` -鐵律:worker thread 一律不碰 UI。普通執行緒走 Queue + QTimer,`QThread` 走 Signal/Slot。 +鐵律:worker thread 一律不碰 UI。普通執行緒走 Queue + QTimer,`QThread` 走 Signal/Slot。分頁或視窗關閉時還在跑的 `QThread` 交給 `thread_keeper.let_run_out()`,不等它、也不讓它在執行中被銷毀。 執行器的壽命:QTimer 接到執行器的 `pull_text()` / `_pull_text()` 這條連線**不會**讓執行器活著;reader 執行緒一結束,沒人持有的執行器就會被 GC,剩下的輸出和結束那一行都不會出現。所以執行器一律掛在它寫入的 `CodeWindow.runner` 上,跟視窗同壽;視窗又掛在主視窗的 `current_run_code_window`。 From c0d26e12716f2159383d42a2c2f6314418b2fa07 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 18:43:42 +0800 Subject: [PATCH 141/312] Keep an imported query that would not survive re-encoding as it was written --- architecture_explore.md | 4 ++-- docs/updates/2026-09.md | 19 +++++++++++++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/curl_import/curl_parser.py | 18 ++++++++++++++++-- pybreeze/utils/har_import/har_parser.py | 14 ++++++++++---- test/test_utils/test_curl_import.py | 15 +++++++++++++-- test/test_utils/test_curl_request_fidelity.py | 18 ++++++++++++++++++ test/test_utils/test_har_import.py | 12 +++++++++--- 8 files changed, 89 insertions(+), 14 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 72b3ece8..a9f3f604 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -344,7 +344,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 暫換成 `public_address()` 回傳的位址)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(597) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | +| `curl_import/` | `curl_parser.py`(611) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_round_trips()`;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯 | @@ -443,7 +443,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1665 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1675 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index a8877b56..b160dfa3 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1960,3 +1960,22 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_text_diff.py`, `test_diff_gui.py`, `test_exact_text.py` - `architecture_explore.md` §6, §16, §18 - **Open items**: the query string re-encoded by the cURL and HAR import, from the same sweep. + +## U-20260923-111 · 2026-09-23 · cURL and HAR import send a query that would not survive re-encoding as it was written · #fix #tools + +- **What**: the URL's query was always decoded into `params` (`parse_qsl`) and encoded again on the way out (`urlencode`, or `requests`' own). `curl 'https://h/a?flag&q=%B0&r=/x&s=a,b'` went out as `?flag=&q=%EF%BF%BD&r=%2Fx&s=a%2Cb`: a bare key gained `=`, a byte that is not UTF-8 became U+FFFD, and `/`, `,` and `%20` were re-spelled. That breaks a signed URL (an S3 presigned link) and any server that reads the query byte for byte. It affected the generated code, the URL Builder hand-off, the LoadDensity target and the HAR list. Found in the review sweep of `tools_gui/`; reproduced. +- **Fix**: + - New `curl_parser.query_round_trips(query)`: whether decoding and encoding again gives the query back. + - Only such a query is split into `params` (cURL's `_split_url_query`, HAR's `_apply_query`). Any other stays in the URL as written, which `requests` sends unchanged; `-G` data and a HAR entry's recorded `queryString` names the URL did not carry still go to `params`, added after it. + - The trade-off: a query spelled that way is no longer an editable dict in the generated code. +- **Tests**: + - `test_curl_import.py` +5: queries that would change stay in the URL, and `full_url` is the original. + - `test_curl_request_fidelity.py` +4: the URL `requests` prepares from the generated code is the pasted one, including a presigned-style URL; `-G` data is appended after it. + - `test_har_import.py` +1 (a recorded URL kept as recorded). + - Three tests whose example queries used `%20` now use `+`, which round-trips. +- **Result / numbers**: cURL, HAR, template and fuzz tests 347 passed, fidelity 27; 1675 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/curl_parser.py`, `pybreeze/utils/har_import/har_parser.py` + - `test/test_utils/test_curl_import.py`, `test_har_import.py`, `test_curl_request_fidelity.py` + - `architecture_explore.md` §12, §18 +- **Open items**: none; this closes the findings of the `tools_gui/` sweep. diff --git a/docs/updates/README.md b/docs/updates/README.md index ac9193b3..201a3b5d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-111 | 2026-09-23 | cURL and HAR import send a query that would not survive re-encoding as it was written | #fix #tools | [2026-09](2026-09.md) | | U-20260923-110 | 2026-09-23 | The Diff tab matches the lines once and off the UI thread | #fix #tools #performance | [2026-09](2026-09.md) | | U-20260923-109 | 2026-09-23 | cURL import reads @file bodies as curl sends them and stops sending a -b cookie file's name as a cookie | #fix #tools | [2026-09](2026-09.md) | | U-20260923-108 | 2026-09-23 | cURL and HAR import send a form as multipart without the copied Content-Type, and the JSON action refuses what it cannot carry | #fix #tools | [2026-09](2026-09.md) | @@ -191,4 +192,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 128 | +| [2026-09.md](2026-09.md) | 2026-09 | 129 | diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index 9eacf746..b11229d5 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -539,19 +539,33 @@ def _split_url_query(request: CurlRequest) -> None: Browser "copy as cURL" keeps the query in the URL; splitting it out lets it show up alongside ``-G`` / ``-d`` query pairs, while :attr:`CurlRequest.full_url` can still rebuild the original address. Values - are URL-decoded, and a key given more than once keeps every value. + are URL-decoded, and a key given more than once keeps every value. A query + that would not come back as written stays in the URL + (:func:`query_round_trips`). """ # The fragment is the browser's: curl never sends it, and left in, it # became part of the last query value (?b=1#frag sent b="1#frag"). request.url = request.url.partition("#")[0] base, separator, query = request.url.partition("?") - if not separator: + if not separator or not query_round_trips(query): return request.url = base for key, value in parse_qsl(query, keep_blank_values=True): add_repeated_value(request.params, key, value) +def query_round_trips(query: str) -> bool: + """Whether decoding *query* into pairs and encoding them again gives it back. + + Only such a query is split into ``params``; any other stays in the URL as + written, which ``requests`` sends as it is. Split and encoded again, + ``?flag&q=%B0&r=/x`` went out as ``?flag=&q=%EF%BF%BD&r=%2Fx``: a + valueless key gained ``=``, a byte that is not UTF-8 became U+FFFD, and + ``/`` was escaped, which breaks a signed URL. + """ + return urlencode(parse_qsl(query, keep_blank_values=True)) == query + + def url_is_well_formed(url: str) -> bool: """Whether *url* can be taken apart: ``urllib`` raises ``ValueError`` on an unclosed IPv6 bracket (``http://[::1/api``) or a port that is not a number. diff --git a/pybreeze/utils/har_import/har_parser.py b/pybreeze/utils/har_import/har_parser.py index 6c8d1c94..05dca1c3 100644 --- a/pybreeze/utils/har_import/har_parser.py +++ b/pybreeze/utils/har_import/har_parser.py @@ -15,7 +15,7 @@ from urllib.parse import parse_qsl, urlparse from pybreeze.utils.curl_import.curl_parser import ( - CurlRequest, add_repeated_value, http_method, url_is_well_formed, + CurlRequest, add_repeated_value, http_method, query_round_trips, url_is_well_formed, ) from pybreeze.utils.exception.exception_tags import ( empty_har_error, @@ -137,15 +137,21 @@ def _apply_query(request: CurlRequest, raw_request: dict, query: str) -> None: values, and :attr:`CurlRequest.full_url` and the generated scripts encode them once on the way out. """ - for key, value in parse_qsl(query, keep_blank_values=True): - add_repeated_value(request.params, key, value) + carried = parse_qsl(query, keep_blank_values=True) + if query_round_trips(query): + for key, value in carried: + add_repeated_value(request.params, key, value) + elif query: + # Split and encoded again it would not be what was sent + request.url = f"{request.url}?{query}" # The recorded list repeats the URL's parameters: only a name the URL did # not carry is taken from it, with all of that name's values. recorded: dict[str, str | list[str]] = {} for name, value in _header_pairs(raw_request.get("queryString")): add_repeated_value(recorded, name, value) for name, values in recorded.items(): - request.params.setdefault(name, values) + if name not in {key for key, _value in carried}: + request.params.setdefault(name, values) def _apply_multipart(request: CurlRequest, params: list[tuple[str, str, str]]) -> None: diff --git a/test/test_utils/test_curl_import.py b/test/test_utils/test_curl_import.py index 73ea4d20..9663f957 100644 --- a/test/test_utils/test_curl_import.py +++ b/test/test_utils/test_curl_import.py @@ -308,8 +308,19 @@ def test_query_moved_to_params(self): assert request.params == {"a": "1", "b": "2"} def test_query_values_are_url_decoded(self): - request = parse_curl("curl 'https://x?q=hello%20world'") - assert request.params["q"] == "hello world" + request = parse_curl("curl 'https://x?q=hello+world&tag=a%2Bb'") + assert request.params == {"q": "hello world", "tag": "a+b"} + + @pytest.mark.parametrize("url", [ + "https://x/a?q=hello%20world", "https://x/a?flag&q=1", "https://x/a?q=%B0", + "https://x/a?r=/x&s=a,b", "https://x/a?X-Amz-Signature=ab%2Fcd&X-Amz-Date=20260923T000000Z%20", + ]) + def test_a_query_that_would_not_come_back_as_written_stays_in_the_url(self, url): + # Split and encoded again it went out changed: %20 as +, flag as flag=, + # %B0 as %EF%BF%BD, / as %2F -- which breaks a signed URL + request = parse_curl(f"curl '{url}'") + assert request.params == {} + assert request.full_url == url def test_blank_query_value_kept(self): request = parse_curl("curl 'https://x?flag='") diff --git a/test/test_utils/test_curl_request_fidelity.py b/test/test_utils/test_curl_request_fidelity.py index 5071a59a..f4f94ed1 100644 --- a/test/test_utils/test_curl_request_fidelity.py +++ b/test/test_utils/test_curl_request_fidelity.py @@ -182,3 +182,21 @@ def test_the_json_action_refuses_it(self): with pytest.raises(CurlParseException): to_apitestka_action_json(parse_curl("curl -b cookies.txt https://h/")) + + +@pytest.mark.parametrize("url", [ + "https://h/a?flag&q=%B0&r=/x&s=a,b", + "https://h/a?q=hello%20world", + "https://h/a?X-Amz-Credential=AK%2F20260923%2Fus-east-1&X-Amz-Signature=abc", +]) +def test_the_query_goes_out_as_it_was_pasted(url): + # Decoded and encoded again, it changed: flag=, %EF%BF%BD, %2Fx, a%2Cb, + + prepared = _what_requests_sends(to_requests_code(parse_curl(f"curl '{url}'"))) + + assert prepared.url == url + + +def test_get_data_is_added_after_a_query_kept_as_written(): + prepared = _what_requests_sends(to_requests_code(parse_curl("curl -G 'https://h/a?q=a%20b' -d k=v"))) + + assert prepared.url == "https://h/a?q=a%20b&k=v" diff --git a/test/test_utils/test_har_import.py b/test/test_utils/test_har_import.py index 64fd1544..39450e09 100644 --- a/test/test_utils/test_har_import.py +++ b/test/test_utils/test_har_import.py @@ -312,16 +312,22 @@ class TestQueryValuesFromTheUrl: """A value in the recorded URL is percent-encoded; params hold it decoded, once.""" def test_an_encoded_value_is_decoded(self): - entry = parse_har(_har(_entry(url="https://x/api?q=hello%20world&tag=a%2Bb")))[0] + entry = parse_har(_har(_entry(url="https://x/api?q=hello+world&tag=a%2Bb")))[0] assert entry.request.params == {"q": "hello world", "tag": "a+b"} def test_the_rebuilt_url_is_encoded_once(self): - entry = parse_har(_har(_entry(url="https://x/api?q=hello%20world")))[0] + entry = parse_har(_har(_entry(url="https://x/api?q=hello+world")))[0] - assert "%2520" not in entry.request.full_url assert entry.request.full_url == "https://x/api?q=hello+world" + def test_a_query_that_would_not_come_back_as_written_stays_as_recorded(self): + # Decoded and encoded again, %20 became + and a bare key gained = + entry = parse_har(_har(_entry(url="https://x/api?q=hello%20world&flag")))[0] + + assert entry.request.params == {} + assert entry.request.full_url == "https://x/api?q=hello%20world&flag" + class TestWhatReachesTheGeneratedCode: """A recording's method and URL end up in code; nothing in them may become code.""" From dd79512d77d5b77ec429f59fbe52570b3456eb4e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 18:45:50 +0800 Subject: [PATCH 142/312] Rebuild a URL's query unchanged in the URL Builder when decoding it would lose something --- architecture_explore.md | 4 ++-- docs/updates/2026-09.md | 14 ++++++++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/curl_import/curl_parser.py | 13 +------------ pybreeze/utils/har_import/har_parser.py | 3 ++- pybreeze/utils/query_tools/query_convert.py | 13 +++++++++++++ pybreeze/utils/url_tools/url_convert.py | 11 +++++++---- test/test_utils/test_url_convert.py | 19 +++++++++++++++++-- 8 files changed, 58 insertions(+), 22 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index a9f3f604..aac5b9d0 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -344,7 +344,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 暫換成 `public_address()` 回傳的位址)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(611) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_round_trips()`;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | +| `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯 | @@ -443,7 +443,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1675 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1680 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index b160dfa3..3165cf41 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1979,3 +1979,17 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_curl_import.py`, `test_har_import.py`, `test_curl_request_fidelity.py` - `architecture_explore.md` §12, §18 - **Open items**: none; this closes the findings of the `tools_gui/` sweep. + +## U-20260923-112 · 2026-09-23 · URL Builder rebuilds a query it cannot decode losslessly exactly as it was · #fix #tools + +- **What**: the item left open in U-20260923-100. A URL taken apart and rebuilt in the URL Builder with nothing edited changed its query: `%zz` came back as `%25zz`, `%ff` as `%EF%BF%BD`, a bare `flag` as `flag=`, `/` and `;` percent-encoded, `%20` as `+`. +- **Fix**: + - `query_round_trips()` moves to `utils/query_tools/query_convert.py`, where the cURL and HAR import now take it from (it was in `curl_parser.py` since U-20260923-111). + - `url_convert.parse_url` gives the query as a dict only when it round-trips, and otherwise as its text. `build_url` already writes a text query as it is. +- **Tests**: `test_url_convert.py` +5: four such queries are text and rebuild unchanged; one that round-trips is still a dict. The test that decoded `%20` now decodes `+`, which round-trips. +- **Result / numbers**: URL, query, cURL, HAR and fuzz tests 366 passed; 1680 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/query_tools/query_convert.py`, `pybreeze/utils/url_tools/url_convert.py`, `pybreeze/utils/curl_import/curl_parser.py`, `pybreeze/utils/har_import/har_parser.py` + - `test/test_utils/test_url_convert.py` + - `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 201a3b5d..c2dfbb88 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-112 | 2026-09-23 | URL Builder rebuilds a query it cannot decode losslessly exactly as it was | #fix #tools | [2026-09](2026-09.md) | | U-20260923-111 | 2026-09-23 | cURL and HAR import send a query that would not survive re-encoding as it was written | #fix #tools | [2026-09](2026-09.md) | | U-20260923-110 | 2026-09-23 | The Diff tab matches the lines once and off the UI thread | #fix #tools #performance | [2026-09](2026-09.md) | | U-20260923-109 | 2026-09-23 | cURL import reads @file bodies as curl sends them and stops sending a -b cookie file's name as a cookie | #fix #tools | [2026-09](2026-09.md) | @@ -192,4 +193,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 129 | +| [2026-09.md](2026-09.md) | 2026-09 | 130 | diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index b11229d5..ecbbca32 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -26,6 +26,7 @@ not_a_curl_command_error, ) from pybreeze.utils.exception.exceptions import CurlParseException +from pybreeze.utils.query_tools.query_convert import query_round_trips from pybreeze.utils.header_tools.header_merge import ( add_header, set_default_header, stored_header_name ) @@ -554,18 +555,6 @@ def _split_url_query(request: CurlRequest) -> None: add_repeated_value(request.params, key, value) -def query_round_trips(query: str) -> bool: - """Whether decoding *query* into pairs and encoding them again gives it back. - - Only such a query is split into ``params``; any other stays in the URL as - written, which ``requests`` sends as it is. Split and encoded again, - ``?flag&q=%B0&r=/x`` went out as ``?flag=&q=%EF%BF%BD&r=%2Fx``: a - valueless key gained ``=``, a byte that is not UTF-8 became U+FFFD, and - ``/`` was escaped, which breaks a signed URL. - """ - return urlencode(parse_qsl(query, keep_blank_values=True)) == query - - def url_is_well_formed(url: str) -> bool: """Whether *url* can be taken apart: ``urllib`` raises ``ValueError`` on an unclosed IPv6 bracket (``http://[::1/api``) or a port that is not a number. diff --git a/pybreeze/utils/har_import/har_parser.py b/pybreeze/utils/har_import/har_parser.py index 05dca1c3..96e7789a 100644 --- a/pybreeze/utils/har_import/har_parser.py +++ b/pybreeze/utils/har_import/har_parser.py @@ -15,8 +15,9 @@ from urllib.parse import parse_qsl, urlparse from pybreeze.utils.curl_import.curl_parser import ( - CurlRequest, add_repeated_value, http_method, query_round_trips, url_is_well_formed, + CurlRequest, add_repeated_value, http_method, url_is_well_formed, ) +from pybreeze.utils.query_tools.query_convert import query_round_trips from pybreeze.utils.exception.exception_tags import ( empty_har_error, invalid_har_json_error, diff --git a/pybreeze/utils/query_tools/query_convert.py b/pybreeze/utils/query_tools/query_convert.py index f91013c8..eb06f769 100644 --- a/pybreeze/utils/query_tools/query_convert.py +++ b/pybreeze/utils/query_tools/query_convert.py @@ -46,6 +46,19 @@ def query_to_dict(query: str) -> dict[str, str | list[str]]: return result +def query_round_trips(query: str) -> bool: + """Whether decoding *query* into pairs and encoding them again gives it back. + + The cURL and HAR import split only such a query into ``params``, and the + URL Builder shows only such a query as a dict; any other stays the text it + was, which ``requests`` sends as it is. Split and encoded again, + ``?flag&q=%B0&r=/x`` went out as ``?flag=&q=%EF%BF%BD&r=%2Fx``: a + valueless key gained ``=``, a byte that is not UTF-8 became U+FFFD, and + ``/`` was escaped, which breaks a signed URL. + """ + return urlencode(parse_qsl(query, keep_blank_values=True)) == query + + def query_to_json(query: str) -> str: """Convert a URL query string into pretty-printed JSON. diff --git a/pybreeze/utils/url_tools/url_convert.py b/pybreeze/utils/url_tools/url_convert.py index b3487030..330108f8 100644 --- a/pybreeze/utils/url_tools/url_convert.py +++ b/pybreeze/utils/url_tools/url_convert.py @@ -17,7 +17,7 @@ ) from pybreeze.utils.exception.exceptions import QueryConvertException, UrlConvertException from pybreeze.utils.logging.logger import pybreeze_logger -from pybreeze.utils.query_tools.query_convert import coerce_scalar, query_to_dict +from pybreeze.utils.query_tools.query_convert import coerce_scalar, query_round_trips, query_to_dict _MAX_PORT = 65535 @@ -35,7 +35,8 @@ def parse_url(url: str) -> dict: :param url: the URL to parse (leading/trailing whitespace is ignored) :return: a dict with scheme, host, port, path, query and fragment, plus - username/password when the URL carries credentials + username/password when the URL carries credentials; the query is a dict + of its values, or its text when that would not rebuild it as written """ split = urlsplit(url.strip()) components: dict = { @@ -43,8 +44,10 @@ def parse_url(url: str) -> dict: "host": split.hostname or "", "port": _safe_port(split), "path": split.path, - # A repeated key keeps every value, as a list, like the query tool. - "query": query_to_dict(split.query), + # A repeated key keeps every value, as a list, like the query tool. A + # query that decoding and encoding again would change stays the text + # it was: rebuilt, %zz became %25zz and a bare key gained "=". + "query": query_to_dict(split.query) if query_round_trips(split.query) else split.query, "fragment": split.fragment, } if split.username is not None: diff --git a/test/test_utils/test_url_convert.py b/test/test_utils/test_url_convert.py index e6a8868f..b02912e1 100644 --- a/test/test_utils/test_url_convert.py +++ b/test/test_utils/test_url_convert.py @@ -36,8 +36,8 @@ def test_minimal_url(self): assert "username" not in parts def test_query_values_url_decoded(self): - parts = parse_url("https://x/?q=hello%20world") - assert parts["query"]["q"] == "hello world" + parts = parse_url("https://x/?q=hello+world&t=a%2Bb") + assert parts["query"] == {"q": "hello world", "t": "a+b"} def test_whitespace_is_stripped(self): assert parse_url(" https://x/api ")["path"] == "/api" @@ -173,3 +173,18 @@ def test_it_is_reported_not_raised_out_of_the_tab(self): # json.loads raises RecursionError, which the tab did not catch with pytest.raises(UrlConvertException): json_to_url("[" * 100000) + + +class TestAQueryThatWouldNotComeBackAsWritten: + @pytest.mark.parametrize("url", [ + "http://h/p?%zz=1&x=%ff", "http://h/p?flag", "http://h/p?a=b/c;d", "https://h/p?q=a%20b", + ]) + def test_it_is_kept_as_text_and_rebuilt_unchanged(self, url): + # Decoded and encoded again, %zz became %25zz, %ff U+FFFD, flag flag=, / %2F + parts = json.loads(url_to_json(url)) + + assert isinstance(parts["query"], str) + assert json_to_url(url_to_json(url)) == url + + def test_one_that_does_is_still_a_dict_to_edit(self): + assert json.loads(url_to_json("https://h/p?a=1&b=x+y"))["query"] == {"a": "1", "b": "x y"} From 18f756d3ed5308c710e0dffe28ffb35e858bce19 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 19:19:43 +0800 Subject: [PATCH 143/312] Show run output in a QPlainTextEdit so lines past the cap stay cheap --- architecture_explore.md | 6 ++-- docs/updates/2026-09.md | 12 +++++++ docs/updates/README.md | 3 +- .../show_code_window/code_window.py | 7 ++-- test/test_utils/test_code_window.py | 35 +++++++++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index aac5b9d0..5a70bb7b 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -156,7 +156,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `read_stream_into_queue(stream, queue, buffer_size, encoding, keep_reading)` — reader 執行緒用。行**原樣**進 queue(縮排、行尾、空行都留著);空讀 = EOF 即停,管線被關掉的 `OSError` / `ValueError` 記 debug 後停。超過 `buffer_size` 的長行分段讀進來:用 incremental decoder 解碼(被切斷的多位元組字元接到下一段),段尾的 `\r` 留到下一段(`\r\n` 被切開時不會變成兩個換行);不認得的 encoding 退回 UTF-8 並記 warning - `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 - `ReaderGrace` / `any_alive()` / `OUTPUT_STILL_HELD_NOTE` — 子行程結束後 reader 還能讀多久(`READER_GRACE_SECONDS = 2.0`,從結束後第一個 tick 起算)。管線要等最後一個握著它的行程結束才會 EOF,子行程開的行程沒轉向輸出時會一直握著;以前兩個執行器在 UI 執行緒上各 join 2 秒,IDE 卡 4 秒還是丟掉之後的輸出。現在由 pump 逐 tick 詢問,時間到就結束執行並在視窗註明 -- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。`\r\n` 與單獨的 `\r` 轉成換行;換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 +- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。`\r\n` 與單獨的 `\r` 轉成換行;換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 --- @@ -399,7 +399,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ``` UI Thread (QApplication) │ - ├── QTimer (100ms / 50ms) ──► pull_text() ──► pump_message_queue() ──► QTextEdit + ├── QTimer (100ms / 50ms) ──► pull_text() ──► pump_message_queue() ──► QPlainTextEdit │ ▲ │ thread-safe Queue │ ▲ @@ -443,7 +443,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1680 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1682 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 3165cf41..13be9e74 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1993,3 +1993,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_url_convert.py` - `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-113 · 2026-09-23 · The run window shows output past its 10,000-line cap without freezing the IDE · #fix #executor #performance + +- **What**: `CodeWindow`'s output was a `QTextEdit` capped at `MAX_OUTPUT_BLOCKS` (10,000) lines. Once full, every line written had to drop the oldest one, and a `QTextEdit` took about 15 ms to do that. Measured here: 10,000 lines went in in 1.7 s, the next 2,000 took 30.8 s. At the pump's 256 messages per stream per 100 ms tick, a chatty run froze the IDE for seconds a tick, and draining the rest at the end of a long run (a verbose log, a tqdm bar whose `\r` becomes a new line) held it for minutes. Found in the review sweep of `show_code_window/`. +- **Fix**: the output is a `QPlainTextEdit`, Qt's widget for a capped log view, with the same cap, no-wrap, read-only setting and character formats. The same 2,000 lines past the cap: 0.28 s. +- **Tests**: `test_code_window.py` +2: 2,000 lines past the cap in under five seconds, with the newest kept and the cap held; the error colour still applies. The run window's 133 other tests pass unchanged. +- **Result / numbers**: `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/show_code_window/code_window.py` + - `test/test_utils/test_code_window.py` + - `architecture_explore.md` §4.5, §16, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c2dfbb88..4f21190e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-113 | 2026-09-23 | The run window shows output past its 10,000-line cap without freezing the IDE | #fix #executor #performance | [2026-09](2026-09.md) | | U-20260923-112 | 2026-09-23 | URL Builder rebuilds a query it cannot decode losslessly exactly as it was | #fix #tools | [2026-09](2026-09.md) | | U-20260923-111 | 2026-09-23 | cURL and HAR import send a query that would not survive re-encoding as it was written | #fix #tools | [2026-09](2026-09.md) | | U-20260923-110 | 2026-09-23 | The Diff tab matches the lines once and off the UI thread | #fix #tools #performance | [2026-09](2026-09.md) | @@ -193,4 +194,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 130 | +| [2026-09.md](2026-09.md) | 2026-09 | 131 | diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index 457d45ec..7865740f 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -5,7 +5,7 @@ from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict from PySide6.QtCore import QTimer, Signal from PySide6.QtGui import QGuiApplication, QTextCharFormat, QTextCursor -from PySide6.QtWidgets import QWidget, QGridLayout, QTextEdit, QScrollArea +from PySide6.QtWidgets import QWidget, QGridLayout, QPlainTextEdit, QScrollArea if TYPE_CHECKING: from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess @@ -14,6 +14,9 @@ # Cap the output scrollback so a runaway script (e.g. an infinite print loop) # cannot grow the document without bound and exhaust memory; the oldest lines # are dropped once the limit is reached, like a terminal's scrollback buffer. +# The output is a QPlainTextEdit, which is built for this: in a QTextEdit +# every line written past the cap cost about 15 ms to drop the oldest one, and +# a chatty run froze the IDE for seconds a tick. MAX_OUTPUT_BLOCKS = 10000 @@ -44,7 +47,7 @@ def __init__(self): self.runner: TaskProcessManager | FileRunnerProcess | None = None self._closed_while_running = False self.grid_layout = QGridLayout() - self.code_result = QTextEdit() + self.code_result = QPlainTextEdit() self.code_result.setLineWrapMode(self.code_result.LineWrapMode.NoWrap) self.code_result.setReadOnly(True) self.code_result.document().setMaximumBlockCount(MAX_OUTPUT_BLOCKS) diff --git a/test/test_utils/test_code_window.py b/test/test_utils/test_code_window.py index 065965f6..e1ded203 100644 --- a/test/test_utils/test_code_window.py +++ b/test/test_utils/test_code_window.py @@ -45,6 +45,41 @@ def test_old_lines_dropped_once_capped(self, qt_app): assert "line 0\n" not in text # oldest dropped +class TestOutputPastTheCap: + def test_a_line_written_past_the_cap_is_cheap(self, qt_app): + # In a QTextEdit each one took about 15 ms to drop the oldest line: + # 2000 of them, 30 s with the IDE frozen + import time + + from pybreeze.pybreeze_ui.show_code_window.code_window import MAX_OUTPUT_BLOCKS, CodeWindow + + window = CodeWindow() + for i in range(MAX_OUTPUT_BLOCKS): + window.append_output(f"line {i}\n") + started = time.perf_counter() + for i in range(2000): + window.append_output(f"more {i}\n", is_error=i % 2 == 0) + elapsed = time.perf_counter() - started + + assert elapsed < 5 + assert window.code_result.document().blockCount() <= MAX_OUTPUT_BLOCKS + assert window.code_result.toPlainText().endswith("more 1999\n") + + def test_the_error_colour_still_applies(self, qt_app): + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict + from PySide6.QtGui import QTextCursor + + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("fine\n") + window.append_output("broken\n", is_error=True) + cursor = QTextCursor(window.code_result.document().findBlockByNumber(1)) + cursor.movePosition(QTextCursor.MoveOperation.Right) + + assert cursor.charFormat().foreground().color() == actually_color_dict.get("error_output_color") + + class TestAppendOutput: def test_indentation_and_blank_lines_are_kept(self, qt_app): from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow From 0aff2af0f76ca7e9277079774e9b043561615582 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 19:20:06 +0800 Subject: [PATCH 144/312] Bound the queue between a run's readers and its window --- architecture_explore.md | 5 +- docs/updates/2026-09.md | 17 ++++ docs/updates/README.md | 3 +- .../process_executor/file_runner_process.py | 12 ++- .../python_task_process_manager.py | 12 ++- .../extend/process_executor/queue_pump.py | 68 ++++++++++--- test/test_utils/test_queue_pump.py | 98 +++++++++++++++++++ test/test_utils/test_run_output.py | 23 +++++ 8 files changed, 213 insertions(+), 25 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 5a70bb7b..8fcbbef7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -95,7 +95,7 @@ Template Method 定義的子行程生命週期: | 啟動 | `subprocess.Popen(args, shell=False, stdin=DEVNULL, creationflags=CREATE_NO_WINDOW, env=PYTHONIOENCODING=...)`;`stdin` 不接 IDE 的主控台(腳本 `input()` 立刻拿到 EOF);`Popen` 丟 `OSError`(直譯器不見、命令列超過 Windows 上限)時寫進執行視窗並顯示,不從選單拋出 | | 讀取 | 兩條 daemon Thread 各自經 `queue_pump.read_stream_into_queue()` 對 stdout / stderr `readline()`,原樣塞進 `Queue`(保留縮排、行尾與空行);**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | | 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則,交給 `CodeWindow.append_output()` | -| 收尾 | 子行程結束後,pump 照樣每 tick 抽 queue,直到兩條 reader 都讀到 EOF 或寬限(`ReaderGrace`,2 秒)用完,UI 執行緒不 join 任何執行緒。`exit_program()`:drain queue(`max_messages=None` 一次抽乾)→ reader 還活著(子行程開的行程還握著管線)就在視窗註明之後的輸出不會顯示 → `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | +| 收尾 | 子行程結束後,pump 照樣每 tick 抽 queue,直到兩條 reader 都讀到 EOF 或寬限(`ReaderGrace`,2 秒,那個 tick 還有輸出就重新起算,最長到結束後 30 秒)用完,UI 執行緒不 join 任何執行緒。`exit_program()`:drain queue(`max_messages=None` 一次抽乾)→ reader 還活著(子行程開的行程還握著管線)就在視窗註明之後的輸出不會顯示 → `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | | 停止 | `stop()`:子行程還在跑就 `terminate()`(只有它本身,它再開的行程不管),之後照一般結束的路徑回報。經 `CodeWindow.stop_runner()` 呼叫;關閉 IDE 時 `PyBreezeMainWindow.closeEvent()` 對每個執行視窗都呼叫一次(經 `_close_guarded()`:一個視窗、分頁或 dock 關閉時丟例外只記錄,其餘照關,JEditor 自己的 `closeEvent` 一定會跑到) | 三種啟動介面: @@ -155,6 +155,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `read_stream_into_queue(stream, queue, buffer_size, encoding, keep_reading)` — reader 執行緒用。行**原樣**進 queue(縮排、行尾、空行都留著);空讀 = EOF 即停,管線被關掉的 `OSError` / `ValueError` 記 debug 後停。超過 `buffer_size` 的長行分段讀進來:用 incremental decoder 解碼(被切斷的多位元組字元接到下一段),段尾的 `\r` 留到下一段(`\r\n` 被切開時不會變成兩個換行);不認得的 encoding 退回 UTF-8 並記 warning - `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 +- `output_queue()` — 每條管線的 queue 最多 `MAX_QUEUED_MESSAGES`(10,000)則;滿了 reader 就等(每 0.2 秒看一次 `keep_reading`),子行程寫管線也跟著等,跑得跟視窗顯示一樣快,像終端機;以前不設上限,印個不停的腳本會一直吃記憶體,按 Stop 後再一口氣全倒進視窗 - `ReaderGrace` / `any_alive()` / `OUTPUT_STILL_HELD_NOTE` — 子行程結束後 reader 還能讀多久(`READER_GRACE_SECONDS = 2.0`,從結束後第一個 tick 起算)。管線要等最後一個握著它的行程結束才會 EOF,子行程開的行程沒轉向輸出時會一直握著;以前兩個執行器在 UI 執行緒上各 join 2 秒,IDE 卡 4 秒還是丟掉之後的輸出。現在由 pump 逐 tick 詢問,時間到就結束執行並在視窗註明 - `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。`\r\n` 與單獨的 `\r` 轉成換行;換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 @@ -443,7 +444,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1682 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1689 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 13be9e74..47147669 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2005,3 +2005,20 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_code_window.py` - `architecture_explore.md` §4.5, §16, §18 - **Open items**: none. + +## U-20260923-114 · 2026-09-23 · A run's output waits in a bounded queue, so a print loop no longer fills memory or floods Stop · #fix #executor + +- **What**: both executors carried output from the reader threads to the window in unbounded `Queue`s. A script printing in a loop (`while True: print(i)`) was read far faster than the window shows it, so the backlog grew in memory for as long as the run went on; the window's line cap bounds only the document. After Stop, the exit path drained the whole backlog into the window on the UI thread. Found in the review sweep of `show_code_window/`. +- **Fix** (`queue_pump.py`): + - `output_queue()` holds at most `MAX_QUEUED_MESSAGES` (10,000) pieces, and the executors use it. A reader waits on a full queue (`_put`, checking `keep_reading` every 0.2 s so it gives up once the run ends); the child's writes to its pipe wait with it. The run goes at the pace the window shows it, as in a terminal. + - Because a backlog can now outlast the exit, `ReaderGrace.still_reading(..., progressed=)` counts the two-second grace again after any tick that showed output, up to `MAX_DRAIN_SECONDS` (30 s) after the exit. The rest is shown, not taken for a process holding the pipes. + - `pump_message_queue` returns how many messages it took. +- **Tests**: + - `test_queue_pump.py` +6: a reader waits while the queue is full and then delivers every line in order; a waiting reader gives up once told to stop; the executors' queues are bounded; output still arriving keeps the grace open, but never past the drain limit; the pump reports its count. + - `test_run_output.py` +1: a print loop run for two seconds holds at most the bound, and Stop ends it with the exit line shown. +- **Result / numbers**: executor, queue and run window tests 57 passed, queue 22; 1689 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/extend/process_executor/queue_pump.py`, `python_task_process_manager.py`, `file_runner_process.py` + - `test/test_utils/test_queue_pump.py`, `test_run_output.py` + - `architecture_explore.md` §4.1, §4.5, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 4f21190e..3a94166b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-114 | 2026-09-23 | A run's output waits in a bounded queue, so a print loop no longer fills memory or floods Stop | #fix #executor | [2026-09](2026-09.md) | | U-20260923-113 | 2026-09-23 | The run window shows output past its 10,000-line cap without freezing the IDE | #fix #executor #performance | [2026-09](2026-09.md) | | U-20260923-112 | 2026-09-23 | URL Builder rebuilds a query it cannot decode losslessly exactly as it was | #fix #tools | [2026-09](2026-09.md) | | U-20260923-111 | 2026-09-23 | cURL and HAR import send a query that would not survive re-encoding as it was written | #fix #tools | [2026-09](2026-09.md) | @@ -194,4 +195,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 131 | +| [2026-09.md](2026-09.md) | 2026-09 | 132 | diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index 685d86c2..6414f7ef 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -22,6 +22,7 @@ OUTPUT_STILL_HELD_NOTE, ReaderGrace, any_alive, + output_queue, pump_message_queue, read_stream_into_queue, ) @@ -46,8 +47,8 @@ def __init__( self.program_buffer_size = program_buffer_size self.still_running: bool = False self.process: subprocess.Popen | None = None - self.output_queue: Queue = Queue() - self.error_queue: Queue = Queue() + self.output_queue: Queue = output_queue() + self.error_queue: Queue = output_queue() self.timer: QTimer | None = None self._stdout_thread: Thread | None = None self._stderr_thread: Thread | None = None @@ -179,15 +180,16 @@ def stop(self) -> None: def _pull_text(self) -> None: """Timer callback: pump queues to UI.""" - pump_message_queue(self.output_queue, self.main_window.append_output, is_error=False) - pump_message_queue(self.error_queue, self.main_window.append_output, is_error=True) + pumped = pump_message_queue(self.output_queue, self.main_window.append_output, is_error=False) + pumped += pump_message_queue(self.error_queue, self.main_window.append_output, is_error=True) if self.process is not None: self.process.poll() if self.process.returncode is not None: # Output still on its way is pumped on the next ticks, not # waited for here: this is the UI thread - if not self._reader_grace.still_reading(self._stdout_thread, self._stderr_thread): + if not self._reader_grace.still_reading( + self._stdout_thread, self._stderr_thread, progressed=pumped > 0): self._finish() elif self._deadline is not None and time.monotonic() > self._deadline: self._deadline = None diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 0a6d8539..5fcbba53 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -17,6 +17,7 @@ OUTPUT_STILL_HELD_NOTE, ReaderGrace, any_alive, + output_queue, pump_message_queue, read_stream_into_queue, ) @@ -62,8 +63,8 @@ def __init__( self.timer: QTimer = QTimer(self.main_window) self.still_run_program: bool = True self.program_encoding: str = program_encoding - self.run_output_queue: Queue = Queue() - self.run_error_queue: Queue = Queue() + self.run_output_queue: Queue = output_queue() + self.run_error_queue: Queue = output_queue() self.process: subprocess.Popen | None = None self._reader_grace = ReaderGrace() @@ -191,8 +192,8 @@ def stop(self) -> None: # Pyside UI update method def pull_text(self): - pump_message_queue(self.run_output_queue, self.main_window.append_output, is_error=False) - pump_message_queue(self.run_error_queue, self.main_window.append_output, is_error=True) + pumped = pump_message_queue(self.run_output_queue, self.main_window.append_output, is_error=False) + pumped += pump_message_queue(self.run_error_queue, self.main_window.append_output, is_error=True) if self.process is None: if self.timer.isActive(): self.timer.stop() @@ -201,7 +202,8 @@ def pull_text(self): # Output still on its way is pumped on the next ticks, not waited # for here: this is the UI thread if self._reader_grace.still_reading( - self.read_program_output_from_thread, self.read_program_error_output_from_thread): + self.read_program_output_from_thread, self.read_program_error_output_from_thread, + progressed=pumped > 0): return if self.timer.isActive(): self.timer.stop() diff --git a/pybreeze/extend/process_executor/queue_pump.py b/pybreeze/extend/process_executor/queue_pump.py index ed6f1b3c..99622bd1 100644 --- a/pybreeze/extend/process_executor/queue_pump.py +++ b/pybreeze/extend/process_executor/queue_pump.py @@ -24,14 +24,42 @@ # thread from stalling when a process floods stdout. MAX_MESSAGES_PER_PUMP = 256 -# After the child exits, its readers get this long to reach end of file +# After the child exits, its readers get this long to reach end of file, counted +# from the last tick that still brought output READER_GRACE_SECONDS = 2.0 +# ... and never longer than this after the exit, however much keeps coming +MAX_DRAIN_SECONDS = 30.0 + +# How many pieces of output wait between a reader and the window. A full queue +# makes the reader wait, and with it the child's writes to its pipe: the run +# goes at the pace the window shows it, as in a terminal. Unbounded, a script +# printing in a loop filled memory for as long as it ran, and Stop then poured +# the whole backlog into the window at once. +MAX_QUEUED_MESSAGES = 10000 +# How often a reader waiting on a full queue checks whether to give up +_PUT_WAIT_SECONDS = 0.2 # Written when a process the run started still holds its output at the end OUTPUT_STILL_HELD_NOTE = ( "[A process started by this run still holds its output; what it writes from now on is not shown]\n") +def output_queue() -> Queue: + """A queue for one pipe's output, holding at most ``MAX_QUEUED_MESSAGES`` pieces.""" + return Queue(maxsize=MAX_QUEUED_MESSAGES) + + +def _put(target_queue: Queue, message: str, keep_reading: Callable[[], bool]) -> bool: + """Put *message* on *target_queue*, waiting while it is full; False once *keep_reading* turns false.""" + while True: + try: + target_queue.put(message, timeout=_PUT_WAIT_SECONDS) + return True + except queue.Full: + if not keep_reading(): + return False + + def read_stream_into_queue( stream: IO, target_queue: Queue, @@ -71,11 +99,11 @@ def read_stream_into_queue( line, held = held + line, "" if line.endswith("\r"): line, held = line[:-1], "\r" - if line: - target_queue.put(line) + if line and not _put(target_queue, line, keep_reading): + return rest = held + decoder.decode(b"", final=True) if rest: - target_queue.put(rest) + _put(target_queue, rest, keep_reading) def _decoder_for(encoding: str) -> codecs.IncrementalDecoder: @@ -104,19 +132,30 @@ class ReaderGrace: """ def __init__(self) -> None: + self._exited_at: float | None = None self._since: float | None = None - def still_reading(self, *readers: threading.Thread | None) -> bool: - """True while a reader is alive and the grace, counted from the first ask, is not over.""" + def still_reading(self, *readers: threading.Thread | None, progressed: bool = False) -> bool: + """True while a reader is alive and the grace is not over. + + :param progressed: whether this tick showed any output. Output still + coming counts the grace again, so a backlog held back by the full + queue is shown, not taken for a process holding the pipes; but + never past ``MAX_DRAIN_SECONDS`` after the first ask. + """ if not any_alive(*readers): return False - if self._since is None: - self._since = time.monotonic() - return time.monotonic() - self._since < READER_GRACE_SECONDS + now = time.monotonic() + if self._exited_at is None: + self._exited_at = self._since = now + if progressed: + self._since = now + return (now - self._since < READER_GRACE_SECONDS + and now - self._exited_at < MAX_DRAIN_SECONDS) def restart(self) -> None: """Count the next child's grace from its own exit.""" - self._since = None + self._exited_at = self._since = None def pump_message_queue( @@ -125,19 +164,24 @@ def pump_message_queue( *, is_error: bool, max_messages: int | None = MAX_MESSAGES_PER_PUMP, -) -> None: +) -> int: """Drain up to *max_messages* pending messages from *q* into *append_fn*. ``None`` drains until the queue is empty, which is what an executor wants once its process has exited. Messages are passed on unchanged; only empty strings are skipped. ``queue.Empty`` from the racy non-blocking get is treated as a clean stop, not an error. + + :return: how many messages were taken off the queue """ budget = itertools.count() if max_messages is None else range(max_messages) + taken = 0 for _ in budget: try: message = str(q.get_nowait()) except queue.Empty: - return + return taken + taken += 1 if message: append_fn(message, is_error) + return taken diff --git a/test/test_utils/test_queue_pump.py b/test/test_utils/test_queue_pump.py index e52edae3..6668c6bb 100644 --- a/test/test_utils/test_queue_pump.py +++ b/test/test_utils/test_queue_pump.py @@ -135,3 +135,101 @@ def test_a_carriage_return_at_the_very_end_is_not_lost(self): def test_an_unknown_encoding_falls_back_to_utf8(self): assert self._pieces("ok 中\n".encode("utf-8"), 1024, encoding="no-such-codec") == ["ok 中\n"] + + +class TestABoundedQueue: + """A reader waits on a full queue instead of holding every line of a runaway run in memory.""" + + def test_the_reader_waits_while_the_queue_is_full(self): + import threading + + from pybreeze.extend.process_executor import queue_pump + + target: Queue = Queue(maxsize=3) + data = b"".join(f"{i}\n".encode() for i in range(10)) + reader = threading.Thread(target=queue_pump.read_stream_into_queue, args=(io.BytesIO(data), target), + kwargs={"buffer_size": 1024, "encoding": "utf-8", "keep_reading": lambda: True}) + reader.start() + reader.join(0.5) + + assert reader.is_alive() and target.qsize() == 3 + got = [target.get(timeout=2) for _ in range(10)] + reader.join(2) + assert got == [f"{i}\n" for i in range(10)] + assert not reader.is_alive() + + def test_a_waiting_reader_gives_up_once_told_to_stop(self): + import threading + + from pybreeze.extend.process_executor import queue_pump + + target: Queue = Queue(maxsize=1) + running = {"on": True} + reader = threading.Thread(target=queue_pump.read_stream_into_queue, args=(io.BytesIO(b"a\nb\nc\n"), target), + kwargs={"buffer_size": 1024, "encoding": "utf-8", + "keep_reading": lambda: running["on"]}) + reader.start() + reader.join(0.3) + running["on"] = False + reader.join(2) + + assert not reader.is_alive() + + def test_the_executors_queues_are_bounded(self): + from pybreeze.extend.process_executor.queue_pump import MAX_QUEUED_MESSAGES, output_queue + + assert output_queue().maxsize == MAX_QUEUED_MESSAGES > 0 + + +class TestTheGraceWhileOutputStillComes: + def test_output_still_arriving_keeps_the_run_open(self, monkeypatch): + import threading + + from pybreeze.extend.process_executor import queue_pump + + now = {"t": 100.0} + monkeypatch.setattr(queue_pump.time, "monotonic", lambda: now["t"]) + alive = threading.Event() + reader = threading.Thread(target=alive.wait, args=(5,)) + reader.start() + grace = queue_pump.ReaderGrace() + try: + assert grace.still_reading(reader) + now["t"] += 1.5 + assert grace.still_reading(reader, progressed=True) + now["t"] += 1.5 # three seconds after the exit, but output came 1.5 s ago + assert grace.still_reading(reader) + now["t"] += 1.0 + assert not grace.still_reading(reader) + finally: + alive.set() + reader.join() + + def test_never_past_the_drain_limit(self, monkeypatch): + import threading + + from pybreeze.extend.process_executor import queue_pump + + now = {"t": 0.0} + monkeypatch.setattr(queue_pump.time, "monotonic", lambda: now["t"]) + alive = threading.Event() + reader = threading.Thread(target=alive.wait, args=(5,)) + reader.start() + grace = queue_pump.ReaderGrace() + try: + grace.still_reading(reader) + now["t"] = queue_pump.MAX_DRAIN_SECONDS + 0.1 + assert not grace.still_reading(reader, progressed=True) + finally: + alive.set() + reader.join() + + +def test_the_pump_says_how_many_it_took(): + from pybreeze.extend.process_executor.queue_pump import pump_message_queue + + target: Queue = Queue() + for message in ("a", "", "b"): + target.put(message) + + assert pump_message_queue(target, lambda _text, _is_error: None, is_error=False) == 3 diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index 856c719b..c22d11b2 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -388,3 +388,26 @@ def test_output_that_comes_within_the_grace_is_shown(self, qt_app, tmp_path): text = run_window.code_result.toPlainText() assert text.index("late line") < text.index("Task exit with code") assert "still holds its output" not in text + + +def test_a_runaway_run_holds_a_bounded_backlog_and_stops_promptly(qt_app, tmp_path): + # The queues were unbounded: a print loop filled memory while it ran, and + # Stop then poured the whole backlog into the window on the UI thread + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.extend.process_executor.queue_pump import MAX_QUEUED_MESSAGES + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + script = tmp_path / "flood.py" + script.write_text("i = 0\nwhile True:\n print(i)\n i += 1\n", encoding="utf-8") + window = CodeWindow() + runner = FileRunnerProcess(window) + runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + qt_app.processEvents() + time.sleep(0.01) + + assert runner.output_queue.qsize() <= MAX_QUEUED_MESSAGES + runner.stop() + _run_events_until(qt_app, lambda: not runner.still_running) + assert "[Process exited with code" in window.code_result.toPlainText() From 37b92b3fbdba30b786c4b1c02eed0add873969df Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 19:23:33 +0800 Subject: [PATCH 145/312] Strip terminal escape codes and control characters from run output --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 12 ++++++++ docs/updates/README.md | 3 +- .../show_code_window/code_window.py | 23 ++++++++++++++- test/test_utils/test_code_window.py | 29 +++++++++++++++++++ 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 8fcbbef7..34fdcdb5 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -157,7 +157,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 - `output_queue()` — 每條管線的 queue 最多 `MAX_QUEUED_MESSAGES`(10,000)則;滿了 reader 就等(每 0.2 秒看一次 `keep_reading`),子行程寫管線也跟著等,跑得跟視窗顯示一樣快,像終端機;以前不設上限,印個不停的腳本會一直吃記憶體,按 Stop 後再一口氣全倒進視窗 - `ReaderGrace` / `any_alive()` / `OUTPUT_STILL_HELD_NOTE` — 子行程結束後 reader 還能讀多久(`READER_GRACE_SECONDS = 2.0`,從結束後第一個 tick 起算)。管線要等最後一個握著它的行程結束才會 EOF,子行程開的行程沒轉向輸出時會一直握著;以前兩個執行器在 UI 執行緒上各 join 2 秒,IDE 卡 4 秒還是丟掉之後的輸出。現在由 pump 逐 tick 詢問,時間到就結束執行並在視窗註明 -- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。`\r\n` 與單獨的 `\r` 轉成換行;換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 +- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。終端機控制碼(CSI 顏色與游標移動、OSC 標題與超連結)和 tab、換行、`\r` 以外的控制字元先拿掉(`strip_terminal_codes()`),`\r\n` 與單獨的 `\r` 轉成換行;換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 --- @@ -444,7 +444,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1689 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1697 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 47147669..fc950fa0 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2022,3 +2022,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_queue_pump.py`, `test_run_output.py` - `architecture_explore.md` §4.1, §4.5, §18 - **Open items**: none. + +## U-20260923-115 · 2026-09-23 · The run window shows coloured and redrawn output as text, not escape codes · #fix #executor + +- **What**: `CodeWindow.append_output` wrote a child's output as it came, terminal control codes included. A script that colours its output (`colorama`, `rich`, `loguru`, `pytest --color=yes`, `FORCE_COLOR`) filled the window with `←[31m`; a progress bar's line clear and cursor moves (`ESC[2K`, `ESC[1G`), OSC titles and hyperlinks showed the same way, and a backspace, form feed or NUL showed as a box. Found in the review sweep of `show_code_window/`; reproduced. +- **Fix**: new `strip_terminal_codes()`, applied before `normalize_line_endings()`: it removes CSI sequences, OSC sequences (ended by BEL or `ESC \\`) and other two-byte escapes, then every C0 control character except tab, newline and carriage return, and DEL. Colours are not mapped to formats; the window keeps its own normal and error colours. +- **Tests**: `test_code_window.py` +8: SGR colours, a bare reset, an OSC title, an OSC 8 hyperlink, a line clear with a cursor move, backspace, form feed and NUL, and non-ASCII text left alone; a progress bar's carriage returns still break lines. +- **Result / numbers**: `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/show_code_window/code_window.py` + - `test/test_utils/test_code_window.py` + - `architecture_explore.md` §4.5, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 3a94166b..13f04fbb 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-115 | 2026-09-23 | The run window shows coloured and redrawn output as text, not escape codes | #fix #executor | [2026-09](2026-09.md) | | U-20260923-114 | 2026-09-23 | A run's output waits in a bounded queue, so a print loop no longer fills memory or floods Stop | #fix #executor | [2026-09](2026-09.md) | | U-20260923-113 | 2026-09-23 | The run window shows output past its 10,000-line cap without freezing the IDE | #fix #executor #performance | [2026-09](2026-09.md) | | U-20260923-112 | 2026-09-23 | URL Builder rebuilds a query it cannot decode losslessly exactly as it was | #fix #tools | [2026-09](2026-09.md) | @@ -195,4 +196,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 132 | +| [2026-09.md](2026-09.md) | 2026-09 | 133 | diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index 7865740f..ab6613a4 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict @@ -29,6 +30,26 @@ def normalize_line_endings(text: str) -> str: return text.replace("\r\n", "\n").replace("\r", "\n") +# A terminal's control sequences: CSI (colours, cursor moves: ESC [ ... final +# byte), OSC (titles, links: ESC ] ... BEL or ESC \), and any other two-byte +# escape +_TERMINAL_SEQUENCE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]") +# Control characters a text view cannot show: all of C0 but tab, newline and +# carriage return (which normalize_line_endings turns into a line break), and DEL +_CONTROL_CHARACTER = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") + + +def strip_terminal_codes(text: str) -> str: + """*text* without the terminal's control sequences and control characters. + + A script that colours its output (``colorama``, ``rich``, ``pytest + --color=yes``, ``FORCE_COLOR``) filled the window with ``←[31m``; a + backspace or form feed showed as a box. Carriage returns are left for + :func:`normalize_line_endings`. + """ + return _CONTROL_CHARACTER.sub("", _TERMINAL_SEQUENCE.sub("", text)) + + class CodeWindow(QWidget): # Emitted when a window closes with nothing left running in it, so the @@ -127,6 +148,6 @@ def append_output(self, text: str, is_error: bool = False, *, own_line: bool = F text_format = QTextCharFormat() color_key = "error_output_color" if is_error else "normal_output_color" text_format.setForeground(actually_color_dict.get(color_key)) - cursor.insertText(normalize_line_endings(text), text_format) + cursor.insertText(normalize_line_endings(strip_terminal_codes(text)), text_format) if follow_output: scroll_bar.setValue(scroll_bar.maximum()) diff --git a/test/test_utils/test_code_window.py b/test/test_utils/test_code_window.py index e1ded203..cf53b1ca 100644 --- a/test/test_utils/test_code_window.py +++ b/test/test_utils/test_code_window.py @@ -259,3 +259,32 @@ def test_the_file_runner_says_when_the_run_ended(qt_app, tmp_path): time.sleep(0.01) assert ended == [True] + + +class TestTerminalCodes: + """A coloured or redrawn line shows as its text, not as escape codes.""" + + @pytest.mark.parametrize(("written", "shown"), [ + ("\x1b[31mred\x1b[0m done\n", "red done\n"), + ("\x1b[1;32mok\x1b[m\n", "ok\n"), + ("\x1b]0;window title\x07after\n", "after\n"), + ("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\\n", "link\n"), + ("\x1b[2K\x1b[1Gprogress 50%\n", "progress 50%\n"), + ("a\bb\x0cc\x00\tt\n", "abc\tt\n"), + ("\u4e2d\u6587 stays\n", "\u4e2d\u6587 stays\n"), + ]) + def test_what_the_window_shows(self, qt_app, written, shown): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output(written) + + assert window.code_result.toPlainText() == shown + + def test_a_progress_bars_carriage_return_still_breaks_the_line(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("10%\r\x1b[32m20%\x1b[0m\r\n") + + assert window.code_result.toPlainText() == "10%\n20%\n" From 9de68ee68ed379c60503e39c3d67457c178efaf8 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 19:29:41 +0800 Subject: [PATCH 146/312] Run JupyterLab in the chosen interpreter, detect it without pip, and refuse to move ports --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 20 ++++++ docs/updates/README.md | 3 +- .../jupyter_lab_gui/jupyter_lab_thread.py | 43 ++++++++++-- .../jupyter_lab_gui/jupyter_lab_widget.py | 8 ++- .../jupyter_lab_tab.py | 2 +- test/test_utils/test_jupyter_lifecycle.py | 2 +- test/test_utils/test_jupyter_ready.py | 66 ++++++++++++++++++- 8 files changed, 134 insertions(+), 16 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 34fdcdb5..e63a8bb1 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -317,10 +317,10 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 10. `pybreeze_ui/jupyter_lab_gui/` -- `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `get_venv_python()` → `is_jupyter_installed()`(缺就自動裝)→ 啟動 server(`_start_server()`,在 `_process_lock` 裡先看 `_stopped`:`stop()` 之後就不再啟動,即使還在安裝)→ `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)`。server 的輸出寫進暫存檔而不是管線(server 起來後沒人讀管線,緩衝區滿了它會卡在 `write()`);提早結束時錯誤訊息取這個檔案的尾巴。失敗時 `error_occurred` 送的是原因(例外訊息,最多 2,000 字),traceback 只進 log;已經 `stop()`(分頁關了)的失敗不算失敗,只記 debug +- `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `choose_python()`(IDE 選定的直譯器優先,其次 venv 的,最後 IDE 自己的)→ `is_jupyter_installed()`(問直譯器 `find_spec('jupyterlab')`,不問 pip;缺就自動裝)→ 啟動 server(`_start_server()`,在 `_process_lock` 裡先看 `_stopped`:`stop()` 之後就不再啟動,即使還在安裝)→ `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)`。server 的輸出寫進暫存檔而不是管線(server 起來後沒人讀管線,緩衝區滿了它會卡在 `write()`);提早結束時錯誤訊息取這個檔案的尾巴。失敗時 `error_occurred` 送的是原因(例外訊息,最多 2,000 字),traceback 只進 log;已經 `stop()`(分頁關了)的失敗不算失敗,只記 debug - `jupyter_lab_widget.py` — 收到 URL 後用 `QWebEngineView.setUrl()` 載入;失敗時在狀態列顯示「初始化失敗:原因」(純文字、可選取、自動換行);`closeEvent` 一律關掉 server(launcher 執行緒在 lab 載入完就結束了,只停「還在跑的執行緒」等於從不停 server);還在安裝或啟動的 launcher 交給 `let_run_out()`,不在 UI 執行緒等(安裝可能要好幾分鐘),也不用 `blockSignals`(那會連 `finished` 一起擋掉,keeper 永遠放不掉它)。IDE 關閉時 `PyBreezeMainWindow._close_tool_tabs_and_docks()` 會關掉所有非編輯器分頁與 `DestroyDock`,這個 `closeEvent` 才會被呼叫到 -安全前提(CLAUDE.md 已明列):server 只綁 localhost,因此 token/password 刻意留空、`disable_check_xsrf=True` 才能內嵌。**不設 `allow_origin`**:loopback 擋不住瀏覽器,開放來源的話使用者逛到的任何網頁都能操作這個沒有 token 的 server。 +安全前提(CLAUDE.md 已明列):server 只綁 localhost,因此 token/password 刻意留空、`disable_check_xsrf=True` 才能內嵌。`--ServerApp.port_retries=0`:port 被占就直接結束(走「提早結束」的回報),不讓它默默換 port。**不設 `allow_origin`**:loopback 擋不住瀏覽器,開放來源的話使用者逛到的任何網頁都能操作這個沒有 token 的 server。 --- @@ -444,7 +444,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1697 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1701 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index fc950fa0..40ae7c2e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2034,3 +2034,23 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_code_window.py` - `architecture_explore.md` §4.5, §18 - **Open items**: none. + +## U-20260923-116 · 2026-09-23 · JupyterLab runs in the interpreter chosen in the IDE, finds itself without pip, and fails fast on a taken port · #fix #jupyter + +- **What**: three faults in the JupyterLab tab's launcher (`jupyter_lab_gui/jupyter_lab_thread.py`), found in the review sweep of `jupyter_lab_gui/`. + - The launcher never used the interpreter chosen in the IDE (`python_compiler`): it took the IDE's own when that ran in a venv, else a `venv`/`.venv` in the working directory, else it failed with "Cannot find venv python executable". Kernels ran in the wrong environment, and an IDE installed outside a venv could not start the lab. + - "Installed?" was `python -m pip show jupyterlab`. A venv made without pip (`uv venv`) answered no with jupyterlab installed, and the install that followed failed with "No module named pip". + - `find_free_port` let the port go before the server bound it, and jupyter_server's default `port_retries=50` moved to the next free port when it had been taken meanwhile. The tab kept polling the first one: a false startup timeout after 60 s, or, with something else listening there, that other service loaded in the view. +- **Fix**: + - New `choose_python(chosen)`: the interpreter chosen in the IDE, else a venv's (`get_venv_python`), else the IDE's own. `JupyterLabWidget(python_exe)` and `JupyterLauncherThread(python_exe=)` carry it, and the Tools tab entry passes the main window's `python_compiler`. + - `is_jupyter_installed` asks the interpreter whether it can find `jupyterlab` (`importlib.util.find_spec`). + - The server gets `--ServerApp.port_retries=0`: a taken port ends it at once, which the existing "exited early" path reports. The port is found and polled on `127.0.0.1` (`_LOOPBACK`). +- **Tests**: + - `test_jupyter_ready.py` +4: the chosen interpreter first, a venv's next; the IDE's own without a venv; "installed" asked of the interpreter, never pip, both ways; the server started with `port_retries=0`. + - The failure-text test injects its failure through `choose_python`, now that a missing venv is not one; the lifecycle stub launcher takes the new keyword. +- **Result / numbers**: JupyterLab tests 22 passed; 1701 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py`, `jupyter_lab_widget.py`, `pybreeze/pybreeze_ui/menu/extend_jeditor_tab_menu/jupyter_lab_tab.py` + - `test/test_utils/test_jupyter_ready.py`, `test_jupyter_lifecycle.py` + - `architecture_explore.md` §10, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 13f04fbb..be7549c6 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-116 | 2026-09-23 | JupyterLab runs in the interpreter chosen in the IDE, finds itself without pip, and fails fast on a taken port | #fix #jupyter | [2026-09](2026-09.md) | | U-20260923-115 | 2026-09-23 | The run window shows coloured and redrawn output as text, not escape codes | #fix #executor | [2026-09](2026-09.md) | | U-20260923-114 | 2026-09-23 | A run's output waits in a bounded queue, so a print loop no longer fills memory or floods Stop | #fix #executor | [2026-09](2026-09.md) | | U-20260923-113 | 2026-09-23 | The run window shows output past its 10,000-line cap without freezing the IDE | #fix #executor #performance | [2026-09](2026-09.md) | @@ -196,4 +197,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 133 | +| [2026-09.md](2026-09.md) | 2026-09 | 134 | 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 f5588876..432c6295 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py @@ -18,13 +18,17 @@ JUPYTER_STARTUP_TIMEOUT = 60 # How much of a failure's reason the tab shows: pip's stderr can run long _SHOWN_REASON_CHARACTERS = 2000 +# Run by the chosen interpreter: exits 0 when it can import jupyterlab +_HAS_JUPYTERLAB = "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('jupyterlab') else 1)" +# The server listens on localhost; the port is checked, and polled, on IPv4 loopback +_LOOPBACK = "127.0.0.1" def find_free_port() -> int: # Bind to loopback only: this socket exists purely to have the kernel pick an # unused port, which the JupyterLab server (also localhost-only) will reuse. with socket.socket() as s: - s.bind(("127.0.0.1", 0)) + s.bind((_LOOPBACK, 0)) return s.getsockname()[1] @@ -52,11 +56,30 @@ def get_venv_python() -> str: raise RuntimeError("Cannot find venv python executable") +def choose_python(chosen: str | None) -> str: + """The interpreter the lab runs in: the one chosen in the IDE, else a venv's, else the IDE's own. + + It took the IDE's own or a ``venv``/``.venv`` in the working directory and + never the one chosen in the IDE, so kernels ran in the wrong environment, + and an IDE installed outside a venv could not start the lab at all. + """ + if chosen: + return chosen + try: + return get_venv_python() + except RuntimeError: + return sys.executable + + def is_jupyter_installed(python_exe: str) -> bool: - # Query local venv for jupyterlab. python_exe is resolved via get_venv_python() - # from a fixed allowlist of venv paths; shell=False. nosec B603. + """Whether *python_exe* can import jupyterlab. + + Asked of the interpreter, not of pip: a venv made without pip (``uv venv``) + failed ``pip show`` with jupyterlab installed, and the install that + followed failed with "No module named pip". shell=False. nosec B603. + """ result = subprocess.run( # nosec B603 # nosemgrep # noqa: S603 - [python_exe, "-m", "pip", "show", "jupyterlab"], + [python_exe, "-c", _HAS_JUPYTERLAB], capture_output=True, timeout=30, check=False, @@ -70,8 +93,11 @@ class JupyterLauncherThread(QThread): status_update = Signal(str) error_occurred = Signal(str) - def __init__(self, parent=None, startup_timeout: int = JUPYTER_STARTUP_TIMEOUT): + def __init__(self, parent=None, startup_timeout: int = JUPYTER_STARTUP_TIMEOUT, + python_exe: str | None = None): super().__init__(parent) + # The interpreter chosen in the IDE, if any (choose_python) + self._chosen_python = python_exe self.process = None # Set by stop(). Checked, under the lock, before the server is started: # a tab closed during the install would otherwise get a server started @@ -84,7 +110,7 @@ def __init__(self, parent=None, startup_timeout: int = JUPYTER_STARTUP_TIMEOUT): def run(self): try: - python_exe = get_venv_python() + python_exe = choose_python(self._chosen_python) if not is_jupyter_installed(python_exe): self.status_update.emit(language_wrapper.language_word_dict.get("jupyterlab_downloading")) @@ -149,6 +175,9 @@ def _start_server(self, python_exe: str, port: int) -> subprocess.Popen: "--no-browser", "--ServerApp.ip=localhost", f"--ServerApp.port={port}", + # A port taken since it was found fails at once: the server moved + # to the next free one, and the tab waited on (or loaded) this one + "--ServerApp.port_retries=0", "--ServerApp.token=", "--ServerApp.password=", "--ServerApp.disable_check_xsrf=True", @@ -158,7 +187,7 @@ def _start_server(self, python_exe: str, port: int) -> subprocess.Popen: @staticmethod def _port_open(port: int) -> bool: try: - with socket.create_connection(("localhost", port), timeout=0.5): + with socket.create_connection((_LOOPBACK, port), timeout=0.5): return True except OSError: return False 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 21219239..1e5a7ffb 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py @@ -13,7 +13,11 @@ class JupyterLabWidget(QWidget): - def __init__(self): + def __init__(self, python_exe: str | None = None): + """ + :param python_exe: the interpreter chosen in the IDE; the lab and its + kernels run in it (see ``choose_python``) + """ super().__init__() layout = QVBoxLayout(self) @@ -25,7 +29,7 @@ def __init__(self): self.browser.hide() layout.addWidget(self.browser) - self.thread = JupyterLauncherThread() + self.thread = JupyterLauncherThread(python_exe=python_exe) self.thread.status_update.connect(self.update_status) self.thread.server_ready.connect(self.load_lab) self.thread.error_occurred.connect(self.show_error) diff --git a/pybreeze/pybreeze_ui/menu/extend_jeditor_tab_menu/jupyter_lab_tab.py b/pybreeze/pybreeze_ui/menu/extend_jeditor_tab_menu/jupyter_lab_tab.py index 04306486..4be6d2d5 100644 --- a/pybreeze/pybreeze_ui/menu/extend_jeditor_tab_menu/jupyter_lab_tab.py +++ b/pybreeze/pybreeze_ui/menu/extend_jeditor_tab_menu/jupyter_lab_tab.py @@ -25,6 +25,6 @@ def extend_tab_tools_menu(ui_we_want_to_set: PyBreezeMainWindow): def add_jupyterlab_tab(ui_we_want_to_set: PyBreezeMainWindow): pybreeze_logger.info(f"jupyter_lab_tab.py add jupyter tab ui_we_want_to_set: {ui_we_want_to_set}") ui_we_want_to_set.tab_widget.addTab( - JupyterLabWidget(), + JupyterLabWidget(getattr(ui_we_want_to_set, "python_compiler", None)), f"{language_wrapper.language_word_dict.get('tab_menu_jupyterlab_tab_name')} " f"{ui_we_want_to_set.tab_widget.count()}") diff --git a/test/test_utils/test_jupyter_lifecycle.py b/test/test_utils/test_jupyter_lifecycle.py index 09360fbb..f5cc885e 100644 --- a/test/test_utils/test_jupyter_lifecycle.py +++ b/test/test_utils/test_jupyter_lifecycle.py @@ -113,7 +113,7 @@ def test_stopping_twice_is_harmless(self, app, launched): class FinishedLauncher: """A launcher whose thread has finished: the server it started is still up.""" - def __init__(self) -> None: + def __init__(self, **_options) -> None: self.stopped = False from PySide6.QtCore import QObject, Signal diff --git a/test/test_utils/test_jupyter_ready.py b/test/test_utils/test_jupyter_ready.py index 4ee06580..8077a79d 100644 --- a/test/test_utils/test_jupyter_ready.py +++ b/test/test_utils/test_jupyter_ready.py @@ -108,7 +108,7 @@ class TestWhatAFailureShows: def _run(self, monkeypatch, fail, stop_first=False): from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod - monkeypatch.setattr(mod, "get_venv_python", fail) + monkeypatch.setattr(mod, "choose_python", lambda _chosen: fail()) thread = mod.JupyterLauncherThread() if stop_first: thread.stop() @@ -155,3 +155,67 @@ def test_the_tab_says_why_as_plain_text(self, qt_app, monkeypatch): assert tab.status_label.textFormat() == Qt.TextFormat.PlainText tab.close() tab.deleteLater() + + +class TestWhichInterpreterRunsTheLab: + def test_the_one_chosen_in_the_ide_comes_first(self, monkeypatch): + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + + monkeypatch.setattr(mod, "get_venv_python", lambda: "venv-python") + + assert mod.choose_python("C:/envs/project/python.exe") == "C:/envs/project/python.exe" + assert mod.choose_python(None) == "venv-python" + + def test_without_a_venv_the_ides_own_is_used(self, monkeypatch): + # It raised "Cannot find venv python executable" and the lab never started + import sys + + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + + def no_venv(): + raise RuntimeError("Cannot find venv python executable") + + monkeypatch.setattr(mod, "get_venv_python", no_venv) + + assert mod.choose_python(None) == sys.executable + + def test_installed_is_asked_of_the_interpreter_not_of_pip(self, tmp_path, monkeypatch): + # A venv made without pip failed "pip show" with jupyterlab installed + import os + import subprocess + import sys + + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + + asked: list = [] + real_run = subprocess.run + + def run(argv, **kwargs): + asked.append(argv) + return real_run(argv, **kwargs) + + monkeypatch.setattr(mod.subprocess, "run", run) + (tmp_path / "jupyterlab").mkdir() + (tmp_path / "jupyterlab" / "__init__.py").write_text("", encoding="utf-8") + + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + found = mod.is_jupyter_installed(sys.executable) + monkeypatch.setenv("PYTHONPATH", os.devnull) + monkeypatch.setattr(mod, "_HAS_JUPYTERLAB", mod._HAS_JUPYTERLAB.replace("jupyterlab", "no_such_lab")) + missing = mod.is_jupyter_installed(sys.executable) + + assert (found, missing) == (True, False) + assert all("pip" not in argv for argv in asked) + + +def test_the_server_is_told_not_to_move_to_another_port(monkeypatch): + # With port_retries left at 50 it moved to the next free port, and the tab + # waited on the one it was given + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + + started: list = [] + monkeypatch.setattr(mod.subprocess, "Popen", lambda argv, **_kwargs: started.append(argv)) + mod.JupyterLauncherThread()._start_server("python", 8888) + + assert "--ServerApp.port_retries=0" in started[0] + assert "--ServerApp.port=8888" in started[0] From e41a30f07120b87ed167a5b39833662b6f854c91 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 23 Sep 2026 19:30:27 +0800 Subject: [PATCH 147/312] Create the data folder for the log with the owner-only mode --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 12 ++++++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/logging/logger.py | 7 ++++++- test/test_utils/test_logger.py | 23 +++++++++++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index e63a8bb1..57b47bb3 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -444,7 +444,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1701 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1702 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 40ae7c2e..ef2e0203 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2054,3 +2054,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_jupyter_ready.py`, `test_jupyter_lifecycle.py` - `architecture_explore.md` §10, §18 - **Open items**: none. + +## U-20260923-117 · 2026-09-23 · A data folder the log creates is its owner's only, like the one pybreeze_data_dir creates · #fix #security + +- **What**: U-20260923-101 made `pybreeze_data_dir()` create `~/.pybreeze` as `0700` and leave an existing folder as it is. The log handler (`PyBreezeLogger._open`) made its own folder with `path.parent.mkdir(parents=True)`; the log is often the first thing written, so on POSIX it created `~/.pybreeze` with the default mode (usually `0755`), and the `0700` promise never applied. Files written with `private=True` stayed `0600`. Found in the review sweep of `utils/logging/`. +- **Fix**: when the log file lies under the data folder, `_open` creates that folder with `DATA_DIR_MODE` first, then `logs/`. +- **Tests**: `test_logger.py` +1: the data folder the log creates is made with mode `0700`, and `logs/` exists. +- **Result / numbers**: logger and app dirs tests 17 passed. `ruff check` clean. +- **Files**: + - `pybreeze/utils/logging/logger.py` + - `test/test_utils/test_logger.py` + - `architecture_explore.md` §18 +- **Open items**: none; this closes the findings of the `show_code_window/`, `jupyter_lab_gui/` and `utils/logging/` sweep. diff --git a/docs/updates/README.md b/docs/updates/README.md index be7549c6..f0439e9a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-117 | 2026-09-23 | A data folder the log creates is its owner's only, like the one pybreeze_data_dir creates | #fix #security | [2026-09](2026-09.md) | | U-20260923-116 | 2026-09-23 | JupyterLab runs in the interpreter chosen in the IDE, finds itself without pip, and fails fast on a taken port | #fix #jupyter | [2026-09](2026-09.md) | | U-20260923-115 | 2026-09-23 | The run window shows coloured and redrawn output as text, not escape codes | #fix #executor | [2026-09](2026-09.md) | | U-20260923-114 | 2026-09-23 | A run's output waits in a bounded queue, so a print loop no longer fills memory or floods Stop | #fix #executor | [2026-09](2026-09.md) | @@ -197,4 +198,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 134 | +| [2026-09.md](2026-09.md) | 2026-09 | 135 | diff --git a/pybreeze/utils/logging/logger.py b/pybreeze/utils/logging/logger.py index bbb3c2be..7199e740 100644 --- a/pybreeze/utils/logging/logger.py +++ b/pybreeze/utils/logging/logger.py @@ -24,7 +24,7 @@ from logging.handlers import RotatingFileHandler from pathlib import Path -from pybreeze.utils.app_dirs import pybreeze_data_path +from pybreeze.utils.app_dirs import DATA_DIR_MODE, pybreeze_data_path # A library must not reconfigure the root logger: forcing it to DEBUG makes # every third-party logger verbose and robs the host application of control @@ -118,6 +118,11 @@ def _open(self): """Create the directory and rotate before opening; fall back to ``os.devnull``.""" path = Path(self.baseFilename) try: + data_dir = pybreeze_data_path() + if data_dir in path.parents: + # The log is often the first thing written: made here with the + # default mode, ~/.pybreeze stayed readable by every local user + data_dir.mkdir(mode=DATA_DIR_MODE, parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True) _rotate_if_large(path, _rotate_at_bytes()) return super()._open() diff --git a/test/test_utils/test_logger.py b/test/test_utils/test_logger.py index e9f369ce..31e0b9ec 100644 --- a/test/test_utils/test_logger.py +++ b/test/test_utils/test_logger.py @@ -78,6 +78,29 @@ def test_the_environment_can_name_another_file(self, monkeypatch, tmp_path): assert logger_module.default_log_file() == tmp_path / "elsewhere.log" + def test_a_data_folder_the_log_makes_is_its_owners_only(self, monkeypatch, tmp_path): + # The log is often written first: it made ~/.pybreeze with the default + # mode, and pybreeze_data_dir then left that folder as it was + from pathlib import Path + + from pybreeze.utils.logging import logger as logger_module + + data = tmp_path / ".pybreeze" + monkeypatch.setattr(logger_module, "pybreeze_data_path", lambda: data) + modes: dict = {} + made = Path.mkdir + + def record(path, mode=0o777, parents=False, exist_ok=False): + modes.setdefault(Path(path), mode) + made(path, mode=mode, parents=parents, exist_ok=exist_ok) + + monkeypatch.setattr(Path, "mkdir", record) + handler = PyBreezeLogger(filename=str(data / "logs" / "PyBreeze.log")) + handler.close() + + assert modes[data] == 0o700 + assert (data / "logs").is_dir() + def test_the_package_handler_opens_nothing_at_import(self): from pybreeze.utils.logging.logger import file_handler From 233f47f5b7a4021db5326a70b91a348262ca6cc9 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 01:47:16 +0800 Subject: [PATCH 148/312] Collect cyclic garbage on the GUI thread so a worker never destroys a Qt object --- CLAUDE.md | 2 + architecture.md | 4 + architecture_explore.md | 6 +- docs/updates/2026-09.md | 25 ++++++ docs/updates/README.md | 3 +- pybreeze/pybreeze_ui/editor_main/main_ui.py | 6 +- pybreeze/pybreeze_ui/gui_thread_gc.py | 58 ++++++++++++ test/test_utils/conftest.py | 25 ++++++ test/test_utils/test_gui_thread_gc.py | 99 +++++++++++++++++++++ 9 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 pybreeze/pybreeze_ui/gui_thread_gc.py create mode 100644 test/test_utils/conftest.py create mode 100644 test/test_utils/test_gui_thread_gc.py diff --git a/CLAUDE.md b/CLAUDE.md index 848d2392..1abec238 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ pybreeze/ │ ├── jupyter_lab_gui/ # JupyterLab tab (QWebEngineView) │ ├── show_code_window/ # CodeWindow — subprocess output display │ ├── thread_keeper.py # let_run_out: a worker QThread outlives its closed widget +│ ├── gui_thread_gc.py # Garbage collected on a GUI-thread timer, never on a worker │ ├── dialog/ # prthinker settings dialog │ └── syntax/ # Automation keyword highlighting definitions ├── extend/ @@ -83,6 +84,7 @@ ruff check pybreeze/ # before committing non-trivia - Python 3.10+: `X | Y` unions, `from __future__ import annotations`, `TYPE_CHECKING` guard for hint-only imports - **Never update UI from a worker thread** — Queue + QTimer (see `TaskProcessManager`) or Qt Signal/Slot +- Automatic garbage collection is off in the IDE: `start_editor()` collects on a GUI-thread timer (`gui_thread_gc.py`), because a collection on a worker destroys Qt objects there. Never call `gc.enable()` - Custom exceptions inherit from `ITEException`; log via `pybreeze_logger` (lazy `%s` formatting, never `print()`) - Plugin API: `register_programming_language()` / `register_natural_language()` from `je_editor.plugins` - A QAction built for a menu must be kept alive: store it on the main window or give it the menu as its parent. A menu does not own the actions added to it, so one held only by a local variable is deleted when the builder returns and its entry disappears diff --git a/architecture.md b/architecture.md index d558591c..f94053e5 100644 --- a/architecture.md +++ b/architecture.md @@ -33,6 +33,7 @@ their output reaches the UI through Queue + QTimer. | `pybreeze/pybreeze_ui/connect_gui/` | `ssh/` terminal + SFTP tree; `url/` HTTP code-review client | | `pybreeze/pybreeze_ui/jupyter_lab_gui/`, `show_code_window/`, `syntax/` | JupyterLab tab; `CodeWindow` subprocess output window; automation keyword highlighting | | `pybreeze/pybreeze_ui/thread_keeper.py` | `let_run_out()`: a worker `QThread` whose widget closed is kept until it ends instead of being waited for | +| `pybreeze/pybreeze_ui/gui_thread_gc.py` | `GuiThreadGarbageCollector`: automatic garbage collection off, collected on a GUI-thread timer instead (installed by `start_editor()`) | | `pybreeze/extend/process_executor/` | Subprocess isolation layer: `TaskProcessManager`, `process_executor_utils.py`, `FileRunnerProcess`, `queue_pump.py`, one sub-package per automation package, plus `test_pioneer/` and `prthinker/` | | `pybreeze/extend/mail_thunder_extend/`, `prthinker_extend/` | Post-test email hook; prthinker settings and argument assembly (pure logic) | | `pybreeze/extend_multi_language/` | PyBreeze's English and Traditional Chinese strings, merged into JEditor's dictionaries | @@ -205,6 +206,9 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() `QThread` and reaches the UI only through signals. A request panel that closes mid-request hands its thread to `thread_keeper.let_run_out()` (cut off from the panel, kept until it ends) rather than waiting for it on the UI thread; a running `QThread` must never be destroyed. +- Cyclic garbage is collected on the GUI thread only (`gui_thread_gc.py`, installed by + `start_editor()`): an automatic collection runs in whichever thread allocates, and one on a worker + destroyed Qt objects there and crashed the IDE. Never call `gc.enable()` in the IDE. - Subprocesses use argument lists, `shell=False` and a `timeout`, and pass secrets through `env` (§ Security › Subprocess). - The JupyterLab server stays localhost-only (§ Security › JupyterLab). diff --git a/architecture_explore.md b/architecture_explore.md index 57b47bb3..e520a35e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -64,7 +64,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE `pybreeze/pybreeze_ui/editor_main/main_ui.py:100` 的 `start_editor()`: -1. 取得(或建立)`QApplication` +1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - `update_language_dict()` 併入 PyBreeze 的 571 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` @@ -420,6 +420,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 └── QThread: JupyterLauncherThread──Signal──► QWebEngineView ``` +垃圾回收只在 UI 執行緒跑:`start_editor()` 裝上 `gui_thread_gc.GuiThreadGarbageCollector`,關掉自動回收,改由 UI 執行緒上每秒一次的 QTimer 照直譯器自己的門檻回收(單元測試由 `test/test_utils/conftest.py` 做同樣的事)。自動回收會在任何配置超過門檻的執行緒跑,worker 上的一次回收曾把 UI 執行緒建立的 Qt 物件在 worker 上銷毀,計時器停不掉,之後 UI 執行緒把計時器事件送給已釋放的物件而崩潰。不要在 IDE 裡呼叫 `gc.enable()`。 + 鐵律:worker thread 一律不碰 UI。普通執行緒走 Queue + QTimer,`QThread` 走 Signal/Slot。分頁或視窗關閉時還在跑的 `QThread` 交給 `thread_keeper.let_run_out()`,不等它、也不讓它在執行中被銷毀。 執行器的壽命:QTimer 接到執行器的 `pull_text()` / `_pull_text()` 這條連線**不會**讓執行器活著;reader 執行緒一結束,沒人持有的執行器就會被 GC,剩下的輸出和結束那一行都不會出現。所以執行器一律掛在它寫入的 `CodeWindow.runner` 上,跟視窗同壽;視窗又掛在主視窗的 `current_run_code_window`。 @@ -444,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 107 個 `test_*.py`、1702 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 108 個 `test_*.py`、1710 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ef2e0203..f72b3349 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2066,3 +2066,28 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_logger.py` - `architecture_explore.md` §18 - **Open items**: none; this closes the findings of the `show_code_window/`, `jupyter_lab_gui/` and `utils/logging/` sweep. + +## U-20260923-118 · 2026-09-24 · Collect cyclic garbage on the GUI thread only, so a worker never destroys a Qt object · #fix #threading #crash + +- **What**: an intermittent access violation in `QCoreApplication::notifyInternal2`, seen in the unit tests as a crash inside `processEvents` (`test_install_menu.py`, `test_diff_gui.py`), about one run in three for the sorted files 21–30 plus `test_install_menu.py`. + - A bisect over the suspected range was misleading: the crash does not come from one commit, and a passing run proves nothing. + - Under WinDbg the crash read freed heap (`0xfeeefeee`) right after Qt's `QBasicTimer::stop: Failed. Possibly trying to stop from a different thread`. A Qt message handler that dumps every thread's stack at that warning showed the cause: the `DiffThread` worker, busy in `difflib`, was `Garbage-collecting`. + - CPython collects in whichever thread allocates past the threshold. A collection on a worker freed a Qt object the GUI thread had made, its destructor ran there and could not stop its timers, and the GUI thread later delivered a timer event to the freed object. + - The IDE has the same exposure: the Diff comparison, a run's output readers, the SSH and AI request threads all run Python and allocate. The same crash class is recorded in napari issue #1029 and PFRSentinel PR #32. +- **Fix**: + - New `pybreeze_ui/gui_thread_gc.py`: `GuiThreadGarbageCollector` turns automatic collection off and, from a 1 s `QTimer` on the GUI thread, collects each generation whose count is past the interpreter's own threshold. A threshold of 0 is never reached (Python 3.14 leaves the third at 0). `stop()` gives collection back. + - `start_editor()` installs it on the application (`collect_garbage_on_gui_thread`), so it covers the IDE only, not a host application that embeds the main window. + - New `test/test_utils/conftest.py` does the same for the suite, with a collection check after each test. +- **Tests**: `test_gui_thread_gc.py`, 8 tests: + - A cyclic Python-owned `QObject` is not freed by a worker that allocates 200,000 dicts, and is freed on the main thread by the next collection. Without the collector the worker frees it. + - Per-generation thresholds, a zero threshold, `stop()`, and `start_editor` installing the collector. +- **Result / numbers**: + - The crashing file set passed 6 of 6 runs, against 4 crashes in 5 runs before. + - Suite: 898 passed; 797 passed and 15 skipped. 1710 tests in 108 files; the new tests also pass on Python 3.14. + - Full collections on the GUI thread took 0.2–0.65 s at 320,000 objects under load. That pause is not new: under the GIL a collection on any thread holds up the GUI thread's Python. + - Both startup tests exit 0. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/gui_thread_gc.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py` + - `test/test_utils/conftest.py`, `test_gui_thread_gc.py` + - `architecture.md`, `architecture_explore.md` §16, §18, `CLAUDE.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index f0439e9a..c8386f31 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-118 | 2026-09-24 | Collect cyclic garbage on the GUI thread only, so a worker never destroys a Qt object | #fix #threading #crash | [2026-09](2026-09.md) | | U-20260923-117 | 2026-09-23 | A data folder the log creates is its owner's only, like the one pybreeze_data_dir creates | #fix #security | [2026-09](2026-09.md) | | U-20260923-116 | 2026-09-23 | JupyterLab runs in the interpreter chosen in the IDE, finds itself without pip, and fails fast on a taken port | #fix #jupyter | [2026-09](2026-09.md) | | U-20260923-115 | 2026-09-23 | The run window shows coloured and redrawn output as text, not escape codes | #fix #executor | [2026-09](2026-09.md) | @@ -198,4 +199,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 135 | +| [2026-09.md](2026-09.md) | 2026-09 | 136 | diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index 47a33266..f0858dfb 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -16,6 +16,7 @@ from pybreeze.extend_multi_language.update_language_dict import update_language_dict from pybreeze.pybreeze_ui.editor_main.file_tree_context_menu import setup_file_tree_context_menu +from pybreeze.pybreeze_ui.gui_thread_gc import collect_garbage_on_gui_thread from pybreeze.pybreeze_ui.menu.build_menubar import add_menu_to_menubar from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow from pybreeze.pybreeze_ui.syntax.syntax_extend import \ @@ -159,7 +160,10 @@ def start_editor(debug_mode: bool = False, theme: str = "dark_amber.xml", **kwar new_ide = QCoreApplication.instance() if new_ide is None: new_ide = QApplication(sys.argv) - window = PyBreezeMainWindow(debug_mode=debug_mode, **kwargs) + # Workers allocate enough to trigger a collection, which then destroyed + # Qt objects on the worker and crashed the IDE later + collect_garbage_on_gui_thread(new_ide) + window =PyBreezeMainWindow(debug_mode=debug_mode, **kwargs) apply_stylesheet(new_ide, theme=theme) window.showMaximized() try: diff --git a/pybreeze/pybreeze_ui/gui_thread_gc.py b/pybreeze/pybreeze_ui/gui_thread_gc.py new file mode 100644 index 00000000..1fe7d56c --- /dev/null +++ b/pybreeze/pybreeze_ui/gui_thread_gc.py @@ -0,0 +1,58 @@ +"""Run Python's cyclic garbage collection on the GUI thread only. + +CPython collects in whichever thread happens to allocate past the threshold, +and a worker allocates plenty (the Diff tab matching lines, a run's output +readers, an AI request). When that collection frees a Qt object the GUI thread +made, its destructor runs on the worker: its timers cannot be stopped from +there ("QBasicTimer::stop: Failed"), and the GUI thread later delivers a timer +event to the freed object and the IDE dies with an access violation. + +Automatic collection is switched off and a timer on the GUI thread collects +instead, by the same thresholds the interpreter would use. +""" +from __future__ import annotations + +import gc + +from PySide6.QtCore import QObject, QTimer + +#: How often the GUI thread looks at the allocation counts. +COLLECT_INTERVAL_MS = 1000 + + +class GuiThreadGarbageCollector(QObject): + """Collect on a timer of the thread that makes it, with automatic collection off. + + Constructing it calls ``gc.disable()`` for the whole process; :meth:`stop` + turns automatic collection back on. + """ + + def __init__(self, parent: QObject | None = None, interval_ms: int = COLLECT_INTERVAL_MS) -> None: + super().__init__(parent) + self._thresholds = gc.get_threshold() + gc.disable() + self._timer = QTimer(self) + self._timer.setInterval(interval_ms) + self._timer.timeout.connect(self.check) + self._timer.start() + + def check(self) -> None: + """Collect each generation whose count is past the interpreter's threshold. + + A threshold of 0 means the interpreter never collects that generation + on its own (Python 3.14 leaves the third at 0), so neither does this. + """ + for count, threshold, generation in zip(gc.get_count(), self._thresholds, range(3)): + if threshold == 0 or count <= threshold: + return + gc.collect(generation) + + def stop(self) -> None: + """Stop the timer and give collection back to the interpreter.""" + self._timer.stop() + gc.enable() + + +def collect_garbage_on_gui_thread(app: QObject) -> GuiThreadGarbageCollector: + """Switch collection to *app*'s thread; the collector lives as long as *app*.""" + return GuiThreadGarbageCollector(app) diff --git a/test/test_utils/conftest.py b/test/test_utils/conftest.py new file mode 100644 index 00000000..107e8ed8 --- /dev/null +++ b/test/test_utils/conftest.py @@ -0,0 +1,25 @@ +"""Collect cyclic garbage on the main thread only, as the IDE does. + +Left automatic, a collection started by a worker (a Diff comparison, a run's +output readers) freed Qt objects an earlier test had left, on that worker, and a +later ``processEvents`` crashed on their timers. +""" +from __future__ import annotations + +import pytest + +from pybreeze.pybreeze_ui.gui_thread_gc import GuiThreadGarbageCollector + + +@pytest.fixture(scope="session", autouse=True) +def _gui_thread_collector(): + collector = GuiThreadGarbageCollector() + yield collector + collector.stop() + + +@pytest.fixture(autouse=True) +def _collect_after_each_test(_gui_thread_collector): + yield + # The timer only fires while a test processes events + _gui_thread_collector.check() diff --git a/test/test_utils/test_gui_thread_gc.py b/test/test_utils/test_gui_thread_gc.py new file mode 100644 index 00000000..bef3874e --- /dev/null +++ b/test/test_utils/test_gui_thread_gc.py @@ -0,0 +1,99 @@ +"""Cyclic garbage is collected on the GUI thread, never on a worker.""" +from __future__ import annotations + +import gc +import os +import threading + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QObject + +from pybreeze.pybreeze_ui import gui_thread_gc +from pybreeze.pybreeze_ui.gui_thread_gc import GuiThreadGarbageCollector + + +@pytest.fixture() +def collector(): + was_enabled = gc.isenabled() + made = GuiThreadGarbageCollector() + yield made + made.stop() + if not was_enabled: + gc.disable() + + +class _Cyclic(QObject): + """A Python-owned QObject that only the cycle collector can free.""" + + def __init__(self) -> None: + super().__init__() + self.me = self + + +def test_automatic_collection_is_off_until_stopped(collector): + assert gc.isenabled() is False + + collector.stop() + + assert gc.isenabled() is True + + +def test_a_worker_that_allocates_frees_no_qt_object(collector): + # A worker allocating past the threshold used to collect a Qt object made + # on the GUI thread, and run its destructor there + import weakref + + freed_in: list = [] + cyclic = _Cyclic() + # A connection to its destroyed signal would keep it alive; a weak + # reference's callback runs in whichever thread frees it + watch = weakref.ref(cyclic, lambda _ref: freed_in.append(threading.current_thread().name)) + del cyclic + + worker = threading.Thread(target=lambda: [{} for _ in range(200_000)], name="worker") + worker.start() + worker.join() + assert freed_in == [] + + gc.collect() + assert freed_in == [threading.main_thread().name] + assert watch() is None + + +@pytest.mark.parametrize(("counts", "collected"), [ + ((700, 0, 0), []), + ((701, 0, 0), [0]), + ((701, 11, 3), [0, 1]), + ((701, 11, 11), [0, 1, 2]), +]) +def test_a_generation_is_collected_past_the_interpreters_threshold(collector, monkeypatch, counts, collected): + done: list = [] + monkeypatch.setattr(collector, "_thresholds", (700, 10, 10)) + monkeypatch.setattr(gui_thread_gc.gc, "get_count", lambda: counts) + monkeypatch.setattr(gui_thread_gc.gc, "collect", done.append) + + collector.check() + + assert done == collected + + +def test_a_threshold_of_zero_is_never_reached(collector, monkeypatch): + # Python 3.14 leaves the third threshold at 0 + done: list = [] + monkeypatch.setattr(collector, "_thresholds", (2000, 10, 0)) + monkeypatch.setattr(gui_thread_gc.gc, "get_count", lambda: (2001, 11, 5)) + monkeypatch.setattr(gui_thread_gc.gc, "collect", done.append) + + collector.check() + + assert done == [0, 1] + + +def test_the_ide_switches_collection_to_its_gui_thread(): + import inspect + + from pybreeze.pybreeze_ui.editor_main import main_ui + + assert "collect_garbage_on_gui_thread(new_ide)" in inspect.getsource(main_ui.start_editor) From 1761f016aac7ac1fcdae84fde9831101fd2f92c5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 01:51:25 +0800 Subject: [PATCH 149/312] Highlight TestPioneer scripts saved as .yaml as well as .yml --- architecture_explore.md | 8 +-- docs/updates/2026-09.md | 21 ++++++++ docs/updates/README.md | 3 +- .../build_test_pioneer_menu.py | 7 ++- pybreeze/pybreeze_ui/syntax/syntax_extend.py | 9 ++-- pybreeze/pybreeze_ui/syntax/syntax_keyword.py | 4 ++ test/test_utils/test_syntax_extend.py | 51 +++++++++++++++++++ 7 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 test/test_utils/test_syntax_extend.py diff --git a/architecture_explore.md b/architecture_explore.md index e520a35e..954feb54 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -71,7 +71,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 - `add_menu_to_menubar()` — 建構全部選單(見 §5) - - `syntax_extend_package()` — 註冊 `.json` / `.yml` 自動化關鍵字高亮 + - `syntax_extend_package()` — 註冊 `.json` / `.yml` / `.yaml` 自動化關鍵字高亮 - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁(`_add_extend_tabs()`:每一個分頁各自建,建不起來的只記 log,不會讓整個 IDE 起不來) - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器;Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) @@ -182,7 +182,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.2 非工廠的兩個選單 -- **`test_pioneer_menu/`** — 建範本目錄(寫在 IDE 的工作目錄,已有範本先問是否取代,寫入失敗跳警告)+ `QFileDialog` 選 `.yml`(會驗副檔名,選錯跳 `QMessageBox`) +- **`test_pioneer_menu/`** — 建範本目錄(寫在 IDE 的工作目錄,已有範本先問是否取代,寫入失敗跳警告)+ `QFileDialog` 選 `.yml` / `.yaml`(副檔名清單與語法高亮共用 `syntax_keyword.TEST_PIONEER_SUFFIXES`;會驗副檔名,選錯跳 `QMessageBox`) - **`prthinker_menu/`** — 審查目前檔案(先照 Run with... 的方式存檔:`save_current_file_for_run()`)/ 審查 PR(`QInputDialog` 問編號,範圍 1–1,000,000)/ 設定對話框 / Help ### 5.3 `tools/tools_menu.py` — 表格驅動的工具註冊 @@ -328,7 +328,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 - `syntax_keyword.py`(625 行)— 七份關鍵字清單,彙整成 `package_keyword_list`: `je_auto_control` / `je_load_density` / `je_api_testka` / `je_web_runner` / `automation_file` / `mail_thunder` / `test_pioneer` -- `syntax_extend.py` — 把前六個註冊到 `.json`(黃色 `#FFFF00`),`test_pioneer` 註冊到 `.yml`(橘色 `#FF9900`),然後重置當前編輯器的 highlighter +- `syntax_extend.py` — 把前六個註冊到 `.json`(黃色 `#FFFF00`),`test_pioneer` 註冊到 `TEST_PIONEER_SUFFIXES` 的每個副檔名(`.yml`、`.yaml`,橘色 `#FF9900`),然後重置當前編輯器的 highlighter `PackageManager.syntax_check_list` 決定要註冊哪些;用 `package_keyword_list.get(pkg, [])` 取值,套件沒有關鍵字清單時註冊空集合而不是炸掉。 @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 108 個 `test_*.py`、1710 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 109 個 `test_*.py`、1714 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index f72b3349..2a291050 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2091,3 +2091,24 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/conftest.py`, `test_gui_thread_gc.py` - `architecture.md`, `architecture_explore.md` §16, §18, `CLAUDE.md` - **Open items**: none. + +## U-20260923-119 · 2026-09-24 · Highlight TestPioneer scripts saved as .yaml, the other suffix its menu runs · #fix #syntax + +- **What**: the TestPioneer menu runs `.yml` and `.yaml` scripts, but `syntax_extend_package()` registered the TestPioneer keywords for `.yml` only, so a `.yaml` script opened with no highlighting. Found in the review sweep of `pybreeze_ui/syntax/`. +- **Fix**: + - New `TEST_PIONEER_SUFFIXES` in `syntax/syntax_keyword.py`. The keywords are registered under each suffix. + - The TestPioneer menu takes its suffix check and file-dialog filter from the same constant, so the two cannot drift apart again. +- **Tests**: new `test_syntax_extend.py`, 4 tests: + - Both suffixes carry the TestPioneer keywords. + - The menu's filter is built from the shared suffixes. + - Every package in `syntax_check_list` gets a `.json` keyword group. +- **Result / numbers**: + - TestPioneer menu tests: 10 passed. + - 1714 tests in 109 files. `ruff check` clean. +- **Also checked**: the highlight keywords compared with the executor tables of the packages installed in the local venv. Those packages are older than the ones the lists were written from, so the comparison says nothing; the lists are unchanged. +- **Files**: + - `pybreeze/pybreeze_ui/syntax/syntax_extend.py`, `syntax_keyword.py` + - `pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py` + - `test/test_utils/test_syntax_extend.py` + - `architecture_explore.md` §3, §5, §11, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c8386f31..03743e98 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-119 | 2026-09-24 | Highlight TestPioneer scripts saved as .yaml, the other suffix its menu runs | #fix #syntax | [2026-09](2026-09.md) | | U-20260923-118 | 2026-09-24 | Collect cyclic garbage on the GUI thread only, so a worker never destroys a Qt object | #fix #threading #crash | [2026-09](2026-09.md) | | U-20260923-117 | 2026-09-23 | A data folder the log creates is its owner's only, like the one pybreeze_data_dir creates | #fix #security | [2026-09](2026-09.md) | | U-20260923-116 | 2026-09-23 | JupyterLab runs in the interpreter chosen in the IDE, finds itself without pip, and fails fast on a taken port | #fix #jupyter | [2026-09](2026-09.md) | @@ -199,4 +200,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 136 | +| [2026-09.md](2026-09.md) | 2026-09 | 137 | diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py index a13cb4c3..1f87e78f 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py @@ -10,15 +10,14 @@ from pybreeze.extend.process_executor.test_pioneer.test_pioneer_process_manager import \ init_and_start_test_pioneer_process +from pybreeze.pybreeze_ui.syntax.syntax_keyword import TEST_PIONEER_SUFFIXES from pybreeze.utils.logging.logger import pybreeze_logger if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -# A TestPioneer script is YAML, under either of YAML's extensions -_YAML_SUFFIXES = (".yml", ".yaml") -_YAML_FILTER = "YAML (*.yml *.yaml)" +_YAML_FILTER = "YAML ({})".format(" ".join(f"*{suffix}" for suffix in TEST_PIONEER_SUFFIXES)) # Where TestPioneer puts its template, under the project directory _TEMPLATE_DIR = ".TestPioneer" @@ -94,7 +93,7 @@ def check_file(ui_we_want_to_set: PyBreezeMainWindow): ui_we_want_to_set, filter=_YAML_FILTER)[0] show_messagebox = False if file_path: - if Path(file_path).is_file() and Path(file_path).suffix.lower() in _YAML_SUFFIXES: + if Path(file_path).is_file() and Path(file_path).suffix.lower() in TEST_PIONEER_SUFFIXES: init_and_start_test_pioneer_process(ui_we_want_to_set, file_path) else: show_messagebox = True diff --git a/pybreeze/pybreeze_ui/syntax/syntax_extend.py b/pybreeze/pybreeze_ui/syntax/syntax_extend.py index f18c1ba8..04734458 100644 --- a/pybreeze/pybreeze_ui/syntax/syntax_extend.py +++ b/pybreeze/pybreeze_ui/syntax/syntax_extend.py @@ -8,8 +8,7 @@ from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow from PySide6.QtGui import QColor -from pybreeze.pybreeze_ui.syntax.syntax_keyword import \ - package_keyword_list +from pybreeze.pybreeze_ui.syntax.syntax_keyword import TEST_PIONEER_SUFFIXES, package_keyword_list from pybreeze.utils.manager.package_manager.package_manager_class import package_manager @@ -25,14 +24,16 @@ def syntax_extend_package(main_window: PyBreezeMainWindow) -> None: } register_programming_language(".json", json_syntax_words) - # Register YAML syntax keywords for test_pioneer + # Register YAML syntax keywords for test_pioneer, under every suffix the + # TestPioneer menu runs (".yaml" files were run but not highlighted) yml_syntax_words = { "test_pioneer": { "words": set(package_keyword_list.get("test_pioneer", [])), "color": QColor(255, 153, 0), } } - register_programming_language(".yml", yml_syntax_words) + for suffix in TEST_PIONEER_SUFFIXES: + register_programming_language(suffix, yml_syntax_words) widget = main_window.tab_widget.currentWidget() if isinstance(widget, EditorWidget): diff --git a/pybreeze/pybreeze_ui/syntax/syntax_keyword.py b/pybreeze/pybreeze_ui/syntax/syntax_keyword.py index 48bacc5b..f3d164f0 100644 --- a/pybreeze/pybreeze_ui/syntax/syntax_keyword.py +++ b/pybreeze/pybreeze_ui/syntax/syntax_keyword.py @@ -614,6 +614,10 @@ "open_program", "close_program", "redirect_stdout", "redirect_stderr", ] +# A TestPioneer script is YAML, under either of YAML's extensions: the menu +# runs both, and both are highlighted +TEST_PIONEER_SUFFIXES = (".yml", ".yaml") + package_keyword_list = { "je_auto_control": auto_control_keys, "je_load_density": load_density_keys, diff --git a/test/test_utils/test_syntax_extend.py b/test/test_utils/test_syntax_extend.py new file mode 100644 index 00000000..af34d80b --- /dev/null +++ b/test/test_utils/test_syntax_extend.py @@ -0,0 +1,51 @@ +"""Automation keyword highlighting is registered for every suffix the IDE runs.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from je_editor.plugins import get_programming_language_plugin + +from pybreeze.pybreeze_ui.syntax.syntax_extend import syntax_extend_package +from pybreeze.pybreeze_ui.syntax.syntax_keyword import TEST_PIONEER_SUFFIXES, package_keyword_list + + +class _Tabs: + @staticmethod + def currentWidget(): + return None + + +class _MainWindow: + tab_widget = _Tabs() + + +@pytest.fixture(scope="module") +def registered(): + syntax_extend_package(_MainWindow()) + + +@pytest.mark.parametrize("suffix", [".yml", ".yaml"]) +def test_a_test_pioneer_script_is_highlighted_under_either_yaml_suffix(registered, suffix): + # ".yaml" scripts ran from the TestPioneer menu but were shown plain + plugin = get_programming_language_plugin(suffix) + + assert plugin is not None + assert plugin["syntax_words"]["test_pioneer"]["words"] == set(package_keyword_list["test_pioneer"]) + + +def test_the_menu_runs_exactly_the_highlighted_suffixes(): + from pybreeze.pybreeze_ui.menu.automation_menu.test_pioneer_menu import build_test_pioneer_menu as menu + + assert menu._YAML_FILTER == "YAML (*.yml *.yaml)" + assert TEST_PIONEER_SUFFIXES == (".yml", ".yaml") + + +def test_every_automation_package_gets_json_keywords(registered): + from pybreeze.utils.manager.package_manager.package_manager_class import package_manager + + groups = get_programming_language_plugin(".json")["syntax_words"] + + assert set(groups) == set(package_manager.syntax_check_list) From e8a0443be9a4bdca4f74e7aaaf65b00dcf49c271 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 01:56:26 +0800 Subject: [PATCH 150/312] Answer No for an SSH host key nobody could be asked about instead of the previous answer --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 17 +++++++++ docs/updates/README.md | 3 +- progress.md | 7 ++++ .../connect_gui/ssh/ssh_host_key_policy.py | 14 +++++++- test/test_utils/test_ssh_host_key_policy.py | 36 +++++++++++++++++++ 6 files changed, 77 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 954feb54..da59d0a8 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -300,7 +300,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,ANSI escape 用 regex 剝除;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | | `ssh_file_viewer_widget.py` (869) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單在 UI 執行緒上的建資料夾/改名/刪除最多等 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),不凍住 IDE;下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | -| `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等 | +| `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 109 個 `test_*.py`、1714 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 109 個 `test_*.py`、1716 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 2a291050..93df0389 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2112,3 +2112,20 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_syntax_extend.py` - `architecture_explore.md` §3, §5, §11, §18 - **Open items**: none. + +## U-20260923-120 · 2026-09-24 · Answer No for an SSH host key nobody could be asked about, instead of the previous question's Yes · #fix #security #ssh + +- **What**: an unknown SSH host key could be trusted and stored without asking the user (`connect_gui/ssh/ssh_host_key_policy.py`, `HostKeyAsker`). Found in the review sweep of `connect_gui/ssh/`. + - `HostKeyAsker` keeps the answer on itself (`_answer`) and never reset it before a question. `ask()` returned whatever `_answer` held after the dialog. + - The panel that connected can be gone by the time the question arrives. A dock is deleted on close, and its connect thread runs on through `let_run_out`. `QMessageBox(parent)` then raises inside the blocking queued slot, `emit` returns anyway, and `ask()` returned the previous question's answer. + - After one Yes earlier in the session, the new host's key (possibly a man in the middle) was written to `~/.pybreeze/ssh_known_hosts` and trusted from then on. Reproduced against the previous code: `[True]` from the connecting thread. +- **Fix**: + - `ask()` sets `_answer = False` before every question. + - `_show` checks the parent with `shiboken6.isValid` and answers No, with a log line, when the panel is gone. +- **Tests**: `test_ssh_host_key_policy.py` +2: a question whose panel was deleted answers No after a previous Yes, asked on the UI thread and from a connecting thread. +- **Result / numbers**: host key tests 7 passed; 1716 tests in 109 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py` + - `test/test_utils/test_ssh_host_key_policy.py` + - `architecture_explore.md` §9, §18 +- **Open items**: the other findings from the same sweep are in progress.md. diff --git a/docs/updates/README.md b/docs/updates/README.md index 03743e98..3a71440c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-120 | 2026-09-24 | Answer No for an SSH host key nobody could be asked about, instead of the previous question's Yes | #fix #security #ssh | [2026-09](2026-09.md) | | U-20260923-119 | 2026-09-24 | Highlight TestPioneer scripts saved as .yaml, the other suffix its menu runs | #fix #syntax | [2026-09](2026-09.md) | | U-20260923-118 | 2026-09-24 | Collect cyclic garbage on the GUI thread only, so a worker never destroys a Qt object | #fix #threading #crash | [2026-09](2026-09.md) | | U-20260923-117 | 2026-09-23 | A data folder the log creates is its owner's only, like the one pybreeze_data_dir creates | #fix #security | [2026-09](2026-09.md) | @@ -200,4 +201,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 137 | +| [2026-09.md](2026-09.md) | 2026-09 | 138 | diff --git a/progress.md b/progress.md index c5338f66..324d25be 100644 --- a/progress.md +++ b/progress.md @@ -12,3 +12,10 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? +- **#55** SFTP menu actions (create folder, rename, delete file or folder) call `sftp.*` in the slot, on the UI thread (`connect_gui/ssh/ssh_file_viewer_widget.py`, `mkdir` / `rename` / `remove_file` / `remove_dir`, called from `on_context_menu`). On a stalled link the IDE freezes until TCP gives up: the SFTP reply wait has no timeout. Move them to a worker `QThread` like `SftpTransferThread`. +- **#56** Opening the shell blocks the UI thread (`connect_gui/ssh/ssh_command_widget.py`, `_on_connected` → `_start_shell` → `invoke_shell()`): `open_session` waits up to paramiko's `channel_timeout` (3600 s), then `get_pty` and `invoke_shell` wait without a timeout. Open the channel inside the `SshConnectThread` callable and hand it back through `connected`. +- **#57** Upload replaces a remote file of the same name without asking, and `sftp.put` truncates it on open, so a dropped link or a Disconnect mid-transfer leaves the server copy cut short (`ssh_file_viewer_widget.py`, upload). Ask first when the target exists; upload to a temporary name and rename it into place, as downloads already do with `.part`. +- **#58** Renaming a remote folder updates only the renamed item: its loaded children keep the old path in column 3, so a later download, delete or rename of a child targets the old path (`ssh_file_viewer_widget.py`, rename). New names are not checked: `/`, `.` or `..` in a rename or a new folder's name moves it elsewhere, or drops the base path. +- **#59** A shell command longer than one SSH packet (about 32 KB) is cut short and loses its newline: `shell_channel.send()` sends only what fits and its return value is ignored; it is also given a `str`, so the window is counted in characters, not UTF-8 bytes (`ssh_command_widget.py`, send). Send the encoded bytes in a loop until all are sent. +- **#60** The terminal still shows common escape sequences as text (`ssh_command_widget.py`, `ANSI_ESCAPE_PATTERN`): `ESC ( B` (from `tput sgr0` in coloured prompts), `ESC 7` / `ESC 8`, `ESC =` / `ESC >`, the body of a DCS string, BEL and backspace. +- **#61** Create folder and upload on a file item never refresh the folder they wrote to: both target the file's folder but call `action_refresh(item)` on the file, which does nothing for a file (`ssh_file_viewer_widget.py`). The new entry appears only after a manual refresh of the parent. diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py index 5bb1c4e2..294a460f 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING import paramiko +import shiboken6 from je_editor import language_wrapper from PySide6.QtCore import QObject, Qt, QThread, Signal, Slot from PySide6.QtWidgets import QMessageBox @@ -162,7 +163,13 @@ def __init__(self) -> None: self._asked.connect(self._show, Qt.ConnectionType.BlockingQueuedConnection) def ask(self, parent: QWidget | None, title: str, message: str) -> bool: - """Show the question and return whether the user trusts the key.""" + """Show the question and return whether the user trusts the key. + + Every question starts from No: a question that could not be shown + returned the previous one's answer, and a Yes given to one host trusted + the next host unasked. + """ + self._answer = False if QThread.currentThread() is self.thread(): # Already on the UI thread: a blocking queued signal to ourselves # would wait for itself forever. @@ -173,6 +180,11 @@ def ask(self, parent: QWidget | None, title: str, message: str) -> bool: @Slot(object, str, str) def _show(self, parent: QWidget | None, title: str, message: str) -> None: + if parent is not None and not shiboken6.isValid(parent): + # The panel that connected was closed while it connected (a dock + # is deleted on close): nobody is there to answer, so it is No + pybreeze_logger.warning("SSH host key question dropped: its panel was closed") + return box = QMessageBox(parent) box.setIcon(QMessageBox.Icon.Warning) box.setWindowTitle(title) diff --git a/test/test_utils/test_ssh_host_key_policy.py b/test/test_utils/test_ssh_host_key_policy.py index 49c85add..a90a4e92 100644 --- a/test/test_utils/test_ssh_host_key_policy.py +++ b/test/test_utils/test_ssh_host_key_policy.py @@ -114,3 +114,39 @@ def ask(self, *_args) -> bool: worker.join(5) assert overlapping == [False, False] + + +class TestAQuestionNobodyCanAnswer: + """A closed panel's question is a No, never the previous question's answer.""" + + def _closed_panel(self): + import shiboken6 + from PySide6.QtWidgets import QWidget + + panel = QWidget() + shiboken6.delete(panel) + return panel + + def test_on_the_ui_thread(self, app): + # The previous Yes used to come back, and the key was stored for good + asker = policy_mod.HostKeyAsker() + asker._answer = True + + assert asker.ask(self._closed_panel(), "title", "message") is False + + def test_from_the_connecting_thread(self, app): + import time + + asker = policy_mod.HostKeyAsker() + asker._answer = True + panel = self._closed_panel() + answers: list = [] + worker = threading.Thread(target=lambda: answers.append(asker.ask(panel, "title", "message"))) + worker.start() + deadline = time.monotonic() + 10 + while worker.is_alive() and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.01) + worker.join(1) + + assert answers == [False] From 0f16d9118c501750840c94c73901eea8618609e4 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 01:59:05 +0800 Subject: [PATCH 151/312] Keep SFTP tree paths right after a folder rename, refresh the folder written to, and refuse path names --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 24 +++ docs/updates/README.md | 3 +- progress.md | 2 - .../extend_multi_language/extend_english.py | 1 + .../extend_traditional_chinese.py | 1 + .../connect_gui/ssh/ssh_file_viewer_widget.py | 52 +++++- test/test_utils/test_sftp_tree_actions.py | 159 ++++++++++++++++++ 8 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 test/test_utils/test_sftp_tree_actions.py diff --git a/architecture_explore.md b/architecture_explore.md index da59d0a8..2f9ab6e7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,ANSI escape 用 regex 剝除;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (869) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單在 UI 執行緒上的建資料夾/改名/刪除最多等 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),不凍住 IDE;下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (909) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單在 UI 執行緒上的建資料夾/改名/刪除最多等 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),不凍住 IDE;下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 109 個 `test_*.py`、1716 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 110 個 `test_*.py`、1729 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 93df0389..9c2046f8 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2129,3 +2129,27 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_ssh_host_key_policy.py` - `architecture_explore.md` §9, §18 - **Open items**: the other findings from the same sweep are in progress.md. + +## U-20260923-121 · 2026-09-24 · Keep the SFTP tree's paths right after a folder rename, refresh the folder written to, and refuse names that are paths · #fix #ssh #done + +- **What**: progress #58 and #61, both in the SFTP tree's context menu (`connect_gui/ssh/ssh_file_viewer_widget.py`). + - Renaming a loaded folder updated only the folder's own item. Its children kept the old path, so a later download, delete or rename of one of them went to the old path: "No such file", or the wrong file once a new folder had taken the old name. + - New names were not checked. A `/` or `..` in a rename moved the entry into another folder while the tree still showed it in place. A leading `/` in a new folder's name made `posixpath.join` drop the folder it was created in. + - Create folder and upload started from a file item wrote into that file's folder, then refreshed the file item, which does nothing. The new entry showed up only after a manual refresh of the folder. +- **Fix**: + - New `plain_remote_name()` accepts one entry's name only: no `/`, NUL, `.` or `..`. Create folder and rename refuse anything else with a warning (new language key `ssh_file_viewer_dialog_message_bad_name`). + - A renamed folder whose children are loaded lists them again under its new path. + - New `folder_item()`: create folder and the upload's done step refresh the folder they wrote to. +- **Tests**: new `test_sftp_tree_actions.py`, 13 tests: + - Paths refused as names; a name kept stripped. + - Create folder and rename refusing a path. + - A renamed folder's children carrying the new path. + - A folder made from a file item appearing in that file's folder. + - `folder_item`. +- **Result / numbers**: SSH, SFTP and language tests: 123 passed. 1729 tests in 110 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_sftp_tree_actions.py` + - `progress.md` (#58, #61 removed), `architecture_explore.md` §9, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 3a71440c..17dad05d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-121 | 2026-09-24 | Keep the SFTP tree's paths right after a folder rename, refresh the folder written to, and refuse names that are paths | #fix #ssh #done | [2026-09](2026-09.md) | | U-20260923-120 | 2026-09-24 | Answer No for an SSH host key nobody could be asked about, instead of the previous question's Yes | #fix #security #ssh | [2026-09](2026-09.md) | | U-20260923-119 | 2026-09-24 | Highlight TestPioneer scripts saved as .yaml, the other suffix its menu runs | #fix #syntax | [2026-09](2026-09.md) | | U-20260923-118 | 2026-09-24 | Collect cyclic garbage on the GUI thread only, so a worker never destroys a Qt object | #fix #threading #crash | [2026-09](2026-09.md) | @@ -201,4 +202,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 138 | +| [2026-09.md](2026-09.md) | 2026-09 | 139 | diff --git a/progress.md b/progress.md index 324d25be..d25ab5be 100644 --- a/progress.md +++ b/progress.md @@ -15,7 +15,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#55** SFTP menu actions (create folder, rename, delete file or folder) call `sftp.*` in the slot, on the UI thread (`connect_gui/ssh/ssh_file_viewer_widget.py`, `mkdir` / `rename` / `remove_file` / `remove_dir`, called from `on_context_menu`). On a stalled link the IDE freezes until TCP gives up: the SFTP reply wait has no timeout. Move them to a worker `QThread` like `SftpTransferThread`. - **#56** Opening the shell blocks the UI thread (`connect_gui/ssh/ssh_command_widget.py`, `_on_connected` → `_start_shell` → `invoke_shell()`): `open_session` waits up to paramiko's `channel_timeout` (3600 s), then `get_pty` and `invoke_shell` wait without a timeout. Open the channel inside the `SshConnectThread` callable and hand it back through `connected`. - **#57** Upload replaces a remote file of the same name without asking, and `sftp.put` truncates it on open, so a dropped link or a Disconnect mid-transfer leaves the server copy cut short (`ssh_file_viewer_widget.py`, upload). Ask first when the target exists; upload to a temporary name and rename it into place, as downloads already do with `.part`. -- **#58** Renaming a remote folder updates only the renamed item: its loaded children keep the old path in column 3, so a later download, delete or rename of a child targets the old path (`ssh_file_viewer_widget.py`, rename). New names are not checked: `/`, `.` or `..` in a rename or a new folder's name moves it elsewhere, or drops the base path. - **#59** A shell command longer than one SSH packet (about 32 KB) is cut short and loses its newline: `shell_channel.send()` sends only what fits and its return value is ignored; it is also given a `str`, so the window is counted in characters, not UTF-8 bytes (`ssh_command_widget.py`, send). Send the encoded bytes in a loop until all are sent. - **#60** The terminal still shows common escape sequences as text (`ssh_command_widget.py`, `ANSI_ESCAPE_PATTERN`): `ESC ( B` (from `tput sgr0` in coloured prompts), `ESC 7` / `ESC 8`, `ESC =` / `ESC >`, the body of a DCS string, BEL and backspace. -- **#61** Create folder and upload on a file item never refresh the folder they wrote to: both target the file's folder but call `action_refresh(item)` on the file, which does nothing for a file (`ssh_file_viewer_widget.py`). The new entry appears only after a manual refresh of the parent. diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index d7dd33b6..98932caa 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -195,6 +195,7 @@ "ssh_file_viewer_log_message_refreshing": "Refreshing current item children (or root)", "ssh_file_viewer_dialog_title_no_selection": "No selection", "ssh_file_viewer_dialog_message_select_folder_to_create": "Select a folder to create inside.", + "ssh_file_viewer_dialog_message_bad_name": "A name is one entry: it cannot contain '/', or be '.' or '..'.", "ssh_file_viewer_dialog_title_create_folder": "Create folder", "ssh_file_viewer_dialog_label_folder_name": "Folder name:", "ssh_file_viewer_dialog_title_rename": "Rename", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index fd3de545..f20917ea 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -195,6 +195,7 @@ "ssh_file_viewer_log_message_refreshing": "正在重新整理目前項目的子項(或根)", "ssh_file_viewer_dialog_title_no_selection": "未選取", "ssh_file_viewer_dialog_message_select_folder_to_create": "請選擇一個資料夾以建立子資料夾。", + "ssh_file_viewer_dialog_message_bad_name": "名稱只能是單一項目:不能含有「/」,也不能是「.」或「..」。", "ssh_file_viewer_dialog_title_create_folder": "建立資料夾", "ssh_file_viewer_dialog_label_folder_name": "資料夾名稱:", "ssh_file_viewer_dialog_title_rename": "重新命名", diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 026bb933..8f08de7c 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -94,6 +94,26 @@ def remote_join(directory: str, name: str) -> str: return joined if joined.startswith("/") else f"/{joined}" +def plain_remote_name(text: str) -> str | None: + """*text* stripped, when it names one entry in a folder; ``None`` otherwise. + + A ``/`` in a new name moved the entry into another folder (or, leading, + dropped the folder it was in), and ``.`` or ``..`` named the folder itself + or its parent. + """ + name = text.strip() + if not name or "/" in name or "\x00" in name or name in (".", ".."): + return None + return name + + +def folder_item(item: QTreeWidgetItem | None) -> QTreeWidgetItem | None: + """The tree item of the folder *item* is in, or *item* itself when it is a folder.""" + if item is None or item.text(1) == "dir": + return item + return item.parent() + + class SftpBusy(RuntimeError): """Another request holds the SFTP session (a transfer, a listing); try again after it.""" @@ -711,9 +731,22 @@ def action_create_folder(self, item: QTreeWidgetItem | None): self.word_dict.get("ssh_file_viewer_dialog_label_folder_name")) if not ok or not name.strip(): return - new_path = remote_join(base_path, name.strip()) - self.client.mkdir(new_path) - self.action_refresh(item) + name = self._checked_name(name) + if name is None: + return + self.client.mkdir(remote_join(base_path, name)) + # The folder it went into: refreshing a file item did nothing + self.action_refresh(folder_item(item)) + + def _checked_name(self, text: str) -> str | None: + """*text* as one entry's name, or ``None`` after saying why it cannot be.""" + name = plain_remote_name(text) + if name is None: + QMessageBox.warning( + self, + self.word_dict.get("ssh_file_viewer_dialog_title_operation_failed"), + self.word_dict.get("ssh_file_viewer_dialog_message_bad_name")) + return name def action_rename(self, item: QTreeWidgetItem | None): """ @@ -728,12 +761,19 @@ def action_rename(self, item: QTreeWidgetItem | None): f"{self.word_dict.get('ssh_file_viewer_dialog_label_new_name_for_item')}: {item.text(0)}") if not ok or not new_name.strip(): return + new_name = self._checked_name(new_name) + if new_name is None: + return base = posixpath.dirname(old_path) or "/" - new_path = remote_join(base, new_name.strip()) + new_path = remote_join(base, new_name) self.client.rename(old_path, new_path) # Update item display - item.setText(0, new_name.strip()) + item.setText(0, new_name) item.setText(3, new_path) + if item.text(1) == "dir" and item.childCount() and not self.is_placeholder_present(item): + # Its loaded children still carried the old path, and a download, + # delete or rename of one went there: list them again + self.action_refresh(item) def action_delete(self, item: QTreeWidgetItem | None): """ @@ -805,7 +845,7 @@ def action_upload(self, item: QTreeWidgetItem | None): title=self.word_dict.get("ssh_file_viewer_dialog_title_uploaded"), message=self.word_dict.get("ssh_file_viewer_dialog_message_uploaded_to"), # The folder only holds the new file once the upload is done. - after=lambda: self.action_refresh(item)) + after=lambda: self.action_refresh(folder_item(item))) def _start_transfer(self, *, downloading: bool, remote_path: str, local_path: str, title: str, message: str, after=None) -> bool: diff --git a/test/test_utils/test_sftp_tree_actions.py b/test/test_utils/test_sftp_tree_actions.py new file mode 100644 index 00000000..b37f4392 --- /dev/null +++ b/test/test_utils/test_sftp_tree_actions.py @@ -0,0 +1,159 @@ +"""Create, rename and upload in the SFTP tree: names are one entry, and the tree follows.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import stat +import time +from types import SimpleNamespace + +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as tree_mod +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import folder_item, plain_remote_name + +WAIT_SECONDS = 5 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _entry(name: str, directory: bool = False): + mode = stat.S_IFDIR if directory else stat.S_IFREG + return SimpleNamespace(filename=name, st_mode=mode, st_size=10) + + +class FakeClient: + """A session over an in-memory listing that records what was asked of it.""" + + connected = True + + def __init__(self, listings: dict[str, list]) -> None: + self.listings = listings + self.made: list[str] = [] + self.renamed: list[tuple[str, str]] = [] + + def list_dir(self, path: str): + return list(self.listings.get(path, [])) + + def mkdir(self, path: str) -> None: + self.made.append(path) + + def rename(self, old: str, new: str) -> None: + self.renamed.append((old, new)) + self.listings[new] = self.listings.pop(old, []) + + def close(self) -> None: + """Nothing to close.""" + + +@pytest.fixture() +def tree(app, monkeypatch): + client = FakeClient({ + "/": [_entry("src", directory=True), _entry("notes.txt")], + "/src": [_entry("main.py")], + }) + widget = tree_mod.SSHFileTreeManager() + widget.client = client + root = widget.make_item("/", "dir", 0, "/") + widget.tree.addTopLevelItem(root) + widget.add_placeholder(root) + widget.on_item_expanded(root) + _wait_for(lambda: not widget._listings) + warnings: list = [] + monkeypatch.setattr(QMessageBox, "warning", lambda *args: warnings.append(args[2])) + yield widget, client, root, warnings + widget.close() + widget.deleteLater() + + +def _wait_for(condition) -> None: + deadline = time.monotonic() + WAIT_SECONDS + while not condition(): + assert time.monotonic() < deadline, "timed out" + QApplication.processEvents() + time.sleep(0.01) + + +def _child(item, name: str): + return next(item.child(i) for i in range(item.childCount()) if item.child(i).text(0) == name) + + +def _answer(widget, monkeypatch, text: str) -> None: + monkeypatch.setattr(widget, "get_text", lambda *_args: (text, True)) + + +class TestANameIsOneEntry: + @pytest.mark.parametrize("text", ["../x", "/tmp/x", "a/b", ".", "..", " ", "a\x00b"]) + def test_a_path_is_not_a_name(self, text): + assert plain_remote_name(text) is None + + def test_a_name_is_kept_stripped(self): + assert plain_remote_name(" new folder ") == "new folder" + + def test_a_folder_named_with_a_path_is_refused(self, tree, monkeypatch): + # "/tmp/x" made posixpath.join drop the folder it was created in + widget, client, root, warnings = tree + _answer(widget, monkeypatch, "/tmp/x") + + widget.action_create_folder(root) + + assert client.made == [] + assert warnings + + def test_a_rename_to_the_parent_is_refused(self, tree, monkeypatch): + widget, client, root, warnings = tree + _answer(widget, monkeypatch, "../x") + + widget.action_rename(_child(root, "notes.txt")) + + assert client.renamed == [] + assert warnings + + +class TestTheTreeFollows: + def test_a_renamed_folders_children_carry_the_new_path(self, tree, monkeypatch): + # They kept "/src/main.py", and a download or delete went there + widget, client, root, _warnings = tree + folder = _child(root, "src") + widget.on_item_expanded(folder) + _wait_for(lambda: not widget._listings) + assert _child(folder, "main.py").text(3) == "/src/main.py" + _answer(widget, monkeypatch, "lib") + + widget.action_rename(folder) + _wait_for(lambda: not widget._listings) + + assert folder.text(3) == "/lib" + assert _child(folder, "main.py").text(3) == "/lib/main.py" + + def test_a_folder_made_from_a_file_shows_in_that_files_folder(self, tree, monkeypatch): + # The refresh went to the file item, which has nothing to refresh + widget, client, root, _warnings = tree + _answer(widget, monkeypatch, "made") + + def made(path: str) -> None: + client.made.append(path) + client.listings["/"].append(_entry("made", directory=True)) + + client.mkdir = made + widget.action_create_folder(_child(root, "notes.txt")) + _wait_for(lambda: not widget._listings) + + assert client.made == ["/made"] + assert _child(root, "made").text(3) == "/made" + + def test_a_file_items_folder_is_its_parent(self, tree): + widget, _client, root, _warnings = tree + + assert folder_item(_child(root, "notes.txt")) is root + assert folder_item(root) is root + assert folder_item(None) is None From 966894a2b01712e3b8719e57f8fe299b74ed0d66 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:02:12 +0800 Subject: [PATCH 152/312] Send a whole SSH command however long and strip the escapes and controls the terminal still showed --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 22 ++++++++ docs/updates/README.md | 3 +- progress.md | 2 - .../connect_gui/ssh/ssh_command_widget.py | 55 +++++++++++++++++-- test/test_utils/test_ansi_escape.py | 28 ++++++++++ test/test_utils/test_ssh_terminal_output.py | 48 ++++++++++++++++ 7 files changed, 151 insertions(+), 11 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 2f9ab6e7..636177d9 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -298,7 +298,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | -| `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,ANSI escape 用 regex 剝除;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | +| `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`strip_terminal_controls()`);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | | `ssh_file_viewer_widget.py` (909) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單在 UI 執行緒上的建資料夾/改名/刪除最多等 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),不凍住 IDE;下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 110 個 `test_*.py`、1729 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 110 個 `test_*.py`、1745 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 9c2046f8..f173d56d 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2153,3 +2153,25 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_sftp_tree_actions.py` - `progress.md` (#58, #61 removed), `architecture_explore.md` §9, §18 - **Open items**: none. + +## U-20260923-122 · 2026-09-24 · Send a whole SSH command however long, and strip the escapes and controls the terminal still showed · #fix #ssh #done + +- **What**: progress #59 and #60, both in the SSH shell tab (`connect_gui/ssh/ssh_command_widget.py`). + - `send_command` ignored what `Channel.send` returned. That call sends at most one packet (about 32 KB with OpenSSH), so a longer pasted command lost its tail and its newline while the input box was cleared as if it had gone. It was also given a `str`, which paramiko counts in characters while the bytes that go out are UTF-8: a CJK command used about three times the window paramiko thought. + - `ANSI_ESCAPE_PATTERN` knew CSI, OSC and the Fe escapes only. `ESC ( B` (from `tput sgr0`, common in coloured prompts) showed as `(B`. `ESC 7` / `ESC 8` and `ESC =` / `ESC >` showed their second byte. A DCS answer showed its body. BEL and backspace stayed in the text. +- **Fix**: + - New `send_all(channel, data)`: sends the command's UTF-8 bytes with `Channel.sendall`. The otherwise non-blocking channel is given a 5 s timeout for that send only (`SEND_TIMEOUT_SECONDS`); a full window past it is reported as a send failure. + - The pattern now takes control strings (OSC, DCS, SOS, PM, APC) to BEL or ST, CSI, nF escapes (`ESC ( B`, `ESC # 8`), and every other two-byte escape. + - New `strip_terminal_controls()` applies each backspace to the character before it (never across a line break) and drops the rest of C0 but tab, newline and carriage return, and DEL. + - `_INCOMPLETE_ESCAPE` also holds back a control string cut between the ESC and the backslash of ST, and a character-set escape cut before its final byte. +- **Tests**: + - `test_ansi_escape.py` +12: the new escape forms, and backspace, BEL, NUL and DEL. + - `test_ssh_terminal_output.py` +4: + - A character-set or control-string escape cut at every position is still removed. + - A 60,006-byte command is sent whole as UTF-8 through a channel that takes one packet per send, with the timeout set and put back. +- **Result / numbers**: terminal tests 42 passed; 1745 tests in 110 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py` + - `test/test_utils/test_ansi_escape.py`, `test_ssh_terminal_output.py` + - `progress.md` (#59, #60 removed), `architecture_explore.md` §9, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 17dad05d..8d0c6382 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-122 | 2026-09-24 | Send a whole SSH command however long, and strip the escapes and controls the terminal still showed | #fix #ssh #done | [2026-09](2026-09.md) | | U-20260923-121 | 2026-09-24 | Keep the SFTP tree's paths right after a folder rename, refresh the folder written to, and refuse names that are paths | #fix #ssh #done | [2026-09](2026-09.md) | | U-20260923-120 | 2026-09-24 | Answer No for an SSH host key nobody could be asked about, instead of the previous question's Yes | #fix #security #ssh | [2026-09](2026-09.md) | | U-20260923-119 | 2026-09-24 | Highlight TestPioneer scripts saved as .yaml, the other suffix its menu runs | #fix #syntax | [2026-09](2026-09.md) | @@ -202,4 +203,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 139 | +| [2026-09.md](2026-09.md) | 2026-09 | 140 | diff --git a/progress.md b/progress.md index d25ab5be..5c0a1e69 100644 --- a/progress.md +++ b/progress.md @@ -15,5 +15,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#55** SFTP menu actions (create folder, rename, delete file or folder) call `sftp.*` in the slot, on the UI thread (`connect_gui/ssh/ssh_file_viewer_widget.py`, `mkdir` / `rename` / `remove_file` / `remove_dir`, called from `on_context_menu`). On a stalled link the IDE freezes until TCP gives up: the SFTP reply wait has no timeout. Move them to a worker `QThread` like `SftpTransferThread`. - **#56** Opening the shell blocks the UI thread (`connect_gui/ssh/ssh_command_widget.py`, `_on_connected` → `_start_shell` → `invoke_shell()`): `open_session` waits up to paramiko's `channel_timeout` (3600 s), then `get_pty` and `invoke_shell` wait without a timeout. Open the channel inside the `SshConnectThread` callable and hand it back through `connected`. - **#57** Upload replaces a remote file of the same name without asking, and `sftp.put` truncates it on open, so a dropped link or a Disconnect mid-transfer leaves the server copy cut short (`ssh_file_viewer_widget.py`, upload). Ask first when the target exists; upload to a temporary name and rename it into place, as downloads already do with `.part`. -- **#59** A shell command longer than one SSH packet (about 32 KB) is cut short and loses its newline: `shell_channel.send()` sends only what fits and its return value is ignored; it is also given a `str`, so the window is counted in characters, not UTF-8 bytes (`ssh_command_widget.py`, send). Send the encoded bytes in a loop until all are sent. -- **#60** The terminal still shows common escape sequences as text (`ssh_command_widget.py`, `ANSI_ESCAPE_PATTERN`): `ESC ( B` (from `tput sgr0` in coloured prompts), `ESC 7` / `ESC 8`, `ESC =` / `ESC >`, the body of a DCS string, BEL and backspace. 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 86b3eda3..1b602258 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -30,15 +30,38 @@ ANSI_ESCAPE_PATTERN = re.compile( r'\x1B(?:' - r'\][^\x07\x1B]*(?:\x07|\x1B\\)?' # OSC; BEL/ST-terminated or implicitly ended by the next ESC / EOF - r'|[@-Z\\-_]' # other two-character C1 Fe sequences + # A control string -- OSC (titles, links), DCS, SOS, PM, APC -- ended by + # BEL or ST, or implicitly by the next ESC / the end + r'[\]PX^_][^\x07\x1B]*(?:\x07|\x1B\\)?' r'|\[[0-?]*[ -/]*[@-~]' # CSI (colours, cursor movement) + r'|[ -/]+[0-~]' # nF: character sets (ESC ( B, from tput sgr0), ESC # 8 + r'|[0-~]' # Fp, Fe, Fs: ESC 7 / ESC 8, ESC = / ESC >, ESC M, ESC c r')' ) +# Control characters the view cannot show, once the sequences are gone: BEL +# and the rest of C0 but tab, newline and carriage return, and DEL. +# Backspace is applied first (_BACKSPACED) +_CONTROL_CHARACTER = re.compile('[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') +# A character and the backspace that takes it back (not across a line break) +_BACKSPACED = re.compile('[^\n\x08]\x08') + # The end of a read that stops inside an escape sequence: a lone ESC, a CSI -# still waiting for its final byte, or an OSC still waiting for its terminator -_INCOMPLETE_ESCAPE = re.compile(r'\x1B(?:\[[0-?]*[ -/]*|\][^\x07\x1B]*)?\Z') +# still waiting for its final byte, a control string still waiting for its +# terminator (or the second byte of ST), or a character-set escape still +# waiting for its final byte +_INCOMPLETE_ESCAPE = re.compile(r'\x1B(?:\[[0-?]*[ -/]*|[\]PX^_][^\x07\x1B]*\x1B?|[ -/]+)?\Z') + + +def strip_terminal_controls(text: str) -> str: + """*text* without escape sequences, with backspaces applied and other controls dropped.""" + text = ANSI_ESCAPE_PATTERN.sub('', text) + while True: + applied = _BACKSPACED.sub('', text) + if applied == text: + break + text = applied + return _CONTROL_CHARACTER.sub('', text) # Longest such tail held back for the next read; anything longer is shown as is _MAX_PENDING_ESCAPE = 256 @@ -69,7 +92,7 @@ def feed(self, data: bytes) -> str: if tail is not None and len(text) - tail.start() <= _MAX_PENDING_ESCAPE: self._pending = text[tail.start():] text = text[:tail.start()] - return ANSI_ESCAPE_PATTERN.sub('', text) + return strip_terminal_controls(text) # Bound the terminal scrollback so an endless stream (``tail -f``, ``yes``) @@ -82,6 +105,26 @@ def feed(self, data: bytes) -> str: SSH_KEEPALIVE_SECONDS = 30 +# Longest a command waits for the server to take it (a full SSH window), on the UI thread +SEND_TIMEOUT_SECONDS = 5 + + +def send_all(channel: paramiko.Channel, data: bytes) -> None: + """Send every byte of *data*, waiting at most ``SEND_TIMEOUT_SECONDS`` for room. + + ``Channel.send`` sends what fits in one packet and the window and returns + how much that was: a pasted command past about 32 KB lost its tail and its + newline. A ``str`` was also counted in characters, not in the UTF-8 bytes + that go out. The shell channel is otherwise non-blocking (its reader polls + it), so it is blocking only for this send; a timeout raises ``OSError``. + """ + channel.settimeout(SEND_TIMEOUT_SECONDS) + try: + channel.sendall(data) + finally: + channel.settimeout(0.0) + + class SSHReaderThread(QThread): data_received = Signal(bytes) closed = Signal(str) @@ -353,7 +396,7 @@ def send_command(self): return if self.shell_channel and not self.shell_channel.closed: try: - self.shell_channel.send(cmd + "\n") + send_all(self.shell_channel, (cmd + "\n").encode("utf-8")) self.command_input_edit.clear() except (OSError, paramiko.SSHException) as e: self.append_text(f"{self.word_dict.get('ssh_command_widget_error_message_send_failed')} {e}\n") diff --git a/test/test_utils/test_ansi_escape.py b/test/test_utils/test_ansi_escape.py index de8d090a..e1db6084 100644 --- a/test/test_utils/test_ansi_escape.py +++ b/test/test_utils/test_ansi_escape.py @@ -36,3 +36,31 @@ def test_csi_and_fe(self, raw, expected): def test_osc_sequences_are_stripped(self, raw, expected): # Regression: OSC bodies (e.g. "0;title") used to leak through as garbage. assert _strip(raw) == expected + + @pytest.mark.parametrize("raw,expected", [ + # tput sgr0 in many coloured prompts: a character-set escape, then SGR + ("\x1b(B\x1b[mhello", "hello"), + ("\x1b#8x", "x"), # nF with another intermediate + ("a\x1b7b\x1b8c", "abc"), # save / restore cursor + ("\x1b=\x1b>keypad", "keypad"), # keypad modes + ("\x1bcreset", "reset"), # full reset (Fs) + ("\x1bP1$r0m\x1b\\after", "after"), # DCS answer, ST terminated + ("\x1b_app\x1b\\x", "x"), # APC + ]) + def test_other_escapes_leave_no_text(self, raw, expected): + # They showed as "(B", "7", "=", or the DCS body + assert _strip(raw) == expected + + +class TestControlCharacters: + @pytest.mark.parametrize("raw,expected", [ + ("bell\x07", "bell"), + ("abc\x08\x08d", "ad"), # backspace takes the character back + ("line\n\x08next", "line\nnext"), # never across a line break + ("tab\there\r\n", "tab\there\r\n"), # tab and line ends stay + ("x\x00y\x7fz", "xyz"), + ]) + def test_controls_are_applied_or_dropped(self, raw, expected): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import strip_terminal_controls + + assert strip_terminal_controls(raw) == expected diff --git a/test/test_utils/test_ssh_terminal_output.py b/test/test_utils/test_ssh_terminal_output.py index fd75534c..a4ccbc07 100644 --- a/test/test_utils/test_ssh_terminal_output.py +++ b/test/test_utils/test_ssh_terminal_output.py @@ -5,6 +5,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +import paramiko import pytest from PySide6.QtWidgets import QApplication @@ -38,6 +39,14 @@ def test_an_escape_cut_between_reads_is_still_removed(self, cut): assert decoder.feed(data[:cut]) + decoder.feed(data[cut:]) == "red" + @pytest.mark.parametrize("data", [b"\x1b(Bok", b"\x1b]0;title\x1b\\ok", b"\x1bP1$r0m\x1b\\ok"]) + def test_a_character_set_or_string_escape_cut_anywhere_is_removed(self, data): + # A read ending between the ESC and the backslash of ST showed "\ok" + for cut in range(1, len(data) - 2): + decoder = TerminalDecoder() + + assert decoder.feed(data[:cut]) + decoder.feed(data[cut:]) == "ok", cut + def test_text_before_an_unfinished_escape_is_shown_now(self): decoder = TerminalDecoder() @@ -124,3 +133,42 @@ def test_output_buffered_when_the_shell_exits_is_not_lost(self, app): reader.run() # on this thread: direct connections assert b"".join(received) == b"last words" + + +class PartialSendChannel: + """Takes at most one packet per send, as paramiko's Channel.send does.""" + + closed = False + sendall = paramiko.Channel.sendall + + def __init__(self) -> None: + self.sent: list[bytes] = [] + self.timeouts: list = [] + + def send(self, data) -> int: + chunk = bytes(data[:32704]) + self.sent.append(chunk) + return len(chunk) + + def settimeout(self, timeout) -> None: + self.timeouts.append(timeout) + + +class TestSendingACommand: + def test_a_long_command_arrives_whole_as_utf8(self, app): + # Past one packet the rest and the newline were dropped, and a str was + # counted in characters, not the bytes that go out + widget = SSHCommandWidget() + channel = PartialSendChannel() + widget.shell_channel = channel + # 20,005 characters (within QLineEdit's 32,767), 60,006 bytes: two packets + command = "echo " + "中文" * 10000 + widget.command_input_edit.setText(command) + + widget.send_command() + + assert b"".join(channel.sent) == (command + "\n").encode("utf-8") + assert channel.timeouts == [5, 0.0] + assert widget.command_input_edit.text() == "" + widget.shell_channel = None + widget.close() From 505b4af520241032777768fa6cb8123136a46c3a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:03:56 +0800 Subject: [PATCH 153/312] Open the SSH shell's channel on the connect thread instead of the UI thread --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 17 ++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../connect_gui/ssh/ssh_command_widget.py | 55 +++++++++++-------- test/test_utils/test_ssh_reentrancy.py | 38 ++++++++++++- 6 files changed, 92 insertions(+), 28 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 636177d9..f5cc5de6 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -298,7 +298,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | -| `ssh_command_widget.py` | 互動式 shell。連線交給 `SshConnectThread`,成功後才在 UI 執行緒開 shell;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`strip_terminal_controls()`);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | +| `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`strip_terminal_controls()`);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | | `ssh_file_viewer_widget.py` (909) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單在 UI 執行緒上的建資料夾/改名/刪除最多等 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),不凍住 IDE;下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | @@ -409,7 +409,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ├── daemon Thread: pybreeze-report-mail(send_after_test → send_report,結果經 _MailNotice queued signal 寫回執行視窗) │ ├── QThread: SSHReaderThread ──Signal──► terminal widget - ├── QThread: SshConnectThread ──Signal──► shell / file tree(host key 問題 BlockingQueued 回 UI) + ├── QThread: SshConnectThread ──Signal──► shell / file tree(連線+開 shell channel;host key 問題 BlockingQueued 回 UI) ├── QThread: SftpListThread / SftpTransferThread ──Signal──► file tree ├── QThread: SenderThread (CoT) ──Signal──► review UI ├── QThread: RequestThread(Skills)──Signal──► result UI @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 110 個 `test_*.py`、1745 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 110 個 `test_*.py`、1746 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index f173d56d..9388b93f 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2175,3 +2175,20 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_ansi_escape.py`, `test_ssh_terminal_output.py` - `progress.md` (#59, #60 removed), `architecture_explore.md` §9, §18 - **Open items**: none. + +## U-20260923-123 · 2026-09-24 · Open the SSH shell's channel on the connect thread, not the UI thread · #fix #ssh #threading #done + +- **What**: progress #56. The SSH connect ran on `SshConnectThread`, but the shell was opened afterwards on the UI thread (`connect_gui/ssh/ssh_command_widget.py`, `_on_connected` → `_start_shell` → `invoke_shell()`). Opening it waits on the server: `open_session` for up to paramiko's `channel_timeout` (3600 s), then `get_pty` and `invoke_shell` without a timeout. A server that went quiet right after authentication froze the IDE. +- **Fix**: + - New `open_shell_channel(client)` sets the keepalive, opens the shell and makes the channel non-blocking. It runs inside the connect callable on `SshConnectThread`, right after the connect, and the channel reaches the UI thread with `connected`. + - `_start_shell(channel, host, port, user)` only starts the reader and updates the labels. + - A shell that will not open fails the connect, reported and cleaned up the same way. +- **Tests**: `test_ssh_reentrancy.py`: + - The fake client opens a channel, and the off-UI-thread test asserts the channel was opened on the thread the connect ran on. + - +1: a shell the server refuses is reported like a failed connect, and the session is closed. +- **Result / numbers**: SSH and SFTP tests 118 passed; 1746 tests in 110 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py` + - `test/test_utils/test_ssh_reentrancy.py` + - `progress.md` (#56 removed), `architecture_explore.md` §9, §16, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 8d0c6382..73c6900a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-123 | 2026-09-24 | Open the SSH shell's channel on the connect thread, not the UI thread | #fix #ssh #threading #done | [2026-09](2026-09.md) | | U-20260923-122 | 2026-09-24 | Send a whole SSH command however long, and strip the escapes and controls the terminal still showed | #fix #ssh #done | [2026-09](2026-09.md) | | U-20260923-121 | 2026-09-24 | Keep the SFTP tree's paths right after a folder rename, refresh the folder written to, and refuse names that are paths | #fix #ssh #done | [2026-09](2026-09.md) | | U-20260923-120 | 2026-09-24 | Answer No for an SSH host key nobody could be asked about, instead of the previous question's Yes | #fix #security #ssh | [2026-09](2026-09.md) | @@ -203,4 +204,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 140 | +| [2026-09.md](2026-09.md) | 2026-09 | 141 | diff --git a/progress.md b/progress.md index 5c0a1e69..db21e1e9 100644 --- a/progress.md +++ b/progress.md @@ -13,5 +13,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? - **#55** SFTP menu actions (create folder, rename, delete file or folder) call `sftp.*` in the slot, on the UI thread (`connect_gui/ssh/ssh_file_viewer_widget.py`, `mkdir` / `rename` / `remove_file` / `remove_dir`, called from `on_context_menu`). On a stalled link the IDE freezes until TCP gives up: the SFTP reply wait has no timeout. Move them to a worker `QThread` like `SftpTransferThread`. -- **#56** Opening the shell blocks the UI thread (`connect_gui/ssh/ssh_command_widget.py`, `_on_connected` → `_start_shell` → `invoke_shell()`): `open_session` waits up to paramiko's `channel_timeout` (3600 s), then `get_pty` and `invoke_shell` wait without a timeout. Open the channel inside the `SshConnectThread` callable and hand it back through `connected`. - **#57** Upload replaces a remote file of the same name without asking, and `sftp.put` truncates it on open, so a dropped link or a Disconnect mid-transfer leaves the server copy cut short (`ssh_file_viewer_widget.py`, upload). Ask first when the target exists; upload to a temporary name and rename it into place, as downloads already do with `.part`. 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 1b602258..b8ba3e44 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -51,6 +51,8 @@ # terminator (or the second byte of ST), or a character-set escape still # waiting for its final byte _INCOMPLETE_ESCAPE = re.compile(r'\x1B(?:\[[0-?]*[ -/]*|[\]PX^_][^\x07\x1B]*\x1B?|[ -/]+)?\Z') +# Longest such tail held back for the next read; anything longer is shown as is +_MAX_PENDING_ESCAPE = 256 def strip_terminal_controls(text: str) -> str: @@ -62,8 +64,6 @@ def strip_terminal_controls(text: str) -> str: break text = applied return _CONTROL_CHARACTER.sub('', text) -# Longest such tail held back for the next read; anything longer is shown as is -_MAX_PENDING_ESCAPE = 256 class TerminalDecoder: @@ -125,6 +125,19 @@ def send_all(channel: paramiko.Channel, data: bytes) -> None: channel.settimeout(0.0) +def open_shell_channel(client: paramiko.SSHClient) -> paramiko.Channel: + """Open an interactive shell on *client*'s connection. Waits on the network: not the UI thread. + + The channel comes back non-blocking: its reader polls it. + """ + transport = client.get_transport() + if transport is not None: + transport.set_keepalive(SSH_KEEPALIVE_SECONDS) + channel = client.invoke_shell(term="xterm", width=120, height=32) + channel.settimeout(0.0) + return channel + + class SSHReaderThread(QThread): data_received = Signal(bytes) closed = Signal(str) @@ -300,18 +313,22 @@ def connect_ssh(self): self.ssh_client = client apply_host_key_policy(client, self) pybreeze_logger.info("SSH connecting to %s:%s", host, port) - if use_key: - def connect() -> None: + opened: dict = {} + + def connect() -> None: + if use_key: self._connect_with_key(client, host, port, user, key_path, password) - else: - def connect() -> None: + else: client.connect( hostname=host, port=port, username=user, password=password, timeout=10, disabled_algorithms=SHA1_ALGORITHMS) - # The connect itself runs off the UI thread: an unreachable host used to - # hold the IDE for the connect, banner and auth timeouts together. + opened["channel"] = open_shell_channel(client) + # The connect and the shell's channel are made off the UI thread: an + # unreachable host used to hold the IDE for the connect, banner and auth + # timeouts together, and a server gone quiet after auth held it while + # the channel waited to open. thread = SshConnectThread(connect) - thread.connected.connect(lambda: self._on_connected(client, host, port, user)) + thread.connected.connect(lambda: self._on_connected(client, opened["channel"], host, port, user)) thread.failed.connect(lambda message: self._on_connect_failed(client, message)) self._connecting = thread thread.start() @@ -332,16 +349,13 @@ def _connect_with_key(self, client: paramiko.SSHClient, host: str, port: int, us raise RuntimeError( f"{self.word_dict.get('ssh_command_widget_error_message_key_auth_failed')} {e}") from e - def _on_connected(self, client: paramiko.SSHClient, host: str, port: int, user: str) -> None: - """Open the shell once the connect has succeeded. UI thread.""" + def _on_connected(self, client: paramiko.SSHClient, channel: paramiko.Channel, + host: str, port: int, user: str) -> None: + """Start reading the shell once it is open. UI thread.""" if client is not self.ssh_client: client.close() # disconnected, or reconnected, while it was connecting return - try: - self._start_shell(host, port, user) - except CONNECT_ERRORS as error: - self._on_connect_failed(client, str(error)) - return + self._start_shell(channel, host, port, user) self.state_changed.emit() def _on_connect_failed(self, client: paramiko.SSHClient, message: str) -> None: @@ -354,12 +368,9 @@ def _on_connect_failed(self, client: paramiko.SSHClient, message: str) -> None: self._cleanup() self.state_changed.emit() - def _start_shell(self, host: str, port: int, user: str) -> None: - transport = self.ssh_client.get_transport() - if transport is not None: - transport.set_keepalive(SSH_KEEPALIVE_SECONDS) - self.shell_channel = self.ssh_client.invoke_shell(term='xterm', width=120, height=32) - self.shell_channel.settimeout(0.0) + def _start_shell(self, channel: paramiko.Channel, host: str, port: int, user: str) -> None: + """Show *channel*'s output from now on. UI thread; nothing here waits on the network.""" + self.shell_channel = channel self._decoder.reset() self.reader_thread = SSHReaderThread(self.shell_channel) self.reader_thread.data_received.connect(self._on_data) diff --git a/test/test_utils/test_ssh_reentrancy.py b/test/test_utils/test_ssh_reentrancy.py index d118ed8b..60f96fa0 100644 --- a/test/test_utils/test_ssh_reentrancy.py +++ b/test/test_utils/test_ssh_reentrancy.py @@ -57,6 +57,21 @@ def connect(self, **options) -> None: def close(self) -> None: self.closed = True + @staticmethod + def get_transport(): + return None + + def invoke_shell(self, **_options): + self.shell_thread = QThread.currentThread() + return FakeShellChannel() + + +class FakeShellChannel: + closed = False + + def settimeout(self, _timeout) -> None: + """Non-blocking is what the reader expects.""" + def _shell(monkeypatch, client: FakeClient): widget = shell_mod.SSHCommandWidget() @@ -97,7 +112,28 @@ def test_the_connect_does_not_hold_the_ui_thread(self, app, monkeypatch): _wait_for(lambda: started and states) assert client.connect_thread is not app.thread() - assert started == [app.thread()] # the shell is opened on the UI thread + # The channel is opened where the connect ran: it waits on the server + # (up to an hour for the session) and used to hold the UI thread + assert client.shell_thread is client.connect_thread + assert started == [app.thread()] # only its reader is started on the UI thread + + def test_a_shell_that_will_not_open_is_reported_like_a_failed_connect(self, app, monkeypatch): + client = FakeClient() + + def refuse(**_options): + raise shell_mod.paramiko.SSHException("administratively prohibited") + + client.invoke_shell = refuse + widget = _shell(monkeypatch, client) + states = [] + widget.state_changed.connect(lambda: states.append("changed")) + + widget.connect_ssh() + _wait_for(lambda: states) + + assert "administratively prohibited" in widget.terminal.toPlainText() + assert client.closed + assert widget.shell_channel is None def test_a_second_click_while_connecting_is_ignored(self, app, monkeypatch): release = threading.Event() From a99bd1273bdecfcf18d63df86b47010e9b3c4ec0 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:09:51 +0800 Subject: [PATCH 154/312] Run the SFTP menu's requests off the UI thread and ask before an upload replaces a file --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 28 +++ docs/updates/README.md | 3 +- progress.md | 2 - .../extend_multi_language/extend_english.py | 2 + .../extend_traditional_chinese.py | 2 + .../connect_gui/ssh/ssh_file_viewer_widget.py | 170 ++++++++++++++--- test/test_utils/test_sftp_tree_actions.py | 63 ++++++- test/test_utils/test_sftp_upload.py | 176 ++++++++++++++++++ 9 files changed, 418 insertions(+), 34 deletions(-) create mode 100644 test/test_utils/test_sftp_upload.py diff --git a/architecture_explore.md b/architecture_explore.md index f5cc5de6..c8ba7311 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`strip_terminal_controls()`);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (909) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單在 UI 執行緒上的建資料夾/改名/刪除最多等 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),不凍住 IDE;下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (1031) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | @@ -410,7 +410,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 │ ├── QThread: SSHReaderThread ──Signal──► terminal widget ├── QThread: SshConnectThread ──Signal──► shell / file tree(連線+開 shell channel;host key 問題 BlockingQueued 回 UI) - ├── QThread: SftpListThread / SftpTransferThread ──Signal──► file tree + ├── QThread: SftpListThread / SftpTransferThread / SftpCallThread ──Signal──► file tree ├── QThread: SenderThread (CoT) ──Signal──► review UI ├── QThread: RequestThread(Skills)──Signal──► result UI ├── QThread: ReviewRequestThread ──Signal──► AI review client panel @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 110 個 `test_*.py`、1746 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1756 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 9388b93f..87699f78 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2192,3 +2192,31 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_ssh_reentrancy.py` - `progress.md` (#56 removed), `architecture_explore.md` §9, §16, §18 - **Open items**: none. + +## U-20260923-124 · 2026-09-24 · Run the SFTP menu's requests off the UI thread, and upload through a temporary name after asking before a replace · #fix #ssh #threading #done + +- **What**: progress #55 and #57, in the SFTP tree (`connect_gui/ssh/ssh_file_viewer_widget.py`). + - Create folder, rename and delete called `sftp.*` in the context menu's slot. `UI_WAIT_SECONDS` bounded only the wait for the session lock; an SFTP reply has no timeout, so on a stalled link the IDE froze until TCP gave up. + - An upload over a file of the same name replaced it without asking. `sftp.put` empties its target on open, so a dropped link or a Disconnect mid-transfer left the server's copy cut short. Downloads already avoid this with a `.part` file. +- **Fix**: + - New `SftpCallThread` runs one menu request. The tree's part (refresh the folder, show the new name, drop the removed item) follows on `done` unless the tree was cleared meanwhile; a failure shows the menu's usual error box. Requests still running when the tab closes are handed to `let_run_out`. + - `SFTPClientWrapper.upload(local, remote, replace=False)`: + - Within one hold of the session, it checks whether the target exists (`stat`). If it does and `replace` is false, it uploads nothing and returns `False`. + - Otherwise it `put`s to `...part` beside the target and moves that into place: `posix_rename`, or remove-then-rename on a server without the extension. + - A failure removes the temporary file and leaves the target as it was. + - `SftpTransferThread` reports an existing target on `exists`. The tree asks (new language keys `ssh_file_viewer_dialog_title_confirm_replace` / `..._message_confirm_replace`, No by default) and restarts with `replace=True` on Yes. +- **Tests**: + - `test_sftp_tree_actions.py` +2: create, rename and delete run on a worker and the tree follows; a refused delete is shown and leaves the item. + - New `test_sftp_upload.py`, 8 tests: + - A new file arrives whole through a temporary name. + - An existing file is untouched without `replace`, and replaced with it. + - A broken upload leaves the old file whole and nothing behind. + - A server without `posix_rename` still gets the file. + - The tree asks once and keeps the file on No, replaces it on Yes, and asks nothing for a new file. +- **Result / numbers**: SSH, SFTP and language tests: 138 passed. 1756 tests in 111 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_sftp_tree_actions.py`, `test_sftp_upload.py` + - `progress.md` (#55, #57 removed), `architecture_explore.md` §9, §16, §18 +- **Open items**: `ssh_file_viewer_widget.py` is now 1031 lines, past the 1000-line limit. The next commit moves the session and its threads to a module of their own. diff --git a/docs/updates/README.md b/docs/updates/README.md index 73c6900a..570650f0 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-124 | 2026-09-24 | Run the SFTP menu's requests off the UI thread, and upload through a temporary name after asking before a replace | #fix #ssh #threading #done | [2026-09](2026-09.md) | | U-20260923-123 | 2026-09-24 | Open the SSH shell's channel on the connect thread, not the UI thread | #fix #ssh #threading #done | [2026-09](2026-09.md) | | U-20260923-122 | 2026-09-24 | Send a whole SSH command however long, and strip the escapes and controls the terminal still showed | #fix #ssh #done | [2026-09](2026-09.md) | | U-20260923-121 | 2026-09-24 | Keep the SFTP tree's paths right after a folder rename, refresh the folder written to, and refuse names that are paths | #fix #ssh #done | [2026-09](2026-09.md) | @@ -204,4 +205,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 141 | +| [2026-09.md](2026-09.md) | 2026-09 | 142 | diff --git a/progress.md b/progress.md index db21e1e9..c5338f66 100644 --- a/progress.md +++ b/progress.md @@ -12,5 +12,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -- **#55** SFTP menu actions (create folder, rename, delete file or folder) call `sftp.*` in the slot, on the UI thread (`connect_gui/ssh/ssh_file_viewer_widget.py`, `mkdir` / `rename` / `remove_file` / `remove_dir`, called from `on_context_menu`). On a stalled link the IDE freezes until TCP gives up: the SFTP reply wait has no timeout. Move them to a worker `QThread` like `SftpTransferThread`. -- **#57** Upload replaces a remote file of the same name without asking, and `sftp.put` truncates it on open, so a dropped link or a Disconnect mid-transfer leaves the server copy cut short (`ssh_file_viewer_widget.py`, upload). Ask first when the target exists; upload to a temporary name and rename it into place, as downloads already do with `.part`. diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 98932caa..56ed716b 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -196,6 +196,8 @@ "ssh_file_viewer_dialog_title_no_selection": "No selection", "ssh_file_viewer_dialog_message_select_folder_to_create": "Select a folder to create inside.", "ssh_file_viewer_dialog_message_bad_name": "A name is one entry: it cannot contain '/', or be '.' or '..'.", + "ssh_file_viewer_dialog_title_confirm_replace": "Replace file", + "ssh_file_viewer_dialog_message_confirm_replace": "'{path}' already exists on the server. Replace it?", "ssh_file_viewer_dialog_title_create_folder": "Create folder", "ssh_file_viewer_dialog_label_folder_name": "Folder name:", "ssh_file_viewer_dialog_title_rename": "Rename", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index f20917ea..2e2c8656 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -196,6 +196,8 @@ "ssh_file_viewer_dialog_title_no_selection": "未選取", "ssh_file_viewer_dialog_message_select_folder_to_create": "請選擇一個資料夾以建立子資料夾。", "ssh_file_viewer_dialog_message_bad_name": "名稱只能是單一項目:不能含有「/」,也不能是「.」或「..」。", + "ssh_file_viewer_dialog_title_confirm_replace": "取代檔案", + "ssh_file_viewer_dialog_message_confirm_replace": "伺服器上已經有「{path}」,要取代它嗎?", "ssh_file_viewer_dialog_title_create_folder": "建立資料夾", "ssh_file_viewer_dialog_label_folder_name": "資料夾名稱:", "ssh_file_viewer_dialog_title_rename": "重新命名", diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 8f08de7c..08b74169 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -6,7 +6,8 @@ import stat import tempfile import threading -from collections.abc import Iterator +import uuid +from collections.abc import Callable, Iterator from contextlib import contextmanager, suppress import paramiko @@ -47,9 +48,11 @@ def format_size(num_bytes: int) -> str: # Keepalive interval (seconds) so an idle SFTP session is not dropped by the # TCP stack, a NAT/firewall, or the SSH server (≈ OpenSSH ServerAliveInterval). SSH_KEEPALIVE_SECONDS = 30 -# How long a menu action on the UI thread waits for the session before -# saying it is busy: long enough for a listing, short enough not to freeze +# How long a menu action waits for the session before saying it is busy: +# long enough for a listing, and a transfer can hold it for minutes UI_WAIT_SECONDS = 1.0 +# How long a replace waits for the upload that asked about it to return +UPLOAD_ASKED_WAIT_MS = 1000 def natural_key(name: str) -> list: @@ -314,13 +317,58 @@ def download(self, remote_path: str, local_path: str): with suppress(OSError): os.remove(partial) - def upload(self, local_path: str, remote_path: str): + def upload(self, local_path: str, remote_path: str, replace: bool = False) -> bool: """ Upload local to remote. Worker thread. 上傳本地檔案至遠端。 + + Uploads nothing and returns ``False`` when *remote_path* exists and + *replace* is false: it used to be replaced without a word. The file + arrives beside it under a temporary name and takes its place only when + complete, as a download does: ``put`` empties its target before the + first byte, so a dropped link used to leave the server's copy cut short. """ + folder, name = posixpath.split(remote_path) + partial = remote_join(folder or "/", f".{name}.{uuid.uuid4().hex[:8]}.part") with self._session() as sftp: - sftp.put(local_path, remote_path) + if not replace and _remote_exists(sftp, remote_path): + return False + complete = False + try: + sftp.put(local_path, partial) + _move_into_place(sftp, partial, remote_path) + complete = True + finally: + if not complete: + with suppress(OSError, EOFError, paramiko.SSHException): + sftp.remove(partial) + return True + + +def _remote_exists(sftp: paramiko.SFTPClient, path: str) -> bool: + """Whether *path* exists on the server.""" + try: + sftp.stat(path) + except FileNotFoundError: + return False + return True + + +def _move_into_place(sftp: paramiko.SFTPClient, partial: str, target: str) -> None: + """Rename *partial* to *target*, replacing it. + + ``posix_rename`` (an OpenSSH extension) replaces in one step. A server + without it gets the plain SFTP rename, which refuses an existing target, + so the target is removed first. + """ + try: + sftp.posix_rename(partial, target) + return + except OSError as error: + pybreeze_logger.debug("SFTP posix_rename unavailable, renaming plainly: %r", error) + with suppress(FileNotFoundError): + sftp.remove(target) + sftp.rename(partial, target) class SftpTransferThread(QThread): @@ -335,22 +383,26 @@ class SftpTransferThread(QThread): done = Signal(str) failed = Signal(str) + # An upload whose target exists, not replaced: nothing was sent + exists = Signal(str) def __init__(self, client: "SFTPClientWrapper", downloading: bool, - remote_path: str, local_path: str) -> None: + remote_path: str, local_path: str, replace: bool = False) -> None: super().__init__() self._client = client self._downloading = downloading self._remote_path = remote_path self._local_path = local_path + self._replace = replace def run(self) -> None: try: if self._downloading: self._client.download(self._remote_path, self._local_path) - else: - self._client.upload(self._local_path, self._remote_path) - except (OSError, RuntimeError, paramiko.SSHException) as error: + elif not self._client.upload(self._local_path, self._remote_path, self._replace): + self.exists.emit(self._remote_path) + return + except (OSError, RuntimeError, EOFError, paramiko.SSHException) as error: self.failed.emit(str(error)) else: self.done.emit(self._local_path if self._downloading else self._remote_path) @@ -381,6 +433,29 @@ def run(self) -> None: self.listed.emit(sort_entries(entries)) +class SftpCallThread(QThread): + """One of the context menu's requests (create folder, rename, delete), off the UI thread. + + An SFTP reply has no timeout: on a stalled link the request waited in the + menu's slot and the IDE froze until TCP gave up. + """ + + done = Signal() + failed = Signal(str) + + def __init__(self, call: Callable[[], None]) -> None: + super().__init__() + self._call = call + + def run(self) -> None: + try: + self._call() + except (OSError, RuntimeError, EOFError, paramiko.SSHException) as error: + self.failed.emit(str(error)) + else: + self.done.emit() + + class SSHFileTreeManager(QWidget): """ QWidget: connection form + tree + context menu. @@ -406,6 +481,8 @@ def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: self._transfer: SftpTransferThread | None = None # Listings in flight / 正在進行的目錄列出 self._listings: set[SftpListThread] = set() + # The context menu's requests in flight / 右鍵選單正在進行的請求 + self._calls: set[SftpCallThread] = set() self._listing_serial = 0 # Bumped whenever the tree is cleared: a listing started before then # returns to items that no longer exist. @@ -509,6 +586,10 @@ def closeEvent(self, event) -> None: if listing.isRunning(): let_run_out(listing, listing.listed, listing.failed) self._listings.clear() + for call in list(self._calls): + if call.isRunning(): + let_run_out(call, call.done, call.failed) + self._calls.clear() if self._connecting is not None and self._connecting.isRunning(): let_run_out(self._connecting, self._connecting.connected, self._connecting.failed) # A connect that still succeeds after the widget has gone would leave @@ -693,10 +774,7 @@ def on_context_menu(self, pos): self.action_upload(item) # what an SFTP operation raises, a closed session's RuntimeError included except CONNECT_ERRORS as e: - QMessageBox.critical( - self, - self.word_dict.get("ssh_file_viewer_dialog_title_operation_failed"), - f"{self.word_dict.get('ssh_file_viewer_dialog_message_operation_failed')}: {e}") + self._operation_failed(str(e)) def action_refresh(self, item: QTreeWidgetItem | None): """ @@ -734,9 +812,30 @@ def action_create_folder(self, item: QTreeWidgetItem | None): name = self._checked_name(name) if name is None: return - self.client.mkdir(remote_join(base_path, name)) + new_path = remote_join(base_path, name) # The folder it went into: refreshing a file item did nothing - self.action_refresh(folder_item(item)) + self._in_background( + lambda: self.client.mkdir(new_path), lambda: self.action_refresh(folder_item(item))) + + def _in_background(self, call: Callable[[], None], after: Callable[[], None]) -> None: + """Run *call* on an ``SftpCallThread``; then *after*, unless the tree was cleared meanwhile. + + A failure is shown like the menu's other failures. UI thread. + """ + generation = self._tree_generation + thread = SftpCallThread(call) + thread.done.connect(lambda: after() if generation == self._tree_generation else None) + thread.failed.connect(self._operation_failed) + thread.finished.connect(lambda: self._calls.discard(thread)) + self._calls.add(thread) + thread.start() + + def _operation_failed(self, message: str) -> None: + """Say that a menu request failed, and why. UI thread.""" + QMessageBox.critical( + self, + self.word_dict.get("ssh_file_viewer_dialog_title_operation_failed"), + f"{self.word_dict.get('ssh_file_viewer_dialog_message_operation_failed')}: {message}") def _checked_name(self, text: str) -> str | None: """*text* as one entry's name, or ``None`` after saying why it cannot be.""" @@ -766,8 +865,12 @@ def action_rename(self, item: QTreeWidgetItem | None): return base = posixpath.dirname(old_path) or "/" new_path = remote_join(base, new_name) - self.client.rename(old_path, new_path) - # Update item display + self._in_background( + lambda: self.client.rename(old_path, new_path), + lambda: self._renamed(item, new_name, new_path)) + + def _renamed(self, item: QTreeWidgetItem, new_name: str, new_path: str) -> None: + """Show *item* under its new name once the server has renamed it. UI thread.""" item.setText(0, new_name) item.setText(3, new_path) if item.text(1) == "dir" and item.childCount() and not self.is_placeholder_present(item): @@ -791,11 +894,11 @@ def action_delete(self, item: QTreeWidgetItem | None): ) if reply != QMessageBox.StandardButton.Yes: return - # Try dir first; if fails, try file - if item.text(1) == "dir": - self.client.remove_dir(path) - else: - self.client.remove_file(path) + remove = self.client.remove_dir if item.text(1) == "dir" else self.client.remove_file + self._in_background(lambda: remove(path), lambda: self._removed(item)) + + def _removed(self, item: QTreeWidgetItem) -> None: + """Take *item* out of the tree once the server has removed it. UI thread.""" parent = item.parent() if parent: parent.removeChild(item) @@ -848,7 +951,7 @@ def action_upload(self, item: QTreeWidgetItem | None): after=lambda: self.action_refresh(folder_item(item))) def _start_transfer(self, *, downloading: bool, remote_path: str, local_path: str, - title: str, message: str, after=None) -> bool: + title: str, message: str, after=None, replace: bool = False) -> bool: """Start one transfer in the background, unless one is already going. :param downloading: download when true, upload when false @@ -857,6 +960,7 @@ def _start_transfer(self, *, downloading: bool, remote_path: str, local_path: st :param title: the title of the message shown when it is done :param message: what that message says, before the path it ended at :param after: what to do once it is done, on the UI thread + :param replace: upload over a file already there; otherwise the user is asked first :return: whether it started """ if self._transfer is not None and self._transfer.isRunning(): @@ -865,13 +969,31 @@ def _start_transfer(self, *, downloading: bool, remote_path: str, local_path: st self.word_dict.get("ssh_file_viewer_dialog_title_transfer_running"), self.word_dict.get("ssh_file_viewer_dialog_message_transfer_running")) return False - self._transfer = SftpTransferThread(self.client, downloading, remote_path, local_path) + self._transfer = SftpTransferThread(self.client, downloading, remote_path, local_path, replace) self._transfer.done.connect( lambda path: self._transfer_done(title, message, path, after)) self._transfer.failed.connect(self._transfer_failed) + self._transfer.exists.connect(lambda path: self._ask_to_replace(path, lambda: self._start_transfer( + downloading=False, remote_path=remote_path, local_path=local_path, + title=title, message=message, after=after, replace=True))) self._transfer.start() return True + def _ask_to_replace(self, remote_path: str, replace: Callable[[], object]) -> None: + """Ask whether an upload may replace *remote_path*, and call *replace* if so. UI thread.""" + answer = QMessageBox.question( + self, + self.word_dict.get("ssh_file_viewer_dialog_title_confirm_replace"), + self.word_dict.get("ssh_file_viewer_dialog_message_confirm_replace").format(path=remote_path), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) + if answer == QMessageBox.StandardButton.Yes: + # The asking transfer has sent nothing and is only returning; the + # new one must not find it still running + if self._transfer is not None: + self._transfer.wait(UPLOAD_ASKED_WAIT_MS) + replace() + def _transfer_done(self, title: str, message: str, path: str, after=None) -> None: """Say where the file ended up, and do whatever was waiting on it. UI thread.""" QMessageBox.information(self, title, f"{message}: {path}") diff --git a/test/test_utils/test_sftp_tree_actions.py b/test/test_utils/test_sftp_tree_actions.py index b37f4392..c8f819e0 100644 --- a/test/test_utils/test_sftp_tree_actions.py +++ b/test/test_utils/test_sftp_tree_actions.py @@ -10,6 +10,7 @@ from types import SimpleNamespace import pytest +from PySide6.QtCore import QThread from PySide6.QtWidgets import QApplication, QMessageBox from pybreeze.extend_multi_language.update_language_dict import update_language_dict @@ -40,14 +41,24 @@ def __init__(self, listings: dict[str, list]) -> None: self.listings = listings self.made: list[str] = [] self.renamed: list[tuple[str, str]] = [] + self.removed: list[str] = [] + self.threads: list = [] def list_dir(self, path: str): return list(self.listings.get(path, [])) def mkdir(self, path: str) -> None: + self.threads.append(QThread.currentThread()) self.made.append(path) + def remove_file(self, path: str) -> None: + self.threads.append(QThread.currentThread()) + self.removed.append(path) + + remove_dir = remove_file + def rename(self, old: str, new: str) -> None: + self.threads.append(QThread.currentThread()) self.renamed.append((old, new)) self.listings[new] = self.listings.pop(old, []) @@ -67,7 +78,7 @@ def tree(app, monkeypatch): widget.tree.addTopLevelItem(root) widget.add_placeholder(root) widget.on_item_expanded(root) - _wait_for(lambda: not widget._listings) + _wait_for(lambda: _idle(widget)) warnings: list = [] monkeypatch.setattr(QMessageBox, "warning", lambda *args: warnings.append(args[2])) yield widget, client, root, warnings @@ -83,6 +94,10 @@ def _wait_for(condition) -> None: time.sleep(0.01) +def _idle(widget) -> bool: + return not widget._listings and not widget._calls + + def _child(item, name: str): return next(item.child(i) for i in range(item.childCount()) if item.child(i).text(0) == name) @@ -125,12 +140,12 @@ def test_a_renamed_folders_children_carry_the_new_path(self, tree, monkeypatch): widget, client, root, _warnings = tree folder = _child(root, "src") widget.on_item_expanded(folder) - _wait_for(lambda: not widget._listings) + _wait_for(lambda: _idle(widget)) assert _child(folder, "main.py").text(3) == "/src/main.py" _answer(widget, monkeypatch, "lib") widget.action_rename(folder) - _wait_for(lambda: not widget._listings) + _wait_for(lambda: _idle(widget)) assert folder.text(3) == "/lib" assert _child(folder, "main.py").text(3) == "/lib/main.py" @@ -146,7 +161,7 @@ def made(path: str) -> None: client.mkdir = made widget.action_create_folder(_child(root, "notes.txt")) - _wait_for(lambda: not widget._listings) + _wait_for(lambda: _idle(widget)) assert client.made == ["/made"] assert _child(root, "made").text(3) == "/made" @@ -157,3 +172,43 @@ def test_a_file_items_folder_is_its_parent(self, tree): assert folder_item(_child(root, "notes.txt")) is root assert folder_item(root) is root assert folder_item(None) is None + + +class TestTheMenuDoesNotWaitOnTheServer: + """Create, rename and delete run off the UI thread: an SFTP reply has no timeout.""" + + def test_each_request_runs_on_a_worker(self, app, tree, monkeypatch): + widget, client, root, _warnings = tree + _answer(widget, monkeypatch, "new") + monkeypatch.setattr(QMessageBox, "question", lambda *_args: QMessageBox.StandardButton.Yes) + + widget.action_create_folder(root) + _wait_for(lambda: _idle(widget)) + _answer(widget, monkeypatch, "renamed.txt") + widget.action_rename(_child(root, "notes.txt")) + _wait_for(lambda: _idle(widget)) + widget.action_delete(_child(root, "renamed.txt")) + _wait_for(lambda: _idle(widget)) + + assert client.made == ["/new"] + assert client.renamed == [("/notes.txt", "/renamed.txt")] + assert client.removed == ["/renamed.txt"] + assert len(client.threads) == 3 + assert all(thread is not app.thread() for thread in client.threads) + assert "renamed.txt" not in [root.child(i).text(0) for i in range(root.childCount())] + + def test_a_failed_request_is_shown_and_changes_nothing(self, app, tree, monkeypatch): + widget, client, root, _warnings = tree + shown: list = [] + monkeypatch.setattr(QMessageBox, "critical", lambda *args: shown.append(args[2])) + monkeypatch.setattr(QMessageBox, "question", lambda *_args: QMessageBox.StandardButton.Yes) + + def refuse(_path: str) -> None: + raise OSError("Permission denied") + + client.remove_file = refuse + widget.action_delete(_child(root, "notes.txt")) + _wait_for(lambda: _idle(widget)) + + assert any("Permission denied" in text for text in shown) + assert _child(root, "notes.txt").text(3) == "/notes.txt" diff --git a/test/test_utils/test_sftp_upload.py b/test/test_utils/test_sftp_upload.py new file mode 100644 index 00000000..25e3ae52 --- /dev/null +++ b/test/test_utils/test_sftp_upload.py @@ -0,0 +1,176 @@ +"""An upload asks before replacing a file, and never leaves the server's copy cut short.""" +from __future__ import annotations + +import errno +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import time +from types import SimpleNamespace + +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + +WAIT_SECONDS = 5 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeSftp: + """A server's files in a dict; ``put`` can fail halfway, ``posix_rename`` can be missing.""" + + def __init__(self, files: dict[str, bytes] | None = None) -> None: + self.files = dict(files or {}) + self.fail_put = False + self.has_posix_rename = True + self.put_to: list[str] = [] + + def stat(self, path: str): + if path not in self.files: + raise OSError(errno.ENOENT, "No such file") + return object() + + def put(self, local: str, remote: str) -> None: + self.put_to.append(remote) + with open(local, "rb") as source: + data = source.read() + if self.fail_put: + self.files[remote] = data[:2] + raise OSError("the link went away") + self.files[remote] = data + + def posix_rename(self, old: str, new: str) -> None: + if not self.has_posix_rename: + raise OSError("Operation unsupported") + self.files[new] = self.files.pop(old) + + def rename(self, old: str, new: str) -> None: + if new in self.files: + raise OSError("Failure") + self.files[new] = self.files.pop(old) + + def close(self) -> None: + """Nothing to close.""" + + def remove(self, path: str) -> None: + if path not in self.files: + raise OSError(errno.ENOENT, "No such file") + del self.files[path] + + +def _wrapper(sftp: FakeSftp) -> viewer.SFTPClientWrapper: + wrapper = viewer.SFTPClientWrapper() + wrapper._ssh = SimpleNamespace(close=lambda: None) + wrapper._sftp = sftp + return wrapper + + +@pytest.fixture() +def local_file(tmp_path): + path = tmp_path / "config.yaml" + path.write_bytes(b"new config") + return str(path) + + +class TestTheWrapper: + def test_a_new_file_arrives_whole_under_its_name(self, app, local_file): + sftp = FakeSftp() + + assert _wrapper(sftp).upload(local_file, "/etc/app/config.yaml") is True + + assert sftp.files == {"/etc/app/config.yaml": b"new config"} + # It went through a temporary name beside the target + assert sftp.put_to[0].startswith("/etc/app/.config.yaml.") + + def test_an_existing_file_is_not_touched_unless_replacing(self, app, local_file): + # It used to be replaced without a word + sftp = FakeSftp({"/etc/app/config.yaml": b"old config"}) + + assert _wrapper(sftp).upload(local_file, "/etc/app/config.yaml") is False + + assert sftp.files == {"/etc/app/config.yaml": b"old config"} + assert sftp.put_to == [] + + def test_replacing_puts_the_new_file_in_its_place(self, app, local_file): + sftp = FakeSftp({"/etc/app/config.yaml": b"old config"}) + + assert _wrapper(sftp).upload(local_file, "/etc/app/config.yaml", replace=True) is True + + assert sftp.files == {"/etc/app/config.yaml": b"new config"} + + def test_a_broken_upload_leaves_the_old_file_whole_and_no_leftover(self, app, local_file): + # put() empties its target first: a dropped link left it cut short + sftp = FakeSftp({"/etc/app/config.yaml": b"old config"}) + sftp.fail_put = True + + with pytest.raises(OSError): + _wrapper(sftp).upload(local_file, "/etc/app/config.yaml", replace=True) + + assert sftp.files == {"/etc/app/config.yaml": b"old config"} + + def test_a_server_without_posix_rename_still_gets_the_file(self, app, local_file): + sftp = FakeSftp({"/config.yaml": b"old config"}) + sftp.has_posix_rename = False + + assert _wrapper(sftp).upload(local_file, "/config.yaml", replace=True) is True + + assert sftp.files == {"/config.yaml": b"new config"} + + +class TestTheTree: + def _upload(self, app, monkeypatch, sftp: FakeSftp, local_file: str, answer) -> list: + widget = viewer.SSHFileTreeManager() + widget.client = _wrapper(sftp) + asked: list = [] + + def question(*args, **_kwargs): + asked.append(args[2]) + return answer + + monkeypatch.setattr(QMessageBox, "question", question) + monkeypatch.setattr(QMessageBox, "information", lambda *_args: None) + widget._start_transfer(downloading=False, remote_path="/config.yaml", local_path=local_file, + title="Uploaded", message="Uploaded to") + deadline = time.monotonic() + WAIT_SECONDS + while widget._transfer.isRunning() or (asked and answer == QMessageBox.StandardButton.Yes + and sftp.files.get("/config.yaml") != b"new config"): + assert time.monotonic() < deadline, "timed out" + app.processEvents() + time.sleep(0.01) + app.processEvents() + widget._transfer.wait(1000) + widget.close() + return asked + + def test_the_user_is_asked_and_a_no_keeps_the_file(self, app, monkeypatch, local_file): + sftp = FakeSftp({"/config.yaml": b"old config"}) + + asked = self._upload(app, monkeypatch, sftp, local_file, QMessageBox.StandardButton.No) + + assert len(asked) == 1 and "/config.yaml" in asked[0] + assert sftp.files == {"/config.yaml": b"old config"} + + def test_a_yes_replaces_it(self, app, monkeypatch, local_file): + sftp = FakeSftp({"/config.yaml": b"old config"}) + + asked = self._upload(app, monkeypatch, sftp, local_file, QMessageBox.StandardButton.Yes) + + assert len(asked) == 1 + assert sftp.files == {"/config.yaml": b"new config"} + + def test_a_new_file_is_not_asked_about(self, app, monkeypatch, local_file): + sftp = FakeSftp() + + asked = self._upload(app, monkeypatch, sftp, local_file, QMessageBox.StandardButton.No) + + assert asked == [] + assert sftp.files == {"/config.yaml": b"new config"} From 64e837a846c920210c104d90f12f06758b83a47c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:12:48 +0800 Subject: [PATCH 155/312] Refactor: move the SFTP session and its threads out of the file tree's module --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 20 + docs/updates/README.md | 3 +- .../connect_gui/ssh/sftp_session.py | 427 ++++++++++++++++++ .../connect_gui/ssh/ssh_file_viewer_widget.py | 427 +----------------- test/test_utils/test_fuzz_pure_logic.py | 7 +- test/test_utils/test_sftp_remote_path.py | 8 +- test/test_utils/test_sftp_session_sharing.py | 2 +- test/test_utils/test_sftp_upload.py | 5 +- test/test_utils/test_ssh_reentrancy.py | 17 +- test/test_utils/test_ssh_teardown.py | 2 +- 11 files changed, 484 insertions(+), 436 deletions(-) create mode 100644 pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py diff --git a/architecture_explore.md b/architecture_explore.md index c8ba7311..9c4663de 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`strip_terminal_controls()`);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (1031) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (632) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 87699f78..0715be45 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2220,3 +2220,23 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_sftp_tree_actions.py`, `test_sftp_upload.py` - `progress.md` (#55, #57 removed), `architecture_explore.md` §9, §16, §18 - **Open items**: `ssh_file_viewer_widget.py` is now 1031 lines, past the 1000-line limit. The next commit moves the session and its threads to a module of their own. + +## U-20260923-125 · 2026-09-24 · Refactor: move the SFTP session and its threads out of the file tree's module · #refactor #ssh + +- **What**: `connect_gui/ssh/ssh_file_viewer_widget.py` had grown to 1031 lines with U-20260923-124, past the 1000-line limit per file. It held two things: the SFTP session with the threads that use it, and the tree widget. +- **Change** (structure only, no behaviour change): + - New `connect_gui/ssh/sftp_session.py` (427 lines) holds everything that is not the widget: + - `SFTPClientWrapper`, `SftpBusy`, `ConnectAbandoned`, `_remote_exists`, `_move_into_place` + - `SftpListThread`, `SftpTransferThread`, `SftpCallThread` + - the pure path helpers `natural_key`, `sort_entries`, `remote_join`, `plain_remote_name` + - `SSH_KEEPALIVE_SECONDS`, `UI_WAIT_SECONDS` + - `ssh_file_viewer_widget.py` (632 lines) keeps `SSHFileTreeManager`, `format_size`, `folder_item`, `LISTING_ROLE` and `UPLOAD_ASKED_WAIT_MS`, and imports the rest. + - `ssh_main_widget.py` still imports `SSHFileTreeManager` from the widget module. +- **Tests**: unchanged in substance. The tests that patched `apply_host_key_policy` or `paramiko.SSHClient` on the widget module, or imported the moved names from it, now use `sftp_session`: + - `test_ssh_reentrancy.py`, `test_sftp_remote_path.py`, `test_sftp_session_sharing.py`, `test_sftp_upload.py`, `test_ssh_teardown.py`, `test_fuzz_pure_logic.py`. +- **Result / numbers**: SSH, SFTP, fuzz and started-menu tests: 145 passed. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py` (new), `ssh_file_viewer_widget.py` + - the six tests above + - `architecture_explore.md` §9 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 570650f0..b4148ea9 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-125 | 2026-09-24 | Refactor: move the SFTP session and its threads out of the file tree's module | #refactor #ssh | [2026-09](2026-09.md) | | U-20260923-124 | 2026-09-24 | Run the SFTP menu's requests off the UI thread, and upload through a temporary name after asking before a replace | #fix #ssh #threading #done | [2026-09](2026-09.md) | | U-20260923-123 | 2026-09-24 | Open the SSH shell's channel on the connect thread, not the UI thread | #fix #ssh #threading #done | [2026-09](2026-09.md) | | U-20260923-122 | 2026-09-24 | Send a whole SSH command however long, and strip the escapes and controls the terminal still showed | #fix #ssh #done | [2026-09](2026-09.md) | @@ -205,4 +206,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 142 | +| [2026-09.md](2026-09.md) | 2026-09 | 143 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py b/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py new file mode 100644 index 00000000..ae24409c --- /dev/null +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py @@ -0,0 +1,427 @@ +"""The SFTP session behind the file tree, and the threads that use it. + +``SFTPClientWrapper`` holds one SSH connection and its SFTP client and lets one +request at a time through. ``SftpListThread``, ``SftpTransferThread`` and +``SftpCallThread`` run listings, transfers and the context menu's requests off +the UI thread; the tree (``ssh_file_viewer_widget.py``) only reacts to their +signals. The path helpers here are pure: remote paths are always POSIX. +""" +from __future__ import annotations + +import os +import posixpath +import re +import stat +import tempfile +import threading +import uuid +from collections.abc import Callable, Iterator +from contextlib import contextmanager, suppress + +import paramiko +from PySide6.QtCore import QThread, Signal +from PySide6.QtWidgets import QWidget +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import SHA1_ALGORITHMS +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import apply_host_key_policy +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key +from pybreeze.utils.logging.logger import pybreeze_logger + + +# Keepalive interval (seconds) so an idle SFTP session is not dropped by the +# TCP stack, a NAT/firewall, or the SSH server (≈ OpenSSH ServerAliveInterval). +SSH_KEEPALIVE_SECONDS = 30 +# How long a menu action waits for the session before saying it is busy: +# long enough for a listing, and a transfer can hold it for minutes +UI_WAIT_SECONDS = 1.0 + + +def natural_key(name: str) -> list: + """Sort key giving natural (human) order, e.g. ``img2`` before ``img10``. + + Embedded digit runs compare as integers and text compares case-insensitively, + matching how Windows Explorer and most file managers order file listings. + """ + # isdecimal (not isdigit): a part like "²³" or "①" is isdigit() but int() + # rejects it, which would crash sorting a file named with such characters. + return [ + int(part) if part.isdecimal() else part.lower() + for part in re.split(r"(\d+)", name) + ] + + +def sort_entries(entries) -> list: + """Return ``[(name, entry)]`` with directories first, each in natural order.""" + dirs: list = [] + files: list = [] + for entry in entries: + bucket = dirs if stat.S_ISDIR(entry.st_mode) else files + bucket.append((entry.filename, entry)) + dirs.sort(key=lambda item: natural_key(item[0])) + files.sort(key=lambda item: natural_key(item[0])) + return dirs + files + + +def remote_join(directory: str, name: str) -> str: + """Join *name* under a remote POSIX *directory* into an absolute path. + + Remote SFTP paths are always POSIX: ``os.path.join`` would emit ``\\`` on a + Windows client and break navigation/transfers on the server. The result + always uses ``/`` separators and has a leading slash. + """ + base = "" if directory == "/" else directory + joined = posixpath.join(base, name) + return joined if joined.startswith("/") else f"/{joined}" + + +def plain_remote_name(text: str) -> str | None: + """*text* stripped, when it names one entry in a folder; ``None`` otherwise. + + A ``/`` in a new name moved the entry into another folder (or, leading, + dropped the folder it was in), and ``.`` or ``..`` named the folder itself + or its parent. + """ + name = text.strip() + if not name or "/" in name or "\x00" in name or name in (".", ".."): + return None + return name + + +class SftpBusy(RuntimeError): + """Another request holds the SFTP session (a transfer, a listing); try again after it.""" + + +class ConnectAbandoned(RuntimeError): + """The session came up after the connect was given up on, and was closed.""" + + +class SFTPClientWrapper: + """ + Lightweight wrapper around Paramiko SFTP client. + 輕量級封裝 Paramiko SFTP 客戶端,提供基本操作。 + """ + + def __init__(self): + self.word_dict = language_wrapper.language_word_dict + self._ssh: paramiko.SSHClient | None = None + self._sftp: paramiko.SFTPClient | None = None + self.root_path: str = "/" + # One request at a time on the session: paramiko's SFTP client throws + # away a reply read by the wrong thread, and the thread that sent that + # request then waits for it forever. Listings, a transfer and the menu's + # own calls used to share it freely. + self._in_use = threading.Lock() + + def connect(self, host: str, port: int, username: str, password: str, + use_key: bool = False, key_path: str = "", + parent_widget: QWidget | None = None): + """ + Establish SSH + SFTP connection. + 建立 SSH + SFTP 連線。 + + Runs on the connect thread while ``close()`` may run on the UI thread + (Disconnect, or the tab closing). The session is kept only if it is + still this connect's when it is up; otherwise it is closed and + ``ConnectAbandoned`` raised. Closing during the TCP connect cannot stop + it, and the session it went on to log in used to stay open, unseen, + until the IDE exited. + """ + self.close() + ssh = paramiko.SSHClient() + self._ssh = ssh + apply_host_key_policy(ssh, parent_widget) + pybreeze_logger.info("SFTP connecting to %s:%s", host, port) + try: + self._log_in(ssh, host, port, username, password, use_key, key_path) + transport = ssh.get_transport() + if transport is not None: + transport.set_keepalive(SSH_KEEPALIVE_SECONDS) + sftp = ssh.open_sftp() + if self._ssh is not ssh: + raise ConnectAbandoned("SFTP connect abandoned") + self._sftp = sftp + except Exception: + # A failure partway (auth, keepalive, or open_sftp) must not leak the + # half-open SSH transport: tear it down so connect() is all-or-nothing. + # Only this connect's own: a newer one may be under way by now. + ssh.close() + if self._ssh is ssh: + self._ssh = None + raise + + def _log_in(self, ssh: paramiko.SSHClient, host: str, port: int, username: str, + password: str, use_key: bool, key_path: str) -> None: + """Connect *ssh* with the key file, or the password when no key is used.""" + if not (use_key and key_path): + ssh.connect(hostname=host, port=port, username=username, password=password, + timeout=10, disabled_algorithms=SHA1_ALGORITHMS) + return + pkey = load_private_key(key_path, password, context="SFTP") + if pkey is None: + raise ValueError( + self.word_dict.get("ssh_command_widget_error_message_unsupported_private_key") + ) + ssh.connect(hostname=host, port=port, username=username, pkey=pkey, timeout=10, + disabled_algorithms=SHA1_ALGORITHMS) + + def close(self): + """ + Close SFTP and SSH safely. + 安全關閉 SFTP 與 SSH。 + """ + try: + if self._sftp: + self._sftp.close() + finally: + self._sftp = None + try: + if self._ssh: + self._ssh.close() + finally: + self._ssh = None + + @property + def connected(self) -> bool: + """ + Check connection state. + 檢查連線狀態。 + """ + return self._ssh is not None and self._sftp is not None + + def _require_connection(self) -> None: + """Raise unless a session is open. + + Every call below goes through this: without it an operation on a stale + tree (a dropped session, a closed connection) reaches ``None`` and the + tree's error box shows the user an ``AttributeError`` about ``NoneType``. + """ + if not self.connected: + raise RuntimeError( + self.word_dict.get("ssh_command_widget_dialog_title_not_connected") + ) + + @contextmanager + def _session(self, wait: float | None = None) -> Iterator[paramiko.SFTPClient]: + """Hold the session for one request and yield it, open. + + :param wait: seconds to wait for a request already running; ``None`` + waits as long as it takes (worker threads). The menu's calls run on + the UI thread and wait briefly: a transfer can hold the session for + minutes, and they are refused with ``SftpBusy`` instead. + """ + acquired = self._in_use.acquire() if wait is None else self._in_use.acquire(timeout=wait) + if not acquired: + raise SftpBusy(self.word_dict.get("ssh_file_viewer_message_session_busy")) + try: + self._require_connection() + sftp = self._sftp + if sftp is None: # closed from the UI thread since the check + self._require_connection() + yield sftp + finally: + self._in_use.release() + + def list_dir(self, path: str): + """ + List directory entries with stat attributes. Worker thread. + 列出目錄項目(含屬性)。 + """ + with self._session() as sftp: + return sftp.listdir_attr(path) + + def mkdir(self, path: str): + """ + Create directory. + 建立目錄。 + """ + with self._session(UI_WAIT_SECONDS) as sftp: + sftp.mkdir(path) + + def remove_file(self, path: str): + """ + Remove file. + 刪除檔案。 + """ + with self._session(UI_WAIT_SECONDS) as sftp: + sftp.remove(path) + + def remove_dir(self, path: str): + """ + Remove empty directory. + 刪除空目錄。 + """ + with self._session(UI_WAIT_SECONDS) as sftp: + sftp.rmdir(path) + + def rename(self, old_path: str, new_path: str): + """ + Rename file/folder. + 重新命名檔案/資料夾。 + """ + with self._session(UI_WAIT_SECONDS) as sftp: + sftp.rename(old_path, new_path) + + def download(self, remote_path: str, local_path: str): + """ + Download remote to local. Worker thread. + 下載遠端檔案至本地。 + + The file arrives beside *local_path* under a temporary name and takes + its place only when complete: ``get`` empties its target before the + first byte, so a dropped link or a Disconnect used to leave the file + the user chose to replace cut short. + """ + self._require_connection() + folder = os.path.dirname(os.path.abspath(local_path)) + handle, partial = tempfile.mkstemp( + prefix=f".{os.path.basename(local_path)}.", suffix=".part", dir=folder) + os.close(handle) + complete = False + try: + with self._session() as sftp: + sftp.get(remote_path, partial) + os.replace(partial, local_path) + complete = True + finally: + if not complete: + with suppress(OSError): + os.remove(partial) + + def upload(self, local_path: str, remote_path: str, replace: bool = False) -> bool: + """ + Upload local to remote. Worker thread. + 上傳本地檔案至遠端。 + + Uploads nothing and returns ``False`` when *remote_path* exists and + *replace* is false: it used to be replaced without a word. The file + arrives beside it under a temporary name and takes its place only when + complete, as a download does: ``put`` empties its target before the + first byte, so a dropped link used to leave the server's copy cut short. + """ + folder, name = posixpath.split(remote_path) + partial = remote_join(folder or "/", f".{name}.{uuid.uuid4().hex[:8]}.part") + with self._session() as sftp: + if not replace and _remote_exists(sftp, remote_path): + return False + complete = False + try: + sftp.put(local_path, partial) + _move_into_place(sftp, partial, remote_path) + complete = True + finally: + if not complete: + with suppress(OSError, EOFError, paramiko.SSHException): + sftp.remove(partial) + return True + + +def _remote_exists(sftp: paramiko.SFTPClient, path: str) -> bool: + """Whether *path* exists on the server.""" + try: + sftp.stat(path) + except FileNotFoundError: + return False + return True + + +def _move_into_place(sftp: paramiko.SFTPClient, partial: str, target: str) -> None: + """Rename *partial* to *target*, replacing it. + + ``posix_rename`` (an OpenSSH extension) replaces in one step. A server + without it gets the plain SFTP rename, which refuses an existing target, + so the target is removed first. + """ + try: + sftp.posix_rename(partial, target) + return + except OSError as error: + pybreeze_logger.debug("SFTP posix_rename unavailable, renaming plainly: %r", error) + with suppress(FileNotFoundError): + sftp.remove(target) + sftp.rename(partial, target) + + +class SftpTransferThread(QThread): + """One SFTP transfer, off the UI thread. + + A transfer has no timeout of its own, and a file can be any size: run in the + button's slot, a download over a stalled link holds every tab, every run + window and the editor itself until it finishes. Only the two signals reach + the UI, and only one transfer runs at a time -- a paramiko SFTP session is + not meant to be used from two places at once. + """ + + done = Signal(str) + failed = Signal(str) + # An upload whose target exists, not replaced: nothing was sent + exists = Signal(str) + + def __init__(self, client: "SFTPClientWrapper", downloading: bool, + remote_path: str, local_path: str, replace: bool = False) -> None: + super().__init__() + self._client = client + self._downloading = downloading + self._remote_path = remote_path + self._local_path = local_path + self._replace = replace + + def run(self) -> None: + try: + if self._downloading: + self._client.download(self._remote_path, self._local_path) + elif not self._client.upload(self._local_path, self._remote_path, self._replace): + self.exists.emit(self._remote_path) + return + except (OSError, RuntimeError, EOFError, paramiko.SSHException) as error: + self.failed.emit(str(error)) + else: + self.done.emit(self._local_path if self._downloading else self._remote_path) + + +class SftpListThread(QThread): + """List one remote directory off the UI thread, sorted for the tree. + + ``listdir_attr()`` has no timeout of its own and returns every entry at + once, so a stalled server or a directory of tens of thousands of files + used to hold the IDE until it answered. + """ + + listed = Signal(object) # [(name, SFTPAttributes)], directories first + failed = Signal(str) + + def __init__(self, client: "SFTPClientWrapper", path: str) -> None: + super().__init__() + self._client = client + self._path = path + + def run(self) -> None: + try: + entries = self._client.list_dir(self._path) + except (OSError, RuntimeError, EOFError, paramiko.SSHException) as error: + self.failed.emit(str(error)) + else: + self.listed.emit(sort_entries(entries)) + + +class SftpCallThread(QThread): + """One of the context menu's requests (create folder, rename, delete), off the UI thread. + + An SFTP reply has no timeout: on a stalled link the request waited in the + menu's slot and the IDE froze until TCP gave up. + """ + + done = Signal() + failed = Signal(str) + + def __init__(self, call: Callable[[], None]) -> None: + super().__init__() + self._call = call + + def run(self) -> None: + try: + self._call() + except (OSError, RuntimeError, EOFError, paramiko.SSHException) as error: + self.failed.emit(str(error)) + else: + self.done.emit() diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 08b74169..7f49719b 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -1,33 +1,30 @@ +"""The SFTP file tree: a lazily listed remote tree and its context menu. + +The session and the threads that use it live in ``sftp_session.py``; this +module only shows what they report and starts them. +""" from __future__ import annotations import os import posixpath -import re import stat -import tempfile -import threading -import uuid -from collections.abc import Callable, Iterator -from contextlib import contextmanager, suppress - -import paramiko -from PySide6.QtCore import Qt, QEvent, QThread, Signal +from collections.abc import Callable + +from PySide6.QtCore import Qt, QEvent, Signal from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QLineEdit, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu, QFileDialog, QMessageBox, QSplitter, QInputDialog, QStyle ) from je_editor import language_wrapper -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import ( - CONNECT_ERRORS, SHA1_ALGORITHMS, SshConnectThread +from pybreeze.pybreeze_ui.connect_gui.ssh.sftp_session import ( + SFTPClientWrapper, SftpCallThread, SftpListThread, SftpTransferThread, plain_remote_name, + remote_join, ) -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import ( - apply_host_key_policy, host_key_asker -) -from pybreeze.pybreeze_ui.thread_keeper import let_run_out -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import CONNECT_ERRORS, SshConnectThread +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import host_key_asker from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget -from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.thread_keeper import let_run_out _SIZE_UNITS = ("B", "KB", "MB", "GB", "TB", "PB") @@ -45,71 +42,14 @@ def format_size(num_bytes: int) -> str: return "" -# Keepalive interval (seconds) so an idle SFTP session is not dropped by the -# TCP stack, a NAT/firewall, or the SSH server (≈ OpenSSH ServerAliveInterval). -SSH_KEEPALIVE_SECONDS = 30 -# How long a menu action waits for the session before saying it is busy: -# long enough for a listing, and a transfer can hold it for minutes -UI_WAIT_SECONDS = 1.0 # How long a replace waits for the upload that asked about it to return UPLOAD_ASKED_WAIT_MS = 1000 -def natural_key(name: str) -> list: - """Sort key giving natural (human) order, e.g. ``img2`` before ``img10``. - - Embedded digit runs compare as integers and text compares case-insensitively, - matching how Windows Explorer and most file managers order file listings. - """ - # isdecimal (not isdigit): a part like "²³" or "①" is isdigit() but int() - # rejects it, which would crash sorting a file named with such characters. - return [ - int(part) if part.isdecimal() else part.lower() - for part in re.split(r"(\d+)", name) - ] - - # Item data: the serial of the listing a directory item is waiting for LISTING_ROLE = Qt.ItemDataRole.UserRole -def sort_entries(entries) -> list: - """Return ``[(name, entry)]`` with directories first, each in natural order.""" - dirs: list = [] - files: list = [] - for entry in entries: - bucket = dirs if stat.S_ISDIR(entry.st_mode) else files - bucket.append((entry.filename, entry)) - dirs.sort(key=lambda item: natural_key(item[0])) - files.sort(key=lambda item: natural_key(item[0])) - return dirs + files - - -def remote_join(directory: str, name: str) -> str: - """Join *name* under a remote POSIX *directory* into an absolute path. - - Remote SFTP paths are always POSIX: ``os.path.join`` would emit ``\\`` on a - Windows client and break navigation/transfers on the server. The result - always uses ``/`` separators and has a leading slash. - """ - base = "" if directory == "/" else directory - joined = posixpath.join(base, name) - return joined if joined.startswith("/") else f"/{joined}" - - -def plain_remote_name(text: str) -> str | None: - """*text* stripped, when it names one entry in a folder; ``None`` otherwise. - - A ``/`` in a new name moved the entry into another folder (or, leading, - dropped the folder it was in), and ``.`` or ``..`` named the folder itself - or its parent. - """ - name = text.strip() - if not name or "/" in name or "\x00" in name or name in (".", ".."): - return None - return name - - def folder_item(item: QTreeWidgetItem | None) -> QTreeWidgetItem | None: """The tree item of the folder *item* is in, or *item* itself when it is a folder.""" if item is None or item.text(1) == "dir": @@ -117,345 +57,6 @@ def folder_item(item: QTreeWidgetItem | None) -> QTreeWidgetItem | None: return item.parent() -class SftpBusy(RuntimeError): - """Another request holds the SFTP session (a transfer, a listing); try again after it.""" - - -class ConnectAbandoned(RuntimeError): - """The session came up after the connect was given up on, and was closed.""" - - -class SFTPClientWrapper: - """ - Lightweight wrapper around Paramiko SFTP client. - 輕量級封裝 Paramiko SFTP 客戶端,提供基本操作。 - """ - - def __init__(self): - self.word_dict = language_wrapper.language_word_dict - self._ssh: paramiko.SSHClient | None = None - self._sftp: paramiko.SFTPClient | None = None - self.root_path: str = "/" - # One request at a time on the session: paramiko's SFTP client throws - # away a reply read by the wrong thread, and the thread that sent that - # request then waits for it forever. Listings, a transfer and the menu's - # own calls used to share it freely. - self._in_use = threading.Lock() - - def connect(self, host: str, port: int, username: str, password: str, - use_key: bool = False, key_path: str = "", - parent_widget: QWidget | None = None): - """ - Establish SSH + SFTP connection. - 建立 SSH + SFTP 連線。 - - Runs on the connect thread while ``close()`` may run on the UI thread - (Disconnect, or the tab closing). The session is kept only if it is - still this connect's when it is up; otherwise it is closed and - ``ConnectAbandoned`` raised. Closing during the TCP connect cannot stop - it, and the session it went on to log in used to stay open, unseen, - until the IDE exited. - """ - self.close() - ssh = paramiko.SSHClient() - self._ssh = ssh - apply_host_key_policy(ssh, parent_widget) - pybreeze_logger.info("SFTP connecting to %s:%s", host, port) - try: - self._log_in(ssh, host, port, username, password, use_key, key_path) - transport = ssh.get_transport() - if transport is not None: - transport.set_keepalive(SSH_KEEPALIVE_SECONDS) - sftp = ssh.open_sftp() - if self._ssh is not ssh: - raise ConnectAbandoned("SFTP connect abandoned") - self._sftp = sftp - except Exception: - # A failure partway (auth, keepalive, or open_sftp) must not leak the - # half-open SSH transport: tear it down so connect() is all-or-nothing. - # Only this connect's own: a newer one may be under way by now. - ssh.close() - if self._ssh is ssh: - self._ssh = None - raise - - def _log_in(self, ssh: paramiko.SSHClient, host: str, port: int, username: str, - password: str, use_key: bool, key_path: str) -> None: - """Connect *ssh* with the key file, or the password when no key is used.""" - if not (use_key and key_path): - ssh.connect(hostname=host, port=port, username=username, password=password, - timeout=10, disabled_algorithms=SHA1_ALGORITHMS) - return - pkey = load_private_key(key_path, password, context="SFTP") - if pkey is None: - raise ValueError( - self.word_dict.get("ssh_command_widget_error_message_unsupported_private_key") - ) - ssh.connect(hostname=host, port=port, username=username, pkey=pkey, timeout=10, - disabled_algorithms=SHA1_ALGORITHMS) - - def close(self): - """ - Close SFTP and SSH safely. - 安全關閉 SFTP 與 SSH。 - """ - try: - if self._sftp: - self._sftp.close() - finally: - self._sftp = None - try: - if self._ssh: - self._ssh.close() - finally: - self._ssh = None - - @property - def connected(self) -> bool: - """ - Check connection state. - 檢查連線狀態。 - """ - return self._ssh is not None and self._sftp is not None - - def _require_connection(self) -> None: - """Raise unless a session is open. - - Every call below goes through this: without it an operation on a stale - tree (a dropped session, a closed connection) reaches ``None`` and the - tree's error box shows the user an ``AttributeError`` about ``NoneType``. - """ - if not self.connected: - raise RuntimeError( - self.word_dict.get("ssh_command_widget_dialog_title_not_connected") - ) - - @contextmanager - def _session(self, wait: float | None = None) -> Iterator[paramiko.SFTPClient]: - """Hold the session for one request and yield it, open. - - :param wait: seconds to wait for a request already running; ``None`` - waits as long as it takes (worker threads). The menu's calls run on - the UI thread and wait briefly: a transfer can hold the session for - minutes, and they are refused with ``SftpBusy`` instead. - """ - acquired = self._in_use.acquire() if wait is None else self._in_use.acquire(timeout=wait) - if not acquired: - raise SftpBusy(self.word_dict.get("ssh_file_viewer_message_session_busy")) - try: - self._require_connection() - sftp = self._sftp - if sftp is None: # closed from the UI thread since the check - self._require_connection() - yield sftp - finally: - self._in_use.release() - - def list_dir(self, path: str): - """ - List directory entries with stat attributes. Worker thread. - 列出目錄項目(含屬性)。 - """ - with self._session() as sftp: - return sftp.listdir_attr(path) - - def mkdir(self, path: str): - """ - Create directory. - 建立目錄。 - """ - with self._session(UI_WAIT_SECONDS) as sftp: - sftp.mkdir(path) - - def remove_file(self, path: str): - """ - Remove file. - 刪除檔案。 - """ - with self._session(UI_WAIT_SECONDS) as sftp: - sftp.remove(path) - - def remove_dir(self, path: str): - """ - Remove empty directory. - 刪除空目錄。 - """ - with self._session(UI_WAIT_SECONDS) as sftp: - sftp.rmdir(path) - - def rename(self, old_path: str, new_path: str): - """ - Rename file/folder. - 重新命名檔案/資料夾。 - """ - with self._session(UI_WAIT_SECONDS) as sftp: - sftp.rename(old_path, new_path) - - def download(self, remote_path: str, local_path: str): - """ - Download remote to local. Worker thread. - 下載遠端檔案至本地。 - - The file arrives beside *local_path* under a temporary name and takes - its place only when complete: ``get`` empties its target before the - first byte, so a dropped link or a Disconnect used to leave the file - the user chose to replace cut short. - """ - self._require_connection() - folder = os.path.dirname(os.path.abspath(local_path)) - handle, partial = tempfile.mkstemp( - prefix=f".{os.path.basename(local_path)}.", suffix=".part", dir=folder) - os.close(handle) - complete = False - try: - with self._session() as sftp: - sftp.get(remote_path, partial) - os.replace(partial, local_path) - complete = True - finally: - if not complete: - with suppress(OSError): - os.remove(partial) - - def upload(self, local_path: str, remote_path: str, replace: bool = False) -> bool: - """ - Upload local to remote. Worker thread. - 上傳本地檔案至遠端。 - - Uploads nothing and returns ``False`` when *remote_path* exists and - *replace* is false: it used to be replaced without a word. The file - arrives beside it under a temporary name and takes its place only when - complete, as a download does: ``put`` empties its target before the - first byte, so a dropped link used to leave the server's copy cut short. - """ - folder, name = posixpath.split(remote_path) - partial = remote_join(folder or "/", f".{name}.{uuid.uuid4().hex[:8]}.part") - with self._session() as sftp: - if not replace and _remote_exists(sftp, remote_path): - return False - complete = False - try: - sftp.put(local_path, partial) - _move_into_place(sftp, partial, remote_path) - complete = True - finally: - if not complete: - with suppress(OSError, EOFError, paramiko.SSHException): - sftp.remove(partial) - return True - - -def _remote_exists(sftp: paramiko.SFTPClient, path: str) -> bool: - """Whether *path* exists on the server.""" - try: - sftp.stat(path) - except FileNotFoundError: - return False - return True - - -def _move_into_place(sftp: paramiko.SFTPClient, partial: str, target: str) -> None: - """Rename *partial* to *target*, replacing it. - - ``posix_rename`` (an OpenSSH extension) replaces in one step. A server - without it gets the plain SFTP rename, which refuses an existing target, - so the target is removed first. - """ - try: - sftp.posix_rename(partial, target) - return - except OSError as error: - pybreeze_logger.debug("SFTP posix_rename unavailable, renaming plainly: %r", error) - with suppress(FileNotFoundError): - sftp.remove(target) - sftp.rename(partial, target) - - -class SftpTransferThread(QThread): - """One SFTP transfer, off the UI thread. - - A transfer has no timeout of its own, and a file can be any size: run in the - button's slot, a download over a stalled link holds every tab, every run - window and the editor itself until it finishes. Only the two signals reach - the UI, and only one transfer runs at a time -- a paramiko SFTP session is - not meant to be used from two places at once. - """ - - done = Signal(str) - failed = Signal(str) - # An upload whose target exists, not replaced: nothing was sent - exists = Signal(str) - - def __init__(self, client: "SFTPClientWrapper", downloading: bool, - remote_path: str, local_path: str, replace: bool = False) -> None: - super().__init__() - self._client = client - self._downloading = downloading - self._remote_path = remote_path - self._local_path = local_path - self._replace = replace - - def run(self) -> None: - try: - if self._downloading: - self._client.download(self._remote_path, self._local_path) - elif not self._client.upload(self._local_path, self._remote_path, self._replace): - self.exists.emit(self._remote_path) - return - except (OSError, RuntimeError, EOFError, paramiko.SSHException) as error: - self.failed.emit(str(error)) - else: - self.done.emit(self._local_path if self._downloading else self._remote_path) - - -class SftpListThread(QThread): - """List one remote directory off the UI thread, sorted for the tree. - - ``listdir_attr()`` has no timeout of its own and returns every entry at - once, so a stalled server or a directory of tens of thousands of files - used to hold the IDE until it answered. - """ - - listed = Signal(object) # [(name, SFTPAttributes)], directories first - failed = Signal(str) - - def __init__(self, client: "SFTPClientWrapper", path: str) -> None: - super().__init__() - self._client = client - self._path = path - - def run(self) -> None: - try: - entries = self._client.list_dir(self._path) - except (OSError, RuntimeError, EOFError, paramiko.SSHException) as error: - self.failed.emit(str(error)) - else: - self.listed.emit(sort_entries(entries)) - - -class SftpCallThread(QThread): - """One of the context menu's requests (create folder, rename, delete), off the UI thread. - - An SFTP reply has no timeout: on a stalled link the request waited in the - menu's slot and the IDE froze until TCP gave up. - """ - - done = Signal() - failed = Signal(str) - - def __init__(self, call: Callable[[], None]) -> None: - super().__init__() - self._call = call - - def run(self) -> None: - try: - self._call() - except (OSError, RuntimeError, EOFError, paramiko.SSHException) as error: - self.failed.emit(str(error)) - else: - self.done.emit() - - class SSHFileTreeManager(QWidget): """ QWidget: connection form + tree + context menu. diff --git a/test/test_utils/test_fuzz_pure_logic.py b/test/test_utils/test_fuzz_pure_logic.py index 22e87d78..df24b18d 100644 --- a/test/test_utils/test_fuzz_pure_logic.py +++ b/test/test_utils/test_fuzz_pure_logic.py @@ -6,11 +6,8 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import ( - format_size, - natural_key, - remote_join, -) +from pybreeze.pybreeze_ui.connect_gui.ssh.sftp_session import natural_key, remote_join +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import format_size from pybreeze.pybreeze_ui.diagram_editor.diagram_mermaid_parser import parse_mermaid from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import ( _is_text_content_type, diff --git a/test/test_utils/test_sftp_remote_path.py b/test/test_utils/test_sftp_remote_path.py index 9079528b..7f319dad 100644 --- a/test/test_utils/test_sftp_remote_path.py +++ b/test/test_utils/test_sftp_remote_path.py @@ -5,15 +5,15 @@ import pytest -from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as sftp_mod -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import ( - sort_entries, +from pybreeze.pybreeze_ui.connect_gui.ssh import sftp_session as sftp_mod +from pybreeze.pybreeze_ui.connect_gui.ssh.sftp_session import ( SSH_KEEPALIVE_SECONDS, SFTPClientWrapper, - format_size, natural_key, remote_join, + sort_entries, ) +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import format_size class TestKeepalive: diff --git a/test/test_utils/test_sftp_session_sharing.py b/test/test_utils/test_sftp_session_sharing.py index 944f09fd..c6e018d2 100644 --- a/test/test_utils/test_sftp_session_sharing.py +++ b/test/test_utils/test_sftp_session_sharing.py @@ -11,7 +11,7 @@ from PySide6.QtWidgets import QApplication from pybreeze.extend_multi_language.update_language_dict import update_language_dict -from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer +from pybreeze.pybreeze_ui.connect_gui.ssh import sftp_session as viewer WAIT_SECONDS = 5 diff --git a/test/test_utils/test_sftp_upload.py b/test/test_utils/test_sftp_upload.py index 25e3ae52..3a3a9a8c 100644 --- a/test/test_utils/test_sftp_upload.py +++ b/test/test_utils/test_sftp_upload.py @@ -13,6 +13,7 @@ from PySide6.QtWidgets import QApplication, QMessageBox from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import sftp_session from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer WAIT_SECONDS = 5 @@ -67,8 +68,8 @@ def remove(self, path: str) -> None: del self.files[path] -def _wrapper(sftp: FakeSftp) -> viewer.SFTPClientWrapper: - wrapper = viewer.SFTPClientWrapper() +def _wrapper(sftp: FakeSftp) -> sftp_session.SFTPClientWrapper: + wrapper = sftp_session.SFTPClientWrapper() wrapper._ssh = SimpleNamespace(close=lambda: None) wrapper._sftp = sftp return wrapper diff --git a/test/test_utils/test_ssh_reentrancy.py b/test/test_utils/test_ssh_reentrancy.py index 60f96fa0..94ab57aa 100644 --- a/test/test_utils/test_ssh_reentrancy.py +++ b/test/test_utils/test_ssh_reentrancy.py @@ -14,6 +14,7 @@ from pybreeze.extend_multi_language.update_language_dict import update_language_dict from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_command_widget as shell_mod +from pybreeze.pybreeze_ui.connect_gui.ssh import sftp_session from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as tree_mod from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_host_key_policy as host_key_mod from pybreeze.pybreeze_ui.thread_keeper import is_kept @@ -274,9 +275,9 @@ class TestTheRealWrapperGivenUpOn: """Closed while it connects, the SFTP wrapper closes the session it still brings up.""" def _connect_in_background(self, monkeypatch, client: FakeClient): - monkeypatch.setattr(tree_mod.paramiko, "SSHClient", lambda: client) - monkeypatch.setattr(tree_mod, "apply_host_key_policy", lambda _client, _parent: None) - wrapper = tree_mod.SFTPClientWrapper() + monkeypatch.setattr(sftp_session.paramiko, "SSHClient", lambda: client) + monkeypatch.setattr(sftp_session, "apply_host_key_policy", lambda _client, _parent: None) + wrapper = sftp_session.SFTPClientWrapper() raised: list = [] def connect() -> None: @@ -304,7 +305,7 @@ def test_a_session_up_after_close_is_closed_and_not_kept(self, app, monkeypatch) # It used to log in, fail on the emptied wrapper with an AttributeError # no one caught, and leave the session open until the IDE exited. - assert isinstance(raised[0], tree_mod.ConnectAbandoned) + assert isinstance(raised[0], sftp_session.ConnectAbandoned) assert client.closed assert not wrapper.connected @@ -398,10 +399,10 @@ def connect(self, **options) -> None: def close(self) -> None: """Nothing to close.""" - monkeypatch.setattr(tree_mod.paramiko, "SSHClient", Client) - monkeypatch.setattr(tree_mod, "apply_host_key_policy", lambda _client, _parent: None) + monkeypatch.setattr(sftp_session.paramiko, "SSHClient", Client) + monkeypatch.setattr(sftp_session, "apply_host_key_policy", lambda _client, _parent: None) with pytest.raises(OSError): - tree_mod.SFTPClientWrapper().connect("host", 22, "user", "pw") + sftp_session.SFTPClientWrapper().connect("host", 22, "user", "pw") - assert connects[0]["disabled_algorithms"] is tree_mod.SHA1_ALGORITHMS + assert connects[0]["disabled_algorithms"] is sftp_session.SHA1_ALGORITHMS diff --git a/test/test_utils/test_ssh_teardown.py b/test/test_utils/test_ssh_teardown.py index f58e5b4f..c3588004 100644 --- a/test/test_utils/test_ssh_teardown.py +++ b/test/test_utils/test_ssh_teardown.py @@ -14,7 +14,7 @@ from PySide6.QtWidgets import QApplication from pybreeze.extend_multi_language.update_language_dict import update_language_dict -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SFTPClientWrapper +from pybreeze.pybreeze_ui.connect_gui.ssh.sftp_session import SFTPClientWrapper @pytest.fixture(scope="module") From 5240c044d18238eda074784e2b3ef67e5d35f44d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:13:55 +0800 Subject: [PATCH 156/312] Keep a Connect click from cutting an SFTP transfer short --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 16 +++++++++++ docs/updates/README.md | 3 ++- .../connect_gui/ssh/ssh_file_viewer_widget.py | 21 +++++++++++---- test/test_utils/test_ssh_teardown.py | 27 +++++++++++++++++++ 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 9c4663de..f447c820 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`strip_terminal_controls()`);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (632) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (645) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1756 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1757 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 0715be45..61545c0d 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2240,3 +2240,19 @@ Index and query commands: [README.md](README.md). New entries go at the end. - the six tests above - `architecture_explore.md` §9 - **Open items**: none. + +## U-20260923-126 · 2026-09-24 · Keep a Connect click from cutting an SFTP transfer short · #fix #ssh + +- **What**: the SSH tab's combined view has one Connect button for the shell and the file tree. + - Clicking it to bring back a shell that had closed also reconnected the file tree. + - `SFTPClientWrapper.connect` begins with `close()`, so a download or upload still running was cut short: a download's temporary file was dropped, an upload's was left on the server. + - The same happened with a Connect click in the standalone file tree. + - Found in the review sweep of `connect_gui/ssh/`. +- **Fix**: `SSHFileTreeManager._connect` refuses while a transfer runs and says so, with the message a second transfer already gets. The check is one helper, `_refused_while_transferring()`, shared with `_start_transfer`. The shell side still reconnects. +- **Tests**: `test_ssh_teardown.py` +1: with a download running, Connect starts no connect and tells the user. +- **Result / numbers**: SSH and SFTP tests 129 passed; 1757 tests in 111 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `test/test_utils/test_ssh_teardown.py` + - `architecture_explore.md` §9, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index b4148ea9..0c348b4f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-126 | 2026-09-24 | Keep a Connect click from cutting an SFTP transfer short | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-125 | 2026-09-24 | Refactor: move the SFTP session and its threads out of the file tree's module | #refactor #ssh | [2026-09](2026-09.md) | | U-20260923-124 | 2026-09-24 | Run the SFTP menu's requests off the UI thread, and upload through a temporary name after asking before a replace | #fix #ssh #threading #done | [2026-09](2026-09.md) | | U-20260923-123 | 2026-09-24 | Open the SSH shell's channel on the connect thread, not the UI thread | #fix #ssh #threading #done | [2026-09](2026-09.md) | @@ -206,4 +207,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 143 | +| [2026-09.md](2026-09.md) | 2026-09 | 144 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 7f49719b..f16d038c 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -139,6 +139,11 @@ def _connect(self): return if self._connecting is not None and self._connecting.isRunning(): return + # A connect starts by closing the session, which cut a transfer short; + # in the combined view the shared Connect button reached it to bring + # the shell back + if self._refused_while_transferring(): + return def connect() -> None: self.client.connect(host, port, user, pwd, use_key, key_path, parent_widget=self) @@ -564,11 +569,7 @@ def _start_transfer(self, *, downloading: bool, remote_path: str, local_path: st :param replace: upload over a file already there; otherwise the user is asked first :return: whether it started """ - if self._transfer is not None and self._transfer.isRunning(): - QMessageBox.information( - self, - self.word_dict.get("ssh_file_viewer_dialog_title_transfer_running"), - self.word_dict.get("ssh_file_viewer_dialog_message_transfer_running")) + if self._refused_while_transferring(): return False self._transfer = SftpTransferThread(self.client, downloading, remote_path, local_path, replace) self._transfer.done.connect( @@ -580,6 +581,16 @@ def _start_transfer(self, *, downloading: bool, remote_path: str, local_path: st self._transfer.start() return True + def _refused_while_transferring(self) -> bool: + """Say a transfer is going and return ``True`` if one is; ``False`` otherwise. UI thread.""" + if self._transfer is None or not self._transfer.isRunning(): + return False + QMessageBox.information( + self, + self.word_dict.get("ssh_file_viewer_dialog_title_transfer_running"), + self.word_dict.get("ssh_file_viewer_dialog_message_transfer_running")) + return True + def _ask_to_replace(self, remote_path: str, replace: Callable[[], object]) -> None: """Ask whether an upload may replace *remote_path*, and call *replace* if so. UI thread.""" answer = QMessageBox.question( diff --git a/test/test_utils/test_ssh_teardown.py b/test/test_utils/test_ssh_teardown.py index c3588004..0e998aa2 100644 --- a/test/test_utils/test_ssh_teardown.py +++ b/test/test_utils/test_ssh_teardown.py @@ -169,6 +169,33 @@ def slow_download(_remote, _local): tree._transfer.wait(5000) app.processEvents() # deliver its "done" while the message box is still stubbed + def test_connect_does_not_cut_a_transfer_short(self, app, monkeypatch): + # A connect starts by closing the session; the combined view's shared + # Connect button, clicked to bring the shell back, killed the transfer + import threading + + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + + answering = threading.Event() + said: list = [] + monkeypatch.setattr(viewer.QMessageBox, "information", staticmethod(lambda *args: said.append(args))) + tree = self._tree(app) + tree.login_widget.host_edit.setText("host") + tree.login_widget.user_edit.setText("user") + tree.client.download = lambda _remote, _local: answering.wait(10) + connects: list = [] + tree.client.connect = lambda *args, **kwargs: connects.append(args) + tree._start_transfer(downloading=True, remote_path="/tmp/big.bin", local_path="big.bin", + title="Downloaded", message="Saved to") + + tree._connect() + + assert tree._connecting is None and connects == [] + assert said, "the user was not told why nothing happened" + answering.set() + tree._transfer.wait(5000) + app.processEvents() + def test_a_second_transfer_is_refused_while_one_runs(self, app, monkeypatch): from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer From d2ed79132d629684673e8f2c9014d3027061a12c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:14:03 +0800 Subject: [PATCH 157/312] Correct the file tree module's line count in the architecture map --- architecture_explore.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/architecture_explore.md b/architecture_explore.md index f447c820..c472fa57 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`strip_terminal_controls()`);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (645) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (643) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | From 88417753bee216627f43a19808bb85760ba43e2e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:18:49 +0800 Subject: [PATCH 158/312] Refactor: drop the executor's exception handlers that nothing could reach --- docs/updates/2026-09.md | 8 ++++ docs/updates/README.md | 3 +- .../process_executor_utils.py | 43 ++++++++----------- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 61545c0d..7805de5a 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2256,3 +2256,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_ssh_teardown.py` - `architecture_explore.md` §9, §18 - **Open items**: none. + +## U-20260923-127 · 2026-09-24 · Refactor: drop the executor's exception handlers that nothing could reach · #refactor #executor + +- **What**: `build_process` caught `json.JSONDecodeError` and `ITETestExecutorException`, and `build_process_from_file` caught `ITETestExecutorException` (`extend/process_executor/process_executor_utils.py`). Nothing inside either `try` raises them. The script goes to the package as text and is parsed in the child, and nothing in PyBreeze raises `ITETestExecutorException`. The handlers were unreachable. Found in the review sweep of `extend/process_executor/`. +- **Change** (no behaviour change): both handlers are removed, along with the imports only they used (`json`, `wrong_test_data_format_exception_tag`, `ITETestExecutorException`). The batch runner's docstring now says what reports a file's failure (its run window, a run that cannot start included). +- **Tests**: unchanged. Executor tests (`test_build_task_process`, `test_closing_the_ide`, `test_install_menu`, `test_prthinker_process`, `test_run_folder`, `test_run_output`): 50 passed. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/process_executor_utils.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 0c348b4f..32f1775c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-127 | 2026-09-24 | Refactor: drop the executor's exception handlers that nothing could reach | #refactor #executor | [2026-09](2026-09.md) | | U-20260923-126 | 2026-09-24 | Keep a Connect click from cutting an SFTP transfer short | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-125 | 2026-09-24 | Refactor: move the SFTP session and its threads out of the file tree's module | #refactor #ssh | [2026-09](2026-09.md) | | U-20260923-124 | 2026-09-24 | Run the SFTP menu's requests off the UI thread, and upload through a temporary name after asking before a replace | #fix #ssh #threading #done | [2026-09](2026-09.md) | @@ -207,4 +208,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 144 | +| [2026-09.md](2026-09.md) | 2026-09 | 145 | diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index 94e5ed78..47bf4742 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import os import time from collections.abc import Callable @@ -13,8 +12,6 @@ from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow from pybreeze.extend.mail_thunder_extend.mail_thunder_setting import DEFAULT_REPORT_PATH, send_after_test 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.file_process.get_dir_file_list import get_dir_files_as_list from pybreeze.utils.logging.logger import pybreeze_logger @@ -29,20 +26,19 @@ def build_process( send_mail: bool = False, program_buffer: int = 1024000, ): - """Run *package* against a script: *exec_str*, or the code in the tab in front.""" - try: - test_format_code = exec_str - if test_format_code is None: - widget = main_window.tab_widget.currentWidget() - if not isinstance(widget, EditorWidget): - report_no_script_tab(main_window, package, program_buffer) - return - test_format_code = widget.code_edit.toPlainText() - start_process(main_window, package, test_format_code, send_mail, program_buffer) - except json.decoder.JSONDecodeError as error: - pybreeze_logger.error(f"{error!r}\n{wrong_test_data_format_exception_tag}") - except ITETestExecutorException as error: - pybreeze_logger.error(repr(error)) + """Run *package* against a script: *exec_str*, or the code in the tab in front. + + The script is handed to the package as written; the package parses it and + reports its own errors in the run window. + """ + test_format_code = exec_str + if test_format_code is None: + widget = main_window.tab_widget.currentWidget() + if not isinstance(widget, EditorWidget): + report_no_script_tab(main_window, package, program_buffer) + return + test_format_code = widget.code_edit.toPlainText() + start_process(main_window, package, test_format_code, send_mail, program_buffer) def report_no_script_tab( @@ -88,11 +84,8 @@ def build_process_from_file( the Windows ~32K argv limit. Useful for batch / multi-file flows where the file is already on disk. """ - try: - process = build_task_process(main_window, send_mail, program_buffer) - process.start_test_process_file(package, file_path) - except ITETestExecutorException as error: - pybreeze_logger.error(repr(error)) + process = build_task_process(main_window, send_mail, program_buffer) + process.start_test_process_file(package, file_path) def run_dir_files_with_package( @@ -105,9 +98,9 @@ def run_dir_files_with_package( Each file is executed via its on-disk path (``--execute_file``) so large action JSON never trips the Windows ~32K command-line limit, and one run - window is opened per file. ``build_process_from_file`` already logs and - contains per-file executor errors; the broad guard here only keeps a single - bad directory pick from crashing the menu callback. + window is opened per file, which reports that file's run, a run that cannot + start included. The broad guard here only keeps a single bad directory pick + from crashing the menu callback. """ try: execute_list = _ask_for_action_files(main_window) From 2c3ef9af8f94852c983a90a4e8d497073027a39d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:19:54 +0800 Subject: [PATCH 159/312] Say in README that SFTP requests run in the background and uploads ask before replacing --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d4124365..935bf1f9 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ A WYSIWYG `QGraphicsScene` editor: rectangle, rounded, ellipse and diamond nodes ![SSH client](images/ssh_client.png) -Password or private-key authentication, an interactive shell with ANSI handling and keepalive, and a lazy-loading SFTP tree with create-folder / rename / delete / upload / download. Unknown host keys are **not** auto-accepted: the SHA256 fingerprint is shown for confirmation on first connection (trust on first use) and persisted to `~/.pybreeze/ssh_known_hosts`. +Password or private-key authentication, an interactive shell with ANSI handling and keepalive, and a lazy-loading SFTP tree with create-folder / rename / delete / upload / download. Every SFTP request runs in the background, so a stalled link never freezes the IDE. An upload asks before it replaces a file on the server. Both directions write to a temporary file first, so a dropped link leaves the old copy whole. Unknown host keys are **not** auto-accepted: the SHA256 fingerprint is shown for confirmation on first connection (trust on first use) and persisted to `~/.pybreeze/ssh_known_hosts`. ### And also From a11e977315590eec1bd02a313304ca4f23b4d213 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:22:07 +0800 Subject: [PATCH 160/312] Let a diagram image be resized by its corner handles --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++ docs/updates/README.md | 3 +- progress.md | 8 ++++ .../diagram_editor/diagram_items.py | 8 ++-- test/test_utils/test_diagram_images.py | 39 +++++++++++++++++++ 6 files changed, 64 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index c472fa57..21bf0255 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1757 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1758 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 7805de5a..cbb81135 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2264,3 +2264,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Tests**: unchanged. Executor tests (`test_build_task_process`, `test_closing_the_ide`, `test_install_menu`, `test_prthinker_process`, `test_run_folder`, `test_run_output`): 50 passed. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/process_executor_utils.py` - **Open items**: none. + +## U-20260923-128 · 2026-09-24 · Let an image on the diagram canvas be resized by its corner handles · #fix #diagram + +- **What**: dragging a corner handle of an image on the diagram canvas raised `AttributeError: 'DiagramImage' object has no attribute 'node_w'` on the press and on every mouse move (`diagram_editor/diagram_items.py`, `ResizeHandle.mousePressEvent`). The handle read the node's `node_w` / `node_h`, but images get the same handles and keep their size in `img_w` / `img_h`. The image never resized and nothing was recorded for undo; only the property panel's spin boxes could resize one. Found in the review sweep of `diagram_editor/`. +- **Fix**: the handle takes the size from `rect()`, which is `(0, 0, w, h)` for nodes and images alike. +- **Tests**: `test_diagram_images.py` +1: dragging an image's bottom-right handle by (50, 50) makes it 150 x 130, as one undo step. +- **Result / numbers**: diagram image tests 14 passed. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_images.py`, `architecture_explore.md` §18 +- **Open items**: the review's other findings are in progress.md. diff --git a/docs/updates/README.md b/docs/updates/README.md index 32f1775c..48470e0d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-128 | 2026-09-24 | Let an image on the diagram canvas be resized by its corner handles | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-127 | 2026-09-24 | Refactor: drop the executor's exception handlers that nothing could reach | #refactor #executor | [2026-09](2026-09.md) | | U-20260923-126 | 2026-09-24 | Keep a Connect click from cutting an SFTP transfer short | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-125 | 2026-09-24 | Refactor: move the SFTP session and its threads out of the file tree's module | #refactor #ssh | [2026-09](2026-09.md) | @@ -208,4 +209,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 145 | +| [2026-09.md](2026-09.md) | 2026-09 | 146 | diff --git a/progress.md b/progress.md index c5338f66..b08f194b 100644 --- a/progress.md +++ b/progress.md @@ -12,3 +12,11 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? +- **#62** Stacking between images and nodes is lost on save/load and on every undo or redo: `DiagramImage.to_dict` / `from_dict` keep no `z`, and `_load_items` adds all nodes before all images (`diagram_editor/diagram_items.py`, `diagram_scene.py`). "Bring to Front" / "Send to Back" (`_change_z`) only look at other nodes, so they cannot put a node above an image. +- **#63** The Mermaid import drops or misreads common input (`diagram_editor/diagram_mermaid_parser.py`): `_SKIP_RE` is case-insensitive, so a node named `End`, `Click` or `Style` is taken for a keyword and its line skipped; a `:::class` suffix replaces the node's label with its ID; `A --- B --- C` makes `B` an edge label; `%%` inside a quoted label cuts the rest of the line; a quoted edge label (`-->|"text"|`) keeps its quotes. +- **#64** Non-finite or huge coordinates in a `.diagram.json` (`"x": NaN`, `1e308`) load as they are: the item is invisible, the scene's bounding rect becomes NaN so every PNG/SVG export fails, and saving writes `NaN` back out (`diagram_items.py`, both `from_dict`). +- **#65** PNG/SVG export writes onto the chosen file in place, and `painter.end()` is not checked for SVG: a failed re-export leaves the previous export truncated or empty with no warning (`diagram_editor_widget.py`, export). +- **#66** A connection whose label is not a string is skipped on load but stays registered on both its nodes (`DiagramConnection.__init__` adds itself before `_create_label` raises), so node moves update a connection that is not in the scene and deleting the node warns (`diagram_items.py`). +- **#67** The timestamp tool rejects ISO-8601 forms that Python 3.10's `fromisoformat` does not take (`+0000`, `+08`, 1, 2 or 9 fraction digits, basic `20240101T000000Z`) and a lowercase `z` on every version; epoch microseconds and nanoseconds overflow; an eight-digit `20240101` is read as seconds since 1970 (`utils/timestamp_tools/timestamp_converter.py`). +- **#68** The response inspector takes the first status line of `curl -i` output, so after `HTTP/1.1 100 Continue` or a proxy's `200 Connection established` the real response is read as the body; a body pasted alone whose first line has a colon is shown as a header (`utils/response_inspector/response_analyzer.py`). +- **#69** The JWT tab decodes the input as it is pasted: `Bearer eyJ...` always fails, and a token with quotes or line breaks around it fails or keeps the quote in the signature depending on its length, because non-strict base64 drops the stray characters after the padding was worked out (`utils/jwt_tools/jwt_decoder.py`, `tools_gui/jwt_decoder_gui.py`). diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index 0fc46297..a3211c11 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -240,9 +240,9 @@ def mouseDoubleClickEvent(self, event) -> None: class ResizeHandle(QGraphicsRectItem): - """Draggable corner handle for node resizing.""" + """Draggable corner handle that resizes its node or image.""" - def __init__(self, role: str, parent_node: DiagramNode): + def __init__(self, role: str, parent_node: DiagramNode | DiagramImage): hs = _HANDLE_SIZE super().__init__(-hs / 2, -hs / 2, hs, hs, parent_node) self.role = role @@ -260,7 +260,9 @@ def __init__(self, role: str, parent_node: DiagramNode): def mousePressEvent(self, event) -> None: if event.button() == Qt.MouseButton.LeftButton: self._drag_start = event.scenePos() - self._orig_rect = QRectF(0, 0, self._parent_node.node_w, self._parent_node.node_h) + # rect() is the size for nodes and images alike (0, 0, w, h); an + # image has no node_w, and a drag on its handle raised on every event + self._orig_rect = QRectF(self._parent_node.rect()) self._orig_pos = QPointF(self._parent_node.pos()) self._parent_node._resizing = True scene = self.scene() diff --git a/test/test_utils/test_diagram_images.py b/test/test_utils/test_diagram_images.py index cb111496..88c4b086 100644 --- a/test/test_utils/test_diagram_images.py +++ b/test/test_utils/test_diagram_images.py @@ -292,3 +292,42 @@ def test_nothing_selected_leaves_the_clipboard_alone(self, app): scene.copy_selected() assert scene._clipboard["images"], "the clipboard was emptied by a copy of nothing" + + +class _Drag: + """The parts of a mouse event a resize handle reads.""" + + def __init__(self, x: float, y: float) -> None: + from PySide6.QtCore import QPointF + + self._pos = QPointF(x, y) + + def scenePos(self): + return self._pos + + @staticmethod + def button(): + from PySide6.QtCore import Qt + + return Qt.MouseButton.LeftButton + + def accept(self) -> None: + """Handled.""" + + +def test_an_image_resizes_by_its_corner_handle(app): + # The handle read node_w, which an image does not have: every press and + # move raised, and only the property panel could resize an image + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage + + scene = DiagramScene() + image = DiagramImage(x=0, y=0, w=100, h=80) + scene.addItem(image) + handle = image._handles["br"] + + handle.mousePressEvent(_Drag(100, 80)) + handle.mouseMoveEvent(_Drag(150, 130)) + handle.mouseReleaseEvent(_Drag(150, 130)) + + assert (image.img_w, image.img_h) == (150, 130) + assert scene.undo_stack.count() == 1 From 7eb490df823b979c57a3553040c12d1bb52565fa Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:23:27 +0800 Subject: [PATCH 161/312] Skip diagram items a file places nowhere and leave nodes alone when a connection cannot be built --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 17 +++++++ docs/updates/README.md | 3 +- progress.md | 2 - .../diagram_editor/diagram_items.py | 44 ++++++++++++----- test/test_utils/test_diagram_serialization.py | 49 +++++++++++++++++++ 6 files changed, 103 insertions(+), 16 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 21bf0255..eb9904a6 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -243,7 +243,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) |---|---| | `diagram_editor_widget.py` (621) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔經 `replace_text()` 先寫 `.saving` 再換上去,存檔失敗不會毀掉上一份 | | `diagram_scene.py` (863) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點之上/之下。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | -| `diagram_items.py` (923) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`add_image()` 把圖放進 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級 | +| `diagram_items.py` (947) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`add_image()` 把圖放進 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級;位置經 `_coordinate()`:不是有限數字就跳過這一筆,超過 `MAX_COORDINATE`(一百萬)就夾回來;連線建好所有東西之後才掛到兩端節點上 | | `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (434) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | | `diagram_view.py` (180) | `QGraphicsView`:滾輪縮放(有上下界;橫向滾輪不縮放)、中鍵平移、`drawBackground` 畫格線 | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1758 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1763 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index cbb81135..9e838506 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2273,3 +2273,20 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: diagram image tests 14 passed. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_images.py`, `architecture_explore.md` §18 - **Open items**: the review's other findings are in progress.md. + +## U-20260923-129 · 2026-09-24 · Skip diagram items a file places nowhere, keep far ones within reach, and leave nodes alone when a connection cannot be built · #fix #diagram #done + +- **What**: progress #64 and #66, in `diagram_editor/diagram_items.py`. + - `x` / `y` from a `.diagram.json` went straight to `setPos`, while sizes, z, colours and fonts were already checked. `"x": NaN` (which `json.loads` accepts) made an item invisible and unselectable, and the scene's bounding rect NaN. Every PNG/SVG export then failed with "Could not export", and a save wrote `NaN` back out. `1e308` made the export's image size infinite, with the same result. + - `DiagramConnection.__init__` added itself to both nodes' `connections` before building its label. A label that is not text raised there, so the load skipped the connection but both nodes kept it: every move of either node updated a connection that was not in the scene, and deleting one warned. +- **Fix**: + - New `_coordinate()`: a position must be a finite number, or the entry is skipped like any other malformed one, and it is clamped to `MAX_COORDINATE` (1,000,000) from the origin. + - `_number()` also falls back for NaN and infinities. Nodes' and images' sizes read from a file go through it, so a non-number size gets the default instead of failing the entry. + - A connection registers on its nodes only after everything else in its constructor has succeeded. +- **Tests**: `test_diagram_serialization.py` +5: + - A node and an image at `NaN`, `inf` or `"left"` are skipped, and the bounding rect stays finite. + - `1e308` is brought to the limit. + - A connection labelled `5` leaves both nodes with no connections. +- **Result / numbers**: diagram tests 89 passed; 1763 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_serialization.py`, `progress.md` (#64, #66 removed), `architecture_explore.md` §7, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 48470e0d..e40fee8f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-129 | 2026-09-24 | Skip diagram items a file places nowhere, keep far ones within reach, and leave nodes alone when a connection cannot be built | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-128 | 2026-09-24 | Let an image on the diagram canvas be resized by its corner handles | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-127 | 2026-09-24 | Refactor: drop the executor's exception handlers that nothing could reach | #refactor #executor | [2026-09](2026-09.md) | | U-20260923-126 | 2026-09-24 | Keep a Connect click from cutting an SFTP transfer short | #fix #ssh | [2026-09](2026-09.md) | @@ -209,4 +210,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 146 | +| [2026-09.md](2026-09.md) | 2026-09 | 147 | diff --git a/progress.md b/progress.md index b08f194b..e25562d3 100644 --- a/progress.md +++ b/progress.md @@ -14,9 +14,7 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? - **#62** Stacking between images and nodes is lost on save/load and on every undo or redo: `DiagramImage.to_dict` / `from_dict` keep no `z`, and `_load_items` adds all nodes before all images (`diagram_editor/diagram_items.py`, `diagram_scene.py`). "Bring to Front" / "Send to Back" (`_change_z`) only look at other nodes, so they cannot put a node above an image. - **#63** The Mermaid import drops or misreads common input (`diagram_editor/diagram_mermaid_parser.py`): `_SKIP_RE` is case-insensitive, so a node named `End`, `Click` or `Style` is taken for a keyword and its line skipped; a `:::class` suffix replaces the node's label with its ID; `A --- B --- C` makes `B` an edge label; `%%` inside a quoted label cuts the rest of the line; a quoted edge label (`-->|"text"|`) keeps its quotes. -- **#64** Non-finite or huge coordinates in a `.diagram.json` (`"x": NaN`, `1e308`) load as they are: the item is invisible, the scene's bounding rect becomes NaN so every PNG/SVG export fails, and saving writes `NaN` back out (`diagram_items.py`, both `from_dict`). - **#65** PNG/SVG export writes onto the chosen file in place, and `painter.end()` is not checked for SVG: a failed re-export leaves the previous export truncated or empty with no warning (`diagram_editor_widget.py`, export). -- **#66** A connection whose label is not a string is skipped on load but stays registered on both its nodes (`DiagramConnection.__init__` adds itself before `_create_label` raises), so node moves update a connection that is not in the scene and deleting the node warns (`diagram_items.py`). - **#67** The timestamp tool rejects ISO-8601 forms that Python 3.10's `fromisoformat` does not take (`+0000`, `+08`, 1, 2 or 9 fraction digits, basic `20240101T000000Z`) and a lowercase `z` on every version; epoch microseconds and nanoseconds overflow; an eight-digit `20240101` is read as seconds since 1970 (`utils/timestamp_tools/timestamp_converter.py`). - **#68** The response inspector takes the first status line of `curl -i` output, so after `HTTP/1.1 100 Continue` or a proxy's `200 Connection established` the real response is read as the body; a body pasted alone whose first line has a colon is shown as a header (`utils/response_inspector/response_analyzer.py`). - **#69** The JWT tab decodes the input as it is pasted: `Bearer eyJ...` always fails, and a token with quotes or line breaks around it fails or keeps the quote in the signature depending on its length, because non-strict base64 drops the stray characters after the padding was worked out (`utils/jwt_tools/jwt_decoder.py`, `tools_gui/jwt_decoder_gui.py`). diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index a3211c11..2db9a8ee 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -154,11 +154,30 @@ def _safe_color(value: str | None, fallback: str) -> QColor: def _number(value: object, fallback: float) -> float: - """Return *value* as a float, or *fallback* when it is not a number.""" + """Return *value* as a float, or *fallback* when it is not a finite number.""" try: - return float(value) + number = float(value) except (TypeError, ValueError): return fallback + return number if math.isfinite(number) else fallback + + +# How far from the origin a saved item may be placed. A file is anyone's to +# edit: NaN (which json.loads accepts) made an item invisible and the scene's +# bounding rect NaN, so every export failed; 1e308 overflowed the export size. +MAX_COORDINATE = 1_000_000.0 + + +def _coordinate(value: object) -> float: + """*value* as a position within ``MAX_COORDINATE`` of the origin. + + :raises ValueError: when it is not a finite number, so the entry is skipped + :raises TypeError: when it is not a number at all + """ + number = float(value) + if not math.isfinite(number): + raise ValueError(f"not a finite coordinate: {value!r}") + return min(max(-MAX_COORDINATE, number), MAX_COORDINATE) def _clamped_line_width(width: float) -> float: @@ -554,10 +573,10 @@ def to_dict(self, node_id: int) -> dict: @classmethod def from_dict(cls, data: dict) -> DiagramNode: node = cls( - x=data["x"], - y=data["y"], - w=data.get("w", _DEFAULT_NODE_W), - h=data.get("h", _DEFAULT_NODE_H), + x=_coordinate(data["x"]), + y=_coordinate(data["y"]), + w=_number(data.get("w", _DEFAULT_NODE_W), _DEFAULT_NODE_W), + h=_number(data.get("h", _DEFAULT_NODE_H), _DEFAULT_NODE_H), text=data.get("text", "Node"), shape=NodeShape.__members__.get(data.get("shape", "RECTANGLE"), NodeShape.RECTANGLE), style=NodeStyle( @@ -602,13 +621,16 @@ def __init__( self.setCacheMode(QGraphicsItem.CacheMode.DeviceCoordinateCache) self.setZValue(-1) - source.connections.append(self) - target.connections.append(self) - self._label_item: QGraphicsTextItem | None = None if label: self._create_label(label) + # Only once nothing else can fail: a label that raised (not text, from + # a file) left the connection registered on both nodes but never in + # the scene, and every move of either node updated it + source.connections.append(self) + target.connections.append(self) + self.update_path() def _apply_pen(self) -> None: @@ -913,8 +935,8 @@ def to_dict(self, img_id: int) -> dict: def from_dict(cls, data: dict) -> DiagramImage: source = data.get("source", "") img = cls( - x=data["x"], y=data["y"], - w=data.get("w", 200), h=data.get("h", 200), + x=_coordinate(data["x"]), y=_coordinate(data["y"]), + w=_number(data.get("w", 200), 200), h=_number(data.get("h", 200), 200), # A file is anyone's to edit: a source that is not text is dropped, # not carried on into a path lookup that raises mid-load. source=source if isinstance(source, str) else "", diff --git a/test/test_utils/test_diagram_serialization.py b/test/test_utils/test_diagram_serialization.py index 8ad78ec3..f2e61142 100644 --- a/test/test_utils/test_diagram_serialization.py +++ b/test/test_utils/test_diagram_serialization.py @@ -519,3 +519,52 @@ def test_a_z_that_is_not_a_number_is_ignored(self, qt_app): node = DiagramNode.from_dict({"x": 0, "y": 0, "text": "A", "z": "front"}) assert node.zValue() == 0.0 + + +class TestWhatAFileCannotDo: + """A .diagram.json is anyone's to edit: what it says is checked before it is drawn.""" + + @pytest.mark.parametrize("x", [float("nan"), float("inf"), "left"]) + def test_an_item_placed_nowhere_is_skipped(self, qt_app, x): + # NaN loaded as it was: an invisible item, a NaN bounding rect, and no + # export would work again + import math + + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({ + "nodes": [{"id": 0, "x": x, "y": 0}, {"id": 1, "x": 10, "y": 10}], + "images": [{"x": x, "y": 0, "source": ""}], + }) + + assert len(scene.to_dict()["nodes"]) == 1 + assert scene.to_dict()["images"] == [] + rect = scene.itemsBoundingRect() + assert all(math.isfinite(v) for v in (rect.x(), rect.y(), rect.width(), rect.height())) + + def test_an_item_placed_absurdly_far_is_brought_within_reach(self, qt_app): + # 1e308 made the export's image size infinite + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import MAX_COORDINATE + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({"nodes": [{"id": 0, "x": 1e308, "y": -1e308}]}) + + node = scene.to_dict()["nodes"][0] + assert (node["x"], node["y"]) == (MAX_COORDINATE, -MAX_COORDINATE) + + def test_a_connection_that_cannot_be_built_leaves_its_nodes_alone(self, qt_app): + # It stayed registered on both nodes while never being in the scene + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({ + "nodes": [{"id": 0, "x": 0, "y": 0}, {"id": 1, "x": 300, "y": 0}], + "connections": [{"source": 0, "target": 1, "label": 5}], + }) + + nodes = [item for item in scene.items() if isinstance(item, DiagramNode)] + assert [len(node.connections) for node in nodes] == [0, 0] + assert scene.to_dict()["connections"] == [] From 4c0161b2ca679615f18990e42295ee9831e62235 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:25:02 +0800 Subject: [PATCH 162/312] Keep diagram images and nodes stacked as they were across save, load and undo --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 17 +++++ docs/updates/README.md | 3 +- progress.md | 1 - .../diagram_editor/diagram_items.py | 13 ++++ .../diagram_editor/diagram_scene.py | 37 +++++++++-- test/test_utils/test_diagram_serialization.py | 62 +++++++++++++++++++ 7 files changed, 128 insertions(+), 11 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index eb9904a6..7477dc21 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -242,8 +242,8 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | 檔案 | 職責 | |---|---| | `diagram_editor_widget.py` (621) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔經 `replace_text()` 先寫 `.saving` 再換上去,存檔失敗不會毀掉上一份 | -| `diagram_scene.py` (863) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點之上/之下。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | -| `diagram_items.py` (947) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`add_image()` 把圖放進 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級;位置經 `_coordinate()`:不是有限數字就跳過這一筆,超過 `MAX_COORDINATE`(一百萬)就夾回來;連線建好所有東西之後才掛到兩端節點上 | +| `diagram_scene.py` (888) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點與圖片之上/之下;`to_dict()` 給每個節點與圖片記下它在兩者之間由下往上的位置(`stack`),載入後 `_restore_stacking()` 照這個順序重新加回場景(同 z 值時後加的在上面),圖片也存 `z`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | +| `diagram_items.py` (960) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`add_image()` 把圖放進 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級;位置經 `_coordinate()`:不是有限數字就跳過這一筆,超過 `MAX_COORDINATE`(一百萬)就夾回來;連線建好所有東西之後才掛到兩端節點上 | | `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (434) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | | `diagram_view.py` (180) | `QGraphicsView`:滾輪縮放(有上下界;橫向滾輪不縮放)、中鍵平移、`drawBackground` 畫格線 | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1763 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1767 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 9e838506..8f995fcb 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2290,3 +2290,20 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: diagram tests 89 passed; 1763 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_serialization.py`, `progress.md` (#64, #66 removed), `architecture_explore.md` §7, §18 - **Open items**: none. + +## U-20260923-130 · 2026-09-24 · Keep diagram images and nodes stacked as they were across save, load and undo · #fix #diagram #done + +- **What**: progress #62, stacking between the diagram's images and nodes (`diagram_editor/diagram_items.py`, `diagram_scene.py`). + - Images saved no `z`, and a load added every node before every image. Items of equal z stack in the order they are added, so after a save and reopen, or after any undo or redo (undo rebuilds the scene from a snapshot), an image came back above every node, including one drawn over it. + - Bring to Front and Send to Back (`_change_z`) only looked at nodes. With one node and an image there was nothing to go above, and the node stayed under the image. +- **Fix**: + - Images save and restore `z`. + - `to_dict` gives every node and image its place among all of them, bottom first (`"stack"`). After loading, `_restore_stacking()` adds them again in that order. A file without `stack` loads as before. + - `_change_z` moves selected nodes and images above or below every other node and image. +- **Tests**: `test_diagram_serialization.py` +4: + - An image under a node stays under it after a reload, and after an unrelated undo. + - Bring to Front puts a lone node above an image. + - A file without stacking loads in the old order. +- **Result / numbers**: diagram tests 93 passed; 1767 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `diagram_scene.py`, `test/test_utils/test_diagram_serialization.py`, `progress.md` (#62 removed), `architecture_explore.md` §7, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index e40fee8f..52297e08 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-130 | 2026-09-24 | Keep diagram images and nodes stacked as they were across save, load and undo | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-129 | 2026-09-24 | Skip diagram items a file places nowhere, keep far ones within reach, and leave nodes alone when a connection cannot be built | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-128 | 2026-09-24 | Let an image on the diagram canvas be resized by its corner handles | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-127 | 2026-09-24 | Refactor: drop the executor's exception handlers that nothing could reach | #refactor #executor | [2026-09](2026-09.md) | @@ -210,4 +211,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 147 | +| [2026-09.md](2026-09.md) | 2026-09 | 148 | diff --git a/progress.md b/progress.md index e25562d3..e443e098 100644 --- a/progress.md +++ b/progress.md @@ -12,7 +12,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -- **#62** Stacking between images and nodes is lost on save/load and on every undo or redo: `DiagramImage.to_dict` / `from_dict` keep no `z`, and `_load_items` adds all nodes before all images (`diagram_editor/diagram_items.py`, `diagram_scene.py`). "Bring to Front" / "Send to Back" (`_change_z`) only look at other nodes, so they cannot put a node above an image. - **#63** The Mermaid import drops or misreads common input (`diagram_editor/diagram_mermaid_parser.py`): `_SKIP_RE` is case-insensitive, so a node named `End`, `Click` or `Style` is taken for a keyword and its line skipped; a `:::class` suffix replaces the node's label with its ID; `A --- B --- C` makes `B` an edge label; `%%` inside a quoted label cuts the rest of the line; a quoted edge label (`-->|"text"|`) keeps its quotes. - **#65** PNG/SVG export writes onto the chosen file in place, and `painter.end()` is not checked for SVG: a failed re-export leaves the previous export truncated or empty with no warning (`diagram_editor_widget.py`, export). - **#67** The timestamp tool rejects ISO-8601 forms that Python 3.10's `fromisoformat` does not take (`+0000`, `+08`, 1, 2 or 9 fraction digits, basic `20240101T000000Z`) and a lowercase `z` on every version; epoch microseconds and nanoseconds overflow; an eight-digit `20240101` is read as seconds since 1970 (`utils/timestamp_tools/timestamp_converter.py`). diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index 2db9a8ee..0765f62b 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -317,6 +317,10 @@ def mouseReleaseEvent(self, event) -> None: class DiagramNode(QGraphicsRectItem): """Composite node: invisible bounding rect holds a shape body + centred label + resize handles.""" + # Where a loaded node stood among the diagram's nodes and images, bottom + # first (the file's "stack"); None when it was not loaded or not saved + saved_stack: float | None = None + # Class-level grid config (set by DiagramScene) grid_enabled: bool = False grid_size: int = 20 @@ -588,6 +592,7 @@ def from_dict(cls, data: dict) -> DiagramNode: # Stacking is part of the diagram: without it, nodes the user brought # to the front come back in whatever order the scene lists them. node.setZValue(_number(data.get("z", 0.0), 0.0)) + node.saved_stack = _number(data.get("stack"), None) return node @@ -756,6 +761,9 @@ def to_dict(self, node_map: dict[DiagramNode, int]) -> dict: class DiagramImage(QGraphicsRectItem): + # As for DiagramNode: its place in the saved stacking order, when loaded + saved_stack: float | None = None + """A movable, resizable image item. Stores the *source* (local path or URL string) so the diagram can be @@ -929,6 +937,7 @@ def to_dict(self, img_id: int) -> dict: "h": self.img_h, "source": self._source, "caption": self.text(), + "z": self.zValue(), } @classmethod @@ -944,4 +953,8 @@ def from_dict(cls, data: dict) -> DiagramImage: caption = data.get("caption", "") if caption: img.set_text(caption) + # Images kept no stacking: after a save, a load or any undo they came + # back above every node, whatever had been drawn over them + img.setZValue(_number(data.get("z", 0.0), 0.0)) + img.saved_stack = _number(data.get("stack"), None) return img diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 761c403e..1b66864b 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -414,15 +414,16 @@ def _cancel_connection(self) -> None: self._temp_line = None def _change_z(self, direction: int) -> None: - """Put the selected nodes above (1) or below (-1) every other node. + """Put the selected nodes and images above (1) or below (-1) every other one. Their order among themselves is kept. Undoable, and kept in the file. Stepping z by one left a node under any other whose z was already higher. """ - chosen = sorted((item for item in self.selectedItems() if isinstance(item, DiagramNode)), - key=lambda node: node.zValue()) - others = [node.zValue() for node in self.get_all_nodes() if node not in chosen] + # Images too: with nodes alone, a node could never be put above an image + chosen = sorted((item for item in self.selectedItems() if isinstance(item, (DiagramNode, DiagramImage))), + key=lambda item: item.zValue()) + others = [item.zValue() for item in self._stackable() if item not in chosen] if not chosen or not others: return with self.undo_scope("Change Z"): @@ -674,12 +675,35 @@ def _bottom_first(self) -> list: def to_dict(self) -> dict: nodes = self.get_all_nodes() node_map: dict[DiagramNode, int] = {n: i for i, n in enumerate(nodes)} + # Where each node and image stands among all of them, bottom first: + # items of equal z stack in the order they were added, and a load adds + # nodes before images + stack = {item: place for place, item in enumerate(self._stackable())} return { - "nodes": [n.to_dict(node_map[n]) for n in nodes], + "nodes": [{**n.to_dict(node_map[n]), "stack": stack[n]} for n in nodes], "connections": [c.to_dict(node_map) for c in self.get_all_connections()], - "images": [img.to_dict(i) for i, img in enumerate(self.get_all_images())], + "images": [{**img.to_dict(i), "stack": stack[img]} for i, img in enumerate(self.get_all_images())], } + def _stackable(self) -> list[DiagramNode | DiagramImage]: + """Nodes and images, lowest in the stacking order first.""" + return [item for item in self._bottom_first() if isinstance(item, (DiagramNode, DiagramImage))] + + def _restore_stacking(self) -> None: + """Add the loaded nodes and images again in their saved order, bottom first. + + Items of equal z stack in the order they were added. An item the file + gives no place keeps its place after those that have one. + """ + stacked = self._stackable() + if all(item.saved_stack is None for item in stacked): + return + ordered = sorted(enumerate(stacked), key=lambda pair: ( + pair[1].saved_stack is None, pair[1].saved_stack or 0.0, pair[0])) + for _place, item in ordered: + self.removeItem(item) + self.addItem(item) + def _clear_items(self) -> None: """Remove all diagram items without clearing the scene entirely.""" # snapshot: removeItem() mutates scene items during iteration @@ -699,6 +723,7 @@ def _load_items(self, data: dict) -> None: id_to_node = self._load_nodes(data.get("nodes", [])) self._load_connections(data.get("connections", []), id_to_node) self._load_images(data.get("images", [])) + self._restore_stacking() def _load_nodes(self, node_dicts: list) -> dict[int, DiagramNode]: id_to_node: dict[int, DiagramNode] = {} diff --git a/test/test_utils/test_diagram_serialization.py b/test/test_utils/test_diagram_serialization.py index f2e61142..c8fafde7 100644 --- a/test/test_utils/test_diagram_serialization.py +++ b/test/test_utils/test_diagram_serialization.py @@ -568,3 +568,65 @@ def test_a_connection_that_cannot_be_built_leaves_its_nodes_alone(self, qt_app): nodes = [item for item in scene.items() if isinstance(item, DiagramNode)] assert [len(node.connections) for node in nodes] == [0, 0] assert scene.to_dict()["connections"] == [] + + +class TestImagesAndNodesKeepTheirStacking: + """An image under a node stays under it: saved, loaded, undone, or brought forward.""" + + def _image_then_node(self): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage, DiagramNode + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + image = DiagramImage(x=0, y=0, w=100, h=100) + scene.addItem(image) + node = DiagramNode(x=10, y=10, text="Over") + scene.addItem(node) + return scene + + @staticmethod + def _kinds_bottom_first(scene) -> list[str]: + return [type(item).__name__ for item in scene._stackable()] + + def test_a_reload_keeps_the_node_on_top(self, qt_app): + # A load added every node before every image: the image came back on top + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + saved = self._image_then_node().to_dict() + scene = DiagramScene() + scene.load_from_dict(saved) + + assert self._kinds_bottom_first(scene) == ["DiagramImage", "DiagramNode"] + + def test_an_unrelated_undo_keeps_the_node_on_top(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode + + scene = self._image_then_node() + with scene.undo_scope("Add"): + scene.addItem(DiagramNode(x=500, y=500, text="Elsewhere")) + scene.undo_stack.undo() + + assert self._kinds_bottom_first(scene) == ["DiagramImage", "DiagramNode"] + + def test_bring_to_front_can_put_a_node_above_an_image(self, qt_app): + # With one node there was no other node to go above, so nothing moved + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage, DiagramNode + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + node = DiagramNode(x=10, y=10) + scene.addItem(node) + scene.addItem(DiagramImage(x=0, y=0, w=100, h=100)) + node.setSelected(True) + + scene._change_z(1) + + assert self._kinds_bottom_first(scene) == ["DiagramImage", "DiagramNode"] + + def test_a_file_without_stacking_loads_as_before(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({"nodes": [{"id": 0, "x": 0, "y": 0}], "images": [{"x": 0, "y": 0}]}) + + assert self._kinds_bottom_first(scene) == ["DiagramNode", "DiagramImage"] From 0812eb493ffb07a1f4f707ac74e6d1b0f7a2ff2f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:26:17 +0800 Subject: [PATCH 163/312] Read capitalised keywords, class suffixes, open-link chains, percent signs and quoted labels right in the Mermaid import --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 19 ++++++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../diagram_editor/diagram_mermaid_parser.py | 23 ++++++++---- test/test_utils/test_mermaid_parser.py | 36 +++++++++++++++++++ 6 files changed, 74 insertions(+), 10 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 7477dc21..4fc76227 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1767 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1773 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 8f995fcb..aabaea40 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2307,3 +2307,22 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: diagram tests 93 passed; 1767 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `diagram_scene.py`, `test/test_utils/test_diagram_serialization.py`, `progress.md` (#62 removed), `architecture_explore.md` §7, §18 - **Open items**: none. + +## U-20260923-131 · 2026-09-24 · Read capitalised keywords as nodes, class suffixes, open-link chains, percent signs and quoted edge labels right in the Mermaid import · #fix #diagram #mermaid #done + +- **What**: progress #63, the Mermaid import (`diagram_editor/diagram_mermaid_parser.py`). + - `_SKIP_RE` was case-insensitive. A node named `End` (the Mermaid docs suggest that spelling to get round the `end` keyword), `Click` or `Style` was taken for a keyword and its line skipped: `End((Finish))` lost its shape and label, `End --> C` dropped the edge and C, and `Style[Style guide] --> B` gave no nodes at all. + - A `:::class` suffix stopped the shape from matching, so `A[Label]:::hot` got the label `A`. A later bare `A:::hot` overwrote a label declared earlier. + - The inline-label rewrite matched the last two dashes of `---`, so `A --- B --- C` became one edge A→C labelled B. + - `%%` started a comment anywhere, so `A["100%% done"] --> B` lost the edge and B. + - A quoted edge label (`-->|"quoted label"|`) kept its quotes. +- **Fix**: + - The keywords match in lower case only. + - A trailing `:::name` is stripped before the shape is read, so a class-only suffix is a bare reference. + - The label rewrites match only a `--` or `==` that starts a link. + - Comments are looked for outside quotes and brackets (`_protect` first). + - Edge labels are unquoted. +- **Tests**: `test_mermaid_parser.py` +6: capitalised keywords as nodes, lower-case keywords still skipped, class suffixes, a chain of open links, percent signs in a label with trailing and whole-line comments, a quoted edge label. +- **Result / numbers**: parser and fuzz tests 90 passed; 1773 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py`, `test/test_utils/test_mermaid_parser.py`, `progress.md` (#63 removed), `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 52297e08..77852c13 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-131 | 2026-09-24 | Read capitalised keywords as nodes, class suffixes, open-link chains, percent signs and quoted edge labels right in the Mermaid import | #fix #diagram #mermaid #done | [2026-09](2026-09.md) | | U-20260923-130 | 2026-09-24 | Keep diagram images and nodes stacked as they were across save, load and undo | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-129 | 2026-09-24 | Skip diagram items a file places nowhere, keep far ones within reach, and leave nodes alone when a connection cannot be built | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-128 | 2026-09-24 | Let an image on the diagram canvas be resized by its corner handles | #fix #diagram | [2026-09](2026-09.md) | @@ -211,4 +212,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 148 | +| [2026-09.md](2026-09.md) | 2026-09 | 149 | diff --git a/progress.md b/progress.md index e443e098..3c0a41a3 100644 --- a/progress.md +++ b/progress.md @@ -12,7 +12,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -- **#63** The Mermaid import drops or misreads common input (`diagram_editor/diagram_mermaid_parser.py`): `_SKIP_RE` is case-insensitive, so a node named `End`, `Click` or `Style` is taken for a keyword and its line skipped; a `:::class` suffix replaces the node's label with its ID; `A --- B --- C` makes `B` an edge label; `%%` inside a quoted label cuts the rest of the line; a quoted edge label (`-->|"text"|`) keeps its quotes. - **#65** PNG/SVG export writes onto the chosen file in place, and `painter.end()` is not checked for SVG: a failed re-export leaves the previous export truncated or empty with no warning (`diagram_editor_widget.py`, export). - **#67** The timestamp tool rejects ISO-8601 forms that Python 3.10's `fromisoformat` does not take (`+0000`, `+08`, 1, 2 or 9 fraction digits, basic `20240101T000000Z`) and a lowercase `z` on every version; epoch microseconds and nanoseconds overflow; an eight-digit `20240101` is read as seconds since 1970 (`utils/timestamp_tools/timestamp_converter.py`). - **#68** The response inspector takes the first status line of `curl -i` output, so after `HTTP/1.1 100 Continue` or a proxy's `200 Connection established` the real response is read as the body; a body pasted alone whose first line has a colon is shown as a header (`utils/response_inspector/response_analyzer.py`). diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py index c42947c5..285499b0 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py @@ -41,12 +41,18 @@ class _EdgeInfo: _DIRECTION_RE = re.compile( r"^\s*(?:graph|flowchart)\b(?:\s+(TD|TB|LR|RL|BT)\b)?", re.IGNORECASE ) +# A comment runs from %% to the end of the line; it is looked for only outside +# quotes and brackets, where "100%% done" is label text _COMMENT_RE = re.compile(r"%%.*$") +# Mermaid's keywords are lower case: "End", "Click" or "Style" is a node (the +# mermaid docs suggest "End" to get round the "end" keyword), and matching +# them in any case skipped the line _SKIP_RE = re.compile( r"^\s*(?:subgraph|end\b|style\b|classDef\b|class\s|click\b|linkStyle\b" - r"|direction\s+(?:TD|TB|LR|RL|BT)\b)", - re.IGNORECASE, + r"|direction\s+(?:TD|TB|LR|RL|BT)\b)" ) +# A node's ":::className" suffix: styling, not a label or a shape +_CLASS_SUFFIX_RE = re.compile(r":::[\w-]+$") # Arrow / link operator with optional pipe-label. Handles every common mermaid # link: normal/thick/dotted bodies, optional right head (arrow ``>``, circle @@ -79,9 +85,11 @@ def _normalize_inline_labels(line: str) -> str: The text may sit on any normal link: ``-- label -->``, ``-- label ---`` (no head), ``-- label --o`` or ``-- label --x``. """ - line = re.sub(rf"--\s+(\S[^|]{{0,{_LABEL_MAX}}}?)\s+(-{{2,}}[>ox]?)", r"\2|\1|", line) + # Only a "--" that starts a link: the last two dashes of "A --- B --- C" + # made B an edge label + line = re.sub(rf"(?ox]?)", r"\2|\1|", line) line = re.sub(rf"-\.\s+(\S[^|]{{0,{_LABEL_MAX}}}?)\s+\.->", r"-.->|\1|", line) - line = re.sub(rf"==\s+(\S[^|]{{0,{_LABEL_MAX}}}?)\s+==>", r"==>|\1|", line) + line = re.sub(rf"(?", r"==>|\1|", line) return line @@ -191,7 +199,7 @@ def _parse_node_ref(raw: str, nodes: dict[str, _NodeInfo]) -> str | None: if not m: return None node_id = m.group(1) - rest = m.group(2).strip() + rest = _CLASS_SUFFIX_RE.sub("", m.group(2).strip()).strip() text, shape = _extract_shape(rest, default_text=node_id) existing = nodes.get(node_id) if existing is None: @@ -247,7 +255,7 @@ def _parse_arrow(token: str) -> tuple[str, ConnectionStyle, float]: label = "" lm = re.search(r"\|([^|]*)\|", token) if lm: - label = lm.group(1).strip() + label = _unquote(lm.group(1).strip()) if "==" in token: return label, ConnectionStyle.SOLID, 3.5 # thick link @@ -552,7 +560,8 @@ def _parse_lines(text: str, nodes: dict[str, _NodeInfo], edges: list[_EdgeInfo]) """Parse every line of *text* into *nodes* and *edges*; return the flow direction.""" direction = "TD" for raw_line in text.splitlines(): - line = _COMMENT_RE.sub("", raw_line).strip() + masked, stash = _protect(raw_line) + line = _restore(_COMMENT_RE.sub("", masked), stash).strip() if not line: continue parsed_dir = _parse_direction(line) diff --git a/test/test_utils/test_mermaid_parser.py b/test/test_utils/test_mermaid_parser.py index d9046495..53b750a4 100644 --- a/test/test_utils/test_mermaid_parser.py +++ b/test/test_utils/test_mermaid_parser.py @@ -339,3 +339,39 @@ def test_an_invisible_link_keeps_both_nodes_and_draws_nothing(self): assert _node_texts(result) == ["A", "B"] assert result["connections"] == [] + + +class TestWhatTheReviewFound: + def test_capitalised_keywords_are_nodes(self): + # "End", "Click", "Style" were taken for keywords and their lines skipped + result = parse_mermaid("graph TD\nStart --> End\nEnd((Finish))\nEnd --> C\nStyle[Style guide] --> Click") + + assert _node_texts(result) == ["Start", "Finish", "C", "Style guide", "Click"] + assert ("Start", "Finish", "") in _edges(result) + assert ("Finish", "C", "") in _edges(result) + + def test_lower_case_keywords_are_still_skipped(self): + result = parse_mermaid("graph TD\nsubgraph one\nA --> B\nend\nstyle A fill:#f9f\nclick A callback") + + assert _node_texts(result) == ["A", "B"] + + def test_a_class_suffix_keeps_the_label(self): + # "A[Label]:::hot" became a node labelled "A" + assert _node_texts(parse_mermaid("graph TD\nA[Label]:::hot --> B")) == ["Label", "B"] + assert _node_texts(parse_mermaid("graph TD\nA[Label] --> B\nA:::hot")) == ["Label", "B"] + + def test_a_chain_of_open_links_keeps_its_middle_node(self): + # The last two dashes of "---" were read as the start of an edge label + result = parse_mermaid("graph TD\nA --- B --- C") + + assert _node_texts(result) == ["A", "B", "C"] + assert _edges(result) == [("A", "B", ""), ("B", "C", "")] + + def test_percent_signs_in_a_label_are_not_a_comment(self): + result = parse_mermaid('graph TD\nA["100%% done"] --> B %% a real comment\n%% a whole-line comment') + + assert _node_texts(result) == ["100%% done", "B"] + assert _edges(result) == [("100%% done", "B", "")] + + def test_a_quoted_edge_label_loses_its_quotes(self): + assert _edges(parse_mermaid('graph TD\nA -->|"quoted label"| B')) == [("A", "B", "quoted label")] From 6cf1061d985010871c0543efae35b413f2594e7c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:28:22 +0800 Subject: [PATCH 164/312] Export diagrams through a file beside the target so a failed export leaves the previous one --- CLAUDE.md | 2 +- architecture_explore.md | 6 +-- docs/updates/2026-09.md | 15 ++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../diagram_editor/diagram_editor_widget.py | 49 +++++++++++------- pybreeze/utils/file_process/replace_file.py | 27 ++++++++++ test/test_utils/test_diagram_editor_widget.py | 50 +++++++++++++++++++ test/test_utils/test_replace_file.py | 35 +++++++++++++ 9 files changed, 165 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1abec238..7ff3ff02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ Reference implementations: `utils/network/url_validation.py` (`validate_url`), ` **JupyterLab** — the embedded server is localhost-only; the empty `--ServerApp.token`/`password` and `--ServerApp.disable_check_xsrf=True` are safe *only* because of that. Never change `--ServerApp.ip` to an externally reachable address, and never set `--ServerApp.allow_origin`: a loopback bind does not stop a browser, and with the origin open any page the user visits can drive a tokenless server. The view loads from the same origin and needs nothing relaxed. The server outlives its launcher thread, so its tab stops it on close whatever the thread's state. -**File I/O** — dialog-chosen paths are trusted; paths loaded from saved data (`.diagram.json`) are not: check `is_file()` and an extension allowlist, or run URLs through SSRF validation. Use `pathlib`, never string concatenation. Write to `~/.pybreeze/` via `app_dirs.pybreeze_data_dir()` with `encoding="utf-8"`; read through `pybreeze_data_path()`, which creates nothing. Replace a file the user would lose through `utils/file_process/replace_file.replace_text` (written beside it, then moved into place), never an in-place `write_text`. Resolve symlinks with `Path.resolve(strict=True)` and verify the result stays in bounds. +**File I/O** — dialog-chosen paths are trusted; paths loaded from saved data (`.diagram.json`) are not: check `is_file()` and an extension allowlist, or run URLs through SSRF validation. Use `pathlib`, never string concatenation. Write to `~/.pybreeze/` via `app_dirs.pybreeze_data_dir()` with `encoding="utf-8"`; read through `pybreeze_data_path()`, which creates nothing. Replace a file the user would lose through `utils/file_process/replace_file.replace_text` (written beside it, then moved into place), never an in-place `write_text`; a file something else writes (an image, an SVG export) goes through `replace_written`. Resolve symlinks with `Path.resolve(strict=True)` and verify the result stays in bounds. **Qt** — `QGraphicsTextItem` text interaction must not be on by default (double-click to edit). Plugin loading takes only `.py` files, skipping `_`/`.` prefixes. `QWebEngineView.setUrl()` only for localhost or user-confirmed URLs; never `setHtml()` with unsanitised content. diff --git a/architecture_explore.md b/architecture_explore.md index 4fc76227..5490badb 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -241,7 +241,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | 檔案 | 職責 | |---|---| -| `diagram_editor_widget.py` (621) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔經 `replace_text()` 先寫 `.saving` 再換上去,存檔失敗不會毀掉上一份 | +| `diagram_editor_widget.py` (636) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔經 `replace_text()` 先寫 `.saving` 再換上去,存檔失敗不會毀掉上一份 | | `diagram_scene.py` (888) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點與圖片之上/之下;`to_dict()` 給每個節點與圖片記下它在兩者之間由下往上的位置(`stack`),載入後 `_restore_stacking()` 照這個順序重新加回場景(同 z 值時後加的在上面),圖片也存 `z`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | | `diagram_items.py` (960) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`add_image()` 把圖放進 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級;位置經 `_coordinate()`:不是有限數字就跳過這一筆,超過 `MAX_COORDINATE`(一百萬)就夾回來;連線建好所有東西之後才掛到兩端節點上 | | `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | @@ -350,7 +350,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | -| `file_process/replace_file.py` | `replace_text(path, text, private=False)`:寫到旁邊的 `<名稱>.saving` 再 `os.replace` 過去,失敗時原檔不動、半成品刪掉;`private` 以 `0600` 建立(存金鑰的檔案用) | +| `file_process/replace_file.py` | `replace_text(path, text, private=False)`:寫到旁邊的 `<名稱>.saving` 再 `os.replace` 過去,失敗時原檔不動、半成品刪掉;`private` 以 `0600` 建立(存金鑰的檔案用);`replace_written(path, write)` 給別人寫的檔案(圖片、SVG):`write` 拿到旁邊的 `<主檔名>.saving<副檔名>`(副檔名不變,看副檔名決定格式的寫入器照樣用),寫完才換上 | | `manager/package_manager/` | `PackageManager`(單例 `package_manager`)持有 `syntax_check_list` | **分層原則**:`utils/` 不 import Qt 或 JEditor,由 `test_utils_has_no_qt.py` 守著,所以全部是不需要視窗的純邏輯測試。 @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1773 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1779 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index aabaea40..077bfd58 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2326,3 +2326,18 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: parser and fuzz tests 90 passed; 1773 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py`, `test/test_utils/test_mermaid_parser.py`, `progress.md` (#63 removed), `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-132 · 2026-09-24 · Export a diagram to PNG or SVG through a file beside the target, so a failed export leaves the previous one · #fix #diagram #done + +- **What**: progress #65. The diagram editor's PNG and SVG exports wrote straight onto the chosen file (`diagram_editor/diagram_editor_widget.py`). `QSvgGenerator` empties its file as soon as painting starts, and `painter.end()` was not checked. Re-exporting over an existing file when the write failed part-way (a full disk, a locked file) left the previous export truncated or empty, and for SVG with no warning. +- **Fix**: + - New `replace_written(path, write)` in `utils/file_process/replace_file.py`, next to `replace_text`. The writer is given `.saving` beside the file (same extension, so a writer that goes by it picks the same format); that file takes the target's place only when complete, and a failure removes it and leaves the target as it was. + - The PNG export saves through `_save_png`, which raises when `QImage.save` returns False. + - The SVG export renders through `_write_svg`, which raises when the painter cannot start or `end()` fails. + - A failure shows the existing "Could not export" warning. +- **Tests**: + - `test_diagram_editor_widget.py` +4: a PNG export writes a PNG, an SVG export writes an SVG, and a failed export of either keeps the previous file whole with nothing left beside it. + - `test_replace_file.py` +2: the writer gets a file with the same extension, and a failing writer leaves the old file and nothing else. +- **Result / numbers**: diagram widget and replace-file tests pass; 1779 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/file_process/replace_file.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_diagram_editor_widget.py`, `test_replace_file.py`, `progress.md` (#65 removed), `CLAUDE.md` (File I/O), `architecture_explore.md` §7, §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 77852c13..945351d2 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-132 | 2026-09-24 | Export a diagram to PNG or SVG through a file beside the target, so a failed export leaves the previous one | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-131 | 2026-09-24 | Read capitalised keywords as nodes, class suffixes, open-link chains, percent signs and quoted edge labels right in the Mermaid import | #fix #diagram #mermaid #done | [2026-09](2026-09.md) | | U-20260923-130 | 2026-09-24 | Keep diagram images and nodes stacked as they were across save, load and undo | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-129 | 2026-09-24 | Skip diagram items a file places nowhere, keep far ones within reach, and leave nodes alone when a connection cannot be built | #fix #diagram #done | [2026-09](2026-09.md) | @@ -212,4 +213,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 149 | +| [2026-09.md](2026-09.md) | 2026-09 | 150 | diff --git a/progress.md b/progress.md index 3c0a41a3..ba2cf377 100644 --- a/progress.md +++ b/progress.md @@ -12,7 +12,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -- **#65** PNG/SVG export writes onto the chosen file in place, and `painter.end()` is not checked for SVG: a failed re-export leaves the previous export truncated or empty with no warning (`diagram_editor_widget.py`, export). - **#67** The timestamp tool rejects ISO-8601 forms that Python 3.10's `fromisoformat` does not take (`+0000`, `+08`, 1, 2 or 9 fraction digits, basic `20240101T000000Z`) and a lowercase `z` on every version; epoch microseconds and nanoseconds overflow; an eight-digit `20240101` is read as seconds since 1970 (`utils/timestamp_tools/timestamp_converter.py`). - **#68** The response inspector takes the first status line of `curl -i` output, so after `HTTP/1.1 100 Continue` or a proxy's `200 Connection established` the real response is read as the body; a body pasted alone whose first line has a colon is shown as a header (`utils/response_inspector/response_analyzer.py`). - **#69** The JWT tab decodes the input as it is pasted: `Bearer eyJ...` always fails, and a token with quotes or line breaks around it fails or keeps the quote in the signature depending on its length, because non-strict base64 drops the stray characters after the padding was worked out (`utils/jwt_tools/jwt_decoder.py`, `tools_gui/jwt_decoder_gui.py`). diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 58a84ffd..6fb9101b 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -30,7 +30,7 @@ from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene, ImageDownloadThread, ToolMode from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView from pybreeze.pybreeze_ui.thread_keeper import let_run_out -from pybreeze.utils.file_process.replace_file import replace_text +from pybreeze.utils.file_process.replace_file import replace_text, replace_written from pybreeze.utils.logging.logger import pybreeze_logger @@ -84,6 +84,16 @@ def _make_hsep() -> QFrame: """ +def _save_png(image: QImage, target: Path) -> None: + """Save *image* as a PNG file at *target*; ``OSError`` when it cannot. + + ``QImage.save`` returns False (without raising) on permission, path or + format errors. + """ + if not image.save(str(target), "PNG"): + raise OSError("QImage.save returned False") + + class MermaidImportDialog(QDialog): """Dialog for pasting Mermaid flowchart code.""" @@ -526,10 +536,9 @@ def _export_png(self) -> None: self._scene.clearSelection() self._scene.render(painter, QRectF(), rect) painter.end() - # QImage.save returns False (without raising) on permission/path/format - # errors, so the result must be checked to avoid a silent failure. - if not image.save(path): - self._warn_export_failed(path, "QImage.save returned False") + # Written beside the file and moved into place: a save that failed + # part-way used to leave the previous export cut short + replace_written(Path(path), lambda target: _save_png(image, target)) except Exception as error: # noqa: BLE001 — export must not crash the editor self._warn_export_failed(path, repr(error)) @@ -542,22 +551,28 @@ def _export_svg(self) -> None: return try: rect = self._get_content_rect() - gen = QSvgGenerator() - gen.setFileName(path) - gen.setSize(QSizeF(rect.width(), rect.height()).toSize()) - gen.setViewBox(QRectF(0, 0, rect.width(), rect.height())) - painter = QPainter(gen) - if not painter.isActive(): - self._warn_export_failed(path, "could not open SVG for writing") - return - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - painter.translate(-rect.topLeft()) self._scene.clearSelection() - self._scene.render(painter, QRectF(), rect) - painter.end() + # QSvgGenerator empties its file as soon as painting starts: it + # writes beside the chosen file, which is replaced only when done + replace_written(Path(path), lambda target: self._write_svg(target, rect)) except Exception as error: # noqa: BLE001 — export must not crash the editor self._warn_export_failed(path, repr(error)) + def _write_svg(self, target: Path, rect: QRectF) -> None: + """Render the scene's *rect* as an SVG file at *target*; ``OSError`` when it cannot.""" + gen = QSvgGenerator() + gen.setFileName(str(target)) + gen.setSize(QSizeF(rect.width(), rect.height()).toSize()) + gen.setViewBox(QRectF(0, 0, rect.width(), rect.height())) + painter = QPainter(gen) + if not painter.isActive(): + raise OSError("could not open SVG for writing") + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.translate(-rect.topLeft()) + self._scene.render(painter, QRectF(), rect) + if not painter.end(): + raise OSError("could not finish writing the SVG") + # ------------------------------------------------------------------ # Image operations # ------------------------------------------------------------------ diff --git a/pybreeze/utils/file_process/replace_file.py b/pybreeze/utils/file_process/replace_file.py index 0deabb54..73db6c62 100644 --- a/pybreeze/utils/file_process/replace_file.py +++ b/pybreeze/utils/file_process/replace_file.py @@ -8,6 +8,7 @@ import contextlib import os +from collections.abc import Callable from pathlib import Path # Owner read and write only, for a file that holds secrets; others as the umask allows @@ -45,3 +46,29 @@ def replace_text(path: Path, text: str, *, private: bool = False) -> None: with contextlib.suppress(OSError): beside.unlink() raise + + +def replace_written(path: Path, write: Callable[[Path], None]) -> None: + """Have *write* produce the file at *path*, replacing it in one step. + + For a file something else writes (an image, an SVG): *write* is given + ``.saving`` beside *path*, with *path*'s extension so a + writer that goes by it picks the same format, and that file then takes + *path*'s place. + + :param path: the file to produce; its folder must exist + :param write: writes the whole file to the path it is given; raises + ``OSError`` when it cannot + :raises OSError: when it cannot be written; *path* is then unchanged and + the partial file removed + """ + beside = path.with_name(f"{path.stem}.saving{path.suffix}") + try: + with contextlib.suppress(FileNotFoundError): + beside.unlink() + write(beside) + os.replace(beside, path) + except OSError: + with contextlib.suppress(OSError): + beside.unlink() + raise diff --git a/test/test_utils/test_diagram_editor_widget.py b/test/test_utils/test_diagram_editor_widget.py index f44957c0..07b1ef8a 100644 --- a/test/test_utils/test_diagram_editor_widget.py +++ b/test/test_utils/test_diagram_editor_widget.py @@ -106,3 +106,53 @@ def test_every_shortcut_applies_only_while_the_editor_has_focus(self, editor): assert len(shortcuts) >= 11 assert {shortcut.context() for shortcut in shortcuts} == { Qt.ShortcutContext.WidgetWithChildrenShortcut} + + +class TestExporting: + """An export replaces the chosen file only once the new one is whole.""" + + def _export(self, editor, monkeypatch, target, kind: str) -> list: + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + + warned: list = [] + monkeypatch.setattr(diagram_editor_widget.QFileDialog, "getSaveFileName", + staticmethod(lambda *args, **kwargs: (str(target), ""))) + monkeypatch.setattr(diagram_editor_widget.QMessageBox, "warning", + staticmethod(lambda *args, **kwargs: warned.append(args))) + editor._scene.load_from_dict(_A_DIAGRAM) + getattr(editor, f"_export_{kind}")() + return warned + + def test_a_png_export_writes_a_png(self, editor, tmp_path, monkeypatch): + target = tmp_path / "diagram.png" + + assert self._export(editor, monkeypatch, target, "png") == [] + + assert target.read_bytes().startswith(b"\x89PNG") + assert list(tmp_path.iterdir()) == [target] + + def test_an_svg_export_writes_an_svg(self, editor, tmp_path, monkeypatch): + target = tmp_path / "diagram.svg" + + assert self._export(editor, monkeypatch, target, "svg") == [] + + assert " Date: Thu, 24 Sep 2026 02:31:28 +0800 Subject: [PATCH 165/312] Read ISO-8601 the same on every Python, epoch micro- and nanoseconds, and eight-digit dates --- README.md | 2 +- architecture_explore.md | 4 +- docs/updates/2026-09.md | 20 +++ docs/updates/README.md | 3 +- progress.md | 1 - .../timestamp_tools/timestamp_converter.py | 118 +++++++++++++++--- test/test_utils/test_timestamp_converter.py | 48 +++++++ 7 files changed, 171 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 935bf1f9..8b055012 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ Each is a tab or a dock, each has the same copy / open-in-editor / save-to-file ![Timestamp converter, hash generator, query/JSON, URL builder](images/tools_montage_b.png) -- **Timestamp Converter** — a Unix epoch (seconds or milliseconds, auto-detected) or an ISO-8601 date-time in, every representation out in UTC. Deterministic and independent of the local time zone. +- **Timestamp Converter** — a Unix epoch (seconds, milliseconds, microseconds or nanoseconds, auto-detected) or an ISO-8601 date-time (with `Z`, `+08`, `+0800` or `+08:00`, any number of fraction digits, basic or extended) in, every representation out in UTC. Deterministic and independent of the local time zone. - **Hash Generator** — SHA-256, SHA-512, SHA-1 and MD5 at once (MD5/SHA-1 for interoperability with `usedforsecurity=False`, never for security decisions). - **Query ⇄ JSON** — `application/x-www-form-urlencoded` to pretty JSON and back; repeated keys become arrays and vice versa. - **URL Parser / Builder** — scheme, host, port, path, query, fragment and credentials as an editable JSON object, and back again. Brackets IPv6 literals and re-encodes query parameters for you. diff --git a/architecture_explore.md b/architecture_explore.md index 5490badb..091ea999 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -218,7 +218,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `CurlImportGUI` | `utils/curl_import/` | 貼上 curl 指令 → 產生 requests / pytest / APITestka(py & json) / LoadDensity 腳本 | | `HarImportGUI` | `utils/har_import/` | 開 `.har` → 列出錄到的請求(可只看 API-like)→ 批次產生腳本 | | `JwtDecoderGUI` | `utils/jwt_tools/` | 解 JWT header/payload(不驗簽),時間戳轉可讀 UTC | -| `TimestampGUI` | `utils/timestamp_tools/` | epoch(自動判秒/毫秒)↔ ISO-8601。epoch 換算用 `utc_from_epoch_seconds()`(epoch + `timedelta`;`datetime.fromtimestamp` 在 Windows 上拒絕 1970 年前幾小時以外的值),JWT 的時間戳 claim 也用它 | +| `TimestampGUI` | `utils/timestamp_tools/` | epoch(依大小自動判秒/毫秒/微秒/奈秒,整數用 `int()` 精確換算)↔ ISO-8601(`_ISO_RE` 自己解析,3.10 到 3.14 讀法一致:`Z`/`z`、`±HH`、`±HHMM`、任意位數小數、basic 格式;八位數而且是合法日期就當 `YYYYMMDD`)。epoch 換算用 `utc_from_epoch_seconds()`(epoch + `timedelta`;`datetime.fromtimestamp` 在 Windows 上拒絕 1970 年前幾小時以外的值),JWT 的時間戳 claim 也用它 | | `HashGUI` | `utils/hash_tools/` | 多演算法摘要 | | `QueryJsonGUI` | `utils/query_tools/` | query string ↔ JSON 雙向 | | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1779 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1792 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 077bfd58..19c1d600 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2341,3 +2341,23 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: diagram widget and replace-file tests pass; 1779 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/utils/file_process/replace_file.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_diagram_editor_widget.py`, `test_replace_file.py`, `progress.md` (#65 removed), `CLAUDE.md` (File I/O), `architecture_explore.md` §7, §12, §18 - **Open items**: none. + +## U-20260923-133 · 2026-09-24 · Read ISO-8601 the same on every Python, epoch microseconds and nanoseconds, and eight-digit dates in the timestamp converter · #fix #tools #done + +- **What**: progress #67, the timestamp converter (`utils/timestamp_tools/timestamp_converter.py`). + - Before Python 3.11, `datetime.fromisoformat` took only what `isoformat()` writes; the converter only turned a trailing `Z` into `+00:00`. On 3.10, which CI runs, `+0000`, `+08`, fractions of 1, 2 or 9 digits (Go and Kubernetes write 9) and the basic `20240101T000000Z` were refused. A lowercase `z` was refused on every version. + - Unit detection knew seconds and milliseconds only. Microseconds (`time.time_ns() // 1000`) and nanoseconds (`time.time_ns()`, Go's `UnixNano`) were divided by 1000 and overflowed. + - `20240101` was read as seconds: 1970-08-23. +- **Fix**: + - New `_ISO_RE` / `_iso_match()` parse a date or date-time the same way on every version: basic or extended format, `Z`, `z`, `±HH`, `±HHMM` or `±HH:MM`, and any number of fraction digits (kept to the microsecond). Anything else still goes to `fromisoformat`, so 3.11+ keeps the rest of what it reads. + - `detect_epoch_unit` tells s, ms, µs and ns apart by magnitude (thresholds 1e11, 1e14, 1e17). A whole number is parsed with `int()` and divided exactly to the microsecond, rounding toward the past. + - Eight digits that make a valid date are read as `YYYYMMDD`; eight that do not (`12345678`) are still seconds. +- **Tests**: `test_timestamp_converter.py` +13: + - six offset and designator forms, three fraction lengths; + - µs and ns epochs; + - an eight-digit date, and one that is not a date; + - month 13 refused. + The file passes on Python 3.10 (uv), 3.11 and 3.14. +- **Result / numbers**: 1792 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/timestamp_tools/timestamp_converter.py`, `test/test_utils/test_timestamp_converter.py`, `progress.md` (#67 removed), `README.md` (Timestamp Converter), `architecture_explore.md` §6, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 945351d2..d6f9e4c3 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-133 | 2026-09-24 | Read ISO-8601 the same on every Python, epoch microseconds and nanoseconds, and eight-digit dates in the timestamp converter | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-132 | 2026-09-24 | Export a diagram to PNG or SVG through a file beside the target, so a failed export leaves the previous one | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-131 | 2026-09-24 | Read capitalised keywords as nodes, class suffixes, open-link chains, percent signs and quoted edge labels right in the Mermaid import | #fix #diagram #mermaid #done | [2026-09](2026-09.md) | | U-20260923-130 | 2026-09-24 | Keep diagram images and nodes stacked as they were across save, load and undo | #fix #diagram #done | [2026-09](2026-09.md) | @@ -213,4 +214,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 150 | +| [2026-09.md](2026-09.md) | 2026-09 | 151 | diff --git a/progress.md b/progress.md index ba2cf377..83e395d3 100644 --- a/progress.md +++ b/progress.md @@ -12,6 +12,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -- **#67** The timestamp tool rejects ISO-8601 forms that Python 3.10's `fromisoformat` does not take (`+0000`, `+08`, 1, 2 or 9 fraction digits, basic `20240101T000000Z`) and a lowercase `z` on every version; epoch microseconds and nanoseconds overflow; an eight-digit `20240101` is read as seconds since 1970 (`utils/timestamp_tools/timestamp_converter.py`). - **#68** The response inspector takes the first status line of `curl -i` output, so after `HTTP/1.1 100 Continue` or a proxy's `200 Connection established` the real response is read as the body; a body pasted alone whose first line has a colon is shown as a header (`utils/response_inspector/response_analyzer.py`). - **#69** The JWT tab decodes the input as it is pasted: `Bearer eyJ...` always fails, and a token with quotes or line breaks around it fails or keeps the quote in the signature depending on its length, because non-strict base64 drops the stray characters after the padding was worked out (`utils/jwt_tools/jwt_decoder.py`, `tools_gui/jwt_decoder_gui.py`). diff --git a/pybreeze/utils/timestamp_tools/timestamp_converter.py b/pybreeze/utils/timestamp_tools/timestamp_converter.py index d4ee167d..b5f45f73 100644 --- a/pybreeze/utils/timestamp_tools/timestamp_converter.py +++ b/pybreeze/utils/timestamp_tools/timestamp_converter.py @@ -9,6 +9,7 @@ """ from __future__ import annotations +import re from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -19,11 +20,16 @@ from pybreeze.utils.exception.exceptions import TimestampParseException from pybreeze.utils.logging.logger import pybreeze_logger -# Values at or above this magnitude are treated as milliseconds, not seconds. -# Epoch seconds around the year 2020 are ~1.6e9; the same instant in -# milliseconds is ~1.6e12, so 1e11 cleanly separates the two for any plausible -# modern timestamp. +# An epoch's unit, by magnitude: the same instant around 2020 is ~1.6e9 in +# seconds, ~1.6e12 in milliseconds, ~1.6e15 in microseconds and ~1.6e18 in +# nanoseconds, so each thousandfold step from 1e11 separates two of them for +# any plausible timestamp. (Microseconds and nanoseconds, from time.time_ns() +# or Go's UnixNano, used to be read as milliseconds and overflow.) _MILLISECONDS_THRESHOLD = 10 ** 11 +_MICROSECONDS_THRESHOLD = 10 ** 14 +_NANOSECONDS_THRESHOLD = 10 ** 17 +# How many of each unit make a second +_PER_SECOND = {"s": 1, "ms": 1000, "us": 10 ** 6, "ns": 10 ** 9} # Milliseconds per second _MS_PER_SECOND = 1000 # The Unix epoch, in UTC @@ -45,12 +51,17 @@ class TimestampResult: def detect_epoch_unit(value: float) -> str: - """Return ``"ms"`` or ``"s"`` for an epoch *value* by magnitude. + """Return ``"s"``, ``"ms"``, ``"us"`` or ``"ns"`` for an epoch *value* by magnitude. :param value: an epoch number whose unit is unknown - :return: ``"ms"`` when the value looks like milliseconds, else ``"s"`` + :return: the unit the value's size suggests """ - return "ms" if abs(value) >= _MILLISECONDS_THRESHOLD else "s" + magnitude = abs(value) + if magnitude >= _NANOSECONDS_THRESHOLD: + return "ns" + if magnitude >= _MICROSECONDS_THRESHOLD: + return "us" + return "ms" if magnitude >= _MILLISECONDS_THRESHOLD else "s" def utc_from_epoch_seconds(seconds: float) -> datetime: @@ -65,22 +76,70 @@ def utc_from_epoch_seconds(seconds: float) -> datetime: return _EPOCH + timedelta(seconds=seconds) -def _from_epoch(value: float) -> datetime: - """Build a UTC datetime from an epoch number, auto-detecting its unit.""" - seconds = value / _MS_PER_SECOND if detect_epoch_unit(value) == "ms" else value +def _from_epoch(value: int | float) -> datetime: + """Build a UTC datetime from an epoch number, auto-detecting its unit. + + A whole number is divided exactly (to the microsecond, rounding toward the + past): as a float, a nanosecond epoch has lost its last digits already. + """ + per_second = _PER_SECOND[detect_epoch_unit(value)] try: - return utc_from_epoch_seconds(seconds) + if isinstance(value, int): + return _EPOCH + timedelta(microseconds=value * 10 ** 6 // per_second) + return utc_from_epoch_seconds(value / per_second) except (OverflowError, ValueError) as error: pybreeze_logger.error(unrecognized_timestamp_error) raise TimestampParseException(unrecognized_timestamp_error) from error +# ISO-8601 as tools write it, parsed the same way on every Python this runs on: +# before 3.11 datetime.fromisoformat took only what isoformat() writes, and +# refused "Z", "+0000", "+08", fractions of other than 3 or 6 digits (Go and +# Kubernetes write 9) and the basic format 20240101T000000Z. A lower-case "z" +# was refused on every version. +_ISO_RE = re.compile( + r"(?P\d{4})-?(?P\d{2})-?(?P\d{2})" + r"(?:[Tt ](?P\d{2})(?::?(?P\d{2})(?::?(?P\d{2})(?:[.,](?P\d+))?)?)?" + r"(?P[Zz]|[+-]\d{2}(?::?\d{2})?)?)?" +) + + +def _zone(text: str | None) -> timezone: + """The time zone an ISO-8601 designator names; none means UTC.""" + if not text or text in ("Z", "z"): + return timezone.utc + sign = -1 if text[0] == "-" else 1 + digits = text[1:].replace(":", "") + offset = timedelta(hours=int(digits[:2]), minutes=int(digits[2:4] or 0)) + return timezone(sign * offset) + + +def _iso_match(text: str) -> datetime | None: + """*text* as an ISO-8601 date or date-time in UTC, or ``None`` when it is not one. + + :raises ValueError: a field out of range (month 13, hour 25) + :raises OverflowError: moving it to UTC leaves the years datetime can hold + """ + match = _ISO_RE.fullmatch(text) + if match is None: + return None + fields = match.groupdict() + fraction = (fields["fraction"] or "")[:6].ljust(6, "0") + moment = datetime( + int(fields["year"]), int(fields["month"]), int(fields["day"]), + int(fields["hour"] or 0), int(fields["minute"] or 0), int(fields["second"] or 0), + int(fraction), tzinfo=_zone(fields["zone"])) + return moment.astimezone(timezone.utc) + + def _from_iso(text: str) -> datetime: - """Parse an ISO-8601 string (accepting a trailing ``Z``) into a UTC datetime.""" - # datetime.fromisoformat does not accept the 'Z' suffix before Python 3.11. - normalised = text[:-1] + "+00:00" if text.endswith("Z") else text + """Parse an ISO-8601 string into a UTC datetime; no offset means UTC.""" try: - parsed = datetime.fromisoformat(normalised) + parsed = _iso_match(text) + if parsed is not None: + return parsed + # Anything else fromisoformat takes on this Python (3.11+ reads more) + parsed = datetime.fromisoformat(text) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) # Moving 0001-01-01 +01:00 (or 9999-12-31 -01:00) to UTC leaves the @@ -91,17 +150,36 @@ def _from_iso(text: str) -> datetime: raise TimestampParseException(unrecognized_timestamp_error) from error +def _eight_digit_date(text: str) -> datetime | None: + """*text* as a ``YYYYMMDD`` date, when it is eight digits that make one. + + 20240101 used to be read as seconds, landing in August 1970. + """ + if len(text) != 8 or not text.isdigit(): + return None + try: + return _iso_match(text) + except ValueError: + return None + + def _parse(text: str) -> datetime: """Parse *text* as an epoch number or an ISO date-time into a UTC datetime.""" stripped = text.strip() if not stripped: pybreeze_logger.error(empty_timestamp_error) raise TimestampParseException(empty_timestamp_error) - try: - return _from_epoch(float(stripped)) - except ValueError: - # Not a plain number; fall through to ISO parsing. - return _from_iso(stripped) + date = _eight_digit_date(stripped) + if date is not None: + return date + for number_type in (int, float): + try: + number = number_type(stripped) + except ValueError: + continue + return _from_epoch(number) + # Not a plain number; fall through to ISO parsing. + return _from_iso(stripped) def convert_timestamp(text: str) -> TimestampResult: diff --git a/test/test_utils/test_timestamp_converter.py b/test/test_utils/test_timestamp_converter.py index 413fb3a9..51516d65 100644 --- a/test/test_utils/test_timestamp_converter.py +++ b/test/test_utils/test_timestamp_converter.py @@ -115,3 +115,51 @@ def test_a_day_or_more_before_1970_converts_on_every_platform(self, text, iso): def test_an_iso_time_with_milliseconds_keeps_them(self): assert convert_timestamp("2021-01-01T00:00:00.250Z").epoch_millis == 1609459200250 + + +class TestFormsToolsWrite: + """Read the same on Python 3.10 as on 3.14: fromisoformat took less before 3.11.""" + + @pytest.mark.parametrize("text", [ + "2024-01-01T00:00:00+0000", + "2024-01-01T00:00:00+00", + "2024-01-01T08:00:00+08", + "2024-01-01T00:00:00z", + "20240101T000000Z", + "2024-01-01 00:00:00Z", + ]) + def test_offsets_and_designators(self, text): + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + assert convert_timestamp(text).epoch_seconds == 1704067200 + + @pytest.mark.parametrize(("text", "millis"), [ + ("2024-01-01T00:00:00.1Z", 1704067200100), + ("2024-01-01T00:00:00.12Z", 1704067200120), + ("2024-01-01T00:00:00.123456789Z", 1704067200123), + ]) + def test_any_number_of_fraction_digits(self, text, millis): + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + assert convert_timestamp(text).epoch_millis == millis + + @pytest.mark.parametrize("text", ["1700000000123456", "1700000000123456789"]) + def test_microseconds_and_nanoseconds(self, text): + # Both were divided by 1000 as milliseconds and overflowed + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + assert convert_timestamp(text).epoch_millis == 1700000000123 + + def test_an_eight_digit_date_is_a_date(self): + # 20240101 was read as seconds: August 1970 + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + assert convert_timestamp("20240101").iso_utc == "2024-01-01T00:00:00+00:00" + assert convert_timestamp("12345678").epoch_seconds == 12345678 # not a date: seconds + + def test_a_field_out_of_range_is_refused(self): + from pybreeze.utils.exception.exceptions import TimestampParseException + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + with pytest.raises(TimestampParseException): + convert_timestamp("2024-13-01T00:00:00Z") From 042ecb55469a0929021c9bee1dd29ffb0ea98895 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:31:45 +0800 Subject: [PATCH 166/312] Say in README that TestPioneer highlighting covers .yaml too --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b055012..d569799b 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Each module gets the same menu shape: **Run** (single script, batch directory, w ### IDE core -- **Automation-aware syntax highlighting** — the `AT_*` / GUI / Web / Load keyword sets are registered for `.json`, and TestPioneer's schema for `.yml`, on top of JEditor's language support +- **Automation-aware syntax highlighting** — the `AT_*` / GUI / Web / Load keyword sets are registered for `.json`, and TestPioneer's schema for `.yml` and `.yaml`, on top of JEditor's language support - **Code editor** — built on [JEditor](https://github.com/Integration-Automation/JEDITOR): tabs, project tree, format checker, debugger, terminal, variable inspector and a git client pane - **Script execution** — single or batch; large action files are passed by path, never through the command line, so Windows' ~32 KB argv limit is never a factor - **Report generation** — HTML / JSON / XML after a run, with optional email delivery From 8628a082bb1ed36202c4e85a2a4d87e15db1e8c9 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:34:00 +0800 Subject: [PATCH 167/312] Decode a JWT pasted with Bearer, quotes or line breaks around it, and refuse non-base64url characters --- architecture_explore.md | 4 ++-- docs/updates/2026-09.md | 13 +++++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - pybreeze/utils/jwt_tools/jwt_decoder.py | 24 ++++++++++++++++--- test/test_utils/test_jwt_decoder.py | 31 +++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 7 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 091ea999..a0543003 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -217,7 +217,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) |---|---|---| | `CurlImportGUI` | `utils/curl_import/` | 貼上 curl 指令 → 產生 requests / pytest / APITestka(py & json) / LoadDensity 腳本 | | `HarImportGUI` | `utils/har_import/` | 開 `.har` → 列出錄到的請求(可只看 API-like)→ 批次產生腳本 | -| `JwtDecoderGUI` | `utils/jwt_tools/` | 解 JWT header/payload(不驗簽),時間戳轉可讀 UTC | +| `JwtDecoderGUI` | `utils/jwt_tools/` | 解 JWT header/payload(不驗簽),時間戳轉可讀 UTC;貼上的文字先去掉空白,不是單純的 token 就取出裡面第一個(`Bearer `、引號、換行都可以),base64url 嚴格解碼 | | `TimestampGUI` | `utils/timestamp_tools/` | epoch(依大小自動判秒/毫秒/微秒/奈秒,整數用 `int()` 精確換算)↔ ISO-8601(`_ISO_RE` 自己解析,3.10 到 3.14 讀法一致:`Z`/`z`、`±HH`、`±HHMM`、任意位數小數、basic 格式;八位數而且是合法日期就當 `YYYYMMDD`)。epoch 換算用 `utc_from_epoch_seconds()`(epoch + `timedelta`;`datetime.fromtimestamp` 在 Windows 上拒絕 1970 年前幾小時以外的值),JWT 的時間戳 claim 也用它 | | `HashGUI` | `utils/hash_tools/` | 多演算法摘要 | | `QueryJsonGUI` | `utils/query_tools/` | query string ↔ JSON 雙向 | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1792 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1806 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 19c1d600..1b483ae5 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2361,3 +2361,16 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: 1792 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/utils/timestamp_tools/timestamp_converter.py`, `test/test_utils/test_timestamp_converter.py`, `progress.md` (#67 removed), `README.md` (Timestamp Converter), `architecture_explore.md` §6, §18 - **Open items**: none. + +## U-20260923-134 · 2026-09-24 · Decode a JWT pasted with Bearer, quotes or line breaks around it, and refuse characters outside base64url · #fix #tools #done + +- **What**: progress #69, the JWT decoder (`utils/jwt_tools/jwt_decoder.py`, used by the JWT tab and the response inspector). + - The tab handed the pasted text to `decode_jwt` as it was. `Bearer eyJ...`, copied from an `Authorization` header, always failed. + - A token copied with quotes or wrapped over lines decoded or failed depending on the header's length. `urlsafe_b64decode` is not strict: it drops characters outside the alphabet after the padding has been worked out from a length that counted them. `{"alg":"none"}` and `{"alg":"ES256","kid":"a"}` failed, while HS256 and RS256 headers happened to work. A quoted token that did decode showed its signature with the quote on it. +- **Fix**: + - New `_the_token()`: whitespace inside the text is removed. If what is left is not a bare token, the first JWT-looking token in it (`find_tokens`) is decoded. + - Segments are decoded strictly (`b64decode(..., altchars=b"-_", validate=True)`), so a character outside base64url is refused instead of dropped. +- **Tests**: `test_jwt_decoder.py` +14: three headers, each bare, after `Bearer `, in quotes and in an `Authorization` line; a token wrapped over lines; a segment with a character outside base64url refused. +- **Result / numbers**: JWT tests 36 passed; 1806 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/jwt_tools/jwt_decoder.py`, `test/test_utils/test_jwt_decoder.py`, `progress.md` (#69 removed), `architecture_explore.md` §6, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index d6f9e4c3..de93e2fe 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-134 | 2026-09-24 | Decode a JWT pasted with Bearer, quotes or line breaks around it, and refuse characters outside base64url | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-133 | 2026-09-24 | Read ISO-8601 the same on every Python, epoch microseconds and nanoseconds, and eight-digit dates in the timestamp converter | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-132 | 2026-09-24 | Export a diagram to PNG or SVG through a file beside the target, so a failed export leaves the previous one | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-131 | 2026-09-24 | Read capitalised keywords as nodes, class suffixes, open-link chains, percent signs and quoted edge labels right in the Mermaid import | #fix #diagram #mermaid #done | [2026-09](2026-09.md) | @@ -214,4 +215,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 151 | +| [2026-09.md](2026-09.md) | 2026-09 | 152 | diff --git a/progress.md b/progress.md index 83e395d3..a338978a 100644 --- a/progress.md +++ b/progress.md @@ -13,4 +13,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? - **#68** The response inspector takes the first status line of `curl -i` output, so after `HTTP/1.1 100 Continue` or a proxy's `200 Connection established` the real response is read as the body; a body pasted alone whose first line has a colon is shown as a header (`utils/response_inspector/response_analyzer.py`). -- **#69** The JWT tab decodes the input as it is pasted: `Bearer eyJ...` always fails, and a token with quotes or line breaks around it fails or keeps the quote in the signature depending on its length, because non-strict base64 drops the stray characters after the padding was worked out (`utils/jwt_tools/jwt_decoder.py`, `tools_gui/jwt_decoder_gui.py`). diff --git a/pybreeze/utils/jwt_tools/jwt_decoder.py b/pybreeze/utils/jwt_tools/jwt_decoder.py index 310efad3..d174e534 100644 --- a/pybreeze/utils/jwt_tools/jwt_decoder.py +++ b/pybreeze/utils/jwt_tools/jwt_decoder.py @@ -58,7 +58,10 @@ def _decode_segment(segment: str) -> dict: # base64url omits padding; restore it so the stdlib decoder accepts the input. padding = "=" * (-len(segment) % 4) try: - raw = base64.urlsafe_b64decode(segment + padding) + # Strict: urlsafe_b64decode drops characters outside the alphabet after + # the padding was worked out from a length that counted them, so a + # stray quote decoded or failed depending on the segment's length + raw = base64.b64decode(segment + padding, altchars=b"-_", validate=True) decoded = json.loads(raw.decode("utf-8")) # binascii.Error and UnicodeDecodeError both derive from ValueError; a # payload nested past the recursion limit raises RecursionError. @@ -73,7 +76,8 @@ def _decode_segment(segment: str) -> dict: def decode_jwt(token: str) -> DecodedJwt: """Decode a JWT's header and payload without verifying its signature. - :param token: the compact JWT string ``header.payload.signature`` + :param token: the compact JWT string ``header.payload.signature``, or + text holding one (``Bearer ``, a quoted or wrapped token) :return: the decoded parts :raises JwtDecodeException: when the token is empty or not three segments, or a segment cannot be decoded @@ -83,7 +87,7 @@ def decode_jwt(token: str) -> DecodedJwt: pybreeze_logger.error(empty_jwt_error) raise JwtDecodeException(empty_jwt_error) - segments = stripped.split(".") + segments = _the_token(stripped).split(".") if len(segments) != _JWT_SEGMENT_COUNT: pybreeze_logger.error(malformed_jwt_error) raise JwtDecodeException(malformed_jwt_error) @@ -93,6 +97,20 @@ def decode_jwt(token: str) -> DecodedJwt: return DecodedJwt(header=header, payload=payload, signature=segments[2]) +def _the_token(text: str) -> str: + """The compact token in *text*, as pasted. + + A token is often copied with something around it: ``Bearer`` from an + ``Authorization`` header, the quotes of a JSON string, line breaks where it + wrapped. Those used to reach the decoder, and ``Bearer eyJ...`` always failed. + """ + compact = "".join(text.split()) + if JWT_TOKEN_RE.fullmatch(compact): + return compact + found = find_tokens(compact) + return found[0] if found else compact + + def find_tokens(text: str) -> list[str]: """Find every JWT-looking token in *text*, in order and without repeats. diff --git a/test/test_utils/test_jwt_decoder.py b/test/test_utils/test_jwt_decoder.py index ac679233..16cff2d7 100644 --- a/test/test_utils/test_jwt_decoder.py +++ b/test/test_utils/test_jwt_decoder.py @@ -123,3 +123,34 @@ def segment(obj_text: str) -> str: token = ".".join([segment('{"alg": "none"}'), segment("[" * 100000 + "]" * 100000), ""]) with pytest.raises(JwtDecodeException): decode_jwt(token) + + +class TestATokenPastedWithSomethingAroundIt: + """Bearer prefixes, quotes and line breaks come along when a token is copied.""" + + @pytest.mark.parametrize("header", [{"alg": "none"}, {"alg": "ES256", "kid": "a"}, {"alg": "HS256"}]) + @pytest.mark.parametrize("wrap", ["Bearer {}", '"{}"', "Authorization: Bearer {}\n", "{}"]) + def test_the_token_inside_is_decoded(self, header, wrap): + # "Bearer eyJ..." always failed; a quoted token failed or kept the quote in + # its signature depending on the header's length + token = _make_jwt(header, {"sub": "1"}) + + decoded = decode_jwt(wrap.format(token)) + + assert decoded.header == header + assert decoded.signature == "sig" + + def test_a_token_wrapped_over_lines_is_joined(self): + token = _make_jwt({"alg": "HS256"}, {"sub": "1", "name": "a long enough name"}) + + decoded = decode_jwt(token[:20] + "\n" + token[20:40] + "\r\n " + token[40:]) + + assert decoded.payload == {"sub": "1", "name": "a long enough name"} + + def test_a_segment_with_characters_outside_base64url_is_refused(self): + # Non-strict decoding dropped them and decoded what was left + token = _make_jwt({"alg": "HS256"}, {"sub": "1"}) + header, payload, signature = token.split(".") + + with pytest.raises(JwtDecodeException): + decode_jwt(f"{header[:4]}!{header[4:]}.{payload}.{signature}") From 7c814e4a2acd227dc92a228706b51164a963456b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:35:30 +0800 Subject: [PATCH 168/312] Read the final response of curl -i output and a one-line body as a body in the response inspector --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 17 +++++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../response_inspector/response_analyzer.py | 22 ++++++++++- test/test_utils/test_response_analyzer.py | 37 +++++++++++++++++++ 6 files changed, 78 insertions(+), 6 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index a0543003..7a0beb3d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -227,7 +227,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計,`compare_texts()` 只比對一次、兩者共用同一個 `SequenceMatcher`(diff 照 `difflib.unified_diff` 的格式從它的 grouped opcodes 寫出),在 `DiffThread` 上算、不佔 UI 執行緒(4 萬行要四秒多),比對中按鈕停用、關閉時交給 `let_run_out()`。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | | `JsonFormatGUI` | `utils/json_format/` | 美化 / 壓縮 / 驗證 | | `HeaderAnalyzerGUI` | `utils/header_tools/` | HTTP header 安全稽核(HSTS、CSP、CORS、Set-Cookie、banner…)| -| `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT | +| `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT;`curl -i` 印出的多段回應(`100 Continue`、proxy 的 `Connection established`、`-L` 的轉址)取最後一段;只有一行又沒有狀態列就當 body | ### 三個橫向共用機制 @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1806 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1811 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 1b483ae5..bf8377bb 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2374,3 +2374,20 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: JWT tests 36 passed; 1806 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/utils/jwt_tools/jwt_decoder.py`, `test/test_utils/test_jwt_decoder.py`, `progress.md` (#69 removed), `architecture_explore.md` §6, §18 - **Open items**: none. + +## U-20260923-135 · 2026-09-24 · Read the final response of curl -i output in the response inspector, and a one-line body as a body · #fix #tools #done + +- **What**: progress #68, the response inspector (`utils/response_inspector/response_analyzer.py`). + - `curl -i` prints every response it read: `HTTP/1.1 100 Continue`, a proxy's `200 Connection established`, and each redirect when `-L` follows one. The inspector took the first status line. The real response became its body, so its status and headers were lost and a JSON body was not recognised. + - A body pasted on its own whose one line has a colon (`Error: invalid token supplied`) was shown as a header with an empty body. +- **Fix**: + - `_parse_head_and_body` parses one response (`_parse_one_response`), and while the body starts with another status line, parses again from there. The final response is the one reported. + - A single line with no status line is the body. + - A header block pasted without a status line is still read as headers. +- **Tests**: `test_response_analyzer.py` +5: + - The final response is read after `100 Continue`, `Connection established` and a `301`. + - A one-line body stays a body. + - A header block stays headers. +- **Result / numbers**: response analyzer tests 29 passed; 1811 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/response_inspector/response_analyzer.py`, `test/test_utils/test_response_analyzer.py`, `progress.md` (#68 removed), `architecture_explore.md` §6, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index de93e2fe..991c710e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-135 | 2026-09-24 | Read the final response of curl -i output in the response inspector, and a one-line body as a body | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-134 | 2026-09-24 | Decode a JWT pasted with Bearer, quotes or line breaks around it, and refuse characters outside base64url | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-133 | 2026-09-24 | Read ISO-8601 the same on every Python, epoch microseconds and nanoseconds, and eight-digit dates in the timestamp converter | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-132 | 2026-09-24 | Export a diagram to PNG or SVG through a file beside the target, so a failed export leaves the previous one | #fix #diagram #done | [2026-09](2026-09.md) | @@ -215,4 +216,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 152 | +| [2026-09.md](2026-09.md) | 2026-09 | 153 | diff --git a/progress.md b/progress.md index a338978a..c5338f66 100644 --- a/progress.md +++ b/progress.md @@ -12,4 +12,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -- **#68** The response inspector takes the first status line of `curl -i` output, so after `HTTP/1.1 100 Continue` or a proxy's `200 Connection established` the real response is read as the body; a body pasted alone whose first line has a colon is shown as a header (`utils/response_inspector/response_analyzer.py`). diff --git a/pybreeze/utils/response_inspector/response_analyzer.py b/pybreeze/utils/response_inspector/response_analyzer.py index 1da43875..b828bb04 100644 --- a/pybreeze/utils/response_inspector/response_analyzer.py +++ b/pybreeze/utils/response_inspector/response_analyzer.py @@ -65,12 +65,30 @@ def _normalise(text: str) -> str: def _parse_head_and_body(text: str) -> tuple[int | None, dict[str, str], str]: - """Split *text* into a status code, headers and body. + """Split *text* into the final response's status code, headers and body. + + ``curl -i`` prints every response it read: ``100 Continue``, a proxy's + ``200 Connection established``, each redirect with ``-L``. The first status + line used to be taken, and the real response read as its body; while a + body starts with a status line, it is parsed again from there. + """ + lines = _normalise(text).split("\n") + if len(lines) == 1 and not _STATUS_LINE_RE.match(lines[0]): + # One line and no status line is a body: "Error: invalid token" is + # not a header + return None, {}, text.strip() + status_code, headers, body = _parse_one_response(lines) + while _STATUS_LINE_RE.match(body): + status_code, headers, body = _parse_one_response(body.split("\n")) + return status_code, headers, body + + +def _parse_one_response(lines: list[str]) -> tuple[int | None, dict[str, str], str]: + """Split *lines* into a status code, headers and body. Headers run from an optional status line until a blank line or the first line that is not a ``Name: Value`` header; everything after is the body. """ - lines = _normalise(text).split("\n") index = 0 status_code: int | None = None if lines and _STATUS_LINE_RE.match(lines[0]): diff --git a/test/test_utils/test_response_analyzer.py b/test/test_utils/test_response_analyzer.py index 2db92043..351a4e7a 100644 --- a/test/test_utils/test_response_analyzer.py +++ b/test/test_utils/test_response_analyzer.py @@ -4,6 +4,8 @@ import base64 import json +import pytest + from pybreeze.utils.response_inspector.response_analyzer import analyze_response @@ -155,3 +157,38 @@ def test_an_http2_status_pseudo_header_gives_the_status_and_keeps_the_headers(se assert analysis.status is not None and analysis.status.code == 404 assert analysis.headers == {"content-type": "application/json"} assert analysis.is_json_body + + +class TestCurlShowsEveryResponse: + """curl -i prints interim and earlier responses before the final one.""" + + @pytest.mark.parametrize("first", [ + "HTTP/1.1 100 Continue\n\n", + "HTTP/1.1 200 Connection established\n\n", + "HTTP/1.1 301 Moved Permanently\nLocation: /new\n\n", + ]) + def test_the_final_response_is_the_one_read(self, first): + # The first status line was taken and the real response read as its body + from pybreeze.utils.response_inspector.response_analyzer import analyze_response + + result = analyze_response(first + 'HTTP/1.1 404 Not Found\nContent-Type: application/json\n\n{"a": 1}') + + assert result.status.code == 404 + assert result.headers == {"Content-Type": "application/json"} + assert result.is_json_body + + def test_a_body_alone_is_not_a_header(self): + from pybreeze.utils.response_inspector.response_analyzer import analyze_response + + result = analyze_response("Error: invalid token supplied") + + assert result.headers == {} + assert result.body == "Error: invalid token supplied" + + def test_a_header_block_is_still_headers(self): + from pybreeze.utils.response_inspector.response_analyzer import analyze_response + + result = analyze_response("Content-Type: text/plain\nX-Id: 7\n\nhello") + + assert result.headers == {"Content-Type": "text/plain", "X-Id": "7"} + assert result.body == "hello" From 6f4f6e448f447f32213a9c5d87e30316ceaca5a5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:44:52 +0800 Subject: [PATCH 169/312] Show AI review answers as text, cut long error pages, save totals in one step, and name only a redirect's host --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 19 +++++++ docs/updates/README.md | 3 +- progress.md | 8 +++ .../connect_gui/url/ai_code_review_gui.py | 43 +++++++++++---- .../extend_ai_gui/skills/skills_send_gui.py | 21 +++++++- test/test_utils/test_ai_code_review_client.py | 53 +++++++++++++++++++ test/test_utils/test_skills_request.py | 10 +++- 8 files changed, 145 insertions(+), 14 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 7a0beb3d..19e52a88 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1811 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1815 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index bf8377bb..90cbf4a5 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2391,3 +2391,22 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: response analyzer tests 29 passed; 1811 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/utils/response_inspector/response_analyzer.py`, `test/test_utils/test_response_analyzer.py`, `progress.md` (#68 removed), `architecture_explore.md` §6, §18 - **Open items**: none. + +## U-20260923-136 · 2026-09-24 · Show AI review answers as text, cut long error pages, save review totals in one step, and name only a redirect's host · #fix #ai #security + +- **What**: four findings from the review sweep of the AI panels. + - The AI review panel (`connect_gui/url/ai_code_review_gui.py`) showed the answer with `QTextEdit.append`, which reads text that looks like markup as rich text. An answer about HTML or JSX lost its tags and a whole ` instead of here' + + client.on_answered(answer) + client.accept_response() + + text = client.response_panel.toPlainText() + assert text.startswith(answer) + assert " Date: Thu, 24 Sep 2026 02:46:31 +0800 Subject: [PATCH 170/312] Decode an AI answer by the charset its Content-Type names, UTF-8 otherwise --- architecture_explore.md | 4 ++-- docs/updates/2026-09.md | 9 +++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - pybreeze/utils/network/http_client.py | 25 ++++++++++++++++++++++--- test/test_utils/test_http_client.py | 26 +++++++++++++++++++++++++- 6 files changed, 60 insertions(+), 8 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 19e52a88..265f7396 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -344,7 +344,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 暫換成 `public_address()` 回傳的位址)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | -| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | +| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | | `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1815 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1818 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 90cbf4a5..d88b3f1e 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2410,3 +2410,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: AI panel tests 38 passed; 1815 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `test/test_utils/test_ai_code_review_client.py`, `test_skills_request.py`, `progress.md` (#70-#77 added), `architecture_explore.md` §9, §18 - **Open items**: the sweep's other findings are progress #70-#77. + +## U-20260923-137 · 2026-09-24 · Decode an AI answer by the charset its Content-Type names, UTF-8 otherwise, not Latin-1 · #fix #ai #network #done + +- **What**: progress #70. `read_capped_text` (`utils/network/http_client.py`) decoded with `response.encoding`, and requests sets that to ISO-8859-1 for any `text/*` answer that names no charset (RFC 2616's old default). A UTF-8 review sent as `text/plain` or `text/markdown` was shown garbled in all three AI panels (`程式碼審查 ✓` as `ç¨å¼ç¢¼...`). In the CoT chain the garbled text was also quoted into every later step. The existing tests covered `encoding=None` and unknown charsets only. +- **Fix**: new `named_charset(response)` reads the charset from the `Content-Type` header itself (`email.message.Message.get_param`). The body is decoded with it when Python knows it, and with the default (UTF-8) when the header names none, or one Python does not know. +- **Tests**: `test_http_client.py` +3: `text/plain` without a charset is UTF-8; a quoted `charset="ISO-8859-1"` is used; JSON without a charset is UTF-8. The fake response now carries the header its encoding implies. +- **Result / numbers**: network and AI panel tests 72 passed; 1818 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/network/http_client.py`, `test/test_utils/test_http_client.py`, `progress.md` (#70 removed), `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 4fb74970..ed5d2ff9 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-137 | 2026-09-24 | Decode an AI answer by the charset its Content-Type names, UTF-8 otherwise, not Latin-1 | #fix #ai #network #done | [2026-09](2026-09.md) | | U-20260923-136 | 2026-09-24 | Show AI review answers as text, cut long error pages, save review totals in one step, and name only a redirect's host | #fix #ai #security | [2026-09](2026-09.md) | | U-20260923-135 | 2026-09-24 | Read the final response of curl -i output in the response inspector, and a one-line body as a body | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-134 | 2026-09-24 | Decode a JWT pasted with Bearer, quotes or line breaks around it, and refuse characters outside base64url | #fix #tools #done | [2026-09](2026-09.md) | @@ -217,4 +218,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 154 | +| [2026-09.md](2026-09.md) | 2026-09 | 155 | diff --git a/progress.md b/progress.md index 8e71625c..e08ade05 100644 --- a/progress.md +++ b/progress.md @@ -12,7 +12,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -- **#70** A `text/*` answer with no charset is decoded as Latin-1: `read_capped_text` uses `response.encoding`, which `requests` sets to ISO-8859-1 for any `text/*` without a charset, so a UTF-8 review in `text/plain` or `text/markdown` is shown garbled, and the CoT chain quotes the garbled text into its later steps (`utils/network/http_client.py`). - **#71** A pinned connection dials only the first address the host resolves to (`url_validation.public_address`, used by `public_http`): a host with a broken IPv6 route, or a first A record that is down, fails after the connect timeout although another checked address would work. - **#72** The AI requests have per-read timeouts only: a server sending a byte every 25 s keeps a request (and the panel's greyed-out Send) going for as long as it likes; the CoT chain's read timeout is 60 s where the rule says 30 s (`code_review_thread.py`, `ai_code_review_gui.py`, `skills_send_gui.py`, `http_client.read_capped_text`). - **#73** A valid international domain name is refused as ambiguous: `url_validation._as_ascii` uses Python's IDNA 2003 codec (`ß` → `ss`) while urllib3 encodes with IDNA 2008 (`straße.de` → `xn--strae-oqa.de`). diff --git a/pybreeze/utils/network/http_client.py b/pybreeze/utils/network/http_client.py index 6d39716b..7a45d251 100644 --- a/pybreeze/utils/network/http_client.py +++ b/pybreeze/utils/network/http_client.py @@ -8,6 +8,8 @@ """ from __future__ import annotations +from email.message import Message + import requests from pybreeze.utils.network.url_validation import UnsafeURLError @@ -39,8 +41,9 @@ def read_capped_text( under the cap instead of being buffered in full by ``requests``. Raises ``ResponseTooLargeError`` once the accumulated body exceeds *max_bytes*. The response is always closed before returning. The charset the response names - is used when Python knows it, and *default_encoding* otherwise: a server can - name anything, and an unknown one would raise ``LookupError`` here. + in its ``Content-Type`` is used when Python knows it, and *default_encoding* + otherwise: a server can name anything, and an unknown one would raise + ``LookupError`` here. """ total = 0 chunks: list[bytes] = [] @@ -58,11 +61,27 @@ def read_capped_text( response.close() body = b"".join(chunks) try: - return body.decode(response.encoding or default_encoding, "replace") + return body.decode(named_charset(response) or default_encoding, "replace") except LookupError: return body.decode(default_encoding, "replace") +def named_charset(response: requests.Response) -> str | None: + """The charset *response*'s ``Content-Type`` names, or ``None`` when it names none. + + Not ``response.encoding``: requests gives every ``text/*`` answer without + a charset ISO-8859-1 (RFC 2616's old default), so a UTF-8 review sent as + ``text/plain`` or ``text/markdown`` came out garbled. + """ + content_type = (getattr(response, "headers", None) or {}).get("Content-Type", "") + if not content_type: + return None + message = Message() + message["Content-Type"] = content_type + charset = message.get_param("charset") + return charset.strip() if isinstance(charset, str) and charset.strip() else None + + # What a failed request is called for the user, most specific first: a connect # timeout is both a Timeout and a ConnectionError _REQUEST_FAILURES: tuple[tuple[type[Exception], str], ...] = ( diff --git a/test/test_utils/test_http_client.py b/test/test_utils/test_http_client.py index 0a9c15b2..0623c345 100644 --- a/test/test_utils/test_http_client.py +++ b/test/test_utils/test_http_client.py @@ -27,9 +27,14 @@ def test_shorter_than_typical_read_timeouts(self): class FakeResponse: """Minimal stand-in for a streamed requests.Response.""" - def __init__(self, body: bytes, encoding: str | None = "utf-8", chunk: int = 8): + def __init__(self, body: bytes, encoding: str | None = "utf-8", chunk: int = 8, + content_type: str | None = None): self._body = body self.encoding = encoding + # The charset is read from the header, as a server sends it + if content_type is None and encoding is not None: + content_type = f"text/plain; charset={encoding}" + self.headers = {"Content-Type": content_type} if content_type else {} self._chunk = chunk self.closed = False @@ -97,3 +102,22 @@ def test_a_2xx_is_an_answer(self, status): def test_anything_else_is_not(self, status): # 3xx included, although requests calls it "ok" assert not succeeded(SimpleNamespace(status_code=status)) + + +class TestWhichCharset: + def test_text_without_a_charset_is_utf8_not_latin1(self): + # requests sets ISO-8859-1 on any text/* without a charset + resp = FakeResponse("程式碼審查 ✓".encode("utf-8"), encoding="ISO-8859-1", content_type="text/plain") + + assert read_capped_text(resp) == "程式碼審查 ✓" + + def test_a_named_charset_is_used(self): + resp = FakeResponse("héllo".encode("latin-1"), encoding="ISO-8859-1", + content_type='text/plain; charset="ISO-8859-1"') + + assert read_capped_text(resp) == "héllo" + + def test_json_without_a_charset_is_utf8(self): + resp = FakeResponse('{"a": "ü"}'.encode("utf-8"), encoding=None, content_type="application/json") + + assert read_capped_text(resp) == '{"a": "ü"}' From 86f5a550b8452c3b1be888be970692099cd7006c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:47:24 +0800 Subject: [PATCH 171/312] Give an AI answer five minutes in all and the CoT chain a 30 s read timeout --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 14 ++++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../code_review/code_review_thread.py | 2 +- pybreeze/utils/network/http_client.py | 14 +++++++++- test/test_utils/test_http_client.py | 26 +++++++++++++++++++ 7 files changed, 58 insertions(+), 6 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 265f7396..02dbf494 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -344,7 +344,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 暫換成 `public_address()` 回傳的位址)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | -| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | +| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),每來一塊檢查一次,超過丟 `ReadTimeout`:讀取逾時只管每一塊之間,一秒一個位元組的伺服器可以一直拖下去)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | | `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1818 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1821 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index d88b3f1e..32762596 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2419,3 +2419,17 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: network and AI panel tests 72 passed; 1818 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/utils/network/http_client.py`, `test/test_utils/test_http_client.py`, `progress.md` (#70 removed), `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-138 · 2026-09-24 · Give an AI answer five minutes in all, and the CoT chain the 30 s read timeout the rule sets · #fix #ai #network #done + +- **What**: progress #72. The AI requests had per-read timeouts only: `(10, 30)` in the review and Skills panels, and `(10, 60)` in the CoT chain, above the 30 s the Network rule sets for API calls. A server sending one byte every 25 s kept a request going for as long as it liked. Send stayed greyed out in the review and Skills panels, which have no cancel, and closing the panel only handed the thread to `let_run_out`, where it ran on. +- **Fix**: + - `read_capped_text` takes `max_seconds` (default `DEFAULT_MAX_READ_SECONDS`, 300). It checks the time as each chunk arrives and raises `requests.exceptions.ReadTimeout` past it, so the callers' existing handling reports "the request timed out". The read timeout is the most it can overrun by. + - The CoT chain's read timeout is 30 s. +- **Tests**: `test_http_client.py` +3: + - A body trickling in a chunk every 25 s is cut off at the deadline, and the response is closed. + - A timely answer is read whole. + - The deadline reads as a timeout to the user. +- **Result / numbers**: network and CoT tests 37 passed; 1821 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/network/http_client.py`, `pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py`, `test/test_utils/test_http_client.py`, `progress.md` (#72 removed), `architecture_explore.md` §8, §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index ed5d2ff9..083b2c42 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-138 | 2026-09-24 | Give an AI answer five minutes in all, and the CoT chain the 30 s read timeout the rule sets | #fix #ai #network #done | [2026-09](2026-09.md) | | U-20260923-137 | 2026-09-24 | Decode an AI answer by the charset its Content-Type names, UTF-8 otherwise, not Latin-1 | #fix #ai #network #done | [2026-09](2026-09.md) | | U-20260923-136 | 2026-09-24 | Show AI review answers as text, cut long error pages, save review totals in one step, and name only a redirect's host | #fix #ai #security | [2026-09](2026-09.md) | | U-20260923-135 | 2026-09-24 | Read the final response of curl -i output in the response inspector, and a one-line body as a body | #fix #tools #done | [2026-09](2026-09.md) | @@ -218,4 +219,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 155 | +| [2026-09.md](2026-09.md) | 2026-09 | 156 | diff --git a/progress.md b/progress.md index e08ade05..bd88343b 100644 --- a/progress.md +++ b/progress.md @@ -13,7 +13,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? - **#71** A pinned connection dials only the first address the host resolves to (`url_validation.public_address`, used by `public_http`): a host with a broken IPv6 route, or a first A record that is down, fails after the connect timeout although another checked address would work. -- **#72** The AI requests have per-read timeouts only: a server sending a byte every 25 s keeps a request (and the panel's greyed-out Send) going for as long as it likes; the CoT chain's read timeout is 60 s where the rule says 30 s (`code_review_thread.py`, `ai_code_review_gui.py`, `skills_send_gui.py`, `http_client.read_capped_text`). - **#73** A valid international domain name is refused as ambiguous: `url_validation._as_ascii` uses Python's IDNA 2003 codec (`ß` → `ss`) while urllib3 encodes with IDNA 2008 (`straße.de` → `xn--strae-oqa.de`). - **#74** The regex tester's `spawn` worker re-imports the launching script: started from the README's `from pybreeze import start_editor; start_editor()` script, every pattern opens a second IDE and then "times out", blamed on the pattern (`utils/regex_tools/regex_tester.py`). Closing the IDE during a run leaves the worker process running (`os._exit` skips multiprocessing's cleanup; `regex_gui.py`). - **#75** Query→JSON and the URL builder crash on a lone-surrogate escape (`"\ud83d"`: `UnicodeEncodeError` escapes the slot and the stale output stays savable), and the JSON formatter drops it; the two converters also rewrite numbers through `float` (`1E3` → `1000.0`, `1e400` → `inf`, `NaN` accepted) (`query_convert.py`, `url_convert.py`, `json_process.py`). 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 00c1fd6a..c49e009a 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 @@ -68,7 +68,7 @@ def _ask(self, session: requests.Session, file: str, prompt: str) -> tuple[str, # 傳送到指定 URL(重用 session 連線) resp = session.post( self.url, json={"prompt": prompt}, - timeout=(CONNECT_TIMEOUT, 60), allow_redirects=False, stream=True, + timeout=(CONNECT_TIMEOUT, 30), allow_redirects=False, stream=True, ) body = read_capped_text(resp) except (requests.RequestException, ResponseTooLargeError) as error: diff --git a/pybreeze/utils/network/http_client.py b/pybreeze/utils/network/http_client.py index 7a45d251..30ddb4c8 100644 --- a/pybreeze/utils/network/http_client.py +++ b/pybreeze/utils/network/http_client.py @@ -8,6 +8,7 @@ """ from __future__ import annotations +import time from email.message import Message import requests @@ -16,6 +17,10 @@ DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024 # 16 MB DISPLAY_TRUNCATE_CHARS = 2000 +# The longest a whole answer may take to arrive. The read timeout bounds each +# wait for a chunk only: a server sending a byte every 25 s kept a request, and +# the panel's greyed-out Send, going for as long as it liked. +DEFAULT_MAX_READ_SECONDS = 300 _CHUNK_SIZE = 65536 # Seconds to wait for the TCP/TLS connection to establish. Use it as a literal @@ -34,6 +39,7 @@ def read_capped_text( *, max_bytes: int = DEFAULT_MAX_RESPONSE_BYTES, default_encoding: str = "utf-8", + max_seconds: float = DEFAULT_MAX_READ_SECONDS, ) -> str: """Read a streamed response body up to *max_bytes* and decode it to text. @@ -43,12 +49,18 @@ def read_capped_text( response is always closed before returning. The charset the response names in its ``Content-Type`` is used when Python knows it, and *default_encoding* otherwise: a server can name anything, and an unknown one would raise - ``LookupError`` here. + ``LookupError`` here. Raises ``requests.exceptions.ReadTimeout`` once the + body has taken more than *max_seconds* (checked as each chunk arrives, so + the read timeout is the most it can overrun by). """ total = 0 chunks: list[bytes] = [] + deadline = time.monotonic() + max_seconds try: for chunk in response.iter_content(chunk_size=_CHUNK_SIZE): + if time.monotonic() > deadline: + raise requests.exceptions.ReadTimeout( + f"The response took more than {max_seconds:g} seconds.") if not chunk: continue total += len(chunk) diff --git a/test/test_utils/test_http_client.py b/test/test_utils/test_http_client.py index 0623c345..49f20842 100644 --- a/test/test_utils/test_http_client.py +++ b/test/test_utils/test_http_client.py @@ -121,3 +121,29 @@ def test_json_without_a_charset_is_utf8(self): resp = FakeResponse('{"a": "ü"}'.encode("utf-8"), encoding=None, content_type="application/json") assert read_capped_text(resp) == '{"a": "ü"}' + + +class TestHowLongAnAnswerMayTake: + def test_a_trickle_is_cut_off_at_the_deadline(self, monkeypatch): + # Each chunk came inside the read timeout, so nothing ever stopped it + import requests + + from pybreeze.utils.network import http_client + + clock = iter(range(0, 10_000, 25)) # every chunk 25 s after the last + monkeypatch.setattr(http_client.time, "monotonic", lambda: next(clock)) + resp = FakeResponse(b"x" * 100, chunk=1) + + with pytest.raises(requests.exceptions.ReadTimeout): + read_capped_text(resp, max_seconds=300) + assert resp.closed + + def test_a_timely_answer_is_read_whole(self): + assert read_capped_text(FakeResponse(b"x" * 100, chunk=1), max_seconds=300) == "x" * 100 + + def test_the_deadline_reads_as_a_timeout_to_the_user(self): + import requests + + from pybreeze.utils.network.http_client import describe_request_error + + assert "timed out" in describe_request_error(requests.exceptions.ReadTimeout("slow")) From 722402cb2164cb928d289db11c1a910124f453d2 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:49:57 +0800 Subject: [PATCH 172/312] Try every checked address of a host in turn and check international names as they are looked up --- CLAUDE.md | 2 +- architecture.md | 2 +- architecture_explore.md | 4 +- docs/updates/2026-09.md | 17 ++++++++ docs/updates/README.md | 3 +- progress.md | 2 - pybreeze/utils/network/public_http.py | 35 +++++++++++------ pybreeze/utils/network/url_validation.py | 38 +++++++++++++++--- test/test_utils/test_public_http.py | 49 ++++++++++++++++++++++++ 9 files changed, 128 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ff3ff02..897c322a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,7 +103,7 @@ ruff check pybreeze/ # before committing non-trivia 2. Resolve the hostname and reject private / loopback / link-local / reserved IPs 3. Enforce timeouts (15 s downloads, 30 s API calls) and response size caps (20 MB binary) 4. `allow_redirects=False`, or re-validate every redirect target -5. Connect only to the address checked: send through `public_session()` (requests) or `PublicHTTPHandler` / `PublicHTTPSHandler` (urllib) from `utils/network/public_http.py`, which check again as they connect and connect to that address, so a name that resolves differently after validation (DNS rebinding) gets nowhere private. `test_http_goes_through_public_connections.py` fails on a direct `requests.*` or `urlopen` call +5. Connect only to the address checked: send through `public_session()` (requests) or `PublicHTTPHandler` / `PublicHTTPSHandler` (urllib) from `utils/network/public_http.py`, which check again as they connect and connect only to the addresses checked (each in turn), so a name that resolves differently after validation (DNS rebinding) gets nowhere private. `test_http_goes_through_public_connections.py` fails on a direct `requests.*` or `urlopen` call Reference implementations: `utils/network/url_validation.py` (`validate_url`), `utils/network/public_http.py` (`public_session`), `utils/network/http_client.py` (`read_capped_text`), `diagram_editor/diagram_net_utils.py` (`safe_download_image`). Never pass a user URL to `urlopen()` / `requests.*` unvalidated, and never set `verify=False`. diff --git a/architecture.md b/architecture.md index f94053e5..bc85c052 100644 --- a/architecture.md +++ b/architecture.md @@ -199,7 +199,7 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() process that would otherwise outlive it unseen. - Every outbound request to a user URL passes SSRF validation (`utils/network/url_validation.py`) with timeouts and size caps, and connects through `utils/network/public_http.py`, which connects - only to the address it checks as it connects (§ Security › Network). + only to the addresses it checks as it connects, trying each in turn (§ Security › Network). - SSH uses the interactive host-key policy, never auto-add (§ Security › SSH). - Work that waits on a network — an AI review request, a diagram's image downloads, an SSH connect, an SFTP listing or transfer — runs on a diff --git a/architecture_explore.md b/architecture_explore.md index 02dbf494..1f28e1a4 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -343,7 +343,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | -| `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 暫換成 `public_address()` 回傳的位址)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | +| `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),每來一塊檢查一次,超過丟 `ReadTimeout`:讀取逾時只管每一塊之間,一秒一個位元組的伺服器可以一直拖下去)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | | `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1821 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1825 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 32762596..d1106485 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2433,3 +2433,20 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: network and CoT tests 37 passed; 1821 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/utils/network/http_client.py`, `pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py`, `test/test_utils/test_http_client.py`, `progress.md` (#72 removed), `architecture_explore.md` §8, §12, §18 - **Open items**: none. + +## U-20260923-139 · 2026-09-24 · Try every checked address of a host in turn, and check international names as they are looked up · #fix #network #security #done + +- **What**: progress #71 and #73, in `utils/network/`. + - The pinned connections (`public_http.py`) dialled only the first address `public_address` returned. urllib3's usual loop over every address, IPv6 then IPv4, was lost. A host with AAAA and A records on a machine whose IPv6 route is broken (common behind a VPN), or a host whose first A record is down, failed every request after the 10 s connect timeout, though another address would have answered. + - `url_validation._as_ascii` encoded a Unicode host with Python's own IDNA 2003 codec, which maps `ß` to `ss`, while urllib3 uses IDNA 2008 through the `idna` package. `http://straße.de/` was refused as "names its host ambiguously" (`strasse.de` against `xn--strae-oqa.de`), and so was any name with `ς` or a zero-width joiner. `validate_url` also resolved the Unicode name, so Python looked up `strasse.de`, a different domain from the one connected to. +- **Fix**: + - New `public_addresses(hostname)` returns every checked address in the resolver's order, each once; `public_address` is the first of them. + - `_PinnedConnectionMixin._new_conn` and `_create_public_connection` try the addresses in turn and raise the last failure when none answers. Every address is still checked first, so one blocked address refuses the name. + - `_as_ascii` encodes as urllib3 does (`idna.encode(name.lower(), strict=True, std3_rules=True)`), and `validate_url` resolves that encoded name. +- **Tests**: `test_public_http.py` +4: + - With a dead first address and a live second one, requests and urllib both reach the second. + - A name with one blocked address is still refused, and nothing is sent. + - `straße.de` validates and is looked up as `xn--strae-oqa.de`. +- **Result / numbers**: network tests 37 + 4 passed; 1825 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/network/url_validation.py`, `public_http.py`, `test/test_utils/test_public_http.py`, `progress.md` (#71, #73 removed), `CLAUDE.md` (Network 5), `architecture.md` §7, `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 083b2c42..41113b07 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-139 | 2026-09-24 | Try every checked address of a host in turn, and check international names as they are looked up | #fix #network #security #done | [2026-09](2026-09.md) | | U-20260923-138 | 2026-09-24 | Give an AI answer five minutes in all, and the CoT chain the 30 s read timeout the rule sets | #fix #ai #network #done | [2026-09](2026-09.md) | | U-20260923-137 | 2026-09-24 | Decode an AI answer by the charset its Content-Type names, UTF-8 otherwise, not Latin-1 | #fix #ai #network #done | [2026-09](2026-09.md) | | U-20260923-136 | 2026-09-24 | Show AI review answers as text, cut long error pages, save review totals in one step, and name only a redirect's host | #fix #ai #security | [2026-09](2026-09.md) | @@ -219,4 +220,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 156 | +| [2026-09.md](2026-09.md) | 2026-09 | 157 | diff --git a/progress.md b/progress.md index bd88343b..a328be1b 100644 --- a/progress.md +++ b/progress.md @@ -12,8 +12,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -- **#71** A pinned connection dials only the first address the host resolves to (`url_validation.public_address`, used by `public_http`): a host with a broken IPv6 route, or a first A record that is down, fails after the connect timeout although another checked address would work. -- **#73** A valid international domain name is refused as ambiguous: `url_validation._as_ascii` uses Python's IDNA 2003 codec (`ß` → `ss`) while urllib3 encodes with IDNA 2008 (`straße.de` → `xn--strae-oqa.de`). - **#74** The regex tester's `spawn` worker re-imports the launching script: started from the README's `from pybreeze import start_editor; start_editor()` script, every pattern opens a second IDE and then "times out", blamed on the pattern (`utils/regex_tools/regex_tester.py`). Closing the IDE during a run leaves the worker process running (`os._exit` skips multiprocessing's cleanup; `regex_gui.py`). - **#75** Query→JSON and the URL builder crash on a lone-surrogate escape (`"\ud83d"`: `UnicodeEncodeError` escapes the slot and the stale output stays savable), and the JSON formatter drops it; the two converters also rewrite numbers through `float` (`1E3` → `1000.0`, `1e400` → `inf`, `NaN` accepted) (`query_convert.py`, `url_convert.py`, `json_process.py`). - **#76** The header analyzer reads an indented header block as one folded header, so its checks do not run (`header_analyzer_gui.py` strips only the first line's indent). diff --git a/pybreeze/utils/network/public_http.py b/pybreeze/utils/network/public_http.py index e8827f15..d262fa7b 100644 --- a/pybreeze/utils/network/public_http.py +++ b/pybreeze/utils/network/public_http.py @@ -23,9 +23,9 @@ from requests.adapters import HTTPAdapter from urllib3.connection import HTTPConnection, HTTPSConnection from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool -from urllib3.exceptions import NewConnectionError +from urllib3.exceptions import ConnectTimeoutError, NewConnectionError -from pybreeze.utils.network.url_validation import UnsafeURLError, public_address +from pybreeze.utils.network.url_validation import UnsafeURLError, public_addresses class AddressNotPublicError(OSError): @@ -33,11 +33,12 @@ class AddressNotPublicError(OSError): class _PinnedConnectionMixin: - """A urllib3 connection that opens its socket to the address ``public_address`` returns. + """A urllib3 connection that opens its socket to an address ``public_addresses`` returns. urllib3 connects to ``_dns_host`` and takes ``host`` (TLS server name, - ``Host`` header) from it. It holds the checked address only while the - socket is opened, and the name again once it is. + ``Host`` header) from it. It holds a checked address only while the + socket is opened, and the name again once it is. The checked addresses + are tried in turn; the last failure is raised when none answers. """ _dns_host: str @@ -45,14 +46,20 @@ class _PinnedConnectionMixin: def _new_conn(self) -> socket.socket: name = self._dns_host try: - address = public_address(name) + addresses = public_addresses(name) except UnsafeURLError as error: raise NewConnectionError(self, f"Refused: {error}") from None - self._dns_host = address + failure: Exception | None = None try: - return super()._new_conn() # type: ignore[misc] + for address in addresses: + self._dns_host = address + try: + return super()._new_conn() # type: ignore[misc] + except (NewConnectionError, ConnectTimeoutError) as error: + failure = error finally: self._dns_host = name + raise failure # type: ignore[misc] # public_addresses never returns none class _PinnedHTTPConnection(_PinnedConnectionMixin, HTTPConnection): @@ -93,13 +100,19 @@ def public_session() -> requests.Session: def _create_public_connection(address: tuple[str, int], *args: Any, **kwargs: Any) -> socket.socket: - """``socket.create_connection`` to the checked address of *address*'s host.""" + """``socket.create_connection`` to a checked address of *address*'s host, each tried in turn.""" host, port = address try: - checked = public_address(host) + checked = public_addresses(host) except UnsafeURLError as error: raise AddressNotPublicError(str(error)) from None - return socket.create_connection((checked, port), *args, **kwargs) + failure: OSError | None = None + for candidate in checked: + try: + return socket.create_connection((candidate, port), *args, **kwargs) + except OSError as error: + failure = error + raise failure # type: ignore[misc] # public_addresses never returns none class _PinnedHTTPClientConnection(http.client.HTTPConnection): diff --git a/pybreeze/utils/network/url_validation.py b/pybreeze/utils/network/url_validation.py index 4cd58886..1bf224c6 100644 --- a/pybreeze/utils/network/url_validation.py +++ b/pybreeze/utils/network/url_validation.py @@ -90,10 +90,22 @@ def _check_one_reading(url: str) -> None: def _as_ascii(host: str) -> str: - """*host* as it goes on the wire: urllib3 IDNA-encodes a Unicode name, urlparse does not.""" + """*host* as it goes on the wire: urllib3 IDNA-encodes a Unicode name, urlparse does not. + + Encoded the way urllib3 encodes it (IDNA 2008 through the ``idna`` + package). Python's own ``idna`` codec is IDNA 2003, which maps ``ß`` to + ``ss``: ``straße.de`` became ``strasse.de``, another domain, so a valid + name was refused as ambiguous and the check resolved a name it would not + connect to. A name that cannot be encoded is returned as it is, and then + fails the comparison or the lookup. + """ + if host.isascii(): + return host.lower() try: - return host.encode("idna").decode("ascii") - except UnicodeError: + import idna + return idna.encode(host.lower(), strict=True, std3_rules=True).decode("ascii") + except (ImportError, UnicodeError, ValueError): + # idna.IDNAError is a UnicodeError return host @@ -126,12 +138,23 @@ def validate_url(url: str) -> str: if not hostname: raise UnsafeURLError("URL has no hostname.") - public_address(hostname) + # The name urllib3 will look up, not Python's reading of a Unicode one + public_address(_as_ascii(hostname)) return url def public_address(hostname: str) -> str: - """Resolve *hostname* and return the address to connect to, if every address it has is public. + """The first address :func:`public_addresses` returns for *hostname*.""" + return public_addresses(hostname)[0] + + +def public_addresses(hostname: str) -> list[str]: + """Resolve *hostname* and return the addresses to connect to, if every address it has is public. + + In the order the resolver gave them, each once. A connection tries them in + turn, as a plain one would: dialling only the first, a host with a broken + IPv6 route (common behind a VPN) or a first A record that is down failed + though another address would have answered. ``validate_url`` checks with this, and the connections in ``public_http`` call it again as they connect and connect to the address it returns, so a @@ -151,10 +174,13 @@ def public_address(hostname: str) -> str: if not infos: raise UnsafeURLError(f"Cannot resolve hostname '{hostname}'.") + addresses: list[str] = [] for *_unused, sockaddr in infos: ip = ipaddress.ip_address(sockaddr[0]) if _is_blocked_ip(ip): raise UnsafeURLError( f"Access to non-public address {ip} is blocked." ) - return infos[0][4][0] + if sockaddr[0] not in addresses: + addresses.append(sockaddr[0]) + return addresses diff --git a/test/test_utils/test_public_http.py b/test/test_utils/test_public_http.py index 6399781e..9bbce252 100644 --- a/test/test_utils/test_public_http.py +++ b/test/test_utils/test_public_http.py @@ -200,3 +200,52 @@ def test_a_proxy_on_the_local_network_is_still_used(self, dns, listener): assert reply.read() == b"ok" assert listener.received[0].startswith(b"GET http://rebind.test/i.png HTTP/1.1\r\n") + + +@pytest.fixture() +def two_addresses(monkeypatch): + """``two.test`` answers an address nothing listens on first, then loopback.""" + def getaddrinfo(host, port=None, *args, **kwargs): + if host == "two.test": + return _answer("127.0.0.2", port) + _answer("127.0.0.1", port) + return _real_getaddrinfo(host, port, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", getaddrinfo) + + +class TestEveryCheckedAddressIsTried: + """A plain connection tries each address; the pinned one dialled only the first.""" + + def test_requests_goes_on_to_the_next_address(self, two_addresses, loopback_allowed, listener): + with _session() as session: + response = session.get(f"http://two.test:{listener.port}/", timeout=(3, 3)) + + assert response.text == "ok" + + def test_urllib_goes_on_to_the_next_address(self, two_addresses, loopback_allowed, listener): + opener = urllib.request.build_opener(PublicHTTPHandler()) + + with opener.open(f"http://two.test:{listener.port}/", timeout=3) as response: + assert response.read() == b"ok" + + def test_one_blocked_address_still_refuses_the_name(self, two_addresses, listener): + with _session() as session, pytest.raises(requests.ConnectionError): + session.get(f"http://two.test:{listener.port}/", timeout=(3, 3)) + listener.close() + assert listener.received == [] + + +class TestInternationalNames: + def test_a_name_is_checked_as_it_will_be_looked_up(self, monkeypatch): + # Python's idna codec made straße.de into strasse.de, another domain: + # the URL was refused as ambiguous + looked_up: list = [] + + def getaddrinfo(host, port=None, *args, **kwargs): + looked_up.append(host) + return _answer(_PUBLIC_IP, port) + + monkeypatch.setattr(socket, "getaddrinfo", getaddrinfo) + + assert validate_url("http://straße.de/") == "http://straße.de/" + assert looked_up == ["xn--strae-oqa.de"] From 418b6d1f226ccb1010f8d7da53c361e5b464b1aa Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:54:16 +0800 Subject: [PATCH 173/312] Run regex patterns in a plain worker script, stop it when the tab closes, and keep a running tab's output unsaved --- architecture.md | 5 +- architecture_explore.md | 4 +- docs/updates/2026-09.md | 23 ++++ docs/updates/README.md | 3 +- exe/start_pybreeze.py | 7 +- progress.md | 4 +- pybreeze/__main__.py | 7 +- .../extend_multi_language/extend_english.py | 1 + .../extend_traditional_chinese.py | 1 + pybreeze/pybreeze_ui/tools_gui/regex_gui.py | 17 ++- pybreeze/utils/regex_tools/regex_tester.py | 124 ++++++++++++++++-- test/test_utils/test_regex_gui.py | 26 ++++ test/test_utils/test_regex_tester.py | 72 +++++++++- 13 files changed, 261 insertions(+), 33 deletions(-) diff --git a/architecture.md b/architecture.md index bc85c052..8e3c39a9 100644 --- a/architecture.md +++ b/architecture.md @@ -50,8 +50,9 @@ The layers are presentation (`pybreeze_ui/`), then execution (`extend/`), then f - **CLI**: `python -m pybreeze` (`pybreeze/__main__.py`). No console script is declared. It and `exe/start_pybreeze.py` start the IDE only under `if __name__ == "__main__":` with - `multiprocessing.freeze_support()`: the regex tester runs patterns in a spawned process, which - re-runs the main script (and, in the packaged executable, the executable itself). + `multiprocessing.freeze_support()`: in the packaged executable the regex tester runs patterns in + a spawned process, which re-runs the executable. From source it runs them in a plain worker script + (`python -I -S -c`), so a launch script without the guard is safe. - **Programmatic**: `pybreeze.start_editor(debug_mode=False, theme="dark_amber.xml", **kwargs)`. `debug_mode=True` adds an auto-close timer, which CI uses. - **Main window**: `PyBreezeMainWindow` exposes `tab_widget`, `current_run_code_window` and diff --git a/architecture_explore.md b/architecture_explore.md index 1f28e1a4..392b24e9 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -222,7 +222,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `HashGUI` | `utils/hash_tools/` | 多演算法摘要 | | `QueryJsonGUI` | `utils/query_tools/` | query string ↔ JSON 雙向 | | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | -| `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組。pattern 在另一個行程裡跑(`find_matches_bounded()`,spawn,5 秒後結束掉),分頁用 `RegexMatchThread` 等它:`re` 開始比對後就停不下來,災難性回溯只有整個行程能停 | +| `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組。pattern 在另一個行程裡跑(`find_matches_bounded()`:從原始碼執行時是 `python -I -S -c` 跑一段只用標準函式庫的固定腳本,工作用 JSON 從 stdin 進、結果從 stdout 出,5 秒後 kill;打包版沒有直譯器可用,仍是 multiprocessing spawn),分頁用 `RegexMatchThread` 等它:`re` 開始比對後就停不下來,災難性回溯只有整個行程能停。spawn 會重新匯入啟動 IDE 的腳本,README 那種沒有 `__main__` 防護的腳本會每跑一次就再開一個 IDE。還在跑的 worker 記在 `_RUNNING`,分頁關閉時 `stop_running_workers()` 結束它(IDE 以 `os._exit` 結束,子行程不會跟著走);執行中不能存檔,列到 `MAX_MATCHES` 上限時會註明可能還有更多 | | `HttpStatusGUI` | `utils/http_reference/` | 狀態碼參考,可依碼前綴或描述搜尋 | | `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計,`compare_texts()` 只比對一次、兩者共用同一個 `SequenceMatcher`(diff 照 `difflib.unified_diff` 的格式從它的 grouped opcodes 寫出),在 `DiffThread` 上算、不佔 UI 執行緒(4 萬行要四秒多),比對中按鈕停用、關閉時交給 `let_run_out()`。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | | `JsonFormatGUI` | `utils/json_format/` | 美化 / 壓縮 / 驗證 | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1825 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1830 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index d1106485..bf135be0 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2450,3 +2450,26 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: network tests 37 + 4 passed; 1825 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/utils/network/url_validation.py`, `public_http.py`, `test/test_utils/test_public_http.py`, `progress.md` (#71, #73 removed), `CLAUDE.md` (Network 5), `architecture.md` §7, `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-140 · 2026-09-24 · Run regex patterns in a plain worker script, stop it when the tab closes, and keep a running tab's output from being saved · #fix #tools #done + +- **What**: progress #74, and the Regex parts of #77 (`utils/regex_tools/regex_tester.py`, `tools_gui/regex_gui.py`). + - The worker was a multiprocessing `spawn` child, which re-imports the script the IDE was started from. The README and the getting-started docs show `from pybreeze import start_editor; start_editor()` with no `__main__` guard, so started that way, every pattern opened a second IDE in the child. The parent then waited 5 s, killed it, and blamed the pattern for backtracking. + - Closing the IDE during a run left the worker running: the IDE exits with `os._exit`, which skips multiprocessing's cleanup, and Windows does not end a child with its parent. `(a+)+$` against 60 `a`s and a `b` kept a core busy for as long as the backtracking took. + - Save or Open during a run wrote "Running the pattern…", and a list cut at 1000 matches did not say it was cut. +- **Fix**: + - The worker is this interpreter running a fixed standard-library script (`python -I -S -c`). The pattern, text, flags and match limit go in as JSON on stdin, the matches come back as JSON, and `communicate(timeout=)` kills it at the deadline. + - A packaged build has no interpreter to run a script with, so it keeps the `spawn` child (`_find_in_spawned_process`). Its entry script is guarded and calls `freeze_support()`. + - Live workers of either kind are kept in `_RUNNING`. New `stop_running_workers()` kills them, and the Regex tab calls it from `closeEvent`, which the IDE's close path reaches. + - `test()` clears `_valid_output` before it shows "Running…". A list at the cap `MAX_MATCHES` (now public) says "The first 1000 match(es); there may be more" (new language key `regex_match_count_capped`). + - The comments in `pybreeze/__main__.py` and `exe/start_pybreeze.py` now say the guard matters for the packaged build. +- **Tests**: + - `test_regex_tester.py` +3: + - An unguarded launch script runs once and gets its matches. + - A running worker is stopped by `stop_running_workers` within seconds. + - The packaged build's spawn path finds the same matches. + - The "no process for a malformed pattern" test now watches `subprocess.Popen`. + - `test_regex_gui.py` +2: nothing is savable while a pattern runs; a capped list says so. +- **Result / numbers**: regex tests 37 + 2 passed; 1830 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/regex_tools/regex_tester.py`, `pybreeze/pybreeze_ui/tools_gui/regex_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py`, `pybreeze/__main__.py`, `exe/start_pybreeze.py`, `test/test_utils/test_regex_tester.py`, `test_regex_gui.py`, `progress.md` (#74 removed, #77 narrowed), `architecture.md` §2, `architecture_explore.md` §6, §18 +- **Open items**: #77's Output Save and pasted carriage returns. diff --git a/docs/updates/README.md b/docs/updates/README.md index 41113b07..61106430 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-140 | 2026-09-24 | Run regex patterns in a plain worker script, stop it when the tab closes, and keep a running tab's output from being saved | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-139 | 2026-09-24 | Try every checked address of a host in turn, and check international names as they are looked up | #fix #network #security #done | [2026-09](2026-09.md) | | U-20260923-138 | 2026-09-24 | Give an AI answer five minutes in all, and the CoT chain the 30 s read timeout the rule sets | #fix #ai #network #done | [2026-09](2026-09.md) | | U-20260923-137 | 2026-09-24 | Decode an AI answer by the charset its Content-Type names, UTF-8 otherwise, not Latin-1 | #fix #ai #network #done | [2026-09](2026-09.md) | @@ -220,4 +221,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 157 | +| [2026-09.md](2026-09.md) | 2026-09 | 158 | diff --git a/exe/start_pybreeze.py b/exe/start_pybreeze.py index 2a7e227a..b2360c69 100644 --- a/exe/start_pybreeze.py +++ b/exe/start_pybreeze.py @@ -4,9 +4,10 @@ from pybreeze import start_editor -# Guarded: a tool that runs work in a spawned process (the regex tester) re-runs -# the main script in the child, and an unguarded start_editor() there would open -# a second IDE. freeze_support() does the same job for the packaged executable. +# Guarded: in the packaged executable the regex tester runs its worker as a +# spawned process, which re-runs the main script in the child, and an unguarded +# start_editor() there would open a second IDE; freeze_support() lets the child +# start as a worker. (From source, the worker is a plain script and needs neither.) if __name__ == "__main__": multiprocessing.freeze_support() start_editor() diff --git a/progress.md b/progress.md index a328be1b..c7c7f1f0 100644 --- a/progress.md +++ b/progress.md @@ -12,7 +12,7 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -- **#74** The regex tester's `spawn` worker re-imports the launching script: started from the README's `from pybreeze import start_editor; start_editor()` script, every pattern opens a second IDE and then "times out", blamed on the pattern (`utils/regex_tools/regex_tester.py`). Closing the IDE during a run leaves the worker process running (`os._exit` skips multiprocessing's cleanup; `regex_gui.py`). - **#75** Query→JSON and the URL builder crash on a lone-surrogate escape (`"\ud83d"`: `UnicodeEncodeError` escapes the slot and the stale output stays savable), and the JSON formatter drops it; the two converters also rewrite numbers through `float` (`1E3` → `1000.0`, `1e400` → `inf`, `NaN` accepted) (`query_convert.py`, `url_convert.py`, `json_process.py`). - **#76** The header analyzer reads an indented header block as one folded header, so its checks do not run (`header_analyzer_gui.py` strips only the first line's indent). -- **#77** Regex tab: Save or Open during a run writes "Running the pattern…", and a list cut at 1000 matches does not say so (`regex_gui.py`). Output Save writes the chosen file in place with `write_text` (truncated on a failure, and CRLF where Copy gives LF) (`output_actions.py`). The input tabs cannot keep a pasted `\r`: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`exact_text.py`). +- **#77** Output Save writes the chosen file in place with `write_text` (truncated on a failure, and CRLF where Copy gives LF) (`tools_gui/output_actions.py`). The input tabs cannot keep a pasted ` +`: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). diff --git a/pybreeze/__main__.py b/pybreeze/__main__.py index 2a7e227a..b2360c69 100644 --- a/pybreeze/__main__.py +++ b/pybreeze/__main__.py @@ -4,9 +4,10 @@ from pybreeze import start_editor -# Guarded: a tool that runs work in a spawned process (the regex tester) re-runs -# the main script in the child, and an unguarded start_editor() there would open -# a second IDE. freeze_support() does the same job for the packaged executable. +# Guarded: in the packaged executable the regex tester runs its worker as a +# spawned process, which re-runs the main script in the child, and an unguarded +# start_editor() there would open a second IDE; freeze_support() lets the child +# start as a worker. (From source, the worker is a plain script and needs neither.) if __name__ == "__main__": multiprocessing.freeze_support() start_editor() diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 56ed716b..9a37845c 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -522,6 +522,7 @@ "regex_test_button": "Find matches", "regex_output_label": "Matches:", "regex_match_count": "{count} match(es):", + "regex_match_count_capped": "The first {count} match(es); there may be more:", "regex_running": "Running the pattern…", "regex_no_match": "No matches.", "regex_error": "Regex error: {error}", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 2e2c8656..56eac9a4 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -496,6 +496,7 @@ "regex_test_button": "尋找符合項", "regex_output_label": "符合項:", "regex_match_count": "{count} 個符合:", + "regex_match_count_capped": "前 {count} 個符合,可能還有更多:", "regex_running": "正在執行這個 pattern…", "regex_no_match": "沒有符合項。", "regex_error": "正規表示式錯誤:{error}", diff --git a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py index eefcd987..65fbeb6b 100644 --- a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py @@ -14,7 +14,7 @@ from pybreeze.utils.exception.exceptions import RegexTesterException from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.regex_tools.regex_tester import ( - MatchResult, available_flags, find_matches_bounded + MAX_MATCHES, MatchResult, available_flags, find_matches_bounded, stop_running_workers ) @@ -47,7 +47,11 @@ def build_matches_text(matches: list[MatchResult], no_match_message: str) -> str if not matches: return no_match_message word = language_wrapper.language_word_dict - lines = [word.get("regex_match_count").format(count=len(matches)), ""] + count = word.get("regex_match_count").format(count=len(matches)) + if len(matches) >= MAX_MATCHES: + # The list stops at the cap; it did not say there might be more + count = word.get("regex_match_count_capped").format(count=len(matches)) + lines = [count, ""] for index, match in enumerate(matches, start=1): lines.append(f"[{index}] ({match.start}-{match.end}) {match.matched_text!r}") for group_index, value in enumerate(match.groups, start=1): @@ -125,6 +129,8 @@ def test(self) -> None: return word = language_wrapper.language_word_dict self.test_button.setEnabled(False) + # Not something to save or open: Save during a run wrote this text + self._valid_output = False self.output_edit.setPlainText(word.get("regex_running")) thread = RegexMatchThread( self.pattern_edit.text(), exact_text(self.text_edit), self.selected_flags()) @@ -146,8 +152,13 @@ def _show_error(self, message: str) -> None: language_wrapper.language_word_dict.get("regex_error").format(error=message)) def closeEvent(self, event) -> None: - """Let a pattern still running run out; its process is stopped on its own deadline.""" + """Stop a pattern still running, and let its thread run out. + + The worker process is stopped rather than left to its deadline: the IDE + closes its tabs as it exits, and an exiting IDE does not end it. + """ thread = self._match_thread if thread is not None and thread.isRunning(): let_run_out(thread, thread.matched, thread.failed) + stop_running_workers() super().closeEvent(event) diff --git a/pybreeze/utils/regex_tools/regex_tester.py b/pybreeze/utils/regex_tools/regex_tester.py index 648c357a..9b452e5e 100644 --- a/pybreeze/utils/regex_tools/regex_tester.py +++ b/pybreeze/utils/regex_tools/regex_tester.py @@ -9,8 +9,12 @@ """ from __future__ import annotations +import json import multiprocessing import re +import subprocess # nosec B404 — runs this interpreter on a fixed script, never a shell +import sys +import threading from dataclasses import dataclass, field from pybreeze.utils.exception.exception_tags import ( @@ -21,10 +25,11 @@ ) from pybreeze.utils.exception.exceptions import RegexTesterException from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.utils.subprocess_util import no_window_creationflags # Cap on reported matches, so a pattern that matches everywhere cannot flood # the output. It bounds how many matches are reported, not how long one takes. -_MAX_MATCHES = 1000 +MAX_MATCHES = 1000 # How long a pattern may run, in its own process, before it is stopped. MATCH_TIMEOUT_SECONDS = 5.0 @@ -118,11 +123,53 @@ def find_matches( named_groups=dict(match.groupdict()), ) ) - if len(results) >= _MAX_MATCHES: + if len(results) >= MAX_MATCHES: break return results +# What the worker runs: the standard library only, so it needs nothing from the +# IDE's path. It reads the job as JSON on stdin and writes the matches as JSON. +_WORKER_SCRIPT = """ +import json, re, sys +job = json.loads(sys.stdin.buffer.read().decode("utf-8")) +found = [] +for match in re.compile(job["pattern"], job["flags"]).finditer(job["text"]): + found.append([match.group(0), match.start(), match.end(), list(match.groups()), match.groupdict()]) + if len(found) >= job["limit"]: + break +sys.stdout.buffer.write(json.dumps(found).encode("utf-8")) +""" + +# The workers still running (Popen, or multiprocessing.Process in a packaged +# build), so closing the IDE can stop them: it exits with os._exit, and a child +# process outlives its parent on Windows +_RUNNING: set = set() +_RUNNING_LOCK = threading.Lock() +# How long to wait for a spawned worker to go once it is done or stopped +_JOIN_SECONDS = 2.0 + + +def stop_running_workers() -> None: + """Stop every pattern still running in a worker process.""" + with _RUNNING_LOCK: + running = list(_RUNNING) + for worker in running: + still_running = worker.is_alive() if hasattr(worker, "is_alive") else worker.poll() is None + if still_running: + worker.kill() + + +def _register(worker) -> None: + with _RUNNING_LOCK: + _RUNNING.add(worker) + + +def _unregister(worker) -> None: + with _RUNNING_LOCK: + _RUNNING.discard(worker) + + def find_matches_bounded( pattern: str, text: str, flag_names: list[str] | set[str] | None = None, timeout_seconds: float = MATCH_TIMEOUT_SECONDS) -> list[MatchResult]: @@ -134,6 +181,15 @@ def find_matches_bounded( Only a process can be stopped mid-match. The pattern is compiled here first, so a malformed one is reported without starting anything. + The worker is this interpreter running a fixed script (``-I -S``), not a + multiprocessing ``spawn`` child: that re-imports the script the IDE was + started from, and one without a ``__main__`` guard (the README's own + ``start_editor()`` example) opened a second IDE for every pattern, which + then "timed out". A packaged build has no interpreter to run a script + with (``sys.executable`` is the app), so there the worker is still a + ``spawn`` child, which its guarded entry script and ``freeze_support()`` + handle. + :param pattern: the regular expression :param text: the sample text to search :param flag_names: optional flag names to apply @@ -142,12 +198,60 @@ def find_matches_bounded( :raises RegexTesterException: when the pattern is empty or invalid, or ran too long """ compile_pattern(pattern, flag_names) + if getattr(sys, "frozen", False): + return _find_in_spawned_process(pattern, text, list(flag_names or []), timeout_seconds) + job = json.dumps({"pattern": pattern, "text": text, "flags": build_flags(flag_names or []), + "limit": MAX_MATCHES}).encode("utf-8") + output = _run_worker(job, timeout_seconds) + try: + found = json.loads(output.decode("utf-8")) + except ValueError as error: # it died part-way (killed, or out of memory) + raise _worker_failed(repr(error)) from error + return [MatchResult(matched_text=text_, start=start, end=end, groups=groups, named_groups=named) + for text_, start, end, groups, named in found] + + +def _run_worker(job: bytes, timeout_seconds: float) -> bytes: + """Run the worker on *job*; its output, or ``RegexTesterException`` when it ran too long or failed.""" + try: + process = subprocess.Popen( # nosec B603 — fixed argument list, no shell + [sys.executable, "-I", "-S", "-c", _WORKER_SCRIPT], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + shell=False, creationflags=no_window_creationflags()) + except OSError as error: + raise _worker_failed(repr(error)) from error + _register(process) + try: + output, _ = process.communicate(job, timeout=timeout_seconds) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + message = regex_timeout_error.format(seconds=timeout_seconds) + pybreeze_logger.error(message) + raise RegexTesterException(message) from None + finally: + _unregister(process) + if process.returncode != 0: + raise _worker_failed(f"exit code {process.returncode}") + return output + + +def _worker_failed(detail: str) -> RegexTesterException: + """The error for a worker that stopped without an answer.""" + message = regex_worker_error.format(detail=detail) + pybreeze_logger.error(message) + return RegexTesterException(message) + + +def _find_in_spawned_process(pattern: str, text: str, flag_names: list[str], + timeout_seconds: float) -> list[MatchResult]: + """:func:`find_matches` in a ``spawn`` child stopped after *timeout_seconds* (packaged builds).""" context = multiprocessing.get_context("spawn") receiving, sending = context.Pipe(duplex=False) process = context.Process( - target=_matches_into_pipe, args=(sending, pattern, text, list(flag_names or [])), - daemon=True) + target=_matches_into_pipe, args=(sending, pattern, text, flag_names), daemon=True) process.start() + _register(process) sending.close() try: if not receiving.poll(timeout_seconds): @@ -156,29 +260,23 @@ def find_matches_bounded( raise RegexTesterException(message) kind, value = receiving.recv() except (EOFError, OSError) as error: - message = regex_worker_error.format(detail=repr(error)) - pybreeze_logger.error(message) - raise RegexTesterException(message) from error + raise _worker_failed(repr(error)) from error finally: if process.is_alive(): process.terminate() process.join(timeout=_JOIN_SECONDS) receiving.close() + _unregister(process) if kind == "error": raise RegexTesterException(value) return value -# How long to wait for the worker process to go once it is done or stopped -_JOIN_SECONDS = 2.0 - - def _matches_into_pipe(sending, pattern: str, text: str, flag_names: list[str]) -> None: - """Run :func:`find_matches` in the worker process and send back what happened.""" + """Run :func:`find_matches` in the spawned worker and send back what happened.""" try: sending.send(("matches", find_matches(pattern, text, flag_names))) except RegexTesterException as error: sending.send(("error", str(error))) finally: sending.close() - diff --git a/test/test_utils/test_regex_gui.py b/test/test_utils/test_regex_gui.py index 8323c42e..ad8fd1e5 100644 --- a/test/test_utils/test_regex_gui.py +++ b/test/test_utils/test_regex_gui.py @@ -120,3 +120,29 @@ def test_catastrophic_backtracking_is_stopped_and_reported(self, widget, monkeyp run(widget) assert "still running" in widget.output_edit.toPlainText() + + +class TestWhatTheTabOffersToSave: + def test_nothing_is_saved_while_a_pattern_runs(self, widget): + # Save during a run wrote "Running the pattern..." + widget.pattern_edit.setText(r"\d") + widget.text_edit.setPlainText("a1") + run(widget) + assert widget._valid_output + + widget.pattern_edit.setText("(a+)+$") + widget.text_edit.setPlainText("a" * 60 + "b") + widget.test() + + assert widget._valid_output is False + widget.close() # stops the worker + widget._match_thread.wait(30_000) + + def test_a_list_cut_at_the_cap_says_so(self, app): + from pybreeze.pybreeze_ui.tools_gui.regex_gui import build_matches_text + from pybreeze.utils.regex_tools.regex_tester import MAX_MATCHES, MatchResult + + matches = [MatchResult("a", i, i + 1) for i in range(MAX_MATCHES)] + + assert "may be more" in build_matches_text(matches, "none") + assert "may be more" not in build_matches_text(matches[:3], "none") diff --git a/test/test_utils/test_regex_tester.py b/test/test_utils/test_regex_tester.py index 453a41d4..e5af7424 100644 --- a/test/test_utils/test_regex_tester.py +++ b/test/test_utils/test_regex_tester.py @@ -90,8 +90,8 @@ def test_group_that_did_not_match_is_none(self): def test_match_cap(self): from pybreeze.utils.regex_tools import regex_tester # More potential matches than the cap; result is capped, not unbounded. - text = "a" * (regex_tester._MAX_MATCHES + 50) - assert len(find_matches("a", text)) == regex_tester._MAX_MATCHES + text = "a" * (regex_tester.MAX_MATCHES + 50) + assert len(find_matches("a", text)) == regex_tester.MAX_MATCHES class TestABoundedRun: @@ -107,8 +107,8 @@ def test_a_malformed_pattern_is_reported_before_any_process(self, monkeypatch): from pybreeze.utils.regex_tools import regex_tester monkeypatch.setattr( - regex_tester.multiprocessing, "get_context", - lambda *_a: pytest.fail("a process was started for a pattern that cannot compile")) + regex_tester.subprocess, "Popen", + lambda *_a, **_k: pytest.fail("a process was started for a pattern that cannot compile")) with pytest.raises(RegexTesterException): regex_tester.find_matches_bounded("(", "abc") @@ -142,3 +142,67 @@ def test_groups_nested_too_deep_are_reported(self): with pytest.raises(RegexTesterException): compile_pattern("(" * 5000 + "a" + ")" * 5000) + + +class TestTheWorkerProcess: + def test_an_unguarded_launch_script_runs_once(self, tmp_path): + # A spawn child re-imported it: the README's start_editor() script + # opened a second IDE for every pattern, which then "timed out" + import subprocess + import sys + from pathlib import Path + + root = Path(__file__).resolve().parents[2] + script = tmp_path / "launch.py" + script.write_text( + "import sys\n" + f"sys.path.insert(0, {str(root)!r})\n" + "print('STARTED', flush=True)\n" + "from pybreeze.utils.regex_tools.regex_tester import find_matches_bounded\n" + "print('FOUND', len(find_matches_bounded(r'\\d', 'a1 b2')), flush=True)\n", + encoding="utf-8") + + result = subprocess.run([sys.executable, str(script)], capture_output=True, text=True, + timeout=60, check=False) + + assert result.stdout.count("STARTED") == 1, result.stderr + assert "FOUND 2" in result.stdout + + def test_a_running_pattern_can_be_stopped(self): + # Closing the IDE ends with os._exit, which left the worker running + import threading + import time + + from pybreeze.utils.exception.exceptions import RegexTesterException + from pybreeze.utils.regex_tools import regex_tester + + failures: list = [] + + def run() -> None: + try: + regex_tester.find_matches_bounded("(a+)+$", "a" * 60 + "b", timeout_seconds=60) + except RegexTesterException as error: + failures.append(error) + + worker = threading.Thread(target=run) + worker.start() + deadline = time.monotonic() + 20 + while not regex_tester._RUNNING and time.monotonic() < deadline: + time.sleep(0.05) + started = time.monotonic() + regex_tester.stop_running_workers() + worker.join(20) + + assert failures and time.monotonic() - started < 10 + assert not regex_tester._RUNNING + + def test_the_packaged_builds_spawned_worker_finds_matches(self): + # A packaged build has no interpreter to run a script with; it keeps the + # spawn child. (Setting sys.frozen here would make multiprocessing hand + # this interpreter the frozen app's arguments.) + from pybreeze.utils.regex_tools import regex_tester + + found = regex_tester._find_in_spawned_process(r"\d+", "a1 b22", [], 30) + + assert found == regex_tester.find_matches(r"\d+", "a1 b22") + assert not regex_tester._RUNNING From c7e4926e18c47e30d51d35f006355905baec039f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:56:52 +0800 Subject: [PATCH 174/312] Keep numbers as written in the query and URL tools and handle lone surrogates in them and the JSON formatter --- docs/updates/2026-09.md | 18 ++++++++++ docs/updates/README.md | 3 +- progress.md | 1 - pybreeze/utils/exception/exception_tags.py | 3 ++ pybreeze/utils/json_format/json_process.py | 14 ++++++-- pybreeze/utils/query_tools/query_convert.py | 37 +++++++++++++++++++-- pybreeze/utils/url_tools/url_convert.py | 11 +++--- test/test_utils/test_json_process.py | 15 +++++++++ test/test_utils/test_query_convert.py | 20 +++++++++++ test/test_utils/test_url_convert.py | 20 +++++++++++ 10 files changed, 132 insertions(+), 10 deletions(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index bf135be0..a62b73d2 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2473,3 +2473,21 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: regex tests 37 + 2 passed; 1830 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/utils/regex_tools/regex_tester.py`, `pybreeze/pybreeze_ui/tools_gui/regex_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py`, `pybreeze/__main__.py`, `exe/start_pybreeze.py`, `test/test_utils/test_regex_tester.py`, `test_regex_gui.py`, `progress.md` (#74 removed, #77 narrowed), `architecture.md` §2, `architecture_explore.md` §6, §18 - **Open items**: #77's Output Save and pasted carriage returns. + +## U-20260923-141 · 2026-09-24 · Keep numbers as written in the query and URL tools, refuse lone surrogates there, and keep them escaped in the JSON formatter · #fix #tools #done + +- **What**: progress #75. + - Query→JSON's "to query" (`utils/query_tools/query_convert.py`) and the URL builder (`utils/url_tools/url_convert.py`) parsed JSON with plain `json.loads` and wrote values with `str()`. `1E3` went out as `1000.0`, `1.10` as `1.1`, `12345678901234567890123.0` as `1.2345678901234568e+22`, `1e400` as `inf`, and `NaN` was accepted. The JSON formatter had already been fixed for this (U-20260923-103). + - A lone-surrogate escape (`"\ud83d"`) made `urlencode` raise `UnicodeEncodeError`. Both tabs catch only their own exception, so it escaped the slot, and the previous output stayed on screen and savable. + - The JSON formatter (`utils/json_format/json_process.py`) wrote a lone surrogate out raw with `ensure_ascii=False`, and the text view dropped it: `{"a": "\ud83d"}` came out as `{"a": ""}`. +- **Fix**: + - New `load_json_verbatim()` keeps every number as the text it was written as and refuses `NaN` / `Infinity`; both converters load through it. A port is still checked as a whole number. + - New `encode_pairs()` wraps `urlencode` and turns `UnicodeEncodeError` into the tool's own error (new tag `unencodable_text_error`); the URL builder uses it too. + - The formatter writes any lone surrogate back as its `\uXXXX` escape. A surrogate pair still becomes one character. +- **Tests**: + - `test_query_convert.py` +9: five numbers as written, three constants refused, a lone surrogate refused. + - `test_url_convert.py` +4: numbers as written, a port still a number, NaN refused, a lone surrogate refused. + - `test_json_process.py` +2: reformat and minify keep the escape, and a pair stays one character. +- **Result / numbers**: converter, GUI, fuzz and tag tests 150 passed; 1845 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/query_tools/query_convert.py`, `pybreeze/utils/url_tools/url_convert.py`, `pybreeze/utils/json_format/json_process.py`, `pybreeze/utils/exception/exception_tags.py`, `test/test_utils/test_query_convert.py`, `test_url_convert.py`, `test_json_process.py`, `progress.md` (#75 removed), `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 61106430..7a6016d7 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-141 | 2026-09-24 | Keep numbers as written in the query and URL tools, refuse lone surrogates there, and keep them escaped in the JSON formatter | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-140 | 2026-09-24 | Run regex patterns in a plain worker script, stop it when the tab closes, and keep a running tab's output from being saved | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-139 | 2026-09-24 | Try every checked address of a host in turn, and check international names as they are looked up | #fix #network #security #done | [2026-09](2026-09.md) | | U-20260923-138 | 2026-09-24 | Give an AI answer five minutes in all, and the CoT chain the 30 s read timeout the rule sets | #fix #ai #network #done | [2026-09](2026-09.md) | @@ -221,4 +222,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 158 | +| [2026-09.md](2026-09.md) | 2026-09 | 159 | diff --git a/progress.md b/progress.md index c7c7f1f0..7ea975f5 100644 --- a/progress.md +++ b/progress.md @@ -12,7 +12,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -- **#75** Query→JSON and the URL builder crash on a lone-surrogate escape (`"\ud83d"`: `UnicodeEncodeError` escapes the slot and the stale output stays savable), and the JSON formatter drops it; the two converters also rewrite numbers through `float` (`1E3` → `1000.0`, `1e400` → `inf`, `NaN` accepted) (`query_convert.py`, `url_convert.py`, `json_process.py`). - **#76** The header analyzer reads an indented header block as one folded header, so its checks do not run (`header_analyzer_gui.py` strips only the first line's indent). - **#77** Output Save writes the chosen file in place with `write_text` (truncated on a failure, and CRLF where Copy gives LF) (`tools_gui/output_actions.py`). The input tabs cannot keep a pasted ` `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index 83a780c5..fa16bab8 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -73,6 +73,9 @@ nested_query_value_error: str = ( "a query value must be a string, number, true/false or null, or a list of them") invalid_json_for_query_error: str = "can't parse the input as JSON" +unencodable_text_error: str = ( + "a value holds a character that cannot be written in a URL (a lone surrogate such as \\ud83d)" +) # Regex testing empty_regex_pattern_error: str = "no pattern provided" diff --git a/pybreeze/utils/json_format/json_process.py b/pybreeze/utils/json_format/json_process.py index 97c16f7a..50a3e991 100644 --- a/pybreeze/utils/json_format/json_process.py +++ b/pybreeze/utils/json_format/json_process.py @@ -41,10 +41,20 @@ def hold(self, text: str) -> str: return f"\x00{self._marker}:{len(self._texts) - 1}\x00" def restore(self, serialised: str) -> str: - """*serialised* with every placeholder replaced by its number's text.""" + """*serialised* with every placeholder replaced by its number's text. + + A lone surrogate (``"\\ud83d"`` in the input) is written back as its + escape: with ``ensure_ascii=False`` it went out raw, and the text view + dropped it, so ``{"a": "\\ud83d"}`` came out as ``{"a": ""}``. + """ # How dumps writes a placeholder: quoted, the NUL escaped placeholder = re.compile(rf'"\\u0000{self._marker}:(\d+)\\u0000"') - return placeholder.sub(lambda match: self._texts[int(match.group(1))], serialised) + restored = placeholder.sub(lambda match: self._texts[int(match.group(1))], serialised) + return _LONE_SURROGATE.sub(lambda match: f"\\u{ord(match.group()):04x}", restored) + + +# Outside a string JSON holds no such character, so every one is inside a string +_LONE_SURROGATE = re.compile("[\ud800-\udfff]") def _refuse_constant(name: str) -> None: diff --git a/pybreeze/utils/query_tools/query_convert.py b/pybreeze/utils/query_tools/query_convert.py index eb06f769..c75e77a1 100644 --- a/pybreeze/utils/query_tools/query_convert.py +++ b/pybreeze/utils/query_tools/query_convert.py @@ -17,6 +17,7 @@ invalid_json_for_query_error, invalid_json_object_error, nested_query_value_error, + unencodable_text_error, ) from pybreeze.utils.exception.exceptions import QueryConvertException from pybreeze.utils.logging.logger import pybreeze_logger @@ -97,7 +98,7 @@ def json_to_query(json_text: str) -> str: :raises QueryConvertException: when the input is not valid JSON or not an object """ try: - parsed = json.loads(json_text) + parsed = load_json_verbatim(json_text) # json.JSONDecodeError derives from ValueError; RecursionError is JSON # nested deeper than the parser goes, which escaped the tab's slot except (ValueError, RecursionError) as error: @@ -113,4 +114,36 @@ def json_to_query(json_text: str) -> str: pairs.extend((key, coerce_scalar(item)) for item in value) else: pairs.append((key, coerce_scalar(value))) - return urlencode(pairs) + return encode_pairs(pairs) + + +def encode_pairs(pairs: list[tuple[str, str]]) -> str: + """``urlencode`` *pairs*, refusing text a URL cannot carry. + + :raises QueryConvertException: for a lone surrogate (``"\\ud83d"`` in the + JSON), which UTF-8 cannot encode: the ``UnicodeEncodeError`` escaped the + tab's slot, and the previous output stayed on screen, savable + """ + try: + return urlencode(pairs) + except UnicodeEncodeError as error: + pybreeze_logger.error(unencodable_text_error) + raise QueryConvertException(unencodable_text_error) from error + + +def _refuse_constant(name: str) -> None: + """``NaN`` and ``Infinity`` are Python's extensions, not JSON.""" + raise ValueError(f"{name} is not JSON") + + +def load_json_verbatim(json_text: str) -> object: + """Parse *json_text* with every number kept as the text it was written as. + + Through ``float`` a query value changed: ``1E3`` became ``1000.0``, a long + integer written with a fraction lost its digits, ``1e400`` became ``inf``, + and ``NaN`` was accepted. ``NaN`` and ``Infinity`` are refused. + + :raises ValueError: when it is not JSON + :raises RecursionError: when it is nested deeper than the parser goes + """ + return json.loads(json_text, parse_float=str, parse_int=str, parse_constant=_refuse_constant) diff --git a/pybreeze/utils/url_tools/url_convert.py b/pybreeze/utils/url_tools/url_convert.py index 330108f8..bbae71a9 100644 --- a/pybreeze/utils/url_tools/url_convert.py +++ b/pybreeze/utils/url_tools/url_convert.py @@ -7,7 +7,7 @@ from __future__ import annotations import json -from urllib.parse import SplitResult, urlencode, urlsplit, urlunsplit +from urllib.parse import SplitResult, urlsplit, urlunsplit from pybreeze.utils.exception.exception_tags import ( invalid_json_for_url_error, @@ -17,7 +17,9 @@ ) from pybreeze.utils.exception.exceptions import QueryConvertException, UrlConvertException from pybreeze.utils.logging.logger import pybreeze_logger -from pybreeze.utils.query_tools.query_convert import coerce_scalar, query_round_trips, query_to_dict +from pybreeze.utils.query_tools.query_convert import ( + coerce_scalar, encode_pairs, load_json_verbatim, query_round_trips, query_to_dict, +) _MAX_PORT = 65535 @@ -133,9 +135,9 @@ def _build_query(query: object) -> str: for key, value in query.items(): values = value if isinstance(value, list) else [value] pairs.extend((str(key), coerce_scalar(item)) for item in values) + return encode_pairs(pairs) except QueryConvertException as error: raise UrlConvertException(str(error)) from error - return urlencode(pairs) return str(query) @@ -173,7 +175,8 @@ def json_to_url(json_text: str) -> str: object, or holds a part :func:`build_url` refuses """ try: - components = json.loads(json_text) + # Numbers as written: a query value 1E3 went into the URL as 1000.0 + components = load_json_verbatim(json_text) # RecursionError: JSON nested deeper than the parser goes, which escaped # the tab's slot except (ValueError, RecursionError) as error: diff --git a/test/test_utils/test_json_process.py b/test/test_utils/test_json_process.py index 3e2ffdee..2aa5e01f 100644 --- a/test/test_utils/test_json_process.py +++ b/test/test_utils/test_json_process.py @@ -133,3 +133,18 @@ def test_nan_and_infinity_are_not_json(self, constant): with pytest.raises(ITEJsonException): minify_json(f"[{constant}]") + + +class TestLoneSurrogates: + """Written out raw, the text view dropped them: an escaped lone surrogate came out as "".""" + + def test_reformat_keeps_the_escape(self): + result = reformat_json(r'{"a": "\ud83d"}') + + assert r'"\ud83d"' in result + assert json.loads(result) == {"a": "\ud83d"} + + def test_minify_keeps_the_escape_and_a_pair_stays_one_character(self): + from pybreeze.utils.json_format.json_process import minify_json + + assert minify_json(r'{"a": "\ud83d", "b": "\ud83d\ude00"}') == '{"a":"\\ud83d","b":"\U0001f600"}' diff --git a/test/test_utils/test_query_convert.py b/test/test_utils/test_query_convert.py index e263ea27..718d6805 100644 --- a/test/test_utils/test_query_convert.py +++ b/test/test_utils/test_query_convert.py @@ -99,3 +99,23 @@ def test_json_nested_too_deep_is_reported_not_raised_out_of_the_tab(): # json.loads raises RecursionError, which the tab did not catch with pytest.raises(QueryConvertException): json_to_query("[" * 100000) + + +class TestValuesAsWritten: + @pytest.mark.parametrize(("value", "sent"), [ + ("1E3", "1E3"), ("1.10", "1.10"), ("12345678901234567890123.0", "12345678901234567890123.0"), + ("1e400", "1e400"), ("-0", "-0"), + ]) + def test_a_number_goes_out_as_written(self, value, sent): + # Through float: 1000.0, 1.1, 1.2345678901234568e+22, inf + assert json_to_query(f'{{"v": {value}}}') == f"v={sent}".replace("+", "%2B") + + @pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"]) + def test_python_constants_are_not_json(self, constant): + with pytest.raises(QueryConvertException): + json_to_query(f'{{"v": {constant}}}') + + def test_a_lone_surrogate_is_refused_not_raised(self): + # The UnicodeEncodeError escaped the tab's slot + with pytest.raises(QueryConvertException): + json_to_query('{"q": "\ud83d"}') diff --git a/test/test_utils/test_url_convert.py b/test/test_utils/test_url_convert.py index b02912e1..a1b0ad02 100644 --- a/test/test_utils/test_url_convert.py +++ b/test/test_utils/test_url_convert.py @@ -188,3 +188,23 @@ def test_it_is_kept_as_text_and_rebuilt_unchanged(self, url): def test_one_that_does_is_still_a_dict_to_edit(self): assert json.loads(url_to_json("https://h/p?a=1&b=x+y"))["query"] == {"a": "1", "b": "x y"} + + +class TestQueryValuesAsWritten: + def test_a_number_goes_into_the_url_as_written(self): + url = json_to_url('{"scheme": "http", "host": "h", "query": {"v": 1E3, "p": 1.10}}') + + assert url == "http://h?v=1E3&p=1.10" + + def test_a_port_is_still_a_number(self): + assert json_to_url('{"scheme": "http", "host": "h", "port": 8080}') == "http://h:8080" + with pytest.raises(UrlConvertException): + json_to_url('{"scheme": "http", "host": "h", "port": 8080.5}') + + def test_nan_is_refused(self): + with pytest.raises(UrlConvertException): + json_to_url('{"scheme": "http", "host": "h", "query": {"x": NaN}}') + + def test_a_lone_surrogate_is_refused_not_raised(self): + with pytest.raises(UrlConvertException): + json_to_url('{"scheme": "http", "host": "h", "query": {"q": "\ud83d"}}') From 7481ed467722ec60d0c01d4d5a3f4128be93b48a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:57:03 +0800 Subject: [PATCH 175/312] Record the query, URL and JSON tools' verbatim numbers in the architecture map --- architecture_explore.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 392b24e9..024645d4 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -348,7 +348,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | -| `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯 | +| `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯、落單的 surrogate 寫回 `\uXXXX` 跳脫。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN`(`load_json_verbatim()`),`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | | `file_process/replace_file.py` | `replace_text(path, text, private=False)`:寫到旁邊的 `<名稱>.saving` 再 `os.replace` 過去,失敗時原檔不動、半成品刪掉;`private` 以 `0600` 建立(存金鑰的檔案用);`replace_written(path, write)` 給別人寫的檔案(圖片、SVG):`write` 拿到旁邊的 `<主檔名>.saving<副檔名>`(副檔名不變,看副檔名決定格式的寫入器照樣用),寫完才換上 | | `manager/package_manager/` | `PackageManager`(單例 `package_manager`)持有 `syntax_check_list` | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1830 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1845 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) From 728ee7431590510b220ce06db53047c12adbe1c1 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 02:58:52 +0800 Subject: [PATCH 176/312] Read an indented header block line by line in the header analyzer --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 11 +++++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../tools_gui/header_analyzer_gui.py | 6 +++++- pybreeze/utils/header_tools/header_analyzer.py | 6 +++++- test/test_utils/test_header_analyzer.py | 18 ++++++++++++++++++ test/test_utils/test_header_analyzer_gui.py | 9 +++++++++ 8 files changed, 51 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 024645d4..2178676e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1845 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1848 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index a62b73d2..65da1fbe 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2491,3 +2491,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: converter, GUI, fuzz and tag tests 150 passed; 1845 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/utils/query_tools/query_convert.py`, `pybreeze/utils/url_tools/url_convert.py`, `pybreeze/utils/json_format/json_process.py`, `pybreeze/utils/exception/exception_tags.py`, `test/test_utils/test_query_convert.py`, `test_url_convert.py`, `test_json_process.py`, `progress.md` (#75 removed), `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-142 · 2026-09-24 · Read an indented header block line by line in the header analyzer · #fix #tools #done + +- **What**: progress #76. A header block pasted with an indent (copied from an indented document or a log) was read as one header. The tab stripped the text before parsing, which took the indent off the first line only. Every later line then started with whitespace and was joined to it as an obs-fold continuation (`tools_gui/header_analyzer_gui.py`, `utils/header_tools/header_analyzer.py`). `Content-Type: text/html Server: nginx Set-Cookie: sid=1` came out as one field, and the cookie-flag, banner and missing-security-header checks did not run. +- **Fix**: `parse_headers` removes the block's common indent (`textwrap.dedent`) before reading it, and the tab dedents before it strips. A line indented further than the rest is still a fold. +- **Tests**: + - `test_header_analyzer.py` +2: an indented block gives one field per line, and a deeper-indented line inside it still folds. + - `test_header_analyzer_gui.py` +1: the tab reads an indented paste line by line. +- **Result / numbers**: header tests 46 + 18 passed; 1848 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/header_tools/header_analyzer.py`, `pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py`, `test/test_utils/test_header_analyzer.py`, `test_header_analyzer_gui.py`, `progress.md` (#76 removed), `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 7a6016d7..4641401d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-142 | 2026-09-24 | Read an indented header block line by line in the header analyzer | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-141 | 2026-09-24 | Keep numbers as written in the query and URL tools, refuse lone surrogates there, and keep them escaped in the JSON formatter | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-140 | 2026-09-24 | Run regex patterns in a plain worker script, stop it when the tab closes, and keep a running tab's output from being saved | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-139 | 2026-09-24 | Try every checked address of a host in turn, and check international names as they are looked up | #fix #network #security #done | [2026-09](2026-09.md) | @@ -222,4 +223,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 159 | +| [2026-09.md](2026-09.md) | 2026-09 | 160 | diff --git a/progress.md b/progress.md index 7ea975f5..916aa960 100644 --- a/progress.md +++ b/progress.md @@ -12,6 +12,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -- **#76** The header analyzer reads an indented header block as one folded header, so its checks do not run (`header_analyzer_gui.py` strips only the first line's indent). - **#77** Output Save writes the chosen file in place with `write_text` (truncated on a failure, and CRLF where Copy gives LF) (`tools_gui/output_actions.py`). The input tabs cannot keep a pasted ` `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). diff --git a/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py b/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py index 7e5afd18..b4efd9ac 100644 --- a/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py @@ -7,6 +7,8 @@ """ from __future__ import annotations +import textwrap + from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget from je_editor import language_wrapper @@ -111,7 +113,9 @@ def _clear_analysis(self, message: str) -> None: def analyze(self) -> None: """Analyse the pasted headers and show the report.""" word = language_wrapper.language_word_dict - text = exact_text(self.input_edit).strip() + # Dedented before stripped: stripping first took the indent off the first + # line only, and every other line then read as folded into it + text = textwrap.dedent(exact_text(self.input_edit)).strip() if not text: self._clear_analysis(word.get("header_analyzer_empty_hint")) return diff --git a/pybreeze/utils/header_tools/header_analyzer.py b/pybreeze/utils/header_tools/header_analyzer.py index e49f6112..815e5a97 100644 --- a/pybreeze/utils/header_tools/header_analyzer.py +++ b/pybreeze/utils/header_tools/header_analyzer.py @@ -14,6 +14,7 @@ from __future__ import annotations import re +import textwrap from collections.abc import Callable from dataclasses import dataclass, field @@ -133,7 +134,10 @@ def parse_headers(text: str) -> list[HeaderField]: :return: the headers found, in order, duplicates kept """ fields: list[HeaderField] = [] - for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + # The block's common indent is not folding: a block copied from an indented + # document read as one header folded over every line, and nothing was checked + block = textwrap.dedent(text.replace("\r\n", "\n").replace("\r", "\n")) + for line in block.split("\n"): if not line.strip(): if fields: break # the blank line between the headers and the body diff --git a/test/test_utils/test_header_analyzer.py b/test/test_utils/test_header_analyzer.py index 5ea4fff2..d6fbd277 100644 --- a/test/test_utils/test_header_analyzer.py +++ b/test/test_utils/test_header_analyzer.py @@ -214,3 +214,21 @@ def test_a_tab_folds_too(self): (field,) = parse_headers("X-Long: a\n\tb") assert field.value == "a b" + + +class TestAnIndentedBlock: + def test_every_line_is_its_own_header(self): + # Every line after the first read as folded into it, and nothing was checked + from pybreeze.utils.header_tools.header_analyzer import parse_headers + + fields = parse_headers(" Content-Type: text/html\n Server: nginx\n Set-Cookie: sid=1") + + assert [field.name for field in fields] == ["Content-Type", "Server", "Set-Cookie"] + + def test_a_folded_line_inside_it_still_folds(self): + from pybreeze.utils.header_tools.header_analyzer import parse_headers + + fields = parse_headers(" Content-Security-Policy: default-src 'self';\n script-src 'self'\n Server: x") + + assert [field.name for field in fields] == ["Content-Security-Policy", "Server"] + assert fields[0].value == "default-src 'self'; script-src 'self'" diff --git a/test/test_utils/test_header_analyzer_gui.py b/test/test_utils/test_header_analyzer_gui.py index aa8acb17..6b4b9451 100644 --- a/test/test_utils/test_header_analyzer_gui.py +++ b/test/test_utils/test_header_analyzer_gui.py @@ -191,3 +191,12 @@ def test_report_never_falls_back_to_a_code(self, app): for finding in analysis.findings: assert finding.code not in report assert "secret" not in report.split("== Findings ==")[1] + + +def test_an_indented_paste_is_read_line_by_line(widget): + # The tab stripped the first line's indent only, and the rest read as folded + widget.input_edit.setPlainText(" Content-Type: text/html\n Server: nginx\n Set-Cookie: sid=1") + + widget.analyze() + + assert [field.name for field in widget._analysis.fields] == ["Content-Type", "Server", "Set-Cookie"] From 137e8963484cc20ddf709294711f5ac7b7a021a1 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:00:03 +0800 Subject: [PATCH 177/312] Save tool output by replacing the chosen file in one step and say pasted line endings come back as LF --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 13 ++++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - pybreeze/pybreeze_ui/tools_gui/exact_text.py | 6 ++++- .../pybreeze_ui/tools_gui/output_actions.py | 5 +++- test/test_utils/test_output_actions.py | 26 +++++++++++++++++++ 7 files changed, 52 insertions(+), 6 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 2178676e..253d2111 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -233,7 +233,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - **`tool_tabs.open_tool_tab()`** — 工具之間互相「轉交」:Response Inspector 把狀態碼丟給 HTTP Status、headers 丟給 Header Analyzer、JWT 丟給 JWT Decoder、JSON body 丟給 JSON Format;curl 匯入把 URL 丟給 URL Builder。開新分頁並自動聚焦。 - **`exact_text.exact_text()`** — 讀工具輸入一律走它(`document().toRawText()`,區塊分隔換回 `\n`):`toPlainText()` 是顯示用的,會把不斷行空白(U+00A0)變成空白、U+2028 變成換行,Hash 算的是別的字串、Diff 看不出差別 -- **`output_actions.OutputActions`** — 統一的「複製 / 在編輯器開啟 / 存檔」三顆按鈕,綁在工具的唯讀輸出 `QTextEdit` 上,輸出也經 `exact_text()` 讀(不讓 U+00A0、U+2028 被改掉);副檔名與檔名可傳 callable 動態決定。存檔失敗(唯讀資料夾、被鎖住的檔案)會跳警告,說出檔名與原因 +- **`output_actions.OutputActions`** — 統一的「複製 / 在編輯器開啟 / 存檔」三顆按鈕,綁在工具的唯讀輸出 `QTextEdit` 上,輸出也經 `exact_text()` 讀(不讓 U+00A0、U+2028 被改掉);副檔名與檔名可傳 callable 動態決定。存檔經 `replace_text()` 整檔替換,失敗(唯讀資料夾、被鎖住的檔案、磁碟滿)時原檔不動、會跳警告,說出檔名與原因。Qt 的文字元件留不住貼上的 CR,換行一律讀成 LF --- @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1848 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1849 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 65da1fbe..67a05145 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2502,3 +2502,16 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: header tests 46 + 18 passed; 1848 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/utils/header_tools/header_analyzer.py`, `pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py`, `test/test_utils/test_header_analyzer.py`, `test_header_analyzer_gui.py`, `progress.md` (#76 removed), `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-143 · 2026-09-24 · Save tool output by replacing the chosen file in one step, and say that pasted line endings come back as LF · #fix #tools #done + +- **What**: progress #77. + - The tool tabs' Save (`tools_gui/output_actions.py`) wrote with `Path.write_text`, which empties the chosen file first. A full disk or a locked file part-way through left the file the user had chosen to replace empty or cut short. The project rule is to replace such a file through `replace_text`. + - `exact_text` promised "every character as entered", but no Qt text widget keeps a pasted `\r\n` or `\r`: it is a block break before anything can read it. The Hash and Regex tabs therefore see LF where CRLF was pasted. The widgets cannot hold a lone `\r`, so there is nothing more to keep. +- **Fix**: + - Save goes through `replace_text` (written beside the file, then moved into place); a failure still shows the existing warning. + - `exact_text`'s docstrings say that line endings come back as `\n`. +- **Tests**: `test_output_actions.py` +1: a save whose final move fails leaves the user's file whole and nothing beside it. +- **Result / numbers**: output-action and exact-text tests 19 passed; 1849 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/output_actions.py`, `exact_text.py`, `test/test_utils/test_output_actions.py`, `progress.md` (#77 removed), `architecture_explore.md` §6, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 4641401d..6c0c7a53 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-143 | 2026-09-24 | Save tool output by replacing the chosen file in one step, and say that pasted line endings come back as LF | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-142 | 2026-09-24 | Read an indented header block line by line in the header analyzer | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-141 | 2026-09-24 | Keep numbers as written in the query and URL tools, refuse lone surrogates there, and keep them escaped in the JSON formatter | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-140 | 2026-09-24 | Run regex patterns in a plain worker script, stop it when the tab closes, and keep a running tab's output from being saved | #fix #tools #done | [2026-09](2026-09.md) | @@ -223,4 +224,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 160 | +| [2026-09.md](2026-09.md) | 2026-09 | 161 | diff --git a/progress.md b/progress.md index 916aa960..3a37bfcb 100644 --- a/progress.md +++ b/progress.md @@ -12,5 +12,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -- **#77** Output Save writes the chosen file in place with `write_text` (truncated on a failure, and CRLF where Copy gives LF) (`tools_gui/output_actions.py`). The input tabs cannot keep a pasted ` `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). diff --git a/pybreeze/pybreeze_ui/tools_gui/exact_text.py b/pybreeze/pybreeze_ui/tools_gui/exact_text.py index 7e3bb2d1..7d4a28ce 100644 --- a/pybreeze/pybreeze_ui/tools_gui/exact_text.py +++ b/pybreeze/pybreeze_ui/tools_gui/exact_text.py @@ -5,6 +5,10 @@ hashes, diffs, matches or formats the text then works on something else than the user gave it -- the Hash tool showed the digest of a different string, and Diff called two texts that differ only there identical. + +Line endings are the one thing no Qt text widget keeps: a pasted ``\\r\\n`` or +``\\r`` becomes a block break before anything can read it, so every line comes +back ending in ``\\n``. """ from __future__ import annotations @@ -15,5 +19,5 @@ def exact_text(edit: QTextEdit | QPlainTextEdit) -> str: - """The text in *edit*, every character as entered, lines ending in ``\\n``.""" + """The text in *edit*, every character as entered but the line endings, which are ``\\n``.""" return edit.document().toRawText().replace(_BLOCK_SEPARATOR, "\n") diff --git a/pybreeze/pybreeze_ui/tools_gui/output_actions.py b/pybreeze/pybreeze_ui/tools_gui/output_actions.py index e9588510..036493fb 100644 --- a/pybreeze/pybreeze_ui/tools_gui/output_actions.py +++ b/pybreeze/pybreeze_ui/tools_gui/output_actions.py @@ -18,6 +18,7 @@ from je_editor import language_wrapper from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.utils.file_process.replace_file import replace_text from pybreeze.utils.logging.logger import pybreeze_logger # A value that is either fixed or computed on demand (e.g. depends on a selector) @@ -125,7 +126,9 @@ def save_to_file(self) -> str | None: if not path: return None try: - Path(path).write_text(exact_text(self._output), encoding="utf-8") + # Replaced in one step: write_text emptied the file first, so a + # failure part-way lost the file the user chose to replace + replace_text(Path(path), exact_text(self._output)) except OSError as error: pybreeze_logger.error("output_actions.py save failed: %r", error) # It used to go to the log only, and the user took it as saved. diff --git a/test/test_utils/test_output_actions.py b/test/test_utils/test_output_actions.py index e5f87e71..432b7343 100644 --- a/test/test_utils/test_output_actions.py +++ b/test/test_utils/test_output_actions.py @@ -194,3 +194,29 @@ def test_a_saved_file_keeps_every_character(self, app, tmp_path): assert saved == self._GENERATED ast.parse(saved) parent.deleteLater() + + +def test_a_save_that_fails_leaves_the_file_it_was_replacing(app, tmp_path): + # write_text emptied the chosen file first; a failure part-way lost it + from pybreeze.utils.file_process import replace_file + + parent, output, actions = _make(app) + output.setPlainText("new content") + target = tmp_path / "keep.txt" + target.write_text("the user's file", encoding="utf-8") + + def refuse(*_args): + raise OSError(28, "No space left on device") + + with patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", + return_value=(str(target), ""), + ), patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QMessageBox.warning", + side_effect=lambda *args: None, + ), patch.object(replace_file.os, "replace", side_effect=refuse): + assert actions.save_to_file() is None + + assert target.read_text(encoding="utf-8") == "the user's file" + assert list(tmp_path.iterdir()) == [target] + parent.deleteLater() From 98a6a5c8143fd5690cf3fc7917fea6d5071c5719 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:04:56 +0800 Subject: [PATCH 178/312] Store an accepted SSH host key by replacing known_hosts in one step --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../connect_gui/ssh/ssh_host_key_policy.py | 5 ++++- test/test_utils/test_ssh_host_key_policy.py | 17 +++++++++++++++++ 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 253d2111..66fc15eb 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1849 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1850 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 67a05145..00eb0952 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2515,3 +2515,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: output-action and exact-text tests 19 passed; 1849 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/tools_gui/output_actions.py`, `exact_text.py`, `test/test_utils/test_output_actions.py`, `progress.md` (#77 removed), `architecture_explore.md` §6, §18 - **Open items**: none. + +## U-20260923-144 · 2026-09-24 · Store an accepted SSH host key by replacing known_hosts in one step · #fix #ssh + +- **What**: `_store` (`connect_gui/ssh/ssh_host_key_policy.py`) saved `~/.pybreeze/ssh_known_hosts` with paramiko's `HostKeys.save`, which opens the file for writing and empties it first. A full disk or a locked file part-way through lost every host trusted so far, and each one was asked about again as unknown. Found by searching the package for writes that bypass `replace_file`, after the output-save fix. +- **Fix**: the file is produced through `replace_written`: `HostKeys.save` writes beside it, and the result takes its place only when complete. A failure is still logged, and the connection goes on with the key trusted for this session. +- **Tests**: `test_ssh_host_key_policy.py` +1: with the final move failing, accepting a second host leaves the file exactly as it was, with nothing beside it. +- **Result / numbers**: host key and SSH security tests 15 passed; 1850 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py`, `test/test_utils/test_ssh_host_key_policy.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 6c0c7a53..8fcc6cc8 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-144 | 2026-09-24 | Store an accepted SSH host key by replacing known_hosts in one step | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-143 | 2026-09-24 | Save tool output by replacing the chosen file in one step, and say that pasted line endings come back as LF | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-142 | 2026-09-24 | Read an indented header block line by line in the header analyzer | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-141 | 2026-09-24 | Keep numbers as written in the query and URL tools, refuse lone surrogates there, and keep them escaped in the JSON formatter | #fix #tools #done | [2026-09](2026-09.md) | @@ -224,4 +225,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 161 | +| [2026-09.md](2026-09.md) | 2026-09 | 162 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py index 294a460f..a8d9b664 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py @@ -22,6 +22,7 @@ from PySide6.QtWidgets import QMessageBox from pybreeze.utils.app_dirs import pybreeze_data_dir +from pybreeze.utils.file_process.replace_file import replace_written from pybreeze.utils.logging.logger import pybreeze_logger if TYPE_CHECKING: @@ -81,7 +82,9 @@ def _store(hostname: str, key_type: str, key: paramiko.PKey) -> None: known = _read_known_hosts() known.add(hostname, key_type, key) try: - known.save(str(_known_hosts_path())) + # Replaced in one step: HostKeys.save opens the file for writing, which + # empties it, and a failure part-way lost every host trusted so far + replace_written(_known_hosts_path(), lambda target: known.save(str(target))) except OSError as err: pybreeze_logger.warning( "Failed to persist SSH host key for %s: %s", hostname, err diff --git a/test/test_utils/test_ssh_host_key_policy.py b/test/test_utils/test_ssh_host_key_policy.py index a90a4e92..862ad7e6 100644 --- a/test/test_utils/test_ssh_host_key_policy.py +++ b/test/test_utils/test_ssh_host_key_policy.py @@ -150,3 +150,20 @@ def test_from_the_connecting_thread(self, app): worker.join(1) assert answers == [False] + + +def test_a_store_that_fails_keeps_the_hosts_already_trusted(asked, keys, tmp_path, monkeypatch): + # HostKeys.save emptied the file first: a failure part-way lost every host + from pybreeze.utils.file_process import replace_file + + _meet("first.example", keys[0]) + before = (tmp_path / "ssh_known_hosts").read_text(encoding="utf-8") + + def refuse(*_args): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(replace_file.os, "replace", refuse) + _meet("second.example", keys[1]) + + assert (tmp_path / "ssh_known_hosts").read_text(encoding="utf-8") == before + assert sorted(path.name for path in tmp_path.iterdir()) == ["ssh_known_hosts"] From db76d15810a03dac47f384f1a38ec88291c97596 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:12:32 +0800 Subject: [PATCH 179/312] Free the report mail's notice on the GUI thread, not on the mail thread --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 9 ++++ docs/updates/README.md | 3 +- progress.md | 6 +++ .../process_executor_utils.py | 12 +++-- test/test_utils/test_build_task_process.py | 45 +++++++++++++++++++ 6 files changed, 72 insertions(+), 7 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 66fc15eb..b471d70d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -406,7 +406,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 │ ▲ ├── daemon Thread: stdout readline ┤ ├── daemon Thread: stderr readline ┘ - ├── daemon Thread: pybreeze-report-mail(send_after_test → send_report,結果經 _MailNotice queued signal 寫回執行視窗) + ├── daemon Thread: pybreeze-report-mail(send_after_test → send_report,結果經 _MailNotice queued signal 寫回執行視窗;_MailNotice 掛在 QApplication 底下、送達後 deleteLater,在 GUI 執行緒上刪除) │ ├── QThread: SSHReaderThread ──Signal──► terminal widget ├── QThread: SshConnectThread ──Signal──► shell / file tree(連線+開 shell channel;host key 問題 BlockingQueued 回 UI) @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1850 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1851 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 00eb0952..2f5216db 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2524,3 +2524,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: host key and SSH security tests 15 passed; 1850 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py`, `test/test_utils/test_ssh_host_key_policy.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-145 · 2026-09-24 · Free the report mail's notice on the GUI thread, not on the mail thread · #fix #crash #threading #done + +- **What**: progress #78. `report_mail_hook` (`extend/process_executor/process_executor_utils.py`) made `_MailNotice`, a QObject with no parent, to carry the mail thread's answer to the run window. Once the run window and its executor were gone (closed during a "run and send"), the mail thread's closure held its last reference. The QObject was destroyed on `pybreeze-report-mail` when that thread ended, off the GUI thread: the crash class U-20260923-118 fixed for garbage cycles. A plain reference count reaching zero on a worker is not a cycle, so that fix did not cover it. The review's probe saw the notice destroyed on the mail thread. +- **Fix**: the notice is owned by the application object (C++ parent on the GUI thread), so a Python reference dropped on the mail thread no longer destroys it. Its `told` signal is also connected to its own `deleteLater`. The emit from the mail thread is queued, so the notice is deleted on the GUI thread once its answer has been delivered. +- **Tests**: `test_build_task_process.py` +1: with the send stubbed on a thread, the run window closed and every GUI-side reference dropped, the notice is destroyed on the main thread. +- **Result / numbers**: executor and mail tests 33 passed; 1851 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `test/test_utils/test_build_task_process.py`, `progress.md` (#78 added and removed; #79-#84 added), `architecture_explore.md` §16, §18 +- **Open items**: progress #79-#84 from the same review. diff --git a/docs/updates/README.md b/docs/updates/README.md index 8fcc6cc8..c33384f8 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-145 | 2026-09-24 | Free the report mail's notice on the GUI thread, not on the mail thread | #fix #crash #threading #done | [2026-09](2026-09.md) | | U-20260923-144 | 2026-09-24 | Store an accepted SSH host key by replacing known_hosts in one step | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-143 | 2026-09-24 | Save tool output by replacing the chosen file in one step, and say that pasted line endings come back as LF | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-142 | 2026-09-24 | Read an indented header block line by line in the header analyzer | #fix #tools #done | [2026-09](2026-09.md) | @@ -225,4 +226,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 162 | +| [2026-09.md](2026-09.md) | 2026-09 | 163 | diff --git a/progress.md b/progress.md index 3a37bfcb..3e25fde4 100644 --- a/progress.md +++ b/progress.md @@ -13,3 +13,9 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). +- **#79** Stop and IDE close terminate only the direct child: a run config that starts the program as a grandchild (`go run`, `cargo run`, `dotnet run`, `npm start`) or a web run's browser keeps running with no window (`python_task_process_manager.py`, `file_runner_process.py`, `main_ui.py`). +- **#80** Run output is read with `readline(buffer_size)`: a progress bar that rewinds with `\r`, or a status line printed without a newline, shows nothing until a newline arrives, then shows every step as its own line (`queue_pump.py`, `code_window.py`). +- **#81** "Run folder" starts every file at once: their HTML reports overwrite one another in the shared working directory, and a run's mail can send another run's report (`process_executor_utils.run_dir_files_with_package`, `mail_thunder_setting.py`). +- **#82** Compile-then-run writes the binary beside the source, replacing and later deleting a file of that name, and two runs of one file fight over it (`file_runner_process.py`). +- **#83** Output of non-Python run configs is decoded as UTF-8, while Java and localized compilers write the console code page (MS950 on zh-TW Windows): their non-ASCII text is garbled (`file_runner_process.py`, `queue_pump.py`). +- **#84** Stop during a compile reports "[Compile failed] exit code 1"; a Stop just after the compile succeeds still runs the binary; and compile readers still alive resume and mix into the run's output (`file_runner_process.py`). diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index 47bf4742..914f9437 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -5,7 +5,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from PySide6.QtCore import QObject, Signal +from PySide6.QtCore import QCoreApplication, QObject, Signal from PySide6.QtWidgets import QFileDialog, QMessageBox from je_editor import EditorWidget, language_wrapper @@ -178,10 +178,14 @@ def report_mail_hook(code_window: CodeWindow) -> Callable[[], None]: """ report_path = os.path.abspath(DEFAULT_REPORT_PATH) started = time.time() - # No parent: the mail thread may answer after the window is gone. Its - # queued connection to the window is dropped with the window. - notice = _MailNotice() + # Owned by the application, not the window: the mail thread may answer + # after the window is gone (its queued connection to the window goes with + # the window). Without a C++ owner, the mail thread's closure held the last + # reference, and the QObject was destroyed on that thread when it ended. + # It is deleted on the GUI thread once its answer has been delivered there. + notice = _MailNotice(QCoreApplication.instance()) notice.told.connect(code_window.append_output) + notice.told.connect(notice.deleteLater) def mail_the_report() -> None: send_after_test(report_path, not_before=started, on_done=notice.tell) diff --git a/test/test_utils/test_build_task_process.py b/test/test_utils/test_build_task_process.py index 3c178d86..17190b24 100644 --- a/test/test_utils/test_build_task_process.py +++ b/test/test_utils/test_build_task_process.py @@ -220,3 +220,48 @@ def test_closing_one_window_does_not_skip_the_others(self, qt_app): assert main_window.current_run_code_window == [] assert all(not window.isVisible() for window in windows) + + +def test_the_mail_notice_is_freed_on_the_gui_thread(qt_app, monkeypatch): + # Its last reference was the mail thread's: with the run window gone, the + # QObject was destroyed on that thread, the crash class GC had caused + import threading + import time + import weakref + + from pybreeze.extend.process_executor import process_executor_utils + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + freed_on: list = [] + sent = threading.Event() + + def send_after_test(_path, not_before, on_done): + def send() -> None: + time.sleep(0.2) + on_done(None) + sent.set() + + threading.Thread(target=send, name="mail").start() + + monkeypatch.setattr(process_executor_utils, "send_after_test", send_after_test) + window = CodeWindow() + hook = process_executor_utils.report_mail_hook(window) + notices = [cell.cell_contents for cell in hook.__closure__ + if isinstance(cell.cell_contents, process_executor_utils._MailNotice)] + notices[0].destroyed.connect(lambda *_: freed_on.append(threading.current_thread().name)) + watch = weakref.ref(notices[0]) + del notices + + hook() + del hook + window.close() + window.deleteLater() + del window + assert sent.wait(5) + deadline = time.monotonic() + 5 + while not freed_on and time.monotonic() < deadline: + qt_app.processEvents() + time.sleep(0.01) + + assert freed_on == [threading.main_thread().name] + assert watch() is None or not __import__("shiboken6").isValid(watch()) From bf0c23f5ae6ca9c95cfb049c4679237564e58bed Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:16:49 +0800 Subject: [PATCH 180/312] Show run output as it arrives and let a carriage return redraw the line as a terminal does --- architecture_explore.md | 10 +++--- docs/updates/2026-09.md | 15 ++++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../extend/process_executor/queue_pump.py | 12 +++++-- .../show_code_window/code_window.py | 33 ++++++++++------- test/test_utils/test_code_window.py | 29 ++++++++++++--- test/test_utils/test_queue_pump.py | 35 ++++++++++++++++--- test/test_utils/test_run_output.py | 22 ++++++++++++ 9 files changed, 129 insertions(+), 31 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index b471d70d..ebedbe1c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -93,7 +93,7 @@ Template Method 定義的子行程生命週期: |---|---| | 解譯器解析 | `renew_path()` → 執行視窗帶著 IDE 選定的直譯器(`python_compiler`,由 `build_task_process()` 從主視窗抄過來)就用它;沒選才用 `find_venv_path()` 找 `venv/`、`.venv/`,交給 `check_and_choose_venv()`;找不到時**不拋例外**,直接把錯誤寫進執行視窗並回傳 `False` | | 啟動 | `subprocess.Popen(args, shell=False, stdin=DEVNULL, creationflags=CREATE_NO_WINDOW, env=PYTHONIOENCODING=...)`;`stdin` 不接 IDE 的主控台(腳本 `input()` 立刻拿到 EOF);`Popen` 丟 `OSError`(直譯器不見、命令列超過 Windows 上限)時寫進執行視窗並顯示,不從選單拋出 | -| 讀取 | 兩條 daemon Thread 各自經 `queue_pump.read_stream_into_queue()` 對 stdout / stderr `readline()`,原樣塞進 `Queue`(保留縮排、行尾與空行);**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | +| 讀取 | 兩條 daemon Thread 各自經 `queue_pump.read_stream_into_queue()` 對 stdout / stderr `read1()`(有多少讀多少,不等換行:沒換行的狀態列與用 `\r` 重畫的進度條才會即時出現;沒有 `read1` 的文字串流才逐行讀),原樣塞進 `Queue`(保留縮排、行尾與空行);**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | | 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則,交給 `CodeWindow.append_output()` | | 收尾 | 子行程結束後,pump 照樣每 tick 抽 queue,直到兩條 reader 都讀到 EOF 或寬限(`ReaderGrace`,2 秒,那個 tick 還有輸出就重新起算,最長到結束後 30 秒)用完,UI 執行緒不 join 任何執行緒。`exit_program()`:drain queue(`max_messages=None` 一次抽乾)→ reader 還活著(子行程開的行程還握著管線)就在視窗註明之後的輸出不會顯示 → `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | | 停止 | `stop()`:子行程還在跑就 `terminate()`(只有它本身,它再開的行程不管),之後照一般結束的路徑回報。經 `CodeWindow.stop_runner()` 呼叫;關閉 IDE 時 `PyBreezeMainWindow.closeEvent()` 對每個執行視窗都呼叫一次(經 `_close_guarded()`:一個視窗、分頁或 dock 關閉時丟例外只記錄,其餘照關,JEditor 自己的 `closeEvent` 一定會跑到) | @@ -157,7 +157,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 - `output_queue()` — 每條管線的 queue 最多 `MAX_QUEUED_MESSAGES`(10,000)則;滿了 reader 就等(每 0.2 秒看一次 `keep_reading`),子行程寫管線也跟著等,跑得跟視窗顯示一樣快,像終端機;以前不設上限,印個不停的腳本會一直吃記憶體,按 Stop 後再一口氣全倒進視窗 - `ReaderGrace` / `any_alive()` / `OUTPUT_STILL_HELD_NOTE` — 子行程結束後 reader 還能讀多久(`READER_GRACE_SECONDS = 2.0`,從結束後第一個 tick 起算)。管線要等最後一個握著它的行程結束才會 EOF,子行程開的行程沒轉向輸出時會一直握著;以前兩個執行器在 UI 執行緒上各 join 2 秒,IDE 卡 4 秒還是丟掉之後的輸出。現在由 pump 逐 tick 詢問,時間到就結束執行並在視窗註明 -- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。終端機控制碼(CSI 顏色與游標移動、OSC 標題與超連結)和 tab、換行、`\r` 以外的控制字元先拿掉(`strip_terminal_codes()`),`\r\n` 與單獨的 `\r` 轉成換行;換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 +- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。終端機控制碼(CSI 顏色與游標移動、OSC 標題與超連結)和 tab、換行、`\r` 以外的控制字元先拿掉(`strip_terminal_codes()`),`\r\n` 是換行,單獨的 `\r` 像終端機一樣回到行首、由後面的文字取代這一行(`_insert_rewinding()`;結尾的 `\r` 等下一段來才回捲,跑完的進度條不會被清掉);換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 --- @@ -404,8 +404,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 │ ▲ │ thread-safe Queue │ ▲ - ├── daemon Thread: stdout readline ┤ - ├── daemon Thread: stderr readline ┘ + ├── daemon Thread: stdout read1 ───┤ + ├── daemon Thread: stderr read1 ───┘ ├── daemon Thread: pybreeze-report-mail(send_after_test → send_report,結果經 _MailNotice queued signal 寫回執行視窗;_MailNotice 掛在 QApplication 底下、送達後 deleteLater,在 GUI 執行緒上刪除) │ ├── QThread: SSHReaderThread ──Signal──► terminal widget @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1851 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1855 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 2f5216db..f3cbd47d 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2533,3 +2533,18 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: executor and mail tests 33 passed; 1851 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `test/test_utils/test_build_task_process.py`, `progress.md` (#78 added and removed; #79-#84 added), `architecture_explore.md` §16, §18 - **Open items**: progress #79-#84 from the same review. + +## U-20260923-146 · 2026-09-24 · Show run output as it arrives, and let a carriage return redraw the line as a terminal does · #fix #executor #done + +- **What**: progress #80. The output readers (`extend/process_executor/queue_pump.py`) read with `readline(buffer_size)`, which blocks until a newline or `buffer_size` bytes (1,024,000 for Python runs). Output without a newline (a `print(..., end="", flush=True)` status line, or a progress bar that rewinds with `\r`) showed nothing until the run's next newline. The review's probe saw `0%\r50%\r100%\rdone\r\n`, written over three seconds, arrive as one message at 3.1 s. The run window then turned each `\r` into a line break, so every step of the bar became its own line. +- **Fix**: + - The readers read with `read1` (whatever has arrived); a stream without it (text mode) is read by line as before. The incremental decoder and the held trailing `\r` already dealt with pieces. + - `CodeWindow.append_output` inserts through new `_insert_rewinding`. `\r\n` and `\n` are line breaks, and a lone `\r` goes back to the start of the line, whose next text replaces it. A trailing `\r` waits for what comes next, so a finished bar's last line stays shown. + - `normalize_line_endings`, no longer used, is removed. +- **Tests**: + - `test_queue_pump.py`: two tests check the text read, not one message per line; the bounded-queue test's stream hands out one line per read; +1: pieces are passed on as they come. + - `test_code_window.py`: the two tests that expected a lone `\r` to break the line now expect it to redraw; +2: a Windows line ending is a break, and a bar ending on `\r` stays shown. + - `test_run_output.py` +1, a real child: `working... 50%` shows while it sleeps before printing ` done`. +- **Result / numbers**: executor and run window tests 114 + 1 passed; 1855 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/queue_pump.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `test/test_utils/test_queue_pump.py`, `test_code_window.py`, `test_run_output.py`, `progress.md` (#80 removed), `architecture_explore.md` §4, §16, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c33384f8..57cb94ed 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-146 | 2026-09-24 | Show run output as it arrives, and let a carriage return redraw the line as a terminal does | #fix #executor #done | [2026-09](2026-09.md) | | U-20260923-145 | 2026-09-24 | Free the report mail's notice on the GUI thread, not on the mail thread | #fix #crash #threading #done | [2026-09](2026-09.md) | | U-20260923-144 | 2026-09-24 | Store an accepted SSH host key by replacing known_hosts in one step | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-143 | 2026-09-24 | Save tool output by replacing the chosen file in one step, and say that pasted line endings come back as LF | #fix #tools #done | [2026-09](2026-09.md) | @@ -226,4 +227,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 163 | +| [2026-09.md](2026-09.md) | 2026-09 | 164 | diff --git a/progress.md b/progress.md index 3e25fde4..837aa20b 100644 --- a/progress.md +++ b/progress.md @@ -14,7 +14,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#79** Stop and IDE close terminate only the direct child: a run config that starts the program as a grandchild (`go run`, `cargo run`, `dotnet run`, `npm start`) or a web run's browser keeps running with no window (`python_task_process_manager.py`, `file_runner_process.py`, `main_ui.py`). -- **#80** Run output is read with `readline(buffer_size)`: a progress bar that rewinds with `\r`, or a status line printed without a newline, shows nothing until a newline arrives, then shows every step as its own line (`queue_pump.py`, `code_window.py`). - **#81** "Run folder" starts every file at once: their HTML reports overwrite one another in the shared working directory, and a run's mail can send another run's report (`process_executor_utils.run_dir_files_with_package`, `mail_thunder_setting.py`). - **#82** Compile-then-run writes the binary beside the source, replacing and later deleting a file of that name, and two runs of one file fight over it (`file_runner_process.py`). - **#83** Output of non-Python run configs is decoded as UTF-8, while Java and localized compilers write the console code page (MS950 on zh-TW Windows): their non-ASCII text is garbled (`file_runner_process.py`, `queue_pump.py`). diff --git a/pybreeze/extend/process_executor/queue_pump.py b/pybreeze/extend/process_executor/queue_pump.py index 99622bd1..9c7ca8bf 100644 --- a/pybreeze/extend/process_executor/queue_pump.py +++ b/pybreeze/extend/process_executor/queue_pump.py @@ -68,7 +68,7 @@ def read_stream_into_queue( encoding: str, keep_reading: Callable[[], bool], ) -> None: - """Copy *stream* into *target_queue* line by line, exactly as read. + """Copy *stream* into *target_queue* as it arrives, exactly as read. Runs on a reader thread. Stops when *keep_reading* turns false or the pipe reaches EOF. Stopping on EOF (an empty read) is essential: without it the @@ -76,7 +76,12 @@ def read_stream_into_queue( process exited. Lines keep their leading whitespace and line ending, and blank lines are kept, so the run window shows the output as it was written. - A line longer than *buffer_size* arrives in pieces, and a piece can end + Whatever has arrived is read (``read1``), not a whole line: waiting for + a newline hid a progress bar that rewinds with ``\\r``, and a status line + printed without one, until the run's next newline, and then showed every + step at once. A stream without ``read1`` (text mode) is read by line. + + A piece can end inside a multi-byte character or between the ``\\r`` and ``\\n`` of a line ending. The character is carried over by an incremental decoder, and a trailing ``\\r`` is held for the next piece: decoded on its own, the @@ -85,9 +90,10 @@ def read_stream_into_queue( """ decoder = _decoder_for(encoding) held = "" + read = getattr(stream, "read1", None) or stream.readline while keep_reading(): try: - line = stream.readline(buffer_size) + line = read(buffer_size) except (OSError, ValueError) as error: # The pipe was closed underneath us during shutdown. pybreeze_logger.debug("Output reader stopped: %s", error) diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index ab6613a4..3e06758e 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -21,21 +21,12 @@ MAX_OUTPUT_BLOCKS = 10000 -def normalize_line_endings(text: str) -> str: - """Turn ``\r\n`` and a lone ``\r`` into ``\n``. - - A child on Windows writes ``\r\n``, and a progress bar rewinds its line - with a bare ``\r``; the text widget only breaks lines on ``\n``. - """ - return text.replace("\r\n", "\n").replace("\r", "\n") - - # A terminal's control sequences: CSI (colours, cursor moves: ESC [ ... final # byte), OSC (titles, links: ESC ] ... BEL or ESC \), and any other two-byte # escape _TERMINAL_SEQUENCE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]") # Control characters a text view cannot show: all of C0 but tab, newline and -# carriage return (which normalize_line_endings turns into a line break), and DEL +# carriage return (which _insert_rewinding handles), and DEL _CONTROL_CHARACTER = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") @@ -45,11 +36,29 @@ def strip_terminal_codes(text: str) -> str: A script that colours its output (``colorama``, ``rich``, ``pytest --color=yes``, ``FORCE_COLOR``) filled the window with ``←[31m``; a backspace or form feed showed as a box. Carriage returns are left for - :func:`normalize_line_endings`. + :func:`_insert_rewinding`. """ return _CONTROL_CHARACTER.sub("", _TERMINAL_SEQUENCE.sub("", text)) +def _insert_rewinding(cursor: QTextCursor, text: str, text_format: QTextCharFormat) -> None: + """Insert *text* at *cursor*, a lone ``\\r`` going back to the start of the line. + + As a terminal does: a progress bar that rewinds with ``\\r`` redraws its + line instead of adding one per step. ``\\r\\n`` and ``\\n`` are line breaks. + """ + pieces = text.replace("\r\n", "\n").split("\r") + cursor.insertText(pieces[0], text_format) + for index, piece in enumerate(pieces[1:], start=1): + if not piece and index == len(pieces) - 1: + # A trailing \r waits for what comes next: rewound now, a finished + # progress bar's last line would be erased with nothing to replace it + break + cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock, QTextCursor.MoveMode.KeepAnchor) + cursor.removeSelectedText() + cursor.insertText(piece, text_format) + + class CodeWindow(QWidget): # Emitted when a window closes with nothing left running in it, so the @@ -148,6 +157,6 @@ def append_output(self, text: str, is_error: bool = False, *, own_line: bool = F text_format = QTextCharFormat() color_key = "error_output_color" if is_error else "normal_output_color" text_format.setForeground(actually_color_dict.get(color_key)) - cursor.insertText(normalize_line_endings(strip_terminal_codes(text)), text_format) + _insert_rewinding(cursor, strip_terminal_codes(text), text_format) if follow_output: scroll_bar.setValue(scroll_bar.maximum()) diff --git a/test/test_utils/test_code_window.py b/test/test_utils/test_code_window.py index cf53b1ca..c749b7de 100644 --- a/test/test_utils/test_code_window.py +++ b/test/test_utils/test_code_window.py @@ -90,13 +90,32 @@ def test_indentation_and_blank_lines_are_kept(self, qt_app): assert window.code_result.toPlainText() == "def f():\n return 1\n\n\tdone\n" - def test_windows_and_carriage_return_endings_become_line_breaks(self, qt_app): + def test_a_windows_line_ending_is_a_line_break(self, qt_app): from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow window = CodeWindow() - window.append_output("one\r\ntwo\r 50%\r100%\n") + window.append_output("one\r\ntwo\r\n") - assert window.code_result.toPlainText() == "one\ntwo\n 50%\n100%\n" + assert window.code_result.toPlainText() == "one\ntwo\n" + + def test_a_lone_carriage_return_redraws_the_line(self, qt_app): + # As a terminal does: a progress bar made one line per step + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("one\n") + for step in [" 0%", "\r 50%", "\r100%", "\rdone\n"]: + window.append_output(step) + + assert window.code_result.toPlainText() == "one\ndone\n" + + def test_a_bar_that_ends_on_a_carriage_return_stays_shown(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output(" 50%\r100%\r") + + assert window.code_result.toPlainText() == "100%" def test_pieces_of_one_line_join_up(self, qt_app): from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow @@ -281,10 +300,10 @@ def test_what_the_window_shows(self, qt_app, written, shown): assert window.code_result.toPlainText() == shown - def test_a_progress_bars_carriage_return_still_breaks_the_line(self, qt_app): + def test_a_coloured_progress_bar_still_redraws_its_line(self, qt_app): from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow window = CodeWindow() window.append_output("10%\r\x1b[32m20%\x1b[0m\r\n") - assert window.code_result.toPlainText() == "10%\n20%\n" + assert window.code_result.toPlainText() == "20%\n" diff --git a/test/test_utils/test_queue_pump.py b/test/test_utils/test_queue_pump.py index 6668c6bb..5cb93472 100644 --- a/test/test_utils/test_queue_pump.py +++ b/test/test_utils/test_queue_pump.py @@ -76,10 +76,29 @@ def test_is_error_flag_forwarded(self): class TestReadStreamIntoQueue: def test_lines_keep_indentation_blank_lines_and_endings(self): data = b"def f():\n return 1\n\n\tdone\r\n" - assert _read_all(data) == ["def f():\n", " return 1\n", "\n", "\tdone\r\n"] + assert "".join(_read_all(data)) == "def f():\n return 1\n\n\tdone\r\n" def test_bytes_are_decoded_with_replacement(self): - assert _read_all("café\n".encode() + b"\xff\n") == ["café\n", "�\n"] + assert "".join(_read_all("café\n".encode() + b"\xff\n")) == "café\n�\n" + + def test_what_has_arrived_is_passed_on_without_waiting_for_a_newline(self): + # readline waited for one: a progress bar rewinding with \r showed nothing + # until the run's next newline + class Trickle: + def __init__(self) -> None: + self.pieces = [b"0%\r", b"50%\r", b"100%\r", b"done\n"] + + def read1(self, _size: int) -> bytes: + return self.pieces.pop(0) if self.pieces else b"" + + target: Queue = Queue() + read_stream_into_queue(Trickle(), target, buffer_size=1024, encoding="utf-8", keep_reading=lambda: True) + + got = [] + while not target.empty(): + got.append(target.get()) + # Each piece as it came (a trailing \r held for the next one) + assert got == ["0%", "\r50%", "\r100%", "\rdone\n"] def test_stops_when_told_to(self): assert _read_all(b"one\ntwo\n", keep_reading=lambda: False) == [] @@ -145,9 +164,17 @@ def test_the_reader_waits_while_the_queue_is_full(self): from pybreeze.extend.process_executor import queue_pump + class LineByLine: + """A pipe that has one line ready each time it is read.""" + + def __init__(self) -> None: + self.lines = [f"{i}\n".encode() for i in range(10)] + + def read1(self, _size: int) -> bytes: + return self.lines.pop(0) if self.lines else b"" + target: Queue = Queue(maxsize=3) - data = b"".join(f"{i}\n".encode() for i in range(10)) - reader = threading.Thread(target=queue_pump.read_stream_into_queue, args=(io.BytesIO(data), target), + reader = threading.Thread(target=queue_pump.read_stream_into_queue, args=(LineByLine(), target), kwargs={"buffer_size": 1024, "encoding": "utf-8", "keep_reading": lambda: True}) reader.start() reader.join(0.5) diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index c22d11b2..473d4aa8 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -411,3 +411,25 @@ def test_a_runaway_run_holds_a_bounded_backlog_and_stops_promptly(qt_app, tmp_pa runner.stop() _run_events_until(qt_app, lambda: not runner.still_running) assert "[Process exited with code" in window.code_result.toPlainText() + + +def test_a_line_without_its_newline_shows_while_the_run_goes_on(qt_app, tmp_path): + # Read by line, a status line or a progress bar showed nothing until the + # run's next newline + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + (tmp_path / "status.py").write_text( + "import sys, time\n" + "sys.stdout.write('working... 50%'); sys.stdout.flush()\n" + "time.sleep(4)\n" + "print(' done')\n", + encoding="utf-8") + main_window = MainWindow(python_compiler=sys.executable) + build_task_process(main_window).start_module_process( + "status", [], environment={"PYTHONPATH": str(tmp_path)}) + run_window = main_window.current_run_code_window[0] + + _run_events_until(qt_app, lambda: "50%" in run_window.code_result.toPlainText()) + assert "done" not in run_window.code_result.toPlainText() + _run_events_until(qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) + assert "working... 50% done" in run_window.code_result.toPlainText() From 55ef5b9aa9fd0c4f93f0598dea04b77fddd61d59 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:19:36 +0800 Subject: [PATCH 181/312] Delete folders with read-only files whole, links as links, and keep new names inside their folder in the file tree --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 18 ++++ docs/updates/README.md | 3 +- progress.md | 4 + .../extend_multi_language/extend_english.py | 1 + .../extend_traditional_chinese.py | 1 + .../editor_main/file_tree_context_menu.py | 89 +++++++++++++++++-- .../test_utils/test_file_tree_context_menu.py | 70 +++++++++++++++ 8 files changed, 181 insertions(+), 9 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index ebedbe1c..c74b597e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -73,7 +73,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE - `add_menu_to_menubar()` — 建構全部選單(見 §5) - `syntax_extend_package()` — 註冊 `.json` / `.yml` / `.yaml` 自動化關鍵字高亮 - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁(`_add_extend_tabs()`:每一個分頁各自建,建不起來的只記 log,不會讓整個 IDE 起不來) - - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器;Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開 + - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器;Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。新增與改名的名稱不能帶磁碟代號、根目錄、`..` 或 `:`,也不能解析到資料夾外(`_inside()`;改名只能是單一名稱)。刪除資料夾用 `remove_folder()`(唯讀檔清掉唯讀屬性再刪,git 的物件檔就是唯讀),符號連結與 junction 只刪連結本身。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) 3. `apply_stylesheet()` 套 qt_material 主題(預設 `dark_amber.xml`) 4. `showMaximized()` → `startup_setting()` → `app.exec()` @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1855 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1867 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index f3cbd47d..23e21fde 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2548,3 +2548,21 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: executor and run window tests 114 + 1 passed; 1855 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/queue_pump.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `test/test_utils/test_queue_pump.py`, `test_code_window.py`, `test_run_output.py`, `progress.md` (#80 removed), `architecture_explore.md` §4, §16, §18 - **Open items**: none. + +## U-20260923-147 · 2026-09-24 · Delete a folder with read-only files whole, delete a link as a link, and keep new names inside their folder in the file tree · #fix #editor #done + +- **What**: progress #85 and #86, the project tree's context menu (`editor_main/file_tree_context_menu.py`). + - Delete used `shutil.rmtree`, which on Windows stops at the first read-only file after removing what came before it. Git makes its objects read-only, so deleting a cloned project removed `HEAD`, `config`, `index` and more, then failed, and left the folder half deleted with a broken repository (reproduced by the review). A link or junction to a folder could not be deleted at all: `rmtree` refuses links. + - New File, New Folder and Rename joined the typed name to the folder unchecked. `/tmp/notes.py` was created as `C:\\tmp\\notes.py`, making `C:\\tmp` on the way, with no message. A rename to `/a.py` moved the file to the drive root, its tab following. A `:` made a drive-relative path or an NTFS stream. +- **Fix**: + - New `remove_folder()` runs `rmtree` with a handler that clears the read-only bit and retries (`onexc` on 3.12+, `onerror` before). + - A symlink or junction (`_is_link`: a reparse point) is removed as a link (`_remove_link`); what it points to is never followed. + - New `_inside()` refuses a name with a drive, a root, `..` or `:`, or one that resolves outside the folder, with a warning (new language key `file_tree_ctx_bad_name`). A rename must also be a single name. A new file in a subfolder (`pkg/module.py`) is still allowed. +- **Tests**: `test_file_tree_context_menu.py` +12: + - Six names that would land outside the folder are refused, and a subfolder path is allowed. + - Three rename targets that are not one name are refused. + - A folder with a read-only git object is removed whole. + - A junction is removed while its target and contents stay (Windows). +- **Result / numbers**: file tree tests 50 passed; 1867 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py`, `test/test_utils/test_file_tree_context_menu.py`, `progress.md` (#85, #86 removed), `architecture_explore.md` §3, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 57cb94ed..e8389b29 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-147 | 2026-09-24 | Delete a folder with read-only files whole, delete a link as a link, and keep new names inside their folder in the file tree | #fix #editor #done | [2026-09](2026-09.md) | | U-20260923-146 | 2026-09-24 | Show run output as it arrives, and let a carriage return redraw the line as a terminal does | #fix #executor #done | [2026-09](2026-09.md) | | U-20260923-145 | 2026-09-24 | Free the report mail's notice on the GUI thread, not on the mail thread | #fix #crash #threading #done | [2026-09](2026-09.md) | | U-20260923-144 | 2026-09-24 | Store an accepted SSH host key by replacing known_hosts in one step | #fix #ssh | [2026-09](2026-09.md) | @@ -227,4 +228,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 164 | +| [2026-09.md](2026-09.md) | 2026-09 | 165 | diff --git a/progress.md b/progress.md index 837aa20b..7826b23b 100644 --- a/progress.md +++ b/progress.md @@ -18,3 +18,7 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#82** Compile-then-run writes the binary beside the source, replacing and later deleting a file of that name, and two runs of one file fight over it (`file_runner_process.py`). - **#83** Output of non-Python run configs is decoded as UTF-8, while Java and localized compilers write the console code page (MS950 on zh-TW Windows): their non-ASCII text is garbled (`file_runner_process.py`, `queue_pump.py`). - **#84** Stop during a compile reports "[Compile failed] exit code 1"; a Stop just after the compile succeeds still runs the binary; and compile readers still alive resume and mix into the run's output (`file_runner_process.py`). +- **#87** Renaming a file with unsaved edits clears its tab's modified mark (`rename_self_tab` in JEditor) while the restarted auto-save waits 5 s: closing the tab in that time loses the edits without asking (`file_tree_context_menu._start_auto_save`). +- **#88** A closed JupyterLab tab's `QWebEngineView` is never deleted (`close_tab` removes the tab without deleting it): each tab opened and closed keeps a Chromium renderer on a dead page until the IDE exits (`jupyter_lab_gui/jupyter_lab_widget.py`). +- **#89** The prthinker settings offer the Gemini, Cohere and Mistral backends but no field for their API keys, and `environment_for` never passes them, so those backends always fail for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). A saved backend, platform or RAG value not in the dialog's lists is shown as the first item and overwritten on the next Save. +- **#90** One malformed plugin in `jeditor_plugins/` stops the IDE from starting: a `PLUGIN_RUN_CONFIG` that is not a dict raises `AttributeError` in the Plugins menu, a `None` name crashes Qt (`addMenu(None)`, an access violation), and mixed `None`/text names make the Run-with list's `sorted` raise (`menu/plugin_menu/`). diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 9a37845c..a60f6890 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -383,6 +383,7 @@ "file_tree_ctx_input_new_name": "New name for '{name}':", "file_tree_ctx_error": "Error", "file_tree_ctx_already_exists": "'{name}' already exists.", + "file_tree_ctx_bad_name": "'{name}' is not a name inside this folder: it may not have a drive, start with a slash, or contain '..' or ':'.", "file_tree_ctx_confirm_delete": "Confirm Delete", "file_tree_ctx_confirm_delete_message": "Are you sure you want to delete '{name}'?", # Diagram Editor — Menu diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 56eac9a4..2dde23bb 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -713,6 +713,7 @@ "file_tree_ctx_input_new_name": "'{name}' 的新名稱:", "file_tree_ctx_error": "錯誤", "file_tree_ctx_already_exists": "'{name}' 已存在。", + "file_tree_ctx_bad_name": "「{name}」不是這個資料夾裡的名稱:不能有磁碟代號、不能以斜線開頭,也不能含有「..」或「:」。", "file_tree_ctx_confirm_delete": "確認刪除", "file_tree_ctx_confirm_delete_message": "確定要刪除 '{name}' 嗎?", # Plugin Menu diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index d0164488..1191eae1 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -2,10 +2,11 @@ import os import shutil +import stat import subprocess import sys from collections.abc import Callable -from pathlib import Path +from pathlib import Path, PureWindowsPath from PySide6.QtCore import Qt, QModelIndex from PySide6.QtGui import QCursor @@ -158,7 +159,9 @@ def _action_new_file(tree_view: QTreeView, path: Path | None) -> None: ) if not ok or not name.strip(): return - new_path = parent / name.strip() + new_path = _inside(tree_view, parent, name.strip()) + if new_path is None: + return if new_path.exists(): QMessageBox.warning( tree_view, @@ -183,7 +186,9 @@ def _action_new_folder(tree_view: QTreeView, path: Path | None) -> None: ) if not ok or not name.strip(): return - new_path = parent / name.strip() + new_path = _inside(tree_view, parent, name.strip()) + if new_path is None: + return if new_path.exists(): QMessageBox.warning( tree_view, @@ -305,7 +310,9 @@ def _action_rename(tree_view: QTreeView, main_window, path: Path | None) -> None ) if not ok or not new_name.strip() or new_name.strip() == path.name: return - target = path.parent / new_name.strip() + target = _inside(tree_view, path.parent, new_name.strip(), single=True) + if target is None: + return # On a case-insensitive filesystem "A.py" already exists when renaming # "a.py" to it -- it is the same file, and a change of case is a rename. if target.exists() and not _is_the_same_file(target, path): @@ -352,8 +359,10 @@ def _action_delete(tree_view: QTreeView, main_window, path: Path | None) -> None _stop_auto_save(editor) def _delete() -> None: - if path.is_dir(): - shutil.rmtree(path) + if _is_link(path): + _remove_link(path) + elif path.is_dir(): + remove_folder(path) else: path.unlink() @@ -398,3 +407,71 @@ def _action_reveal_in_explorer(path: Path | None) -> None: subprocess.Popen(["open", str(target)]) # nosec B603 B607 # nosemgrep # noqa: S603,S607 else: subprocess.Popen(["xdg-open", str(target)]) # nosec B603 B607 # nosemgrep # noqa: S603,S607 + + +def _inside(tree_view: QTreeView, parent: Path, name: str, *, single: bool = False) -> Path | None: + """*parent* / *name*, or ``None`` after saying why *name* cannot go there. + + A name with a drive, a root, ``..`` or a ``:`` (a drive-relative path, or an + NTFS stream) went elsewhere: ``/tmp/notes.py`` was created as + ``C:\\tmp\\notes.py`` and a rename to ``/a.py`` moved the file to the drive + root. With *single*, the name must be one entry (a rename), not a path. + """ + parts = PureWindowsPath(name) + escapes = (parts.drive or parts.root or ":" in name or ".." in parts.parts + or (single and len(parts.parts) != 1)) + target = parent / name + if not escapes: + try: + escapes = not target.resolve().is_relative_to(parent.resolve()) + except OSError: + escapes = True + if not escapes: + return target + word = language_wrapper.language_word_dict + QMessageBox.warning(tree_view, word.get("file_tree_ctx_error"), + word.get("file_tree_ctx_bad_name").format(name=name)) + return None + + +def _is_link(path: Path) -> bool: + """Whether *path* is a symbolic link or a Windows junction (a link either way).""" + if path.is_symlink(): + return True + try: + attributes = getattr(os.lstat(path), "st_file_attributes", 0) + except OSError: + return False + return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)) + + +def _remove_link(path: Path) -> None: + """Remove the link at *path*, never what it points to. + + ``rmtree`` refuses a link to a folder, so one could not be deleted at all. + """ + try: + os.unlink(path) + except (IsADirectoryError, PermissionError): + os.rmdir(path) # a junction, or a directory symlink on an older Windows + + +def _clear_read_only_and_retry(function: Callable[[str], None], path: str, _error: object) -> None: + """``rmtree``'s error handler: make *path* writable and try *function* again.""" + os.chmod(path, stat.S_IWRITE) + function(path) + + +def remove_folder(path: Path) -> None: + """Delete the folder *path* and everything in it, read-only files included. + + ``shutil.rmtree`` stops at the first read-only file on Windows, after + removing what came before it: git makes its objects read-only, so deleting + a cloned project left it half deleted with a broken repository. + + :raises OSError: when something in it still cannot be removed + """ + if sys.version_info >= (3, 12): + shutil.rmtree(path, onexc=_clear_read_only_and_retry) + else: + shutil.rmtree(path, onerror=_clear_read_only_and_retry) diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py index 44354961..4fd19d74 100644 --- a/test/test_utils/test_file_tree_context_menu.py +++ b/test/test_utils/test_file_tree_context_menu.py @@ -426,3 +426,73 @@ def test_setup_leaves_later_tabs_working(self, app): assert index == 0 assert window.tab_widget.count() == 1 placeholder.deleteLater() + + +class TestANameStaysInItsFolder: + """A drive, a root, '..' or ':' put the file elsewhere: /tmp/notes.py became C:\\tmp\\notes.py.""" + + @pytest.mark.parametrize("name", ["/tmp/notes.py", "C:\\x.py", "C:x.py", "..\\up.py", "a/../../up.py", "notes.py:stream"]) + def test_a_new_file_outside_the_folder_is_refused(self, tree, tmp_path, monkeypatch, warnings, name): + folder = tmp_path / "project" + folder.mkdir() + answer(monkeypatch, name) + + _action_new_file(tree, folder) + + assert warnings + assert list(folder.iterdir()) == [] + assert sorted(item.name for item in tmp_path.iterdir()) == ["project"] + + def test_a_new_file_in_a_subfolder_is_still_allowed(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, "pkg/module.py") + + _action_new_file(tree, None) + + assert (tmp_path / "pkg" / "module.py").is_file() + + @pytest.mark.parametrize("name", ["/a.py", "sub/a.py", "..\\a.py"]) + def test_a_rename_is_one_name(self, tree, tmp_path, monkeypatch, warnings, name): + original = tmp_path / "a.py" + original.write_text("x", encoding="utf-8") + answer(monkeypatch, name) + + _action_rename(tree, FakeWindow(), original) + + assert warnings + assert original.is_file() + + +class TestDeletingAFolder: + def test_read_only_files_go_too(self, tmp_path): + # rmtree stopped at the first one: git makes its objects read-only, and + # a cloned project was left half deleted with a broken repository + import stat + + folder = tmp_path / "project" + (folder / ".git" / "objects" / "ab").mkdir(parents=True) + (folder / ".git" / "HEAD").write_text("ref", encoding="utf-8") + locked = folder / ".git" / "objects" / "ab" / "cdef" + locked.write_bytes(b"blob") + os.chmod(locked, stat.S_IREAD) + + ctx.remove_folder(folder) + + assert not folder.exists() + + @pytest.mark.skipif(os.name != "nt", reason="junctions are Windows'") + def test_a_junction_is_removed_and_what_it_points_to_is_kept(self, tree, tmp_path, monkeypatch): + import subprocess + + target = tmp_path / "real" + target.mkdir() + (target / "keep.txt").write_text("keep", encoding="utf-8") + link = tmp_path / "link" + subprocess.run(["cmd", "/c", "mklink", "/J", str(link), str(target)], + check=True, capture_output=True, timeout=30) + confirm(monkeypatch, yes=True) + + _action_delete(tree, FakeWindow(), link) + + assert not link.exists() and not os.path.lexists(link) + assert (target / "keep.txt").read_text(encoding="utf-8") == "keep" + From ff7d87b452a10394d465cd56e5e44fdcd3816315 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:21:08 +0800 Subject: [PATCH 182/312] Keep an edited tab's unsaved mark when the file tree renames its file --- architecture.md | 5 ++-- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 11 ++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../editor_main/file_tree_context_menu.py | 6 +++++ .../test_utils/test_file_tree_context_menu.py | 26 +++++++++++++++++++ test/test_utils/test_jeditor_contract.py | 3 +++ 8 files changed, 53 insertions(+), 6 deletions(-) diff --git a/architecture.md b/architecture.md index 8e3c39a9..4c03e208 100644 --- a/architecture.md +++ b/architecture.md @@ -146,8 +146,9 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() | `actually_color_dict` | `pyside_ui.main_ui.save_settings.user_color_setting_file` | `show_code_window/code_window.py`, `automation_menu/auto_control_menu/build_autocontrol_menu.py` | PyBreeze also relies on `EditorWidget`'s `current_file`, `code_edit`, `file_encoding`, - `line_ending`, `mark_ignore_next_file_change()` and `mark_saved()`, on its private `_file_watcher` - and `_ignore_next_change` and on `CodeEditor`'s `reset_highlighter()`, `load_git_baseline()` and + `line_ending`, `mark_ignore_next_file_change()` and `mark_saved()`, on its private `_file_watcher`, + `_ignore_next_change`, `_is_modified` and `_on_text_changed()` (a rename puts the unsaved mark + back after `rename_self_tab()` clears it) and on `CodeEditor`'s `reset_highlighter()`, `load_git_baseline()` and `start_language_server()` (a rename moves the tab the way `open_an_file` does), and on `language_wrapper`'s `choose_language_dict` serving English and Traditional Chinese from the exported dict objects themselves. Having je_editor export the names in the table is workspace X-17. It diff --git a/architecture_explore.md b/architecture_explore.md index c74b597e..f7cad2a1 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -73,7 +73,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE - `add_menu_to_menubar()` — 建構全部選單(見 §5) - `syntax_extend_package()` — 註冊 `.json` / `.yml` / `.yaml` 自動化關鍵字高亮 - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁(`_add_extend_tabs()`:每一個分頁各自建,建不起來的只記 log,不會讓整個 IDE 起不來) - - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器;Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。新增與改名的名稱不能帶磁碟代號、根目錄、`..` 或 `:`,也不能解析到資料夾外(`_inside()`;改名只能是單一名稱)。刪除資料夾用 `remove_folder()`(唯讀檔清掉唯讀屬性再刪,git 的物件檔就是唯讀),符號連結與 junction 只刪連結本身。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開 + - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器(`rename_self_tab()` 會清掉「未儲存」標記,有未存的編輯就用 `_on_text_changed()` 放回去,否則改名後五秒內關分頁會直接丟掉編輯);Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。新增與改名的名稱不能帶磁碟代號、根目錄、`..` 或 `:`,也不能解析到資料夾外(`_inside()`;改名只能是單一名稱)。刪除資料夾用 `remove_folder()`(唯讀檔清掉唯讀屬性再刪,git 的物件檔就是唯讀),符號連結與 junction 只刪連結本身。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) 3. `apply_stylesheet()` 套 qt_material 主題(預設 `dark_amber.xml`) 4. `showMaximized()` → `startup_setting()` → `app.exec()` @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1867 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1868 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 23e21fde..3786dc92 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2566,3 +2566,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: file tree tests 50 passed; 1867 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py`, `test/test_utils/test_file_tree_context_menu.py`, `progress.md` (#85, #86 removed), `architecture_explore.md` §3, §18 - **Open items**: none. + +## U-20260923-148 · 2026-09-24 · Keep an edited tab's unsaved mark when the file tree renames its file · #fix #editor #done + +- **What**: progress #87. A rename in the file tree (and the restart after a failed rename or delete) ended in `editor.rename_self_tab()` (`editor_main/file_tree_context_menu.py`, `_start_auto_save`). In JEditor that also sets `_is_modified = False` and drops the " *" from the tab. Nothing had been saved, though: `_stop_auto_save` stopped the old save thread without writing, and the new thread waits five seconds before its first write. JEditor's `close_tab` asks about unsaved changes only when `_is_modified` is set, so closing the tab soon after a rename lost the last edits without asking. +- **Fix**: `_start_auto_save` notes whether the tab had unsaved edits before `rename_self_tab()`, and if so puts the mark back through JEditor's `_on_text_changed()`, which sets the flag and the " *" as typing does. +- **Tests**: + - `test_file_tree_context_menu.py`: the stand-in editor clears the mark on `rename_self_tab` as JEditor's does; +1: an edited tab keeps its unsaved mark through a rename. + - `test_jeditor_contract.py`: checks that `rename_self_tab` touches `_is_modified` and that `_on_text_changed` sets it (je_editor 1.0.22 and 1.0.26 both do). +- **Result / numbers**: file tree and contract tests 85 passed; 1868 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_file_tree_context_menu.py`, `test_jeditor_contract.py`, `progress.md` (#87 removed), `architecture.md` §6, `architecture_explore.md` §3, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index e8389b29..ed7e2310 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-148 | 2026-09-24 | Keep an edited tab's unsaved mark when the file tree renames its file | #fix #editor #done | [2026-09](2026-09.md) | | U-20260923-147 | 2026-09-24 | Delete a folder with read-only files whole, delete a link as a link, and keep new names inside their folder in the file tree | #fix #editor #done | [2026-09](2026-09.md) | | U-20260923-146 | 2026-09-24 | Show run output as it arrives, and let a carriage return redraw the line as a terminal does | #fix #executor #done | [2026-09](2026-09.md) | | U-20260923-145 | 2026-09-24 | Free the report mail's notice on the GUI thread, not on the mail thread | #fix #crash #threading #done | [2026-09](2026-09.md) | @@ -228,4 +229,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 165 | +| [2026-09.md](2026-09.md) | 2026-09 | 166 | diff --git a/progress.md b/progress.md index 7826b23b..a10b95f0 100644 --- a/progress.md +++ b/progress.md @@ -18,7 +18,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#82** Compile-then-run writes the binary beside the source, replacing and later deleting a file of that name, and two runs of one file fight over it (`file_runner_process.py`). - **#83** Output of non-Python run configs is decoded as UTF-8, while Java and localized compilers write the console code page (MS950 on zh-TW Windows): their non-ASCII text is garbled (`file_runner_process.py`, `queue_pump.py`). - **#84** Stop during a compile reports "[Compile failed] exit code 1"; a Stop just after the compile succeeds still runs the binary; and compile readers still alive resume and mix into the run's output (`file_runner_process.py`). -- **#87** Renaming a file with unsaved edits clears its tab's modified mark (`rename_self_tab` in JEditor) while the restarted auto-save waits 5 s: closing the tab in that time loses the edits without asking (`file_tree_context_menu._start_auto_save`). - **#88** A closed JupyterLab tab's `QWebEngineView` is never deleted (`close_tab` removes the tab without deleting it): each tab opened and closed keeps a Chromium renderer on a dead page until the IDE exits (`jupyter_lab_gui/jupyter_lab_widget.py`). - **#89** The prthinker settings offer the Gemini, Cohere and Mistral backends but no field for their API keys, and `environment_for` never passes them, so those backends always fail for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). A saved backend, platform or RAG value not in the dialog's lists is shown as the first item and overwritten on the next Save. - **#90** One malformed plugin in `jeditor_plugins/` stops the IDE from starting: a `PLUGIN_RUN_CONFIG` that is not a dict raises `AttributeError` in the Plugins menu, a `None` name crashes Qt (`addMenu(None)`, an access violation), and mixed `None`/text names make the Run-with list's `sorted` raise (`menu/plugin_menu/`). diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index 1191eae1..dd2be7b0 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -286,7 +286,13 @@ def _start_auto_save(editor: EditorWidget, file_path: Path) -> None: editor.code_edit.reset_highlighter() editor.code_edit.load_git_baseline() editor.code_edit.start_language_server() + # rename_self_tab clears the unsaved mark, but nothing was saved: the old + # save thread was stopped without writing and the new one waits before its + # first write, and closing the tab in that time lost the edits unasked + unsaved = bool(getattr(editor, "_is_modified", False)) editor.rename_self_tab() + if unsaved: + editor._on_text_changed() def _is_the_same_file(first: Path, second: Path) -> bool: diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py index 4fd19d74..a9d15dc8 100644 --- a/test/test_utils/test_file_tree_context_menu.py +++ b/test/test_utils/test_file_tree_context_menu.py @@ -105,8 +105,15 @@ def __init__(self, path: str) -> None: self.closed = False self.code_save_thread = None + _is_modified = False + def rename_self_tab(self) -> None: + # As JEditor's: it clears the unsaved mark self.renamed = True + self._is_modified = False + + def _on_text_changed(self) -> None: + self._is_modified = True def close(self) -> bool: self.closed = True @@ -496,3 +503,22 @@ def test_a_junction_is_removed_and_what_it_points_to_is_kept(self, tree, tmp_pat assert not link.exists() and not os.path.lexists(link) assert (target / "keep.txt").read_text(encoding="utf-8") == "keep" + +def test_a_rename_keeps_the_unsaved_mark_of_an_edited_tab(tree, tmp_path, monkeypatch): + # rename_self_tab cleared it while nothing had been saved: closing the tab + # before the new auto-save wrote lost the edits without asking + original = tmp_path / "a.py" + original.write_text("x", encoding="utf-8") + editor = FakeEditor(str(original)) + editor._is_modified = True + monkeypatch.setattr(ctx, "_editors_under", lambda _w, _p: [(editor, original)]) + monkeypatch.setattr( + ctx, "init_new_auto_save_thread", + lambda file_path, widget: setattr(widget, "current_file", file_path)) + answer(monkeypatch, "b.py") + + _action_rename(tree, FakeWindow(), original) + + assert (tmp_path / "b.py").is_file() + assert editor.renamed and editor._is_modified + diff --git a/test/test_utils/test_jeditor_contract.py b/test/test_utils/test_jeditor_contract.py index 65816938..00b79b0c 100644 --- a/test/test_utils/test_jeditor_contract.py +++ b/test/test_utils/test_jeditor_contract.py @@ -147,6 +147,9 @@ def test_a_rename_can_move_what_an_editor_tab_follows_its_file_with(self): source = inspect.getsource(EditorWidget) assert "self._file_watcher = QFileSystemWatcher" in source assert "self._ignore_next_change" in source + # A rename puts the unsaved mark back after rename_self_tab clears it + assert "self._is_modified" in inspect.getsource(EditorWidget.rename_self_tab) + assert "self._is_modified = True" in inspect.getsource(EditorWidget._on_text_changed) code_editor = _internal("je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext", "CodeEditor") for method in ("reset_highlighter", "load_git_baseline", "start_language_server"): assert callable(getattr(code_editor, method, None)), method From 1d645539374a76f90a2a5e6fbb41709f6f6ecc5e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:22:10 +0800 Subject: [PATCH 183/312] Delete a closed JupyterLab tab and its web view --- architecture_explore.md | 4 ++-- docs/updates/2026-09.md | 9 +++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../jupyter_lab_gui/jupyter_lab_widget.py | 4 ++++ test/test_utils/test_jupyter_lifecycle.py | 16 ++++++++++++++++ 6 files changed, 33 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index f7cad2a1..c5377431 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -318,7 +318,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 10. `pybreeze_ui/jupyter_lab_gui/` - `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `choose_python()`(IDE 選定的直譯器優先,其次 venv 的,最後 IDE 自己的)→ `is_jupyter_installed()`(問直譯器 `find_spec('jupyterlab')`,不問 pip;缺就自動裝)→ 啟動 server(`_start_server()`,在 `_process_lock` 裡先看 `_stopped`:`stop()` 之後就不再啟動,即使還在安裝)→ `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)`。server 的輸出寫進暫存檔而不是管線(server 起來後沒人讀管線,緩衝區滿了它會卡在 `write()`);提早結束時錯誤訊息取這個檔案的尾巴。失敗時 `error_occurred` 送的是原因(例外訊息,最多 2,000 字),traceback 只進 log;已經 `stop()`(分頁關了)的失敗不算失敗,只記 debug -- `jupyter_lab_widget.py` — 收到 URL 後用 `QWebEngineView.setUrl()` 載入;失敗時在狀態列顯示「初始化失敗:原因」(純文字、可選取、自動換行);`closeEvent` 一律關掉 server(launcher 執行緒在 lab 載入完就結束了,只停「還在跑的執行緒」等於從不停 server);還在安裝或啟動的 launcher 交給 `let_run_out()`,不在 UI 執行緒等(安裝可能要好幾分鐘),也不用 `blockSignals`(那會連 `finished` 一起擋掉,keeper 永遠放不掉它)。IDE 關閉時 `PyBreezeMainWindow._close_tool_tabs_and_docks()` 會關掉所有非編輯器分頁與 `DestroyDock`,這個 `closeEvent` 才會被呼叫到 +- `jupyter_lab_widget.py` — 設 `WA_DeleteOnClose`:分頁關閉就刪掉(`close_tab` 只移除分頁、不刪 widget,網頁檢視與它的 Chromium renderer 會一直留到 IDE 結束)。收到 URL 後用 `QWebEngineView.setUrl()` 載入;失敗時在狀態列顯示「初始化失敗:原因」(純文字、可選取、自動換行);`closeEvent` 一律關掉 server(launcher 執行緒在 lab 載入完就結束了,只停「還在跑的執行緒」等於從不停 server);還在安裝或啟動的 launcher 交給 `let_run_out()`,不在 UI 執行緒等(安裝可能要好幾分鐘),也不用 `blockSignals`(那會連 `finished` 一起擋掉,keeper 永遠放不掉它)。IDE 關閉時 `PyBreezeMainWindow._close_tool_tabs_and_docks()` 會關掉所有非編輯器分頁與 `DestroyDock`,這個 `closeEvent` 才會被呼叫到 安全前提(CLAUDE.md 已明列):server 只綁 localhost,因此 token/password 刻意留空、`disable_check_xsrf=True` 才能內嵌。`--ServerApp.port_retries=0`:port 被占就直接結束(走「提早結束」的回報),不讓它默默換 port。**不設 `allow_origin`**:loopback 擋不住瀏覽器,開放來源的話使用者逛到的任何網頁都能操作這個沒有 token 的 server。 @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1868 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1869 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 3786dc92..c25dae78 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2577,3 +2577,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: file tree and contract tests 85 passed; 1868 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_file_tree_context_menu.py`, `test_jeditor_contract.py`, `progress.md` (#87 removed), `architecture.md` §6, `architecture_explore.md` §3, §18 - **Open items**: none. + +## U-20260923-149 · 2026-09-24 · Delete a closed JupyterLab tab and its web view · #fix #jupyter #done + +- **What**: progress #88. JEditor's `close_tab` closes the widget and removes its tab, but does not delete it, and `JupyterLabWidget` (`jupyter_lab_gui/jupyter_lab_widget.py`) did not ask to be deleted on close. Its server was stopped, but each closed tab kept its hidden `QWebEngineView`, with the Lab page and the Chromium renderer behind it, loaded against a dead server until the IDE exited. Every tab opened and closed added one more. +- **Fix**: the widget sets `WA_DeleteOnClose`. `close()` then deletes it once control is back in the event loop, after `removeTab`. A launcher thread still installing or starting is held by `let_run_out`, cut off from the tab, as before. +- **Tests**: `test_jupyter_lifecycle.py` +1: closing a tab and delivering deferred deletes destroys its web view. +- **Result / numbers**: JupyterLab tests 19 passed; 1869 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py`, `test/test_utils/test_jupyter_lifecycle.py`, `progress.md` (#88 removed), `architecture_explore.md` §10, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index ed7e2310..2291af81 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-149 | 2026-09-24 | Delete a closed JupyterLab tab and its web view | #fix #jupyter #done | [2026-09](2026-09.md) | | U-20260923-148 | 2026-09-24 | Keep an edited tab's unsaved mark when the file tree renames its file | #fix #editor #done | [2026-09](2026-09.md) | | U-20260923-147 | 2026-09-24 | Delete a folder with read-only files whole, delete a link as a link, and keep new names inside their folder in the file tree | #fix #editor #done | [2026-09](2026-09.md) | | U-20260923-146 | 2026-09-24 | Show run output as it arrives, and let a carriage return redraw the line as a terminal does | #fix #executor #done | [2026-09](2026-09.md) | @@ -229,4 +230,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 166 | +| [2026-09.md](2026-09.md) | 2026-09 | 167 | diff --git a/progress.md b/progress.md index a10b95f0..58af918a 100644 --- a/progress.md +++ b/progress.md @@ -18,6 +18,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#82** Compile-then-run writes the binary beside the source, replacing and later deleting a file of that name, and two runs of one file fight over it (`file_runner_process.py`). - **#83** Output of non-Python run configs is decoded as UTF-8, while Java and localized compilers write the console code page (MS950 on zh-TW Windows): their non-ASCII text is garbled (`file_runner_process.py`, `queue_pump.py`). - **#84** Stop during a compile reports "[Compile failed] exit code 1"; a Stop just after the compile succeeds still runs the binary; and compile readers still alive resume and mix into the run's output (`file_runner_process.py`). -- **#88** A closed JupyterLab tab's `QWebEngineView` is never deleted (`close_tab` removes the tab without deleting it): each tab opened and closed keeps a Chromium renderer on a dead page until the IDE exits (`jupyter_lab_gui/jupyter_lab_widget.py`). - **#89** The prthinker settings offer the Gemini, Cohere and Mistral backends but no field for their API keys, and `environment_for` never passes them, so those backends always fail for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). A saved backend, platform or RAG value not in the dialog's lists is shown as the first item and overwritten on the next Save. - **#90** One malformed plugin in `jeditor_plugins/` stops the IDE from starting: a `PLUGIN_RUN_CONFIG` that is not a dict raises `AttributeError` in the Plugins menu, a `None` name crashes Qt (`addMenu(None)`, an access violation), and mixed `None`/text names make the Run-with list's `sorted` raise (`menu/plugin_menu/`). 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 1e5a7ffb..2654714f 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py @@ -19,6 +19,10 @@ def __init__(self, python_exe: str | None = None): kernels run in it (see ``choose_python``) """ super().__init__() + # Deleted when its tab closes: close_tab removes the tab but keeps the + # widget, and every closed tab kept its web view, and the Chromium + # renderer behind it, on a dead page until the IDE exited + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) layout = QVBoxLayout(self) diff --git a/test/test_utils/test_jupyter_lifecycle.py b/test/test_utils/test_jupyter_lifecycle.py index f5cc885e..12165541 100644 --- a/test/test_utils/test_jupyter_lifecycle.py +++ b/test/test_utils/test_jupyter_lifecycle.py @@ -215,3 +215,19 @@ def test_closing_the_tab_does_not_wait_for_the_launcher(self, app, monkeypatch): assert time.monotonic() < deadline, "the launcher was never let go" app.processEvents() time.sleep(0.01) + + +def test_a_closed_tab_is_deleted_with_its_web_view(app, monkeypatch): + # close_tab removes the tab but keeps the widget: every closed tab kept a + # web view and its renderer until the IDE exited + from PySide6.QtCore import QCoreApplication, QEvent + + monkeypatch.setattr(jupyter_lab_widget, "JupyterLauncherThread", FinishedLauncher) + tab = jupyter_lab_widget.JupyterLabWidget() + gone: list = [] + tab.browser.destroyed.connect(lambda *_: gone.append("browser")) + + tab.close() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + + assert gone == ["browser"] From d6a56f428b36c76a9b5b3278b20d121eef4e9385 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:24:06 +0800 Subject: [PATCH 184/312] Record the prthinker key fields and unlisted saved choices as a decision in progress --- progress.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/progress.md b/progress.md index 58af918a..8721bf3d 100644 --- a/progress.md +++ b/progress.md @@ -18,5 +18,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#82** Compile-then-run writes the binary beside the source, replacing and later deleting a file of that name, and two runs of one file fight over it (`file_runner_process.py`). - **#83** Output of non-Python run configs is decoded as UTF-8, while Java and localized compilers write the console code page (MS950 on zh-TW Windows): their non-ASCII text is garbled (`file_runner_process.py`, `queue_pump.py`). - **#84** Stop during a compile reports "[Compile failed] exit code 1"; a Stop just after the compile succeeds still runs the binary; and compile readers still alive resume and mix into the run's output (`file_runner_process.py`). -- **#89** The prthinker settings offer the Gemini, Cohere and Mistral backends but no field for their API keys, and `environment_for` never passes them, so those backends always fail for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). A saved backend, platform or RAG value not in the dialog's lists is shown as the first item and overwritten on the next Save. +- **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#90** One malformed plugin in `jeditor_plugins/` stops the IDE from starting: a `PLUGIN_RUN_CONFIG` that is not a dict raises `AttributeError` in the Plugins menu, a `None` name crashes Qt (`addMenu(None)`, an access violation), and mixed `None`/text names make the Run-with list's `sorted` raise (`menu/plugin_menu/`). From 6064706804cbba957a7ffdda04fcda0caf449af0 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:26:04 +0800 Subject: [PATCH 185/312] Keep one malformed plugin from stopping the IDE's start --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 15 +++++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../menu/plugin_menu/build_plugin_menu.py | 25 ++++++++++---- .../menu/plugin_menu/build_run_with_menu.py | 31 +++++++++++++---- test/test_utils/test_plugin_menu.py | 33 +++++++++++++++++++ 7 files changed, 96 insertions(+), 16 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index c5377431..a613e1ee 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -197,7 +197,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.4 插件選單 -- **`build_plugin_menu.py`** — 讀 `je_editor.plugins.get_all_plugin_metadata()`,每個插件一個子選單(About + 一個 Run 動作,多個副檔名時一併列在標籤裡,動作直接呼叫 `run_current_file_with()`);另有「Plugin Browser」分頁入口 +- **`build_plugin_menu.py`** — 讀 `je_editor.plugins.get_all_plugin_metadata()`,每個插件一個子選單(About + 一個 Run 動作,多個副檔名時一併列在標籤裡,動作直接呼叫 `run_current_file_with()`);另有「Plugin Browser」分頁入口。插件是第三方程式:不是 dict 的 metadata 或 run config 略過並記 log,名稱經 `plugin_text()` 轉成文字(`addMenu(None)` 會讓 Qt access violation),每個插件的選單各自建、失敗只少它自己那一項 - **`build_run_with_menu.py`** — 讀 `get_all_plugin_run_configs()`,在 Run 選單下加「Run with…」。`run_config_suffixes()` 把插件登記的副檔名正規化成 `Path.suffix` 的樣子(小寫、一個前導點;JEditor 原樣保存,`.R`、`r` 以前永遠比對不上)。`run_current_file_with()` 先經 `save_current_file_for_run()` 存檔(已有檔名的分頁照 JEditor 自己存檔的方式寫:`write_file_with_encoding()` 用分頁的編碼與行尾,寫成功後才 `mark_ignore_next_file_change()` 與 `mark_saved()`;存檔失敗跳警告、不執行;沒檔名的走 JEditor 的另存新檔),再驗副檔名、交給 `FileRunnerProcess`。Plugins 選單的 Run 動作也走這一條 ### 5.5 安裝選單 @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1869 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1872 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index c25dae78..a0f9104a 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2586,3 +2586,18 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: JupyterLab tests 19 passed; 1869 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py`, `test/test_utils/test_jupyter_lifecycle.py`, `progress.md` (#88 removed), `architecture_explore.md` §10, §18 - **Open items**: none. + +## U-20260923-150 · 2026-09-24 · Keep one malformed plugin from stopping the IDE's start: check run configs and names, and build each plugin's entry on its own · #fix #plugins #crash #done + +- **What**: progress #90, the Plugins and Run-with menus (`menu/plugin_menu/`), built while the main window is made. JEditor records a plugin's metadata before it registers the run config, and checks neither. + - A `PLUGIN_RUN_CONFIG` that is not a dict: JEditor catches the error, but the metadata stays, and `run_config_suffixes` raised `AttributeError` in `PyBreezeMainWindow.__init__`. The IDE did not start. + - `"name": None` reached `QMenu.addMenu(None)`, which the review found is an access violation in PySide6, not a Python error. + - Run configs with both a `None` name and a text name made `sorted` raise `TypeError`. +- **Fix**: + - New `plugin_text(value, fallback)` makes a plugin-supplied name, author or version text, with the fallback when there is none. + - New `plugin_run_configs()` keeps the run configs that are dicts, logs the rest, and sorts by name case-insensitively. + - The Plugins menu skips metadata that is not a dict, treats a run config that is not a dict as none (logged), and builds each plugin's entry inside its own guard, so a plugin that still fails costs only its entry. +- **Tests**: `test_plugin_menu.py` +3: a run config that is not a dict leaves a bare entry and the other plugins' entries intact; `None` names get labels and non-dict metadata is skipped; configs named `None` and text sort, with the `None` one labelled "Unknown". +- **Result / numbers**: plugin menu tests 36 passed; 1872 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py`, `build_run_with_menu.py`, `test/test_utils/test_plugin_menu.py`, `progress.md` (#90 removed), `architecture_explore.md` §5, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 2291af81..05ae4441 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-150 | 2026-09-24 | Keep one malformed plugin from stopping the IDE's start: check run configs and names, and build each plugin's entry on its own | #fix #plugins #crash #done | [2026-09](2026-09.md) | | U-20260923-149 | 2026-09-24 | Delete a closed JupyterLab tab and its web view | #fix #jupyter #done | [2026-09](2026-09.md) | | U-20260923-148 | 2026-09-24 | Keep an edited tab's unsaved mark when the file tree renames its file | #fix #editor #done | [2026-09](2026-09.md) | | U-20260923-147 | 2026-09-24 | Delete a folder with read-only files whole, delete a link as a link, and keep new names inside their folder in the file tree | #fix #editor #done | [2026-09](2026-09.md) | @@ -230,4 +231,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 167 | +| [2026-09.md](2026-09.md) | 2026-09 | 168 | diff --git a/progress.md b/progress.md index 8721bf3d..556b0d29 100644 --- a/progress.md +++ b/progress.md @@ -19,4 +19,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#83** Output of non-Python run configs is decoded as UTF-8, while Java and localized compilers write the console code page (MS950 on zh-TW Windows): their non-ASCII text is garbled (`file_runner_process.py`, `queue_pump.py`). - **#84** Stop during a compile reports "[Compile failed] exit code 1"; a Stop just after the compile succeeds still runs the binary; and compile readers still alive resume and mix into the run's output (`file_runner_process.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? -- **#90** One malformed plugin in `jeditor_plugins/` stops the IDE from starting: a `PLUGIN_RUN_CONFIG` that is not a dict raises `AttributeError` in the Plugins menu, a `None` name crashes Qt (`addMenu(None)`, an access violation), and mixed `None`/text names make the Run-with list's `sorted` raise (`menu/plugin_menu/`). diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py index e9ca9359..986a75bf 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py @@ -8,7 +8,10 @@ from je_editor import get_all_plugin_metadata, language_wrapper from je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget import PluginBrowserWidget -from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import run_config_suffixes, run_current_file_with +from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import ( + plugin_text, run_config_suffixes, run_current_file_with, +) +from pybreeze.utils.logging.logger import pybreeze_logger if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -40,15 +43,25 @@ def set_plugin_menu(ui_we_want_to_set: PyBreezeMainWindow) -> None: ui_we_want_to_set.plugin_menu.addSeparator() for meta in metadata_list: - _add_plugin_entry(ui_we_want_to_set, meta) + # One plugin with bad metadata costs its own entry, not the IDE's start + if not isinstance(meta, dict): + pybreeze_logger.error("Plugin metadata ignored: not a dict (%s)", type(meta).__name__) + continue + try: + _add_plugin_entry(ui_we_want_to_set, meta) + except Exception as error: # noqa: BLE001 — a plugin is third-party code; the menu must still build + pybreeze_logger.error("Plugin %r left out of the menu: %r", meta.get("name"), error) def _add_plugin_entry(ui_we_want_to_set: PyBreezeMainWindow, meta: dict) -> None: """Add one plugin's menu entries (submenu with run actions, or a bare About action).""" - plugin_name = meta.get("name", "Unknown") - plugin_author = meta.get("author", "") - plugin_version = meta.get("version", "") + plugin_name = plugin_text(meta.get("name"), "Unknown") + plugin_author = plugin_text(meta.get("author"), "") + plugin_version = plugin_text(meta.get("version"), "") run_config = meta.get("run_config") + if run_config is not None and not isinstance(run_config, dict): + pybreeze_logger.error("Plugin %s run config ignored: not a dict", plugin_name) + run_config = None if run_config is None: # 沒有執行設定的插件(如翻譯插件),只顯示關於 @@ -61,7 +74,7 @@ def _add_plugin_entry(ui_we_want_to_set: PyBreezeMainWindow, meta: dict) -> None return suffixes = run_config_suffixes(run_config) - config_name = run_config.get("name", plugin_name) + config_name = plugin_text(run_config.get("name"), plugin_name) sub_menu = ui_we_want_to_set.plugin_menu.addMenu(config_name) about_action = QAction( diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index bef5ce3e..79245514 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -27,6 +27,28 @@ from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow +def plugin_text(value: object, fallback: str) -> str: + """A plugin-supplied name or label as text, *fallback* when there is none. + + A plugin is anyone's code: a ``None`` name reached ``QMenu.addMenu(None)``, + which is an access violation, not a Python error, and a mix of ``None`` + and text names made sorting the configs raise, both while the IDE started. + """ + text = "" if value is None else str(value).strip() + return text or fallback + + +def plugin_run_configs() -> list[dict]: + """The registered run configs that are dicts, named, in name order; others are logged and skipped.""" + configs = [] + for config in get_all_plugin_run_configs(): + if isinstance(config, dict): + configs.append(config) + else: + pybreeze_logger.error("Plugin run config ignored: not a dict (%s)", type(config).__name__) + return sorted(configs, key=lambda config: plugin_text(config.get("name"), "Unknown").lower()) + + def run_config_suffixes(run_config: dict) -> tuple[str, ...]: """The suffixes a plugin's run config accepts, as ``Path.suffix`` gives them: lower case, with the dot. @@ -117,7 +139,7 @@ def run_current_file_with(main_window: PyBreezeMainWindow, run_config: dict) -> # JEditor does not check what a plugin registers: a config without a # name raised KeyError here, after the file had been saved. code_window = open_run_window( - main_window, f"{run_config.get('name', 'Run')} - {Path(file_path).name}") + main_window, f"{plugin_text(run_config.get('name'), 'Run')} - {Path(file_path).name}") code_window.runner = FileRunnerProcess( main_window=code_window, @@ -128,19 +150,16 @@ def run_current_file_with(main_window: PyBreezeMainWindow, run_config: dict) -> def set_run_with_menu(ui_we_want_to_set: PyBreezeMainWindow) -> None: """Build the 'Run with...' submenu. Only creates if configs exist.""" - configs = get_all_plugin_run_configs() + configs = plugin_run_configs() if not configs: return - # 依名稱排序 / Sort by name - configs = sorted(configs, key=lambda c: c.get("name", "")) - ui_we_want_to_set.run_with_menu = ui_we_want_to_set.run_menu.addMenu( language_wrapper.language_word_dict.get("run_with_menu_label") ) for config in configs: - name = config.get("name", "Unknown") + name = plugin_text(config.get("name"), "Unknown") suffixes = ", ".join(run_config_suffixes(config)) label = f"{name} ({suffixes})" if suffixes else name diff --git a/test/test_utils/test_plugin_menu.py b/test/test_utils/test_plugin_menu.py index dbf2c904..0e96f25c 100644 --- a/test/test_utils/test_plugin_menu.py +++ b/test/test_utils/test_plugin_menu.py @@ -396,3 +396,36 @@ def test_a_config_without_a_compiler_is_reported_in_the_run_window(self, app): FileRunnerProcess(run_window).run_file({"name": "Broken"}, "main.go") assert "names no compiler" in run_window.code_result.toPlainText() + + +class TestAPluginThatDoesNotFollowTheRules: + """A plugin is third-party code: one bad plugin stopped the IDE from starting.""" + + def test_a_run_config_that_is_not_a_dict_is_skipped(self, window, monkeypatch): + # run_config_suffixes raised AttributeError while the main window was built + monkeypatch.setattr( + plugin_menu, "get_all_plugin_metadata", + lambda: [{"name": "Odd", "run_config": ["not", "a", "dict"]}, + {"name": "Go", "run_config": GO_CONFIG}]) + set_plugin_menu(window) + + assert "Odd" in labels(window.plugin_menu) + assert [text for text in submenu_action_texts(window.plugin_menu)[0] if text] == ["About", "Run with Go"] + + def test_a_name_that_is_none_gets_a_label(self, window, monkeypatch): + # addMenu(None) is an access violation, not a Python error + monkeypatch.setattr( + plugin_menu, "get_all_plugin_metadata", + lambda: [{"name": None, "run_config": {"name": None, "suffixes": [".x"]}}, "not metadata"]) + set_plugin_menu(window) + + assert submenu_action_texts(window.plugin_menu) + + def test_run_configs_with_none_and_text_names_sort(self, window, monkeypatch): + # sorted() raised comparing None with a string + monkeypatch.setattr( + run_with, "get_all_plugin_run_configs", + lambda: [{"name": "Go", "suffixes": [".go"]}, {"name": None}, "not a config"]) + set_run_with_menu(window) + + assert labels(window.run_with_menu) == ["Go (.go)", "Unknown"] From d6d00ea4fb4fc581d799cf1c7d18c48aee009b31 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:29:16 +0800 Subject: [PATCH 186/312] Build compile-then-run binaries in a folder of their own and make Stop around a compile run nothing --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 18 ++++ docs/updates/README.md | 3 +- progress.md | 2 - .../process_executor/file_runner_process.py | 76 +++++++++++----- test/test_utils/test_run_output.py | 88 +++++++++++++++++++ 6 files changed, 165 insertions(+), 28 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index a613e1ee..63b3255b 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -141,13 +141,13 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - **`file_runner_process.py`** — `FileRunnerProcess`。**唯一不跑 Python 的執行器**,服務插件註冊的 run config: - 直譯式:`compiler [args...] file`(如 `go run main.go`) - - 編譯式:`compiler file -o out` → 執行 `out` → 執行完 `os.remove()` 清掉產物。編譯器跟執行一樣走 `_start_process()`(輸出即時串流、不佔 UI 執行緒),結束碼交給 `after_exit`:0 才接著跑產物,否則印 `[Compile failed]` + - 編譯式:`compiler file -o out` → 執行 `out` → 執行完整個建置資料夾刪掉。`out` 建在 `tempfile.mkdtemp()` 開的資料夾裡,不在原始檔旁邊(旁邊同名的檔案會被蓋掉再刪掉,同一個檔案跑兩次也會互搶)。編譯器跟執行一樣走 `_start_process()`(輸出即時串流、不佔 UI 執行緒),結束碼交給 `after_exit`:0 才接著跑產物,否則印 `[Compile failed]` - 編譯最多 `COMPILE_TIME_LIMIT_SECONDS`(60 秒),由 pump 檢查、超過就 `terminate()`;編譯中也能 `stop()`。QTimer 間隔 50 ms(比 Python 執行器更快),編譯與執行共用同一個 - 讀取、pump、drain 與寫進視窗都用 §4.5 的共用函式 - `run_current_file_with()` 建好後同樣掛在執行視窗的 `runner` 上 - 有和 `TaskProcessManager` 相同語意的 `stop()` - 子行程的 `stdin` 是 `DEVNULL`:執行視窗沒有輸入欄,讀取要立刻拿到 EOF,不能卡在沒人寫的管線上 - - 啟動失敗(找不到指令、是資料夾、沒有執行權限、產物被鎖)都寫進執行視窗,編譯產物經 `_remove_binary()` 一併刪掉 + - 啟動失敗(找不到指令、是資料夾、沒有執行權限、產物被鎖)都寫進執行視窗,建置資料夾經 `_remove_build_dir()` 一併刪掉。`stop()` 會設 `_cancelled`:編譯中或剛編譯完按停止都顯示「[Stopped]」、不執行產物;每個子行程有自己的讀取旗標(`_reading`),編譯的讀取執行緒不會延續到執行階段 ### 4.5 `queue_pump.py` 與 `CodeWindow.append_output()` @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1872 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1875 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index a0f9104a..abf16a73 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2601,3 +2601,21 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: plugin menu tests 36 passed; 1872 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py`, `build_run_with_menu.py`, `test/test_utils/test_plugin_menu.py`, `progress.md` (#90 removed), `architecture_explore.md` §5, §18 - **Open items**: none. + +## U-20260923-151 · 2026-09-24 · Build compile-then-run binaries in a folder of their own, and make Stop during or just after a compile run nothing · #fix #executor #done + +- **What**: progress #82 and #84, in `FileRunnerProcess` (`extend/process_executor/file_runner_process.py`). + - A compile-then-run config (gcc, rustc, ...) wrote its binary beside the source, as `.exe` there. It replaced a file of that name (a release build of the user's own), then deleted it after the run. Two runs of one file failed on Windows (the first binary was locked), or one run's cleanup deleted the other's binary. + - Stop during a compile printed "[Compile failed] exit code 1". A Stop after the compiler exited but before the next tick (up to the 2 s reader grace when a helper such as `mspdbsrv` keeps the pipes open) found nothing to stop, and the binary ran anyway. + - All readers shared one `still_running` flag, which the run step set again. A compile reader still alive resumed and mixed the compiler's leftover output into the run's. +- **Fix**: + - The binary is built in a new `tempfile.mkdtemp(prefix="pybreeze-run-")` folder, removed whole after the run, or after a failed or stopped compile (`_remove_build_dir`). + - `stop()` sets `_cancelled`; a compile that ends with it set reports "[Stopped]" and starts nothing. + - Each process has its own reading flag (`_reading`, an `Event` cleared when it finishes), captured by its readers as they start. +- **Tests**: `test_run_output.py` +3, on Windows, with a fake compiler that copies `hostname.exe` to the `-o` path: + - The binary is built outside the source folder, a `hello.exe` beside the source is untouched, and the build folder is gone after the run. + - A Stop during the compile reports "[Stopped]", with no "[Compile failed]" and no run. + - A Stop just after the compile finished runs nothing. +- **Result / numbers**: run output and reader tests 26 passed; 1875 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/file_runner_process.py`, `test/test_utils/test_run_output.py`, `progress.md` (#82, #84 removed), `architecture_explore.md` §4, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 05ae4441..c201546c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-151 | 2026-09-24 | Build compile-then-run binaries in a folder of their own, and make Stop during or just after a compile run nothing | #fix #executor #done | [2026-09](2026-09.md) | | U-20260923-150 | 2026-09-24 | Keep one malformed plugin from stopping the IDE's start: check run configs and names, and build each plugin's entry on its own | #fix #plugins #crash #done | [2026-09](2026-09.md) | | U-20260923-149 | 2026-09-24 | Delete a closed JupyterLab tab and its web view | #fix #jupyter #done | [2026-09](2026-09.md) | | U-20260923-148 | 2026-09-24 | Keep an edited tab's unsaved mark when the file tree renames its file | #fix #editor #done | [2026-09](2026-09.md) | @@ -231,4 +232,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 168 | +| [2026-09.md](2026-09.md) | 2026-09 | 169 | diff --git a/progress.md b/progress.md index 556b0d29..c71a6d41 100644 --- a/progress.md +++ b/progress.md @@ -15,7 +15,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#79** Stop and IDE close terminate only the direct child: a run config that starts the program as a grandchild (`go run`, `cargo run`, `dotnet run`, `npm start`) or a web run's browser keeps running with no window (`python_task_process_manager.py`, `file_runner_process.py`, `main_ui.py`). - **#81** "Run folder" starts every file at once: their HTML reports overwrite one another in the shared working directory, and a run's mail can send another run's report (`process_executor_utils.run_dir_files_with_package`, `mail_thunder_setting.py`). -- **#82** Compile-then-run writes the binary beside the source, replacing and later deleting a file of that name, and two runs of one file fight over it (`file_runner_process.py`). - **#83** Output of non-Python run configs is decoded as UTF-8, while Java and localized compilers write the console code page (MS950 on zh-TW Windows): their non-ASCII text is garbled (`file_runner_process.py`, `queue_pump.py`). -- **#84** Stop during a compile reports "[Compile failed] exit code 1"; a Stop just after the compile succeeds still runs the binary; and compile readers still alive resume and mix into the run's output (`file_runner_process.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index 6414f7ef..e37cc2ff 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -7,14 +7,15 @@ """ from __future__ import annotations -import os +import shutil import subprocess import sys +import tempfile import time from collections.abc import Callable from pathlib import Path from queue import Queue -from threading import Thread +from threading import Event, Thread from PySide6.QtCore import QTimer @@ -52,7 +53,14 @@ def __init__( self.timer: QTimer | None = None self._stdout_thread: Thread | None = None self._stderr_thread: Thread | None = None - self._cleanup_binary: str | None = None + # The folder a compiled binary was built in, removed once it has run + self._cleanup_dir: str | None = None + # Set while this process's readers should read: one per process, so a + # compile's reader still alive stops when the compile ends and does + # not carry on into the run's output + self._reading: Event | None = None + # Stop was pressed: a compile that ends then does not start the run + self._cancelled = False # What to do with the exit code instead of reporting it (the compile # step starts the run from here), and when to give up on the child self._after_exit: Callable[[int], None] | None = None @@ -70,6 +78,7 @@ def run_file(self, run_config: dict, file_path: str) -> None: compile_then_run: bool (optional) - if True, compile first then run output output_flag: str (optional) - flag for output file (e.g. "-o") """ + self._cancelled = False compile_then_run = run_config.get("compile_then_run", False) compiler = run_config.get("compiler") if not isinstance(compiler, str) or not compiler: @@ -90,9 +99,13 @@ def _compile_and_run(self, compiler: str, args: list, output_flag: str, file_pat The compiler is a child like the run: its output streams into the window as it comes, and the IDE stays usable while it works. It used to run to completion on the UI thread, which froze the IDE for up to a minute. + + The binary is built in a folder of its own, removed after the run: built + beside the source, it replaced (and then deleted) a file of that name + there, and two runs of one file fought over it. """ - path = Path(file_path) - output_name = str(path.with_suffix("")) + build_dir = tempfile.mkdtemp(prefix="pybreeze-run-") + output_name = str(Path(build_dir) / Path(file_path).stem) if sys.platform in ("win32", "cygwin", "msys"): output_name += ".exe" @@ -100,22 +113,31 @@ def _compile_and_run(self, compiler: str, args: list, output_flag: str, file_pat self.main_window.append_output(f"[Compile] {' '.join(compile_cmd)}\n", is_error=False) def run_if_compiled(exit_code: int) -> None: + if self._cancelled: + # Stopped: while compiling (reported as a failed compile), or + # just after it succeeded (the binary ran anyway) + self.main_window.append_output("[Stopped]\n", is_error=True, own_line=True) + self._remove_build_dir(build_dir) + return if exit_code != 0: self.main_window.append_output(f"[Compile failed] exit code {exit_code}\n", is_error=True) + self._remove_build_dir(build_dir) return self.main_window.append_output(f"[Run] {output_name}\n", is_error=False) - self._start_process([output_name], cleanup_binary=output_name) + self._start_process([output_name], cleanup_dir=build_dir) self._start_process( - compile_cmd, after_exit=run_if_compiled, time_limit=COMPILE_TIME_LIMIT_SECONDS) + compile_cmd, after_exit=run_if_compiled, time_limit=COMPILE_TIME_LIMIT_SECONDS, + cleanup_dir=build_dir) - def _start_process(self, command: list[str], cleanup_binary: str | None = None, + def _start_process(self, command: list[str], cleanup_dir: str | None = None, after_exit: Callable[[int], None] | None = None, time_limit: float | None = None) -> None: """Launch subprocess and start output reading. :param command: the argv to run - :param cleanup_binary: a file to delete once the child has exited + :param cleanup_dir: a folder to remove once the child has exited (and + its ``after_exit`` has not taken it over) :param after_exit: called with the exit code, on the UI thread, in place of the exit line :param time_limit: seconds after which the child is stopped @@ -139,7 +161,7 @@ def _start_process(self, command: list[str], cleanup_binary: str | None = None, ) except FileNotFoundError: self.main_window.append_output(f"[Error] Command not found: {command[0]}\n", is_error=True) - self._remove_binary(cleanup_binary) + self._remove_build_dir(cleanup_dir) return except OSError as error: # Not executable, a folder, or a compiled binary locked or blocked @@ -147,13 +169,15 @@ def _start_process(self, command: list[str], cleanup_binary: str | None = None, # slot after a compile, and the window said nothing. self.main_window.append_output( f"[Error] Could not start {command[0]}: {error.strerror or error}\n", is_error=True) - self._remove_binary(cleanup_binary) + self._remove_build_dir(cleanup_dir) return - self._cleanup_binary = cleanup_binary + self._cleanup_dir = cleanup_dir self._after_exit = after_exit self._deadline = None if time_limit is None else time.monotonic() + time_limit self.still_running = True + self._reading = Event() + self._reading.set() self._reader_grace.restart() self._stdout_thread = Thread(target=self._read_stdout, daemon=True) @@ -173,8 +197,10 @@ def stop(self) -> None: """Stop the child if it is still running. Only the child itself: processes it started are left to it. The run - window reports the exit on the next pump, as for any other exit. + window reports the exit on the next pump, as for any other exit. A + compile that is stopped, or that has just finished, starts no run. """ + self._cancelled = True if self.process is not None and self.process.poll() is None: self.process.terminate() @@ -200,6 +226,8 @@ def _pull_text(self) -> None: def _finish(self) -> None: """Clean up after process exits.""" self.still_running = False + if self._reading is not None: + self._reading.clear() if self.timer and self.timer.isActive(): self.timer.stop() @@ -218,6 +246,8 @@ def _finish(self) -> None: exit_code = self.process.returncode self.process = None if after_exit is not None: + # It takes the build folder over: the run removes it, or it does + self._cleanup_dir = None after_exit(exit_code) if self.process is None: # the compile failed: nothing runs next self.main_window.run_ended() @@ -227,18 +257,18 @@ def _finish(self) -> None: is_error=exit_code != 0, ) - self._remove_binary(self._cleanup_binary) + self._remove_build_dir(self._cleanup_dir) + self._cleanup_dir = None self.main_window.run_ended() @staticmethod - def _remove_binary(binary: str | None) -> None: - """Delete a compiled binary once it has run, or could not be started.""" - if not binary: + def _remove_build_dir(folder: str | None) -> None: + """Delete a compile's build folder once its binary has run, or could not.""" + if not folder: return - try: - os.remove(binary) - except OSError as error: - pybreeze_logger.debug("Could not remove compiled binary %s: %s", binary, error) + shutil.rmtree(folder, ignore_errors=True) + if Path(folder).exists(): # a binary something still holds open + pybreeze_logger.debug("Could not remove the build folder %s", folder) def _drain_queues(self) -> None: """Drain all remaining messages from output/error queues to UI.""" @@ -249,11 +279,13 @@ def _read_stream(self, stream_name: str, target_queue: Queue) -> None: stream = getattr(self.process, stream_name, None) if stream is None: return + # This process's flag, as it is when the reader starts + reading = getattr(self, "_reading", None) read_stream_into_queue( stream, target_queue, buffer_size=self.program_buffer_size, encoding=self.program_encoding, - keep_reading=lambda: self.still_running, + keep_reading=lambda: self.still_running and (reading is None or reading.is_set()), ) def _read_stdout(self) -> None: diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index 473d4aa8..ecb61945 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -6,6 +6,7 @@ import os import sys import time +from pathlib import Path os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -433,3 +434,90 @@ def test_a_line_without_its_newline_shows_while_the_run_goes_on(qt_app, tmp_path assert "done" not in run_window.code_result.toPlainText() _run_events_until(qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) assert "working... 50% done" in run_window.code_result.toPlainText() + + +def _fake_compiler(folder: Path, seconds: float = 0.0) -> Path: + """A "compiler" that sleeps *seconds*, then copies a stand-alone Windows program to the -o path.""" + script = folder / "fakecc.py" + script.write_text( + "import shutil, sys, time\n" + f"time.sleep({seconds})\n" + "shutil.copy(r'C:\\Windows\\System32\\hostname.exe', sys.argv[sys.argv.index('-o') + 1])\n" + "print('compiled', flush=True)\n", + encoding="utf-8") + return script + + +def _compile_config(script: Path) -> dict: + return {"name": "Fake", "compiler": sys.executable, "args": (str(script),), "compile_then_run": True} + + +@pytest.mark.skipif(os.name != "nt", reason="the stand-in program is Windows'") +class TestCompileThenRun: + def test_the_binary_is_built_away_from_the_source_and_removed(self, qt_app, tmp_path): + # It was built beside the source, replacing and then deleting a file of + # that name there + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + source = tmp_path / "hello.c" + source.write_text("int main(){}", encoding="utf-8") + mine = tmp_path / "hello.exe" + mine.write_bytes(b"the user's own build") + window = CodeWindow() + runner = FileRunnerProcess(window) + window.runner = runner + + runner.run_file(_compile_config(_fake_compiler(tmp_path)), str(source)) + _run_events_until(qt_app, lambda: "[Process exited with code" in window.code_result.toPlainText()) + + text = window.code_result.toPlainText() + assert "[Process exited with code 0]" in text + assert mine.read_bytes() == b"the user's own build" + built = next(line for line in text.splitlines() if line.startswith("[Run] "))[len("[Run] "):] + assert Path(built).parent != tmp_path + assert not Path(built).parent.exists() + + def test_stop_during_the_compile_is_reported_and_runs_nothing(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + source = tmp_path / "slow.c" + source.write_text("int main(){}", encoding="utf-8") + window = CodeWindow() + runner = FileRunnerProcess(window) + window.runner = runner + ended: list = [] + window.run_ended = lambda: ended.append(True) + + runner.run_file(_compile_config(_fake_compiler(tmp_path, seconds=20)), str(source)) + _run_events_until(qt_app, lambda: runner.process is not None) + runner.stop() + _run_events_until(qt_app, lambda: ended) + + text = window.code_result.toPlainText() + # It said "[Compile failed] exit code 1" + assert "[Stopped]" in text + assert "[Compile failed]" not in text and "[Run]" not in text + + def test_stop_just_after_the_compile_runs_nothing(self, qt_app, tmp_path): + # Once the compiler had exited, Stop found nothing to stop and the binary ran + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + source = tmp_path / "quick.c" + source.write_text("int main(){}", encoding="utf-8") + window = CodeWindow() + runner = FileRunnerProcess(window) + window.runner = runner + ended: list = [] + window.run_ended = lambda: ended.append(True) + + runner.run_file(_compile_config(_fake_compiler(tmp_path)), str(source)) + runner.process.wait(30) + runner.stop() + _run_events_until(qt_app, lambda: ended) + + text = window.code_result.toPlainText() + assert "[Stopped]" in text and "[Run]" not in text + From 9a19e776aa25d5fcbc886b5c589dde74beff793c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:30:50 +0800 Subject: [PATCH 187/312] Let a plugin run config name the encoding its program writes --- PLUGIN_GUIDE.md | 9 +++++ architecture.md | 3 +- architecture_explore.md | 4 +- docs/updates/2026-09.md | 17 ++++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../menu/plugin_menu/build_run_with_menu.py | 26 +++++++++++- test/test_utils/test_plugin_menu.py | 40 +++++++++++++++++++ 8 files changed, 97 insertions(+), 6 deletions(-) diff --git a/PLUGIN_GUIDE.md b/PLUGIN_GUIDE.md index 02926d5d..6c53e1fd 100644 --- a/PLUGIN_GUIDE.md +++ b/PLUGIN_GUIDE.md @@ -10,6 +10,11 @@ for both editors, kept in the JEditor repository: Ready-made plugins (C, C++, Go, Java and Rust highlighting with run support, a French UI translation) live in [IDE_Plugins](https://github.com/Jeffrey-Plugin-Repos/IDE_Plugins). +PyBreeze reads one run-config key JEditor does not: `"encoding"`, the encoding the program writes +its output in (`"cp950"`, `"utf-8"`, or `"locale"` for the machine's own). Without it the output is +read in the IDE's encoding, UTF-8 by default. Java and localized compilers write the console's code +page, so a Java run config on a non-English Windows wants `"encoding": "locale"`. + --- PyBreeze 建立在 JEditor 之上,原封不動地使用它的插件系統:`je_editor.plugins` API @@ -20,3 +25,7 @@ PyBreeze 建立在 JEditor 之上,原封不動地使用它的插件系統:`j 現成的插件(C、C++、Go、Java、Rust 語法高亮與執行設定,以及法文介面翻譯)放在 [IDE_Plugins](https://github.com/Jeffrey-Plugin-Repos/IDE_Plugins)。 + +PyBreeze 多讀一個 JEditor 不讀的執行設定欄位:`"encoding"`,程式輸出用的編碼(`"cp950"`、`"utf-8"`, +或 `"locale"` 表示這台電腦自己的)。沒寫就用 IDE 的編碼讀,預設 UTF-8。Java 與中文化的編譯器輸出的是 +主控台的字碼頁,所以在非英文 Windows 上,Java 的執行設定要寫 `"encoding": "locale"`。 diff --git a/architecture.md b/architecture.md index 4c03e208..606a0fe9 100644 --- a/architecture.md +++ b/architecture.md @@ -182,7 +182,8 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() prthinker's config (`prthinker.cli._build_parser`, `_build_config`, both in its `__all__`) to check each backend gets the model. - **IDE_Plugins**: the plugin browser's default repo (set in JEditor). Its run configs execute here via - `FileRunnerProcess`. + `FileRunnerProcess`. PyBreeze reads one key JEditor's plugin guide does not define: `"encoding"`, the + encoding the program's output is in (`"locale"` for the machine's own), documented in `PLUGIN_GUIDE.md`. - **PySide6 pin**: it must match JEditor and FrontEngine. PyBreeze pins it in `pyproject.toml`, `dev.toml` and `requirements.txt`. All three pin 6.11.2, the exact pin of the published je_editor 1.0.26 and frontengine 1.0.78 (both `==`, so PyBreeze cannot move ahead of them without becoming diff --git a/architecture_explore.md b/architecture_explore.md index 63b3255b..634de5fe 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -144,7 +144,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - 編譯式:`compiler file -o out` → 執行 `out` → 執行完整個建置資料夾刪掉。`out` 建在 `tempfile.mkdtemp()` 開的資料夾裡,不在原始檔旁邊(旁邊同名的檔案會被蓋掉再刪掉,同一個檔案跑兩次也會互搶)。編譯器跟執行一樣走 `_start_process()`(輸出即時串流、不佔 UI 執行緒),結束碼交給 `after_exit`:0 才接著跑產物,否則印 `[Compile failed]` - 編譯最多 `COMPILE_TIME_LIMIT_SECONDS`(60 秒),由 pump 檢查、超過就 `terminate()`;編譯中也能 `stop()`。QTimer 間隔 50 ms(比 Python 執行器更快),編譯與執行共用同一個 - 讀取、pump、drain 與寫進視窗都用 §4.5 的共用函式 - - `run_current_file_with()` 建好後同樣掛在執行視窗的 `runner` 上 + - `run_current_file_with()` 建好後同樣掛在執行視窗的 `runner` 上;輸出用執行設定的 `"encoding"` 解碼(`output_encoding()`,`"locale"` 表示本機字碼頁,Java 與中文化的編譯器就是輸出本機字碼頁),沒寫就用 IDE 的編碼 - 有和 `TaskProcessManager` 相同語意的 `stop()` - 子行程的 `stdin` 是 `DEVNULL`:執行視窗沒有輸入欄,讀取要立刻拿到 EOF,不能卡在沒人寫的管線上 - 啟動失敗(找不到指令、是資料夾、沒有執行權限、產物被鎖)都寫進執行視窗,建置資料夾經 `_remove_build_dir()` 一併刪掉。`stop()` 會設 `_cancelled`:編譯中或剛編譯完按停止都顯示「[Stopped]」、不執行產物;每個子行程有自己的讀取旗標(`_reading`),編譯的讀取執行緒不會延續到執行階段 @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1875 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1882 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index abf16a73..8465aa9b 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2619,3 +2619,20 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: run output and reader tests 26 passed; 1875 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/file_runner_process.py`, `test/test_utils/test_run_output.py`, `progress.md` (#82, #84 removed), `architecture_explore.md` §4, §18 - **Open items**: none. + +## U-20260923-152 · 2026-09-24 · Let a plugin run config name the encoding its program writes · #fix #plugins #executor #done + +- **What**: progress #83. Output from a plugin run config (`FileRunnerProcess`) was decoded in the IDE's encoding, UTF-8 by default. The environment `utf8_subprocess_env` sets affects only Python children (`PYTHONIOENCODING`). Go, Rust and C programs write the bytes they were given, usually UTF-8, but Java writes redirected output in the console's code page (MS950 on a Traditional Chinese Windows), and so do localized `javac` and MSVC messages. `System.out.println("中文")` or a `javac` error in Chinese came out as replacement characters. +- **Fix**: `run_current_file_with` (`menu/plugin_menu/build_run_with_menu.py`) takes the output encoding from the run config through new `output_encoding()`: + - A new optional key `"encoding"` names a codec; `"locale"` means the machine's own (`locale.getpreferredencoding`). + - Without it, or with a name Python does not know (logged), the IDE's encoding is used as before. + - The key is PyBreeze's own: JEditor's plugin guide does not have it, so `PLUGIN_GUIDE.md` (both languages) describes it, and `architecture.md` §6 lists it with the IDE_Plugins contract. +- **Tests**: `test_plugin_menu.py` +7: + - the IDE's encoding by default; + - a named codec normalised (`MS950` → `cp950`); + - `locale`; + - three unusable values keep the default; + - the run is built with the config's encoding. +- **Result / numbers**: plugin menu tests 43 passed; 1882 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `test/test_utils/test_plugin_menu.py`, `PLUGIN_GUIDE.md`, `progress.md` (#83 removed), `architecture.md` §6, `architecture_explore.md` §4, §18 +- **Open items**: IDE_Plugins' Java run config could set `"encoding": "locale"`; that repository is the owner's. diff --git a/docs/updates/README.md b/docs/updates/README.md index c201546c..53760be4 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-152 | 2026-09-24 | Let a plugin run config name the encoding its program writes | #fix #plugins #executor #done | [2026-09](2026-09.md) | | U-20260923-151 | 2026-09-24 | Build compile-then-run binaries in a folder of their own, and make Stop during or just after a compile run nothing | #fix #executor #done | [2026-09](2026-09.md) | | U-20260923-150 | 2026-09-24 | Keep one malformed plugin from stopping the IDE's start: check run configs and names, and build each plugin's entry on its own | #fix #plugins #crash #done | [2026-09](2026-09.md) | | U-20260923-149 | 2026-09-24 | Delete a closed JupyterLab tab and its web view | #fix #jupyter #done | [2026-09](2026-09.md) | @@ -232,4 +233,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 169 | +| [2026-09.md](2026-09.md) | 2026-09 | 170 | diff --git a/progress.md b/progress.md index c71a6d41..4b8f8146 100644 --- a/progress.md +++ b/progress.md @@ -15,5 +15,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#79** Stop and IDE close terminate only the direct child: a run config that starts the program as a grandchild (`go run`, `cargo run`, `dotnet run`, `npm start`) or a web run's browser keeps running with no window (`python_task_process_manager.py`, `file_runner_process.py`, `main_ui.py`). - **#81** "Run folder" starts every file at once: their HTML reports overwrite one another in the shared working directory, and a run's mail can send another run's report (`process_executor_utils.run_dir_files_with_package`, `mail_thunder_setting.py`). -- **#83** Output of non-Python run configs is decoded as UTF-8, while Java and localized compilers write the console code page (MS950 on zh-TW Windows): their non-ASCII text is garbled (`file_runner_process.py`, `queue_pump.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index 79245514..688cfc9a 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -6,6 +6,8 @@ """ from __future__ import annotations +import codecs +import locale from pathlib import Path from typing import TYPE_CHECKING @@ -143,11 +145,33 @@ def run_current_file_with(main_window: PyBreezeMainWindow, run_config: dict) -> code_window.runner = FileRunnerProcess( main_window=code_window, - program_encoding=main_window.encoding, + program_encoding=output_encoding(run_config, main_window.encoding), ) code_window.runner.run_file(run_config, file_path) +def output_encoding(run_config: dict, default: str) -> str: + """The encoding the program run by *run_config* writes: its ``encoding``, else *default*. + + Go, Rust and C programs write the bytes they were given, usually UTF-8, but + Java and localized compilers write the console's code page (MS950 on a + Traditional Chinese Windows): decoded as UTF-8 their text was garbled. A + plugin says so with ``"encoding": "cp950"``, or ``"locale"`` for the + machine's own. A name Python does not know is logged and ignored. + """ + named = run_config.get("encoding") + if not isinstance(named, str) or not named.strip(): + return default + if named.strip().lower() == "locale": + return locale.getpreferredencoding(False) + try: + return codecs.lookup(named.strip()).name + except LookupError: + pybreeze_logger.error("Run config %s names an unknown encoding %r; using %s", + plugin_text(run_config.get("name"), "Run"), named, default) + return default + + def set_run_with_menu(ui_we_want_to_set: PyBreezeMainWindow) -> None: """Build the 'Run with...' submenu. Only creates if configs exist.""" configs = plugin_run_configs() diff --git a/test/test_utils/test_plugin_menu.py b/test/test_utils/test_plugin_menu.py index 0e96f25c..dc67a159 100644 --- a/test/test_utils/test_plugin_menu.py +++ b/test/test_utils/test_plugin_menu.py @@ -429,3 +429,43 @@ def test_run_configs_with_none_and_text_names_sort(self, window, monkeypatch): set_run_with_menu(window) assert labels(window.run_with_menu) == ["Go (.go)", "Unknown"] + + +class TestTheEncodingAProgramWrites: + """Java and localized compilers write the console code page, not UTF-8.""" + + def test_the_ides_encoding_by_default(self): + from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import output_encoding + + assert output_encoding({"name": "Go"}, "utf-8") == "utf-8" + + def test_a_config_can_name_its_own(self): + from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import output_encoding + + assert output_encoding({"name": "Java", "encoding": "MS950"}, "utf-8") == "cp950" + + def test_locale_means_this_machines(self): + import locale + + from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import output_encoding + + assert output_encoding({"encoding": "locale"}, "utf-8") == locale.getpreferredencoding(False) + + @pytest.mark.parametrize("named", ["no-such-codec", 5, " "]) + def test_anything_else_keeps_the_default(self, named): + from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import output_encoding + + assert output_encoding({"name": "Odd", "encoding": named}, "utf-8") == "utf-8" + + def test_the_run_decodes_with_it(self, window, monkeypatch, tmp_path): + script = tmp_path / "anything.xyz" + script.touch() + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) + made: list = [] + monkeypatch.setattr( + run_with, "FileRunnerProcess", + lambda **kwargs: made.append(kwargs) or type("R", (), {"run_file": lambda *a: None})()) + + run_current_file_with(window, {"name": "Java", "compiler": "java", "encoding": "cp950"}) + + assert made[0]["program_encoding"] == "cp950" From d722bd0238a367a9b48f520ee555621fa18a5b62 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:36:17 +0800 Subject: [PATCH 188/312] Run a folder's files one after another so each run has its own report and mail --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 13 ++++ docs/updates/README.md | 3 +- progress.md | 1 - .../process_executor_utils.py | 59 ++++++++++++++--- .../python_task_process_manager.py | 3 + test/test_utils/test_run_output.py | 63 +++++++++++++++++++ 7 files changed, 134 insertions(+), 14 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 634de5fe..4b594b05 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -110,8 +110,8 @@ Template Method 定義的子行程生命週期: |---|---| | `build_process()` | 取當前分頁的程式碼(或傳入的 `exec_str`)→ `start_process()`。沒給 `exec_str` 又不是編輯器分頁時,開一個執行視窗寫明「腳本要在前面的編輯器分頁裡」(`report_no_script_tab()`),不會把 `None` 交給套件 | | `start_process()` | 建 `CodeWindow` + `TaskProcessManager` → `start_test_process()` | -| `build_process_from_file()` | 以檔案路徑執行單一檔案 | -| `run_dir_files_with_package()` | 問使用者選資料夾(`_ask_for_action_files()`,對話框掛在主視窗上;資料夾裡沒有 `.json` 就明說),對每個 `.json` 開一個執行視窗批次跑 | +| `build_process_from_file()` | 以檔案路徑執行單一檔案,回傳它的 manager;`then` 在執行結束時呼叫 | +| `run_dir_files_with_package()` | 問使用者選資料夾(`_ask_for_action_files()`,對話框掛在主視窗上;資料夾裡沒有 `.json` 就明說),每個 `.json` 一個執行視窗,一個跑完才跑下一個(`run_one_after_another()`:報告都寫到同一個 `default_name.html`,同時跑會互相蓋掉、寄錯報告);被停止的執行(停止鈕、關閉 IDE)結束整批,啟動不了的檔案跳過 | | `open_run_window()` | 開一個執行視窗、掛進 `main_window.current_run_code_window`,並接上 `finished_and_closed`:使用者關掉一個已經跑完的執行視窗時主視窗就放掉它(以前這份清單只增不減,每次執行都留下一個視窗、一個執行器、兩個 queue 和一個 timer);執行中被關掉的視窗記下 `_closed_while_running`,等執行器在結束路徑尾端呼叫 `CodeWindow.run_ended()` 時才放掉(排到 timer 的 slot 回來之後才發,視窗是 timer 的 parent)。插件執行也走這裡 | | `build_task_process()` | 共用建構:`open_run_window()` 並帶上主視窗選定的直譯器、決定要不要接寄報告的 hook(`report_mail_hook(code_window)`:建立時記下子行程工作目錄裡 `default_name.html` 的絕對路徑與開始時間,比這次執行還舊的報告不寄;寄送結果經無 parent 的 `_MailNotice` 以 queued signal 回到執行視窗,寫出寄到了或沒寄的原因)。建好的 `TaskProcessManager` 掛在執行視窗的 `runner` 上,所以呼叫端可以不留參考。prthinker 審查也走這裡 | @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1882 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1884 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 8465aa9b..e9fc2d26 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2636,3 +2636,16 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: plugin menu tests 43 passed; 1882 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `test/test_utils/test_plugin_menu.py`, `PLUGIN_GUIDE.md`, `progress.md` (#83 removed), `architecture.md` §6, `architecture_explore.md` §4, §18 - **Open items**: IDE_Plugins' Java run config could set `"encoding": "locale"`; that repository is the owner's. + +## U-20260923-153 · 2026-09-24 · Run a folder's files one after another, so each run has its own report and mail · #fix #executor #done + +- **What**: progress #81. "Run folder" (`run_dir_files_with_package`, `extend/process_executor/process_executor_utils.py`) started every file at once. Each run writes its HTML report to the same `default_name.html` in the IDE's working directory, and the mail hook only checks that the report is newer than the run's start. The reports overwrote one another, and a run that crashed before writing its own could mail another run's report as its own. N files also opened N browsers at once. +- **Fix**: + - New `run_one_after_another()` starts the first file with a done-hook that starts the rest once it has ended. `build_task_process` and `build_process_from_file` take a `then` callable, called after the report mail has started, and `build_process_from_file` returns its manager. + - A run that was stopped (its Stop, or the IDE closing: `TaskProcessManager.was_stopped`) ends the batch. A file whose run could not start is passed over from the event loop. +- **Tests**: `test_run_output.py` +2, with a stand-in package that logs its start and end: + - Three files run strictly one after another, each in its own window. + - Stopping the first run leaves the other two unstarted. +- **Result / numbers**: executor, run-folder, mail and close tests 39 + 2 passed; 1884 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `python_task_process_manager.py`, `test/test_utils/test_run_output.py`, `progress.md` (#81 removed), `architecture_explore.md` §4, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 53760be4..4d498e4a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-153 | 2026-09-24 | Run a folder's files one after another, so each run has its own report and mail | #fix #executor #done | [2026-09](2026-09.md) | | U-20260923-152 | 2026-09-24 | Let a plugin run config name the encoding its program writes | #fix #plugins #executor #done | [2026-09](2026-09.md) | | U-20260923-151 | 2026-09-24 | Build compile-then-run binaries in a folder of their own, and make Stop during or just after a compile run nothing | #fix #executor #done | [2026-09](2026-09.md) | | U-20260923-150 | 2026-09-24 | Keep one malformed plugin from stopping the IDE's start: check run configs and names, and build each plugin's entry on its own | #fix #plugins #crash #done | [2026-09](2026-09.md) | @@ -233,4 +234,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 170 | +| [2026-09.md](2026-09.md) | 2026-09 | 171 | diff --git a/progress.md b/progress.md index 4b8f8146..bb9e127c 100644 --- a/progress.md +++ b/progress.md @@ -14,5 +14,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#79** Stop and IDE close terminate only the direct child: a run config that starts the program as a grandchild (`go run`, `cargo run`, `dotnet run`, `npm start`) or a web run's browser keeps running with no window (`python_task_process_manager.py`, `file_runner_process.py`, `main_ui.py`). -- **#81** "Run folder" starts every file at once: their HTML reports overwrite one another in the shared working directory, and a run's mail can send another run's report (`process_executor_utils.run_dir_files_with_package`, `mail_thunder_setting.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index 914f9437..df2459a6 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -5,7 +5,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from PySide6.QtCore import QCoreApplication, QObject, Signal +from PySide6.QtCore import QCoreApplication, QObject, QTimer, Signal from PySide6.QtWidgets import QFileDialog, QMessageBox from je_editor import EditorWidget, language_wrapper @@ -77,15 +77,17 @@ def build_process_from_file( file_path: str, send_mail: bool = False, program_buffer: int = 1024000, -): - """Run ``package`` against an action JSON file path. + then: Callable[[], None] | None = None, +) -> TaskProcessManager: + """Run ``package`` against an action JSON file path; return the run's manager. Bypasses the ``--execute_str`` cmdline path so large scripts cannot trip the Windows ~32K argv limit. Useful for batch / multi-file flows where - the file is already on disk. + the file is already on disk. *then* is called when the run has ended. """ - process = build_task_process(main_window, send_mail, program_buffer) + process = build_task_process(main_window, send_mail, program_buffer, then=then) process.start_test_process_file(package, file_path) + return process def run_dir_files_with_package( @@ -94,24 +96,51 @@ def run_dir_files_with_package( send_mail: bool = False, program_buffer: int = 1024000, ) -> None: - """Prompt for a directory and run every matching file through *package*. + """Prompt for a directory and run every matching file through *package*, one after another. Each file is executed via its on-disk path (``--execute_file``) so large action JSON never trips the Windows ~32K command-line limit, and one run window is opened per file, which reports that file's run, a run that cannot start included. The broad guard here only keeps a single bad directory pick from crashing the menu callback. + + They used to start all at once: every run writes its report to the same + ``default_name.html``, so the reports overwrote one another and a run's + mail could carry another run's report, and N browsers opened together. """ try: execute_list = _ask_for_action_files(main_window) if not execute_list: return - for execute_file in execute_list: - build_process_from_file(main_window, package, execute_file, send_mail, program_buffer) + run_one_after_another(main_window, package, list(execute_list), send_mail, program_buffer) except Exception as error: # noqa: BLE001 — batch UI action must not abort on one bad entry pybreeze_logger.error("%s multi file error: %r", package, error) +def run_one_after_another( + main_window: PyBreezeMainWindow, package: str, files: list[str], + send_mail: bool = False, program_buffer: int = 1024000) -> None: + """Run *files* through *package*, each in its own window once the one before has ended. + + A run that was stopped (its Stop, or the IDE closing) ends the batch; a + file whose run could not start is passed over. + """ + if not files: + return + first, rest = files[0], files[1:] + process: TaskProcessManager | None = None + + def next_file() -> None: + if process is not None and process.was_stopped: + return + run_one_after_another(main_window, package, rest, send_mail, program_buffer) + + process = build_process_from_file(main_window, package, first, send_mail, program_buffer, then=next_file) + if process.process is None: + # It never started, so it never ends: go on from the event loop + QTimer.singleShot(0, next_file) + + def _ask_for_action_files(main_window: PyBreezeMainWindow) -> list[str]: """Ask for a folder and return the action JSON files under it, none when cancelled. @@ -197,9 +226,12 @@ def build_task_process( main_window: PyBreezeMainWindow, send_mail: bool = False, program_buffer: int = 1024000, + then: Callable[[], None] | None = None, ) -> TaskProcessManager: """Open a fresh run window and the task process manager that writes to it. + *then* is called when the run has ended, after the report mail is started. + The run window carries the interpreter chosen in the IDE (the Python environment menu, or the saved setting), so the child runs with that interpreter; only when none was chosen does the manager fall back to a @@ -209,10 +241,19 @@ def build_task_process( code_window = open_run_window(main_window) code_window.python_compiler = main_window.python_compiler main_window.clear_code_result() + hooks = [hook for hook in (report_mail_hook(code_window) if send_mail else None, then) if hook] code_window.runner = TaskProcessManager( code_window, - task_done_trigger_function=report_mail_hook(code_window) if send_mail else None, + task_done_trigger_function=_one_after_another(hooks) if hooks else None, program_buffer_size=program_buffer, program_encoding=main_window.encoding, ) return code_window.runner + + +def _one_after_another(hooks: list[Callable[[], None]]) -> Callable[[], None]: + """One done-hook that calls each of *hooks* in turn.""" + def call_each() -> None: + for hook in hooks: + hook() + return call_each diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 5fcbba53..32523dfb 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -67,6 +67,8 @@ def __init__( self.run_error_queue: Queue = output_queue() self.process: subprocess.Popen | None = None self._reader_grace = ReaderGrace() + # Stop was asked for: a batch run that follows this one does not go on + self.was_stopped = False self.task_done_trigger_function: Callable = task_done_trigger_function self.error_trigger_function: Callable = error_trigger_function @@ -187,6 +189,7 @@ def stop(self) -> None: Only the child itself: processes it started are left to it. The run window reports the exit on the next pump, as for any other exit. """ + self.was_stopped = True if self.process is not None and self.process.poll() is None: self.process.terminate() diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index ecb61945..00cea49b 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -521,3 +521,66 @@ def test_stop_just_after_the_compile_runs_nothing(self, qt_app, tmp_path): text = window.code_result.toPlainText() assert "[Stopped]" in text and "[Run]" not in text + +def _logging_package(folder: Path, log: Path, seconds: float) -> None: + """A stand-in automation package: logs when it starts and ends on the file it is given.""" + package = folder / "fakepkg" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "__main__.py").write_text( + "import sys, time\n" + f"log = {str(log)!r}\n" + "name = sys.argv[sys.argv.index('--execute_file') + 1]\n" + "open(log, 'a', encoding='utf-8').write(f'start {name}\\n')\n" + f"time.sleep({seconds})\n" + "open(log, 'a', encoding='utf-8').write(f'end {name}\\n')\n", + encoding="utf-8") + + +class TestRunningAFolder: + """The files of a folder run one after another: their reports went to one file.""" + + def _files(self, folder: Path, count: int) -> list[str]: + files = [] + for index in range(count): + path = folder / f"case{index}.json" + path.write_text("[]", encoding="utf-8") + files.append(str(path)) + return files + + def test_each_file_starts_after_the_one_before_has_ended(self, qt_app, tmp_path, monkeypatch): + from pybreeze.extend.process_executor.process_executor_utils import run_one_after_another + + log = tmp_path / "log.txt" + _logging_package(tmp_path, log, seconds=0.5) + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + main_window = MainWindow(python_compiler=sys.executable) + files = self._files(tmp_path, 3) + + run_one_after_another(main_window, "fakepkg", files) + _run_events_until(qt_app, lambda: log.exists() and log.read_text(encoding="utf-8").count("end") == 3) + + events = log.read_text(encoding="utf-8").split() + assert events == [word for name in files for word in ("start", name, "end", name)] + assert len(main_window.current_run_code_window) == 3 + + def test_a_stopped_run_ends_the_batch(self, qt_app, tmp_path, monkeypatch): + from pybreeze.extend.process_executor.process_executor_utils import run_one_after_another + + log = tmp_path / "log.txt" + _logging_package(tmp_path, log, seconds=20) + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + main_window = MainWindow(python_compiler=sys.executable) + + run_one_after_another(main_window, "fakepkg", self._files(tmp_path, 3)) + first = main_window.current_run_code_window[0] + _run_events_until(qt_app, lambda: log.exists()) + first.stop_runner() + _run_events_until(qt_app, lambda: "Task exit with code" in first.code_result.toPlainText()) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + qt_app.processEvents() + time.sleep(0.05) + + assert len(main_window.current_run_code_window) == 1 + From 80ded25205aa885c9518c4c4ec70d687763d635e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:39:15 +0800 Subject: [PATCH 189/312] Stop a run's whole process tree, not only its direct child --- architecture_explore.md | 6 +-- docs/updates/2026-09.md | 11 +++++ docs/updates/README.md | 3 +- progress.md | 1 - .../process_executor/file_runner_process.py | 19 +++++---- .../python_task_process_manager.py | 15 ++++--- pybreeze/utils/subprocess_util.py | 41 ++++++++++++++++++- test/test_utils/test_run_output.py | 38 +++++++++++++++++ 8 files changed, 116 insertions(+), 18 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 4b594b05..929ef366 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -96,7 +96,7 @@ Template Method 定義的子行程生命週期: | 讀取 | 兩條 daemon Thread 各自經 `queue_pump.read_stream_into_queue()` 對 stdout / stderr `read1()`(有多少讀多少,不等換行:沒換行的狀態列與用 `\r` 重畫的進度條才會即時出現;沒有 `read1` 的文字串流才逐行讀),原樣塞進 `Queue`(保留縮排、行尾與空行);**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | | 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則,交給 `CodeWindow.append_output()` | | 收尾 | 子行程結束後,pump 照樣每 tick 抽 queue,直到兩條 reader 都讀到 EOF 或寬限(`ReaderGrace`,2 秒,那個 tick 還有輸出就重新起算,最長到結束後 30 秒)用完,UI 執行緒不 join 任何執行緒。`exit_program()`:drain queue(`max_messages=None` 一次抽乾)→ reader 還活著(子行程開的行程還握著管線)就在視窗註明之後的輸出不會顯示 → `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | -| 停止 | `stop()`:子行程還在跑就 `terminate()`(只有它本身,它再開的行程不管),之後照一般結束的路徑回報。經 `CodeWindow.stop_runner()` 呼叫;關閉 IDE 時 `PyBreezeMainWindow.closeEvent()` 對每個執行視窗都呼叫一次(經 `_close_guarded()`:一個視窗、分頁或 dock 關閉時丟例外只記錄,其餘照關,JEditor 自己的 `closeEvent` 一定會跑到) | +| 停止 | `stop()`:子行程還在跑就 `stop_tree()`(連它開的行程一起:Windows 用 `taskkill /T /F`,POSIX 對子行程自己的 process group 送 SIGTERM;`go run`、`cargo run` 的程式與網頁執行開的瀏覽器是孫行程,只停子行程會留下它們),失敗就退回只 `terminate()` 子行程,之後照一般結束的路徑回報。經 `CodeWindow.stop_runner()` 呼叫;關閉 IDE 時 `PyBreezeMainWindow.closeEvent()` 對每個執行視窗都呼叫一次(經 `_close_guarded()`:一個視窗、分頁或 dock 關閉時丟例外只記錄,其餘照關,JEditor 自己的 `closeEvent` 一定會跑到) | 三種啟動介面: @@ -142,7 +142,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - **`file_runner_process.py`** — `FileRunnerProcess`。**唯一不跑 Python 的執行器**,服務插件註冊的 run config: - 直譯式:`compiler [args...] file`(如 `go run main.go`) - 編譯式:`compiler file -o out` → 執行 `out` → 執行完整個建置資料夾刪掉。`out` 建在 `tempfile.mkdtemp()` 開的資料夾裡,不在原始檔旁邊(旁邊同名的檔案會被蓋掉再刪掉,同一個檔案跑兩次也會互搶)。編譯器跟執行一樣走 `_start_process()`(輸出即時串流、不佔 UI 執行緒),結束碼交給 `after_exit`:0 才接著跑產物,否則印 `[Compile failed]` - - 編譯最多 `COMPILE_TIME_LIMIT_SECONDS`(60 秒),由 pump 檢查、超過就 `terminate()`;編譯中也能 `stop()`。QTimer 間隔 50 ms(比 Python 執行器更快),編譯與執行共用同一個 + - 編譯最多 `COMPILE_TIME_LIMIT_SECONDS`(60 秒),由 pump 檢查、超過就 `stop_tree()`;編譯中也能 `stop()`。QTimer 間隔 50 ms(比 Python 執行器更快),編譯與執行共用同一個 - 讀取、pump、drain 與寫進視窗都用 §4.5 的共用函式 - `run_current_file_with()` 建好後同樣掛在執行視窗的 `runner` 上;輸出用執行設定的 `"encoding"` 解碼(`output_encoding()`,`"locale"` 表示本機字碼頁,Java 與中文化的編譯器就是輸出本機字碼頁),沒寫就用 IDE 的編碼 - 有和 `TaskProcessManager` 相同語意的 `stop()` @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1884 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1885 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index e9fc2d26..3d61846c 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2649,3 +2649,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: executor, run-folder, mail and close tests 39 + 2 passed; 1884 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `python_task_process_manager.py`, `test/test_utils/test_run_output.py`, `progress.md` (#81 removed), `architecture_explore.md` §4, §18 - **Open items**: none. + +## U-20260923-154 · 2026-09-24 · Stop a run's whole process tree, not only its direct child · #fix #executor #done + +- **What**: progress #79. Stop, the compile time limit and closing the IDE called `terminate()` on the run's direct child only (`python_task_process_manager.py`, `file_runner_process.py`); on Windows that ends one process. `go run`, `cargo run`, `dotnet run` and `npm start` run the user's program as a grandchild, a web run starts a browser and its driver, and gcc runs `cc1`/`as`/`ld`. Pressing Stop on `go run main.go` with an endless loop ended `go.exe` and left `main.exe` running, and closing the IDE left it running with no console at all. +- **Fix**: + - New `stop_tree(process)` in `utils/subprocess_util.py`. On Windows it runs `taskkill /T /F /PID` (by its full System32 path, argument list, 10 s timeout). On POSIX it sends SIGTERM to the child's process group; both executors now start their child in a session of its own (`own_session_options()`). If either fails, the child alone is terminated, as before. + - `TaskProcessManager.stop`, `FileRunnerProcess.stop` and the compile time limit use it, and so does closing the IDE, which stops runs through `stop_runner`. +- **Tests**: `test_run_output.py` +1 (Windows): a run whose child starts a grandchild and sleeps; after Stop, `tasklist` no longer lists the grandchild. +- **Result / numbers**: run output, IDE close and subprocess-util tests 34 passed; 1885 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/subprocess_util.py`, `pybreeze/extend/process_executor/python_task_process_manager.py`, `file_runner_process.py`, `test/test_utils/test_run_output.py`, `progress.md` (#79 removed), `architecture_explore.md` §4, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 4d498e4a..b2c04ba3 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-154 | 2026-09-24 | Stop a run's whole process tree, not only its direct child | #fix #executor #done | [2026-09](2026-09.md) | | U-20260923-153 | 2026-09-24 | Run a folder's files one after another, so each run has its own report and mail | #fix #executor #done | [2026-09](2026-09.md) | | U-20260923-152 | 2026-09-24 | Let a plugin run config name the encoding its program writes | #fix #plugins #executor #done | [2026-09](2026-09.md) | | U-20260923-151 | 2026-09-24 | Build compile-then-run binaries in a folder of their own, and make Stop during or just after a compile run nothing | #fix #executor #done | [2026-09](2026-09.md) | @@ -234,4 +235,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 171 | +| [2026-09.md](2026-09.md) | 2026-09 | 172 | diff --git a/progress.md b/progress.md index bb9e127c..1bca2909 100644 --- a/progress.md +++ b/progress.md @@ -13,5 +13,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). -- **#79** Stop and IDE close terminate only the direct child: a run config that starts the program as a grandchild (`go run`, `cargo run`, `dotnet run`, `npm start`) or a web run's browser keeps running with no window (`python_task_process_manager.py`, `file_runner_process.py`, `main_ui.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index e37cc2ff..0165a67e 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -29,7 +29,9 @@ ) from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow from pybreeze.utils.logging.logger import pybreeze_logger -from pybreeze.utils.subprocess_util import no_window_creationflags, utf8_subprocess_env +from pybreeze.utils.subprocess_util import ( + no_window_creationflags, own_session_options, stop_tree, utf8_subprocess_env, +) COMPILE_TIME_LIMIT_SECONDS = 60 @@ -158,6 +160,7 @@ def _start_process(self, command: list[str], cleanup_dir: str | None = None, shell=False, creationflags=no_window_creationflags(), env=utf8_subprocess_env(self.program_encoding), + **own_session_options(), ) except FileNotFoundError: self.main_window.append_output(f"[Error] Command not found: {command[0]}\n", is_error=True) @@ -196,13 +199,15 @@ def _start_process(self, command: list[str], cleanup_dir: str | None = None, def stop(self) -> None: """Stop the child if it is still running. - Only the child itself: processes it started are left to it. The run - window reports the exit on the next pump, as for any other exit. A - compile that is stopped, or that has just finished, starts no run. + The child and every process it started (``stop_tree``): ``go run``, + ``cargo run`` and ``dotnet run`` start the program as a grandchild, + which Stop used to leave running. The run window reports the exit on + the next pump, as for any other exit. A compile that is stopped, or that + has just finished, starts no run. """ self._cancelled = True - if self.process is not None and self.process.poll() is None: - self.process.terminate() + if self.process is not None: + stop_tree(self.process) def _pull_text(self) -> None: """Timer callback: pump queues to UI.""" @@ -221,7 +226,7 @@ def _pull_text(self) -> None: self._deadline = None self.main_window.append_output( f"[Error] Timed out after {COMPILE_TIME_LIMIT_SECONDS}s\n", is_error=True) - self.process.terminate() + stop_tree(self.process) def _finish(self) -> None: """Clean up after process exits.""" diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 32523dfb..9ec6fad2 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -23,7 +23,9 @@ ) from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow from pybreeze.utils.logging.logger import pybreeze_logger -from pybreeze.utils.subprocess_util import no_window_creationflags, utf8_subprocess_env +from pybreeze.utils.subprocess_util import ( + no_window_creationflags, own_session_options, stop_tree, utf8_subprocess_env, +) def find_venv_path() -> Path: @@ -153,6 +155,7 @@ def _spawn_and_pump( stderr=subprocess.PIPE, creationflags=no_window_creationflags(), env=child_environment, + **own_session_options(), ) except OSError as error: # An interpreter that is gone, or a command line over Windows' @@ -186,12 +189,14 @@ def _spawn_and_pump( def stop(self) -> None: """Stop the child if it is still running. - Only the child itself: processes it started are left to it. The run - window reports the exit on the next pump, as for any other exit. + The child and every process it started (``stop_tree``): a browser a + web run opened, or a program a launcher started, used to be left + running. The run window reports the exit on the next pump, as for any + other exit. """ self.was_stopped = True - if self.process is not None and self.process.poll() is None: - self.process.terminate() + if self.process is not None: + stop_tree(self.process) # Pyside UI update method def pull_text(self): diff --git a/pybreeze/utils/subprocess_util.py b/pybreeze/utils/subprocess_util.py index d8e10682..5ebfe54f 100644 --- a/pybreeze/utils/subprocess_util.py +++ b/pybreeze/utils/subprocess_util.py @@ -2,9 +2,15 @@ from __future__ import annotations import os -import subprocess +import signal +import subprocess # nosec B404 — fixed argument lists, never a shell import sys +from pybreeze.utils.logging.logger import pybreeze_logger + +# How long ending a process tree may take on the UI thread +_TREE_KILL_SECONDS = 10 + def utf8_subprocess_env(encoding: str = "utf-8") -> dict[str, str]: """Return a copy of ``os.environ`` forcing a child Python's stdio *encoding*. @@ -31,3 +37,36 @@ def no_window_creationflags() -> int: if sys.platform == "win32": return subprocess.CREATE_NO_WINDOW return 0 + + +def own_session_options() -> dict: + """``Popen`` options that give the child a process group of its own (POSIX), for :func:`stop_tree`.""" + return {} if sys.platform == "win32" else {"start_new_session": True} + + +def stop_tree(process: subprocess.Popen) -> None: + """Stop *process* and every process it started, if it is still running. + + Stopping only the child left the program running whenever the child was a + launcher: ``go run``, ``cargo run``, ``dotnet run`` and ``npm start`` start + the user's program as a grandchild, and a web run starts a browser. On + Windows ``taskkill /T /F`` ends the tree; on POSIX the child's process + group (see :func:`own_session_options`) is sent SIGTERM. Either failing, + the child alone is terminated, as before. + """ + if process.poll() is not None: + return + try: + if sys.platform == "win32": + taskkill = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "taskkill.exe") + subprocess.run( # nosec B603 — fixed argument list, no shell + [taskkill, "/T", "/F", "/PID", str(process.pid)], + capture_output=True, timeout=_TREE_KILL_SECONDS, check=False, shell=False, + creationflags=no_window_creationflags()) + else: + os.killpg(process.pid, signal.SIGTERM) + except (OSError, subprocess.SubprocessError) as error: + pybreeze_logger.debug("Could not stop the process tree of %s: %r", process.pid, error) + if process.poll() is None: + process.terminate() + diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index 00cea49b..a434f0a2 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -584,3 +584,41 @@ def test_a_stopped_run_ends_the_batch(self, qt_app, tmp_path, monkeypatch): assert len(main_window.current_run_code_window) == 1 + +def _is_running(pid: int) -> bool: + """Whether a process with *pid* is still there (not os.kill: on Windows that ends it).""" + import subprocess + + listed = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH"], capture_output=True, + text=True, timeout=30, check=False) + return str(pid) in listed.stdout + + +@pytest.mark.skipif(os.name != "nt", reason="checked with tasklist") +def test_stop_ends_what_the_run_started_too(qt_app, tmp_path): + # A launcher's program (go run, cargo run) or a web run's browser was left + # running: only the direct child was stopped + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + script = tmp_path / "launcher.py" + script.write_text( + "import subprocess, sys, time\n" + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n" + "print('grandchild', child.pid, flush=True)\n" + "time.sleep(60)\n", + encoding="utf-8") + main_window = MainWindow(python_compiler=sys.executable) + build_task_process(main_window).start_module_process( + "launcher", [], environment={"PYTHONPATH": str(tmp_path)}) + run_window = main_window.current_run_code_window[0] + _run_events_until(qt_app, lambda: "grandchild" in run_window.code_result.toPlainText()) + line = next(text for text in run_window.code_result.toPlainText().splitlines() if text.startswith("grandchild")) + grandchild = int(line.split()[1]) + try: + run_window.stop_runner() + _run_events_until(qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) + + assert not _is_running(grandchild) + finally: + _stop_grandchild(run_window.code_result.toPlainText()) + From aaee67f64b17a1663c2a6ac887bb5d2fe05d80d3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 03:50:53 +0800 Subject: [PATCH 190/312] Bring two tests in line with the compile folder and the JupyterLab tab's delete-on-close --- docs/updates/2026-09.md | 12 ++++++++++++ docs/updates/README.md | 3 ++- test/test_utils/test_compile_then_run.py | 17 +++++++++++------ test/test_utils/test_jupyter_lifecycle.py | 7 +++++-- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 3d61846c..1c2e78ae 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2660,3 +2660,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: run output, IDE close and subprocess-util tests 34 passed; 1885 tests in 111 files. `ruff check` clean. - **Files**: `pybreeze/utils/subprocess_util.py`, `pybreeze/extend/process_executor/python_task_process_manager.py`, `file_runner_process.py`, `test/test_utils/test_run_output.py`, `progress.md` (#79 removed), `architecture_explore.md` §4, §18 - **Open items**: none. + +## U-20260923-155 · 2026-09-24 · Bring two tests in line with the compile folder and the JupyterLab tab's delete-on-close · #test + +- **What**: the full suite failed after U-20260923-151 and U-20260923-149: + - `test_compile_then_run.py` stubbed `_start_process` with the old `cleanup_binary` parameter, and expected the binary beside the source. All four of its tests failed with `TypeError`. The file was not among those run for U-20260923-151. + - U-20260923-149's new test delivered every pending deferred delete (`sendPostedEvents(None, DeferredDelete)`). In the full run that reached objects earlier tests had left, and the interpreter crashed with an access violation. It also read `WA_DeleteOnClose` after `close()`, which clears it as it schedules the delete. +- **Fix**: + - The compile test's stub takes `cleanup_dir`, and the run test now checks that the binary is `main(.exe)` inside the build folder handed over for cleanup, not in the source folder. + - The JupyterLab test reads the attribute before `close()` and delivers deferred deletes to the tab only. +- **Result / numbers**: first half of the suite 1044 passed; second half 826 passed, 15 skipped; both startup tests exit 0. `ruff check` clean. +- **Files**: `test/test_utils/test_compile_then_run.py`, `test_jupyter_lifecycle.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index b2c04ba3..c804f630 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-155 | 2026-09-24 | Bring two tests in line with the compile folder and the JupyterLab tab's delete-on-close | #test | [2026-09](2026-09.md) | | U-20260923-154 | 2026-09-24 | Stop a run's whole process tree, not only its direct child | #fix #executor #done | [2026-09](2026-09.md) | | U-20260923-153 | 2026-09-24 | Run a folder's files one after another, so each run has its own report and mail | #fix #executor #done | [2026-09](2026-09.md) | | U-20260923-152 | 2026-09-24 | Let a plugin run config name the encoding its program writes | #fix #plugins #executor #done | [2026-09](2026-09.md) | @@ -235,4 +236,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 172 | +| [2026-09.md](2026-09.md) | 2026-09 | 173 | diff --git a/test/test_utils/test_compile_then_run.py b/test/test_utils/test_compile_then_run.py index 58cb21ca..5c7d0ebe 100644 --- a/test/test_utils/test_compile_then_run.py +++ b/test/test_utils/test_compile_then_run.py @@ -4,6 +4,7 @@ import os import sys import time +from pathlib import Path os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -47,11 +48,11 @@ def _runner(monkeypatch): runs: list = [] real_start = runner._start_process - def start(command, cleanup_binary=None, after_exit=None, time_limit=None): + def start(command, cleanup_dir=None, after_exit=None, time_limit=None): if after_exit is None: # the run of the compiled binary - runs.append((command, cleanup_binary)) + runs.append((command, cleanup_dir)) return - real_start(command, cleanup_binary, after_exit, time_limit) + real_start(command, cleanup_dir, after_exit, time_limit) monkeypatch.setattr(runner, "_start_process", start) return window, runner, runs @@ -77,9 +78,13 @@ def test_the_compile_does_not_hold_the_ui_and_the_run_follows(self, qt_app, tmp_ go.touch() _run_events_until(qt_app, lambda: runs) - expected = str(source.with_suffix("")) + (".exe" if sys.platform == "win32" else "") - assert runs == [([expected], expected)] - assert f"[Run] {expected}" in window.code_result.toPlainText() + # Built in a folder of its own, which goes once it has run: beside the + # source it replaced a file of that name there + [(command, build_dir)] = runs + built = Path(command[0]) + assert built.name == "main" + (".exe" if sys.platform == "win32" else "") + assert built.parent == Path(build_dir) and built.parent != tmp_path + assert f"[Run] {built}" in window.code_result.toPlainText() def test_a_failed_compile_is_reported_and_nothing_runs(self, qt_app, tmp_path, monkeypatch): config = _compiler(tmp_path, "print('main.c:1: error', file=sys.stderr)\nsys.exit(3)\n") diff --git a/test/test_utils/test_jupyter_lifecycle.py b/test/test_utils/test_jupyter_lifecycle.py index 12165541..36c3687f 100644 --- a/test/test_utils/test_jupyter_lifecycle.py +++ b/test/test_utils/test_jupyter_lifecycle.py @@ -220,14 +220,17 @@ def test_closing_the_tab_does_not_wait_for_the_launcher(self, app, monkeypatch): def test_a_closed_tab_is_deleted_with_its_web_view(app, monkeypatch): # close_tab removes the tab but keeps the widget: every closed tab kept a # web view and its renderer until the IDE exited - from PySide6.QtCore import QCoreApplication, QEvent + from PySide6.QtCore import QCoreApplication, QEvent, Qt monkeypatch.setattr(jupyter_lab_widget, "JupyterLauncherThread", FinishedLauncher) tab = jupyter_lab_widget.JupyterLabWidget() gone: list = [] tab.browser.destroyed.connect(lambda *_: gone.append("browser")) + # Read before close(), which clears it as it schedules the delete + assert tab.testAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) tab.close() - QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + # Only this tab's: every deferred delete reached objects earlier tests had left + QCoreApplication.sendPostedEvents(tab, QEvent.Type.DeferredDelete) assert gone == ["browser"] From 6e0b379444cbf188f6a3026deb8151c7f1640466 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:04:36 +0800 Subject: [PATCH 191/312] Show server and file text in message boxes and labels as text, never as markup --- CLAUDE.md | 2 +- architecture_explore.md | 2 +- docs/updates/2026-09.md | 20 ++++++ docs/updates/README.md | 3 +- progress.md | 4 ++ .../connect_gui/ssh/ssh_file_viewer_widget.py | 20 +++--- .../editor_main/file_tree_context_menu.py | 15 ++-- pybreeze/pybreeze_ui/plain_text.py | 20 ++++++ .../pybreeze_ui/tools_gui/har_import_gui.py | 4 ++ test/test_utils/test_plain_text.py | 69 +++++++++++++++++++ 10 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 pybreeze/pybreeze_ui/plain_text.py create mode 100644 test/test_utils/test_plain_text.py diff --git a/CLAUDE.md b/CLAUDE.md index 897c322a..1d9dffd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ Reference implementations: `utils/network/url_validation.py` (`validate_url`), ` **File I/O** — dialog-chosen paths are trusted; paths loaded from saved data (`.diagram.json`) are not: check `is_file()` and an extension allowlist, or run URLs through SSRF validation. Use `pathlib`, never string concatenation. Write to `~/.pybreeze/` via `app_dirs.pybreeze_data_dir()` with `encoding="utf-8"`; read through `pybreeze_data_path()`, which creates nothing. Replace a file the user would lose through `utils/file_process/replace_file.replace_text` (written beside it, then moved into place), never an in-place `write_text`; a file something else writes (an image, an SVG export) goes through `replace_written`. Resolve symlinks with `Path.resolve(strict=True)` and verify the result stays in bounds. -**Qt** — `QGraphicsTextItem` text interaction must not be on by default (double-click to edit). Plugin loading takes only `.py` files, skipping `_`/`.` prefixes. `QWebEngineView.setUrl()` only for localhost or user-confirmed URLs; never `setHtml()` with unsanitised content. +**Qt** — `QGraphicsTextItem` text interaction must not be on by default (double-click to edit). Text from a server or a file (a remote path, an error message, a host name, a file name) never reaches a `QMessageBox` or `QLabel` as it is: Qt reads markup in it (`Qt::AutoText`) and loads an ``. Pass it through `pybreeze_ui/plain_text.as_text()`, or give the label `Qt.TextFormat.PlainText`. Plugin loading takes only `.py` files, skipping `_`/`.` prefixes. `QWebEngineView.setUrl()` only for localhost or user-confirmed URLs; never `setHtml()` with unsanitised content. **Secrets** — SSH passwords and passphrases stay in memory for the session only. A secret that must persist (the prthinker keys and token) is written with `replace_text(..., private=True)` under the `0700` data folder. Password fields use `QLineEdit.EchoMode.Password`. diff --git a/architecture_explore.md b/architecture_explore.md index 929ef366..377e77f2 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 111 個 `test_*.py`、1885 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 112 個 `test_*.py`、1892 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 1c2e78ae..ff0c5dc3 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2672,3 +2672,23 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: first half of the suite 1044 passed; second half 826 passed, 15 skipped; both startup tests exit 0. `ruff check` clean. - **Files**: `test/test_utils/test_compile_then_run.py`, `test_jupyter_lifecycle.py` - **Open items**: none. + +## U-20260923-156 · 2026-09-24 · Show server and file text in message boxes and labels as text, never as markup Qt would load · #fix #security #ssh + +- **What**: two findings of the cross-cutting security review. `QMessageBox` and `QLabel` take text as rich text when its first line looks like HTML (`Qt::AutoText`). + - **SFTP boxes**: the tree showed server-controlled text in them: remote paths and file names, and error messages. That covers connect failed, listing failed, operation failed, delete confirm, replace confirm, upload done and transfer failed. On connect, paramiko quotes the server's first line (`Indecipherable protocol version "..."`) before any host key is checked, so a man in the middle could put `` in the box. The review checked offscreen that such an image loads. A UNC source makes Windows connect out and offer the user's NTLM credentials, and links in the box open through the system handler. + - **HAR summary**: the HAR tab's summary label lists host names from the file, which `url_is_well_formed` does not restrict to host characters. + - **File tree**: its messages quote file names, which may be markup in a project cloned from elsewhere (the names are legal off Windows). +- **Fix**: + - New `pybreeze_ui/plain_text.as_text(text)` (`Qt.convertFromPlainText`) turns text into markup that displays it exactly: `<`, `>` and `&` escaped, line breaks kept. The static `QMessageBox` calls stay as they were, so the tests that patch them still apply. + - Every SFTP box that shows server text goes through it, as do the file tree's messages that quote a name or an OS error, and its rename prompt. + - The HAR summary label is `Qt.TextFormat.PlainText`. + - CLAUDE.md § Security › Qt now says so. +- **Tests**: new `test_plain_text.py`, 7 tests: + - Four texts, markup and plain, display exactly as given. + - A hostile SFTP connect error reaches the box escaped. + - The HAR summary is plain text. + - A file-tree message quotes a markup name escaped. +- **Result / numbers**: SSH, SFTP, file tree and HAR tests 203 passed; 1892 tests in 112 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/plain_text.py` (new), `connect_gui/ssh/ssh_file_viewer_widget.py`, `tools_gui/har_import_gui.py`, `editor_main/file_tree_context_menu.py`, `test/test_utils/test_plain_text.py`, `CLAUDE.md`, `architecture_explore.md` §18 +- **Open items**: the review's other findings are in progress.md. diff --git a/docs/updates/README.md b/docs/updates/README.md index c804f630..9a0e056b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-156 | 2026-09-24 | Show server and file text in message boxes and labels as text, never as markup Qt would load | #fix #security #ssh | [2026-09](2026-09.md) | | U-20260923-155 | 2026-09-24 | Bring two tests in line with the compile folder and the JupyterLab tab's delete-on-close | #test | [2026-09](2026-09.md) | | U-20260923-154 | 2026-09-24 | Stop a run's whole process tree, not only its direct child | #fix #executor #done | [2026-09](2026-09.md) | | U-20260923-153 | 2026-09-24 | Run a folder's files one after another, so each run has its own report and mail | #fix #executor #done | [2026-09](2026-09.md) | @@ -236,4 +237,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 173 | +| [2026-09.md](2026-09.md) | 2026-09 | 174 | diff --git a/progress.md b/progress.md index 1bca2909..5f689aeb 100644 --- a/progress.md +++ b/progress.md @@ -14,3 +14,7 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? +- **#91** Generated scripts can be given code through U+2028: a `curl -b ` name goes into a `#` comment (`curl_import/request_codegen.py`, `cookie_file_notes`) with U+2028 kept, and JEditor's save and run turn it into a line break, so text after it runs as code. `har_codegen._numbered_comment` has the same gap. Escape U+2028, U+2029 and U+0085 in `python_string` and in comments. +- **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. +- **#93** The HAR tab and the diagram editor read an opened file whole on the UI thread with no size limit (`har_import_gui.py`, `diagram_editor_widget.py`): a multi-GB file freezes the IDE and a `MemoryError` escapes the slot. +- **#94** The SFTP download's suggested file name keeps backslashes from the server's name (`posixpath.basename`), so `..\..\AppData\...\Startup\u.bat` is offered as the save name; suggest only the last Windows-safe component. diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index f16d038c..d3b66de2 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -24,6 +24,7 @@ from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import CONNECT_ERRORS, SshConnectThread from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import host_key_asker from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget +from pybreeze.pybreeze_ui.plain_text import as_text from pybreeze.pybreeze_ui.thread_keeper import let_run_out @@ -170,7 +171,8 @@ def _on_connect_failed(self, message: str) -> None: QMessageBox.critical( self, self.word_dict.get("ssh_file_viewer_dialog_title_connection_failed"), - f"{self.word_dict.get('ssh_file_viewer_dialog_message_connection_failed')}: {message}") + # The server's text (paramiko quotes its first line): shown as text + as_text(f"{self.word_dict.get('ssh_file_viewer_dialog_message_connection_failed')}: {message}")) self.state_changed.emit() def closeEvent(self, event) -> None: @@ -334,8 +336,8 @@ def _listing_failed(self, parent_item: QTreeWidgetItem, generation: int, serial: QMessageBox.critical( self, self.word_dict.get("ssh_file_viewer_dialog_title_list_error"), - f"{self.word_dict.get('ssh_file_viewer_dialog_message_list_failed')} " - f"'{parent_item.text(3)}': {message}") + as_text(f"{self.word_dict.get('ssh_file_viewer_dialog_message_list_failed')} " + f"'{parent_item.text(3)}': {message}")) def _add_entry_row(self, parent_item: QTreeWidgetItem, path: str, name: str, entry) -> None: full_path = remote_join(path, name) @@ -441,7 +443,7 @@ def _operation_failed(self, message: str) -> None: QMessageBox.critical( self, self.word_dict.get("ssh_file_viewer_dialog_title_operation_failed"), - f"{self.word_dict.get('ssh_file_viewer_dialog_message_operation_failed')}: {message}") + as_text(f"{self.word_dict.get('ssh_file_viewer_dialog_message_operation_failed')}: {message}")) def _checked_name(self, text: str) -> str | None: """*text* as one entry's name, or ``None`` after saying why it cannot be.""" @@ -495,7 +497,7 @@ def action_delete(self, item: QTreeWidgetItem | None): reply = QMessageBox.question( self, self.word_dict.get("ssh_file_viewer_dialog_title_confirm_delete"), - f"{self.word_dict.get('ssh_file_viewer_dialog_message_confirm_delete')} '{path}'?", + as_text(f"{self.word_dict.get('ssh_file_viewer_dialog_message_confirm_delete')} '{path}'?"), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: @@ -596,7 +598,7 @@ def _ask_to_replace(self, remote_path: str, replace: Callable[[], object]) -> No answer = QMessageBox.question( self, self.word_dict.get("ssh_file_viewer_dialog_title_confirm_replace"), - self.word_dict.get("ssh_file_viewer_dialog_message_confirm_replace").format(path=remote_path), + as_text(self.word_dict.get("ssh_file_viewer_dialog_message_confirm_replace").format(path=remote_path)), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) if answer == QMessageBox.StandardButton.Yes: @@ -608,7 +610,7 @@ def _ask_to_replace(self, remote_path: str, replace: Callable[[], object]) -> No def _transfer_done(self, title: str, message: str, path: str, after=None) -> None: """Say where the file ended up, and do whatever was waiting on it. UI thread.""" - QMessageBox.information(self, title, f"{message}: {path}") + QMessageBox.information(self, title, as_text(f"{message}: {path}")) if after is not None: after() @@ -617,8 +619,8 @@ def _transfer_failed(self, error_message: str) -> None: QMessageBox.critical( self, self.word_dict.get("ssh_file_viewer_dialog_title_operation_failed"), - f"{self.word_dict.get('ssh_file_viewer_dialog_message_operation_failed')}: " - f"{error_message}") + as_text(f"{self.word_dict.get('ssh_file_viewer_dialog_message_operation_failed')}: " + f"{error_message}")) def get_text(self, title: str, label: str): """ diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index dd2be7b0..55478808 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -20,6 +20,7 @@ ) from je_editor.pyside_ui.main_ui.editor.editor_widget_dock import FullEditorWidget +from pybreeze.pybreeze_ui.plain_text import as_text from pybreeze.utils.logging.logger import pybreeze_logger @@ -34,7 +35,7 @@ def _perform_file_op(tree_view: QTreeView, operation: Callable[[], None]) -> boo return True except OSError as error: pybreeze_logger.error("File tree operation failed: %r", error) - QMessageBox.warning(tree_view, word.get("file_tree_ctx_error"), str(error)) + QMessageBox.warning(tree_view, word.get("file_tree_ctx_error"), as_text(str(error))) return False @@ -166,7 +167,7 @@ def _action_new_file(tree_view: QTreeView, path: Path | None) -> None: QMessageBox.warning( tree_view, word.get("file_tree_ctx_error"), - word.get("file_tree_ctx_already_exists").format(name=str(new_path)), + as_text(word.get("file_tree_ctx_already_exists").format(name=str(new_path))), ) return def _create() -> None: @@ -193,7 +194,7 @@ def _action_new_folder(tree_view: QTreeView, path: Path | None) -> None: QMessageBox.warning( tree_view, word.get("file_tree_ctx_error"), - word.get("file_tree_ctx_already_exists").format(name=str(new_path)), + as_text(word.get("file_tree_ctx_already_exists").format(name=str(new_path))), ) return _perform_file_op(tree_view, lambda: new_path.mkdir(parents=True)) @@ -311,7 +312,7 @@ def _action_rename(tree_view: QTreeView, main_window, path: Path | None) -> None new_name, ok = QInputDialog.getText( tree_view, word.get("file_tree_ctx_rename"), - word.get("file_tree_ctx_input_new_name").format(name=path.name), + as_text(word.get("file_tree_ctx_input_new_name").format(name=path.name)), text=path.name, ) if not ok or not new_name.strip() or new_name.strip() == path.name: @@ -325,7 +326,7 @@ def _action_rename(tree_view: QTreeView, main_window, path: Path | None) -> None QMessageBox.warning( tree_view, word.get("file_tree_ctx_error"), - word.get("file_tree_ctx_already_exists").format(name=str(target)), + as_text(word.get("file_tree_ctx_already_exists").format(name=str(target))), ) return @@ -351,7 +352,7 @@ def _action_delete(tree_view: QTreeView, main_window, path: Path | None) -> None reply = QMessageBox.question( tree_view, word.get("file_tree_ctx_confirm_delete"), - word.get("file_tree_ctx_confirm_delete_message").format(name=str(path)), + as_text(word.get("file_tree_ctx_confirm_delete_message").format(name=str(path))), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: @@ -436,7 +437,7 @@ def _inside(tree_view: QTreeView, parent: Path, name: str, *, single: bool = Fal return target word = language_wrapper.language_word_dict QMessageBox.warning(tree_view, word.get("file_tree_ctx_error"), - word.get("file_tree_ctx_bad_name").format(name=name)) + as_text(word.get("file_tree_ctx_bad_name").format(name=name))) return None diff --git a/pybreeze/pybreeze_ui/plain_text.py b/pybreeze/pybreeze_ui/plain_text.py new file mode 100644 index 00000000..2624e49f --- /dev/null +++ b/pybreeze/pybreeze_ui/plain_text.py @@ -0,0 +1,20 @@ +"""Show text in a Qt message box or label exactly as it is, never as markup. + +``QMessageBox`` and ``QLabel`` take text as rich text when its first line looks +like HTML (``Qt::AutoText``). A file name, a remote path or an error message +from an SSH server or a file the user opened could therefore carry +````, which Qt then loads (a UNC path makes Windows connect out and +offer the user's credentials), or links and fake dialog text. +""" +from __future__ import annotations + +from PySide6.QtGui import Qt + + +def as_text(text: str) -> str: + """*text* as markup that displays it exactly: every ``<``, ``>`` and ``&`` escaped. + + For the static ``QMessageBox`` functions, which take the text as it is and + guess its format. Line breaks are kept; runs of spaces are not. + """ + return Qt.convertFromPlainText(text, Qt.WhiteSpaceMode.WhiteSpaceNormal) diff --git a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py index 5dc6a645..b1696ba9 100644 --- a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py @@ -10,6 +10,7 @@ from pathlib import Path +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QAbstractItemView, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QListWidget, QPushButton, QTextEdit, QVBoxLayout, QWidget @@ -56,6 +57,9 @@ def __init__(self, main_window=None) -> None: top_row.addWidget(self.api_only_check) self.summary_label = QLabel(word.get("har_import_empty_hint")) + # It lists the file's host names: plain text, or a host written as + # was loaded as an image + self.summary_label.setTextFormat(Qt.TextFormat.PlainText) self.entry_list = QListWidget() self.entry_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) diff --git a/test/test_utils/test_plain_text.py b/test/test_utils/test_plain_text.py new file mode 100644 index 00000000..9c607e5f --- /dev/null +++ b/test/test_utils/test_plain_text.py @@ -0,0 +1,69 @@ +"""Text from a server or a file is shown as text, never as markup Qt would load.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtGui import QTextDocument +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.plain_text import as_text + +_HOSTILE = 'Listing failed: & click' + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.mark.parametrize("text", [_HOSTILE, "plain words", "two\nlines", "a < b && c > d"]) +def test_what_is_shown_is_the_text_itself(app, text): + document = QTextDocument() + document.setHtml(as_text(text)) + + assert document.toPlainText() == text + + +def test_a_failed_sftp_listing_shows_the_servers_text_as_text(app, monkeypatch): + # A hostile server's error went into the box as HTML: an with a UNC + # path made Windows connect out + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + + shown: list = [] + monkeypatch.setattr(viewer.QMessageBox, "critical", staticmethod(lambda *args: shown.append(args[2]))) + tree = viewer.SSHFileTreeManager() + + tree._on_connect_failed('Indecipherable protocol version ""') + + assert "<img src=x>" in shown[0] and "x") + + assert "<b>" in shown[0] and "" not in shown[0] From 07fc8a83addbc9ed819ee87e8a821e13271a913d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:06:52 +0800 Subject: [PATCH 192/312] Escape U+2028, U+2029 and U+0085 in generated scripts so a pasted curl command cannot add code --- CLAUDE.md | 1 + architecture_explore.md | 2 +- docs/updates/2026-09.md | 13 ++++ docs/updates/README.md | 3 +- progress.md | 1 - pybreeze/utils/curl_import/request_codegen.py | 16 ++++- pybreeze/utils/har_import/har_codegen.py | 5 +- test/test_utils/test_codegen_line_breaks.py | 70 +++++++++++++++++++ 8 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 test/test_utils/test_codegen_line_breaks.py diff --git a/CLAUDE.md b/CLAUDE.md index 1d9dffd2..689f37c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ pybreeze/ │ ├── show_code_window/ # CodeWindow — subprocess output display │ ├── thread_keeper.py # let_run_out: a worker QThread outlives its closed widget │ ├── gui_thread_gc.py # Garbage collected on a GUI-thread timer, never on a worker +│ ├── plain_text.py # as_text: server/file text shown in message boxes as text, not markup │ ├── dialog/ # prthinker settings dialog │ └── syntax/ # Automation keyword highlighting definitions ├── extend/ diff --git a/architecture_explore.md b/architecture_explore.md index 377e77f2..9dd71686 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 112 個 `test_*.py`、1892 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 113 個 `test_*.py`、1901 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ff0c5dc3..b5d7d477 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2692,3 +2692,16 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: SSH, SFTP, file tree and HAR tests 203 passed; 1892 tests in 112 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/plain_text.py` (new), `connect_gui/ssh/ssh_file_viewer_widget.py`, `tools_gui/har_import_gui.py`, `editor_main/file_tree_context_menu.py`, `test/test_utils/test_plain_text.py`, `CLAUDE.md`, `architecture_explore.md` §18 - **Open items**: the review's other findings are in progress.md. + +## U-20260923-157 · 2026-09-24 · Escape U+2028, U+2029 and U+0085 in generated scripts, so a pasted curl command cannot add code through a comment · #fix #security #tools #done + +- **What**: progress #91, from the cross-cutting security review. `python_string` (`utils/curl_import/request_codegen.py`) writes text with `json.dumps(ensure_ascii=False)`, which leaves U+2028, U+2029 and U+0085 as they are. `cookie_file_notes` puts a `curl -b ` name into a `#` comment. "Open in editor" keeps the character, but JEditor saves and runs a tab through `toPlainText()`, which turns it into a real line break, so the text after it became a line of code. The review confirmed that `curl https://x -b "sraise SystemExit('PWNED')#"`, copied from a page, produced a script that ran the injected line before its request. `har_codegen._numbered_comment` escaped ASCII controls only; it was safe only because the URL also went into a string literal. +- **Fix**: + - New `escape_line_breaks()` writes those three as `\uXXXX`. `python_string` applies it, which covers every string literal and the cookie-file comment. + - The HAR comment's escaper takes them as well, and writes all escapes as `\uXXXX`. +- **Tests**: new `test_codegen_line_breaks.py`, 9 tests: + - Each of the three breaks stays an escape in a string literal and reads back as the same text. + - A curl cookie-file name and a HAR URL carrying each break and `raise SystemExit(1)` produce scripts with no `raise`, after a round trip through a `QPlainTextEdit` as JEditor does. +- **Result / numbers**: codegen tests 271 + 9 passed; 1901 tests in 113 files. `ruff check` clean. +- **Files**: `pybreeze/utils/curl_import/request_codegen.py`, `pybreeze/utils/har_import/har_codegen.py`, `test/test_utils/test_codegen_line_breaks.py`, `progress.md` (#91 removed), `CLAUDE.md` (tree: `plain_text.py`), `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9a0e056b..cf28af36 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-157 | 2026-09-24 | Escape U+2028, U+2029 and U+0085 in generated scripts, so a pasted curl command cannot add code through a comment | #fix #security #tools #done | [2026-09](2026-09.md) | | U-20260923-156 | 2026-09-24 | Show server and file text in message boxes and labels as text, never as markup Qt would load | #fix #security #ssh | [2026-09](2026-09.md) | | U-20260923-155 | 2026-09-24 | Bring two tests in line with the compile folder and the JupyterLab tab's delete-on-close | #test | [2026-09](2026-09.md) | | U-20260923-154 | 2026-09-24 | Stop a run's whole process tree, not only its direct child | #fix #executor #done | [2026-09](2026-09.md) | @@ -237,4 +238,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 174 | +| [2026-09.md](2026-09.md) | 2026-09 | 175 | diff --git a/progress.md b/progress.md index 5f689aeb..1e20771c 100644 --- a/progress.md +++ b/progress.md @@ -14,7 +14,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? -- **#91** Generated scripts can be given code through U+2028: a `curl -b ` name goes into a `#` comment (`curl_import/request_codegen.py`, `cookie_file_notes`) with U+2028 kept, and JEditor's save and run turn it into a line break, so text after it runs as code. `har_codegen._numbered_comment` has the same gap. Escape U+2028, U+2029 and U+0085 in `python_string` and in comments. - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. - **#93** The HAR tab and the diagram editor read an opened file whole on the UI thread with no size limit (`har_import_gui.py`, `diagram_editor_widget.py`): a multi-GB file freezes the IDE and a `MemoryError` escapes the slot. - **#94** The SFTP download's suggested file name keeps backslashes from the server's name (`posixpath.basename`), so `..\..\AppData\...\Startup\u.bat` is offered as the save name; suggest only the last Windows-safe component. diff --git a/pybreeze/utils/curl_import/request_codegen.py b/pybreeze/utils/curl_import/request_codegen.py index 1b19b2f0..a54c0a8b 100644 --- a/pybreeze/utils/curl_import/request_codegen.py +++ b/pybreeze/utils/curl_import/request_codegen.py @@ -102,10 +102,24 @@ def python_string(text: str) -> str: surrogates: one character to JSON, two to Python, which then cannot send them. Written as itself it is one character to both. A lone surrogate, which cannot be written to a UTF-8 file, falls back to ``repr``. + + The line breaks JSON leaves as they are (U+0085, U+2028, U+2029) are + escaped: JEditor saves and runs a tab through ``toPlainText()``, which makes + them real line breaks, so in a comment the text after one became code. """ if _SURROGATE.search(text): return repr(text) - return json.dumps(text, ensure_ascii=False) + return escape_line_breaks(json.dumps(text, ensure_ascii=False)) + + +# Line breaks outside ASCII: JSON does not escape them, and a Qt editor turns +# them into real ones +_UNICODE_LINE_BREAKS = re.compile("[\x85\u2028\u2029]") + + +def escape_line_breaks(text: str) -> str: + """*text* with every U+0085, U+2028 and U+2029 written as its ``\\uXXXX`` escape.""" + return _UNICODE_LINE_BREAKS.sub(lambda match: f"\\u{ord(match.group()):04x}", text) def python_literal(value: object, *, inline: bool = False, level: int = 0) -> str: diff --git a/pybreeze/utils/har_import/har_codegen.py b/pybreeze/utils/har_import/har_codegen.py index fd973dba..53ad9d19 100644 --- a/pybreeze/utils/har_import/har_codegen.py +++ b/pybreeze/utils/har_import/har_codegen.py @@ -50,7 +50,8 @@ def unique_test_names(requests: list[CurlRequest]) -> list[str]: # Characters that must not reach a comment as they are: they would end it -_CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]") +# ASCII controls, and the line breaks outside ASCII that a Qt editor makes real +_CONTROL_CHARACTERS = re.compile("[\x00-\x1f\x7f\x85\u2028\u2029]") def _numbered_comment(index: int, request: CurlRequest) -> str: @@ -60,7 +61,7 @@ def _numbered_comment(index: int, request: CurlRequest) -> str: in a comment ends it: control characters are written as escapes. """ text = f"{request.method} {request.full_url}" - return f"# {index}. " + _CONTROL_CHARACTERS.sub(lambda match: f"\\x{ord(match.group()):02x}", text) + return f"# {index}. " + _CONTROL_CHARACTERS.sub(lambda match: f"\\u{ord(match.group()):04x}", text) def _requests_script(requests: list[CurlRequest]) -> str: diff --git a/test/test_utils/test_codegen_line_breaks.py b/test/test_utils/test_codegen_line_breaks.py new file mode 100644 index 00000000..6f8aae73 --- /dev/null +++ b/test/test_utils/test_codegen_line_breaks.py @@ -0,0 +1,70 @@ +"""A generated script stays the script it was, after a Qt editor has held it. + +JEditor saves and runs a tab through ``toPlainText()``, which turns U+2028, +U+2029 and U+0085 into real line breaks. JSON leaves them as they are, so text +after one in a comment became a line of code: a curl command pasted from a page +could run anything in the script it generated. +""" +from __future__ import annotations + +import ast +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QPlainTextEdit + +from pybreeze.utils.curl_import.curl_parser import parse_curl +from pybreeze.utils.curl_import.request_codegen import python_string +from pybreeze.utils.curl_import.script_templates import generate_template +from pybreeze.utils.har_import.har_codegen import generate_har_script + +_BREAKS = ["\u2028", "\u2029", "\x85"] + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def _through_an_editor(code: str) -> str: + """*code* as JEditor saves or runs it: set in a text edit, read back with toPlainText().""" + editor = QPlainTextEdit() + editor.setPlainText(code) + return editor.toPlainText() + + +def _raises(code: str) -> bool: + return any(isinstance(node, ast.Raise) for node in ast.walk(ast.parse(code))) + + +@pytest.mark.parametrize("line_break", _BREAKS) +def test_a_string_keeps_the_break_as_an_escape(line_break): + written = python_string(f"a{line_break}b") + + assert line_break not in written + assert ast.literal_eval(written) == f"a{line_break}b" + + +@pytest.mark.parametrize("line_break", _BREAKS) +def test_a_cookie_file_name_cannot_add_code(app, line_break): + request = parse_curl(f'curl https://x.example -b "s{line_break}raise SystemExit(1){line_break}#"') + + code = _through_an_editor(generate_template("requests", request)) + + assert not _raises(code) + + +@pytest.mark.parametrize("line_break", _BREAKS) +def test_a_har_url_cannot_add_code(app, line_break): + from pybreeze.utils.har_import.har_parser import parse_har + + har = ('{"log": {"entries": [{"request": {"method": "GET", "url": "https://x.example/a' + + line_break.encode("unicode_escape").decode().replace("\\x85", "\\u0085") + + 'raise SystemExit(1)", "headers": []}, "response": {"status": 200}}]}}') + requests = [entry.request for entry in parse_har(har)] + + code = _through_an_editor(generate_har_script("requests", requests)) + + assert not _raises(code) From ad709875c897b678f0acd1cc674a0e340ff2b788 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:08:08 +0800 Subject: [PATCH 193/312] Suggest only the last Windows-safe part of a server's file name when saving an SFTP download --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../connect_gui/ssh/ssh_file_viewer_widget.py | 20 ++++++++++++++++++- test/test_utils/test_sftp_tree_actions.py | 17 ++++++++++++++++ 6 files changed, 48 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 9dd71686..b11b8dd4 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -446,7 +446,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 113 個 `test_*.py`、1901 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 113 個 `test_*.py`、1906 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index b5d7d477..ce377332 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2705,3 +2705,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: codegen tests 271 + 9 passed; 1901 tests in 113 files. `ruff check` clean. - **Files**: `pybreeze/utils/curl_import/request_codegen.py`, `pybreeze/utils/har_import/har_codegen.py`, `test/test_utils/test_codegen_line_breaks.py`, `progress.md` (#91 removed), `CLAUDE.md` (tree: `plain_text.py`), `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-158 · 2026-09-24 · Suggest only the last, Windows-safe part of a server's file name when saving an SFTP download · #fix #security #ssh #done + +- **What**: progress #94, from the cross-cutting security review. The SFTP download (`connect_gui/ssh/ssh_file_viewer_widget.py`) suggested `posixpath.basename(remote_path)` as the save name. That splits on `/` only, so a server file named with backslashes (`..\\..\\AppData\\Roaming\\...\\Startup\\u.bat`) was offered whole. A save dialog may take it as a path relative to the folder it shows, and a user who just clicks Save could write into the Startup folder. The native dialog could not be checked offscreen, so this is guarded, not reproduced. +- **Fix**: new `local_file_name(remote_path)` keeps only what follows the last `/` or `\\`, turns what Windows refuses in a name (`<>:"/\\|?*` and controls) into `_`, strips trailing dots and spaces, and falls back to `download` when nothing is left. +- **Tests**: `test_sftp_tree_actions.py` +5: a backslash path keeps only `u.bat`; reserved characters become `_`; a plain name stays as it is; `..` and a name of dots and spaces fall back to `download`. +- **Result / numbers**: SFTP tree tests 20 passed; 1906 tests in 113 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_sftp_tree_actions.py`, `progress.md` (#94 removed), `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index cf28af36..d2b53c76 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-158 | 2026-09-24 | Suggest only the last, Windows-safe part of a server's file name when saving an SFTP download | #fix #security #ssh #done | [2026-09](2026-09.md) | | U-20260923-157 | 2026-09-24 | Escape U+2028, U+2029 and U+0085 in generated scripts, so a pasted curl command cannot add code through a comment | #fix #security #tools #done | [2026-09](2026-09.md) | | U-20260923-156 | 2026-09-24 | Show server and file text in message boxes and labels as text, never as markup Qt would load | #fix #security #ssh | [2026-09](2026-09.md) | | U-20260923-155 | 2026-09-24 | Bring two tests in line with the compile folder and the JupyterLab tab's delete-on-close | #test | [2026-09](2026-09.md) | @@ -238,4 +239,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 175 | +| [2026-09.md](2026-09.md) | 2026-09 | 176 | diff --git a/progress.md b/progress.md index 1e20771c..72525e03 100644 --- a/progress.md +++ b/progress.md @@ -16,4 +16,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. - **#93** The HAR tab and the diagram editor read an opened file whole on the UI thread with no size limit (`har_import_gui.py`, `diagram_editor_widget.py`): a multi-GB file freezes the IDE and a `MemoryError` escapes the slot. -- **#94** The SFTP download's suggested file name keeps backslashes from the server's name (`posixpath.basename`), so `..\..\AppData\...\Startup\u.bat` is offered as the save name; suggest only the last Windows-safe component. diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index d3b66de2..737dc31c 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -7,6 +7,7 @@ import os import posixpath +import re import stat from collections.abc import Callable @@ -51,6 +52,23 @@ def format_size(num_bytes: int) -> str: LISTING_ROLE = Qt.ItemDataRole.UserRole +# What a Windows file name cannot hold +_NOT_IN_A_FILE_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]') + + +def local_file_name(remote_path: str) -> str: + """A name to suggest for saving *remote_path* here: its last part, safe for this machine. + + A server's file name may hold backslashes: ``..\\..\\Startup\\u.bat`` was + offered as the save name, which a save dialog can take as a path out of + the folder it shows. Everything up to the last ``/`` or ``\\`` goes, and + what Windows refuses in a name becomes ``_``. + """ + name = re.split(r"[/\\]", remote_path)[-1] + name = _NOT_IN_A_FILE_NAME.sub("_", name).strip(" .") + return name or "download" + + def folder_item(item: QTreeWidgetItem | None) -> QTreeWidgetItem | None: """The tree item of the folder *item* is in, or *item* itself when it is a folder.""" if item is None or item.text(1) == "dir": @@ -525,7 +543,7 @@ def action_download(self, item: QTreeWidgetItem | None): self.word_dict.get("ssh_file_viewer_dialog_message_select_file_to_download")) return remote_path = item.text(3) - suggested = posixpath.basename(remote_path) # remote path uses POSIX separators + suggested = local_file_name(remote_path) local_path, _ = QFileDialog.getSaveFileName( self, self.word_dict.get("ssh_file_viewer_dialog_title_save_as"), diff --git a/test/test_utils/test_sftp_tree_actions.py b/test/test_utils/test_sftp_tree_actions.py index c8f819e0..64ddfb83 100644 --- a/test/test_utils/test_sftp_tree_actions.py +++ b/test/test_utils/test_sftp_tree_actions.py @@ -212,3 +212,20 @@ def refuse(_path: str) -> None: assert any("Permission denied" in text for text in shown) assert _child(root, "notes.txt").text(3) == "/notes.txt" + + +class TestTheNameADownloadSuggests: + """A server's name may hold backslashes, and one climbing into Startup was offered as the save name.""" + + @pytest.mark.parametrize(("remote", "suggested"), [ + ("/home/u/..\\..\\AppData\\Startup\\u.bat", "u.bat"), + ("/srv/a:b?.txt", "a_b_.txt"), + ("/data/report.csv", "report.csv"), + ("/x/..", "download"), + ("/x/ . ", "download"), + ]) + def test_only_the_last_safe_part(self, remote, suggested): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import local_file_name + + assert local_file_name(remote) == suggested + From bc686d84ca13a84d03caaf892d0478b893333337 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:09:49 +0800 Subject: [PATCH 194/312] Refuse to open a HAR export or diagram over 100 MB and report read errors without the path --- architecture_explore.md | 3 +- docs/updates/2026-09.md | 14 ++++ docs/updates/README.md | 3 +- progress.md | 1 - .../diagram_editor/diagram_editor_widget.py | 13 +++- .../pybreeze_ui/tools_gui/har_import_gui.py | 4 +- pybreeze/utils/file_process/read_capped.py | 35 +++++++++ test/test_utils/test_read_capped.py | 74 +++++++++++++++++++ 8 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 pybreeze/utils/file_process/read_capped.py create mode 100644 test/test_utils/test_read_capped.py diff --git a/architecture_explore.md b/architecture_explore.md index b11b8dd4..f6e53c06 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -350,6 +350,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯、落單的 surrogate 寫回 `\uXXXX` 跳脫。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN`(`load_json_verbatim()`),`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | +| `file_process/read_capped.py` | `read_text_capped(path, encoding, max_bytes=None)`:先看大小,超過 `MAX_OPEN_BYTES`(100 MB)丟 `FileTooLargeError`(`OSError`,`strerror` 說明大小與上限),HAR 分頁與架構圖編輯器開檔都經它,不在 UI 執行緒讀好幾 GB 的檔案 | | `file_process/replace_file.py` | `replace_text(path, text, private=False)`:寫到旁邊的 `<名稱>.saving` 再 `os.replace` 過去,失敗時原檔不動、半成品刪掉;`private` 以 `0600` 建立(存金鑰的檔案用);`replace_written(path, write)` 給別人寫的檔案(圖片、SVG):`write` 拿到旁邊的 `<主檔名>.saving<副檔名>`(副檔名不變,看副檔名決定格式的寫入器照樣用),寫完才換上 | | `manager/package_manager/` | `PackageManager`(單例 `package_manager`)持有 `syntax_check_list` | @@ -446,7 +447,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 113 個 `test_*.py`、1906 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 114 個 `test_*.py`、1910 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ce377332..ef5456fb 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2714,3 +2714,17 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: SFTP tree tests 20 passed; 1906 tests in 113 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_sftp_tree_actions.py`, `progress.md` (#94 removed), `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-159 · 2026-09-24 · Refuse to open a HAR export or diagram over 100 MB, and report the diagram editor's read errors without the path · #fix #tools #diagram #done + +- **What**: progress #93, from the cross-cutting security review. The HAR tab (`tools_gui/har_import_gui.py`) and the diagram editor (`diagram_editor/diagram_editor_widget.py`) read the file they open whole with `read_text()`, then parsed it, on the UI thread, with no size limit. A multi-gigabyte file froze the IDE, and a `MemoryError` or (for the diagram) a `RecursionError` from deeply nested JSON escaped the slot unhandled. The diagram editor's error box also showed `str(error)`, which carries the file's path. +- **Fix**: + - New `utils/file_process/read_capped.read_text_capped(path, encoding, max_bytes=None)` checks the size first. Over `MAX_OPEN_BYTES` (100 MB; a HAR export with response bodies runs to tens of MB) it raises `FileTooLargeError`, an `OSError` whose `strerror` says how large the file is and what the limit is. Both callers already report an `OSError`'s reason. + - The diagram editor also catches `RecursionError` and `MemoryError`, and shows the error's reason (not its path) through `as_text`. +- **Tests**: new `test_read_capped.py`, 4 tests: + - A file within the limit is read. + - One over it is refused as a read error. + - The HAR tab and the diagram editor, with the limit lowered, say the file was not opened and leave their state as it was. +- **Result / numbers**: size-cap, HAR tab and diagram widget tests 34 passed; 1910 tests in 114 files. `ruff check` clean. +- **Files**: `pybreeze/utils/file_process/read_capped.py` (new), `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_read_capped.py`, `progress.md` (#93 removed), `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index d2b53c76..3c0e8f8a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-159 | 2026-09-24 | Refuse to open a HAR export or diagram over 100 MB, and report the diagram editor's read errors without the path | #fix #tools #diagram #done | [2026-09](2026-09.md) | | U-20260923-158 | 2026-09-24 | Suggest only the last, Windows-safe part of a server's file name when saving an SFTP download | #fix #security #ssh #done | [2026-09](2026-09.md) | | U-20260923-157 | 2026-09-24 | Escape U+2028, U+2029 and U+0085 in generated scripts, so a pasted curl command cannot add code through a comment | #fix #security #tools #done | [2026-09](2026-09.md) | | U-20260923-156 | 2026-09-24 | Show server and file text in message boxes and labels as text, never as markup Qt would load | #fix #security #ssh | [2026-09](2026-09.md) | @@ -239,4 +240,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 176 | +| [2026-09.md](2026-09.md) | 2026-09 | 177 | diff --git a/progress.md b/progress.md index 72525e03..e45e002b 100644 --- a/progress.md +++ b/progress.md @@ -15,4 +15,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. -- **#93** The HAR tab and the diagram editor read an opened file whole on the UI thread with no size limit (`har_import_gui.py`, `diagram_editor_widget.py`): a multi-GB file freezes the IDE and a `MemoryError` escapes the slot. diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 6fb9101b..a3a0db5d 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -30,6 +30,8 @@ from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene, ImageDownloadThread, ToolMode from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView from pybreeze.pybreeze_ui.thread_keeper import let_run_out +from pybreeze.pybreeze_ui.plain_text import as_text +from pybreeze.utils.file_process.read_capped import read_text_capped from pybreeze.utils.file_process.replace_file import replace_text, replace_written from pybreeze.utils.logging.logger import pybreeze_logger @@ -410,14 +412,17 @@ def _open_diagram(self) -> None: if not path: return try: - data = json.loads(Path(path).read_text(encoding="utf-8")) + # Size-checked first: a multi-GB file froze the IDE while read here + data = json.loads(read_text_capped(Path(path))) self._scene.load_from_dict(data) self._current_path = Path(path) # ValueError covers bad JSON, a file that is not UTF-8 and one that is - # not a diagram; TypeError and KeyError an item with the wrong fields - except (OSError, ValueError, TypeError, KeyError) as e: + # not a diagram; TypeError and KeyError an item with the wrong fields; + # RecursionError JSON nested deeper than the parser goes + except (OSError, ValueError, TypeError, KeyError, RecursionError, MemoryError) as e: pybreeze_logger.error("Open diagram failed: %r", e) - QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), str(e)) + reason = e.strerror if isinstance(e, OSError) and e.strerror else str(e) or type(e).__name__ + QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), as_text(reason)) def _save_diagram(self) -> None: if self._current_path is None: diff --git a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py index b1696ba9..13560e0f 100644 --- a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py @@ -22,6 +22,7 @@ from pybreeze.utils.exception.exceptions import CurlParseException, HarParseException from pybreeze.utils.har_import.har_codegen import generate_har_script from pybreeze.utils.har_import.har_parser import HarEntry, api_entries, parse_har, summarize +from pybreeze.utils.file_process.read_capped import read_text_capped from pybreeze.utils.logging.logger import pybreeze_logger # The single target that generates JSON rather than Python @@ -113,7 +114,8 @@ def open_file(self) -> str | None: return None try: # utf-8-sig: an export saved with a byte-order mark is still JSON - text = Path(path).read_text(encoding="utf-8-sig") + # Size-checked first: a multi-GB export froze the IDE while read here + text = read_text_capped(Path(path), encoding="utf-8-sig") except (OSError, UnicodeDecodeError) as error: pybreeze_logger.info("har_import_gui.py read failed: %r", error) # The reason without the path: str(OSError) carries the file's full diff --git a/pybreeze/utils/file_process/read_capped.py b/pybreeze/utils/file_process/read_capped.py new file mode 100644 index 00000000..f07adb06 --- /dev/null +++ b/pybreeze/utils/file_process/read_capped.py @@ -0,0 +1,35 @@ +"""Read a file the user opens, refusing one too large to read on the UI thread. + +The HAR tab and the diagram editor read the file they open whole, then parse +it, on the UI thread: a multi-gigabyte file froze the IDE, and the +``MemoryError`` it could end in escaped the slot unhandled. +""" +from __future__ import annotations + +import errno +from pathlib import Path + +#: The largest file opened: a HAR export with response bodies runs to tens of MB +MAX_OPEN_BYTES = 100 * 1024 * 1024 +_MEGABYTE = 1024 * 1024 + + +class FileTooLargeError(OSError): + """The file is larger than it may be to be opened; ``strerror`` says so.""" + + +def read_text_capped(path: Path, encoding: str = "utf-8", max_bytes: int | None = None) -> str: + """The text of *path*, when it is no larger than *max_bytes* (``MAX_OPEN_BYTES`` by default). + + :raises FileTooLargeError: when it is larger (an ``OSError``, so a caller's + read-error handling reports it, ``strerror`` included) + :raises OSError: when it cannot be read + :raises UnicodeDecodeError: when it is not in *encoding* + """ + limit = MAX_OPEN_BYTES if max_bytes is None else max_bytes + size = path.stat().st_size + if size > limit: + raise FileTooLargeError( + errno.EFBIG, + f"the file is {size / _MEGABYTE:.0f} MB; files over {limit // _MEGABYTE} MB are not opened") + return path.read_text(encoding=encoding) diff --git a/test/test_utils/test_read_capped.py b/test/test_utils/test_read_capped.py new file mode 100644 index 00000000..2522cd14 --- /dev/null +++ b/test/test_utils/test_read_capped.py @@ -0,0 +1,74 @@ +"""A file the user opens is not read on the UI thread when it is too large.""" +from __future__ import annotations + +import json +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.utils.file_process import read_capped +from pybreeze.utils.file_process.read_capped import FileTooLargeError, read_text_capped + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def test_a_file_within_the_limit_is_read(tmp_path): + path = tmp_path / "small.json" + path.write_text("{}", encoding="utf-8") + + assert read_text_capped(path, max_bytes=10) == "{}" + + +def test_a_file_over_the_limit_is_refused_as_a_read_error(tmp_path): + path = tmp_path / "big.json" + path.write_bytes(b"x" * 11) + + with pytest.raises(FileTooLargeError) as raised: + read_text_capped(path, max_bytes=10) + + assert isinstance(raised.value, OSError) and "not opened" in raised.value.strerror + + +def test_the_har_tab_says_why_it_did_not_open_a_huge_export(app, tmp_path, monkeypatch): + # It read a multi-GB export whole, on the UI thread + from pybreeze.pybreeze_ui.tools_gui import har_import_gui + + monkeypatch.setattr(read_capped, "MAX_OPEN_BYTES", 10) + path = tmp_path / "session.har" + path.write_text(json.dumps({"log": {"entries": []}}), encoding="utf-8") + monkeypatch.setattr(har_import_gui.QFileDialog, "getOpenFileName", + staticmethod(lambda *args, **kwargs: (str(path), ""))) + tab = har_import_gui.HarImportGUI() + + assert tab.open_file() is None + assert "not opened" in tab.summary_label.text() + tab.close() + + +def test_the_diagram_editor_says_why_it_did_not_open_a_huge_file(app, tmp_path, monkeypatch): + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + + monkeypatch.setattr(read_capped, "MAX_OPEN_BYTES", 10) + path = tmp_path / "big.diagram.json" + path.write_text(json.dumps({"nodes": [], "connections": [], "images": []}), encoding="utf-8") + said: list = [] + monkeypatch.setattr(diagram_editor_widget.QFileDialog, "getOpenFileName", + staticmethod(lambda *args, **kwargs: (str(path), ""))) + monkeypatch.setattr(diagram_editor_widget.QMessageBox, "warning", + staticmethod(lambda *args, **kwargs: said.append(args[2]))) + editor = diagram_editor_widget.DiagramEditorWidget() + + editor._open_diagram() + + assert said and "not opened" in said[0] + assert editor._current_path is None + editor.deleteLater() From 48606b69ea711ea81cec2809a3400b3f695e5463 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:25:38 +0800 Subject: [PATCH 195/312] Keep the diagram property panel in step with the canvas and merge steps on one property into one undo --- architecture_explore.md | 6 +- docs/updates/2026-09.md | 19 +++++++ docs/updates/README.md | 3 +- progress.md | 4 ++ .../diagram_editor/diagram_commands.py | 22 +++++++- .../diagram_editor/diagram_property_panel.py | 47 +++++++++++----- .../diagram_editor/diagram_scene.py | 25 ++++++--- test/test_utils/test_diagram_editing.py | 55 +++++++++++++++++++ 8 files changed, 155 insertions(+), 26 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index f6e53c06..df18be3d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -245,9 +245,9 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `diagram_scene.py` (888) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點與圖片之上/之下;`to_dict()` 給每個節點與圖片記下它在兩者之間由下往上的位置(`stack`),載入後 `_restore_stacking()` 照這個順序重新加回場景(同 z 值時後加的在上面),圖片也存 `z`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | | `diagram_items.py` (960) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`add_image()` 把圖放進 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級;位置經 `_coordinate()`:不是有限數字就跳過這一筆,超過 `MAX_COORDINATE`(一百萬)就夾回來;連線建好所有東西之後才掛到兩端節點上 | | `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | -| `diagram_property_panel.py` (434) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | +| `diagram_property_panel.py` (453) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單;每記一步 undo(場景的 `recorded`)就重新整理(在畫布上拖把手改大小時選取沒變);寬、高各自只改自己那一邊;數字欄位不追鍵盤、輸入完才套用 | | `diagram_view.py` (180) | `QGraphicsView`:滾輪縮放(有上下界;橫向滾輪不縮放)、中鍵平移、`drawBackground` 畫格線 | -| `diagram_commands.py` (28) | `DiagramSnapshotCommand(QUndoCommand)` — 快照式 undo,存變更前後完整場景狀態 | +| `diagram_commands.py` (48) | `DiagramSnapshotCommand(QUndoCommand)` — 快照式 undo,存變更前後完整場景狀態;有 `merge_key` 的連續步驟(同一個項目的同一個屬性:大小、字級、線寬)合併成一步(`id()` / `mergeWith`) | | `diagram_net_utils.py` (108) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗、`_OPENER` 用 `PublicHTTPHandler` / `PublicHTTPSHandler`(連線當下再檢查一次並只連到那個位址)、20 MB 大小上限、15 秒 timeout | `diagram_net_utils` 是 CLAUDE.md 指定的網路安全參考實作,其他 HTTP 呼叫端則統一走 `utils/network/url_validation.py` 驗證、經 `utils/network/public_http.py` 的 `public_session()` 送出。 @@ -447,7 +447,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 114 個 `test_*.py`、1910 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 114 個 `test_*.py`、1914 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ef5456fb..61ee775c 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2728,3 +2728,22 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: size-cap, HAR tab and diagram widget tests 34 passed; 1910 tests in 114 files. `ruff check` clean. - **Files**: `pybreeze/utils/file_process/read_capped.py` (new), `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_read_capped.py`, `progress.md` (#93 removed), `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-160 · 2026-09-24 · Keep the diagram property panel in step with the canvas, set only the side that changed, and make a run of steps on one property one undo step · #fix #diagram #done + +- **What**: progress #95 and #97, the diagram property panel (`diagram_editor/diagram_property_panel.py`). Both reproduced by the review. + - The panel refreshed only when the selection changed. A resize by the canvas handles left the node selected and the Width/Height boxes showing the old size. The next edit of either box set both from the boxes, so changing Height after dragging the width from 140 to 340 put the width back to 140. Images behaved the same. + - Every spin-box change was its own undo step, with two whole-scene snapshots: ten font-size clicks were ten undos, and typing "1000" into Width resized to 100 first, then to 1000. +- **Fix**: + - The scene emits a new `recorded` signal when it pushes an undo step, and the panel refreshes from it. + - Width and height each set only their own side (`_resize_node`, `_resize_image`); the other side is read from the item. + - The spin boxes stop keyboard tracking, so a typed number applies once, when it is entered. + - `undo_scope` / `begin_undo` / `record_change` take a `merge_key`. `DiagramSnapshotCommand` gives commands with the same key the same `id()` and merges the next one into the one before (`mergeWith`), so consecutive steps on one property of one item (size, font, line width) are one undo step. Another change in between ends the run. +- **Tests**: `test_diagram_editing.py` +4: + - The panel follows a drag on the canvas, and a later height edit keeps the dragged width. + - Five font steps are one undo step, which undoes all five. + - Font, width, font again are three steps. + - The size and font boxes do not track the keyboard. +- **Result / numbers**: diagram tests 97 + 4 passed; 1914 tests in 114 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py`, `diagram_scene.py`, `diagram_commands.py`, `test/test_utils/test_diagram_editing.py`, `progress.md` (#95, #97 removed; #96, #98-#100 added), `architecture_explore.md` §7, §18 +- **Open items**: the review's other findings are in progress.md. diff --git a/docs/updates/README.md b/docs/updates/README.md index 3c0e8f8a..8e52b44e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-160 | 2026-09-24 | Keep the diagram property panel in step with the canvas, set only the side that changed, and make a run of steps on one property one undo step | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-159 | 2026-09-24 | Refuse to open a HAR export or diagram over 100 MB, and report the diagram editor's read errors without the path | #fix #tools #diagram #done | [2026-09](2026-09.md) | | U-20260923-158 | 2026-09-24 | Suggest only the last, Windows-safe part of a server's file name when saving an SFTP download | #fix #security #ssh #done | [2026-09](2026-09.md) | | U-20260923-157 | 2026-09-24 | Escape U+2028, U+2029 and U+0085 in generated scripts, so a pasted curl command cannot add code through a comment | #fix #security #tools #done | [2026-09](2026-09.md) | @@ -240,4 +241,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 177 | +| [2026-09.md](2026-09.md) | 2026-09 | 178 | diff --git a/progress.md b/progress.md index e45e002b..a204497e 100644 --- a/progress.md +++ b/progress.md @@ -15,3 +15,7 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. +- **#96** After Fit, the wheel and the zoom buttons cannot zoom when the fitted scale is outside 0.1-5.0: every step is refused, including one back toward the range (`diagram_editor/diagram_view.py`). +- **#98** Closing the prompt editor or the diagram editor loses unsaved edits without asking; JEditor's `close_tab` removes the tab even when `closeEvent` refuses, so asking needs a `close_tab` that respects it (`extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `diagram_editor/diagram_editor_widget.py`, `editor_main/main_ui.py`). +- **#99** The prompt editor's Create replaces text typed into a not-yet-created template without asking, and a template file that cannot be read leaves the previous template's text on screen under the new name (`prompt_editor_widget.py`). +- **#100** The Response Inspector's "Open headers" hands over the text in the input box, not the text that was analysed (`tools_gui/response_inspector_gui.py`). diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_commands.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_commands.py index ab3e2342..ecf98864 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_commands.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_commands.py @@ -1,5 +1,6 @@ from __future__ import annotations +import zlib from typing import TYPE_CHECKING from PySide6.QtGui import QUndoCommand @@ -11,12 +12,31 @@ class DiagramSnapshotCommand(QUndoCommand): """Snapshot-based undo/redo: stores full scene state before and after a change.""" - def __init__(self, scene: DiagramScene, description: str, old_data: dict, new_data: dict): + def __init__(self, scene: DiagramScene, description: str, old_data: dict, new_data: dict, + merge_key: str | None = None): + """ + :param merge_key: steps with the same key, one after another, are one + undo step (every arrow click on a spin box was its own) + """ super().__init__(description) self._scene = scene self._old_data = old_data self._new_data = new_data self._first_redo = True + self._merge_key = merge_key + + def id(self) -> int: + """The same for commands that may merge; -1 (never merged) without a key.""" + if self._merge_key is None: + return -1 + return zlib.crc32(self._merge_key.encode("utf-8")) & 0x7FFFFFFF + + def mergeWith(self, other: QUndoCommand) -> bool: + """Take in *other*, the next step on the same property: its result is this one's.""" + if not isinstance(other, DiagramSnapshotCommand) or other._merge_key != self._merge_key: + return False + self._new_data = other._new_data + return True def redo(self) -> None: if self._first_redo: diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py index e87e2ca3..e855144b 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py @@ -144,6 +144,9 @@ def __init__(self, scene: DiagramScene, parent=None): # Connect to scene self._scene.selectionChanged.connect(self._on_selection_changed) + # A change made on the canvas (a resize by its handles) leaves the + # selection as it was: the panel showed the old size until it changed + self._scene.recorded.connect(self._on_selection_changed) # ------------------------------------------------------------------ # UI construction @@ -176,12 +179,14 @@ def _build_node_group(self) -> None: self._node_w = QSpinBox() self._node_w.setRange(int(_MIN_NODE_SIDE), int(MAX_ITEM_SIZE)) - self._node_w.valueChanged.connect(self._on_node_size) + self._node_w.setKeyboardTracking(False) + self._node_w.valueChanged.connect(lambda width: self._resize_node(width=width)) nf.addRow(_lang("diagram_editor_prop_width", "Width"), self._node_w) self._node_h = QSpinBox() self._node_h.setRange(int(_MIN_NODE_HEIGHT), int(MAX_ITEM_SIZE)) - self._node_h.valueChanged.connect(self._on_node_size) + self._node_h.setKeyboardTracking(False) + self._node_h.valueChanged.connect(lambda height: self._resize_node(height=height)) nf.addRow(_lang("diagram_editor_prop_height", "Height"), self._node_h) self._node_shape = QComboBox() @@ -200,6 +205,7 @@ def _build_node_group(self) -> None: self._node_font = QSpinBox() self._node_font.setRange(MIN_FONT_SIZE, MAX_FONT_SIZE) + self._node_font.setKeyboardTracking(False) self._node_font.valueChanged.connect(self._on_node_font) nf.addRow(_lang("diagram_editor_prop_font_size", "Font"), self._node_font) @@ -227,6 +233,7 @@ def _build_connection_group(self) -> None: self._conn_width = QDoubleSpinBox() self._conn_width.setRange(0.5, 10.0) self._conn_width.setSingleStep(0.5) + self._conn_width.setKeyboardTracking(False) self._conn_width.valueChanged.connect(self._on_conn_width) cf.addRow(_lang("diagram_editor_prop_line_width", "Width"), self._conn_width) @@ -248,12 +255,14 @@ def _build_image_group(self) -> None: self._img_w = QSpinBox() self._img_w.setRange(int(_MIN_NODE_SIDE), int(MAX_ITEM_SIZE)) - self._img_w.valueChanged.connect(self._on_img_size) + self._img_w.setKeyboardTracking(False) + self._img_w.valueChanged.connect(lambda width: self._resize_image(width=width)) imf.addRow(_lang("diagram_editor_prop_width", "Width"), self._img_w) self._img_h = QSpinBox() self._img_h.setRange(int(_MIN_NODE_SIDE), int(MAX_ITEM_SIZE)) - self._img_h.valueChanged.connect(self._on_img_size) + self._img_h.setKeyboardTracking(False) + self._img_h.valueChanged.connect(lambda height: self._resize_image(height=height)) imf.addRow(_lang("diagram_editor_prop_height", "Height"), self._img_h) self._img_group.setLayout(imf) @@ -344,11 +353,18 @@ def _on_node_text(self) -> None: with self._scene.undo_scope("Edit Text"): self._current_node.set_text(self._node_text.text()) - def _on_node_size(self) -> None: - if self._updating or self._current_node is None: + def _resize_node(self, width: int | None = None, height: int | None = None) -> None: + """Set the node's width or height; the other side stays as the node has it. + + Both were set from the spin boxes, and one could be stale: a width from + before a drag on the canvas undid the drag. + """ + node = self._current_node + if self._updating or node is None: return - with self._scene.undo_scope("Resize"): - self._current_node.set_size(self._node_w.value(), self._node_h.value()) + with self._scene.undo_scope("Resize", merge_key=f"node-size:{id(node)}"): + node.set_size(node.node_w if width is None else width, + node.node_h if height is None else height) def _on_node_shape(self, index: int) -> None: if self._updating or self._current_node is None: @@ -372,7 +388,7 @@ def _on_node_border(self, color: QColor) -> None: def _on_node_font(self, size: int) -> None: if self._updating or self._current_node is None: return - with self._scene.undo_scope("Change Font"): + with self._scene.undo_scope("Change Font", merge_key=f"node-font:{id(self._current_node)}"): self._current_node.set_font_size(size) # ------------------------------------------------------------------ @@ -401,7 +417,7 @@ def _on_conn_color(self, color: QColor) -> None: def _on_conn_width(self, width: float) -> None: if self._updating or self._current_conn is None: return - with self._scene.undo_scope("Change Width"): + with self._scene.undo_scope("Change Width", merge_key=f"line-width:{id(self._current_conn)}"): self._current_conn.set_line_width(width) # ------------------------------------------------------------------ @@ -427,8 +443,11 @@ def _on_img_caption(self) -> None: with self._scene.undo_scope("Edit Caption"): self._current_img.set_text(self._img_caption.text()) - def _on_img_size(self) -> None: - if self._updating or self._current_img is None: + def _resize_image(self, width: int | None = None, height: int | None = None) -> None: + """Set the image's width or height; the other side stays as the image has it.""" + img = self._current_img + if self._updating or img is None: return - with self._scene.undo_scope("Resize Image"): - self._current_img.set_size(self._img_w.value(), self._img_h.value()) + with self._scene.undo_scope("Resize Image", merge_key=f"image-size:{id(img)}"): + img.set_size(img.img_w if width is None else width, + img.img_h if height is None else height) diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 1b66864b..95f7b2ea 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -98,6 +98,9 @@ class DiagramScene(QGraphicsScene): mode_changed = Signal(ToolMode) item_count_changed = Signal() + # An undo step was recorded: something changed, perhaps on the canvas with + # the selection unchanged (a resize by its handles) + recorded = Signal() def __init__(self, parent=None): super().__init__(parent) @@ -114,6 +117,7 @@ def __init__(self, parent=None): self.undo_stack = QUndoStack(self) self._pending_undo_snapshot: dict | None = None self._pending_undo_desc: str | None = None + self._pending_merge_key: str | None = None # Images already loaded, and the fetches still going, by source self._pixmap_cache: dict[str, QPixmap] = {} @@ -176,36 +180,43 @@ def mode(self, value: ToolMode) -> None: def _snapshot(self) -> dict: return self.to_dict() - def begin_undo(self, description: str) -> None: + def begin_undo(self, description: str, merge_key: str | None = None) -> None: self._pending_undo_desc = description + self._pending_merge_key = merge_key self._pending_undo_snapshot = self._snapshot() def end_undo(self) -> None: if self._pending_undo_snapshot is None: return - self.record_change(self._pending_undo_desc or "Edit", self._pending_undo_snapshot) + self.record_change(self._pending_undo_desc or "Edit", self._pending_undo_snapshot, + merge_key=self._pending_merge_key) self._pending_undo_snapshot = None self._pending_undo_desc = None + self._pending_merge_key = None - def record_change(self, description: str, before: dict) -> None: + def record_change(self, description: str, before: dict, merge_key: str | None = None) -> None: """Put one undo step on the stack, from *before* to the scene as it is now. Nothing is recorded when nothing changed. The command skips its first - redo, so the scene is not rebuilt under the caller. + redo, so the scene is not rebuilt under the caller. A step with the + same *merge_key* as the one before it joins that step. """ after = self._snapshot() if after != before: - self.undo_stack.push(DiagramSnapshotCommand(self, description, before, after)) + self.undo_stack.push(DiagramSnapshotCommand(self, description, before, after, merge_key)) + self.recorded.emit() @contextmanager - def undo_scope(self, description: str): + def undo_scope(self, description: str, merge_key: str | None = None): """Take a snapshot, run the body, and record what it changed. + *merge_key*: see :meth:`record_change`. + The closing snapshot is taken even when the body raises: a scope left open would be closed by the next mouse release instead, turning an unrelated click into an undo entry that reverts everything since. """ - self.begin_undo(description) + self.begin_undo(description, merge_key) try: yield finally: diff --git a/test/test_utils/test_diagram_editing.py b/test/test_utils/test_diagram_editing.py index 142003ad..0ea6b76f 100644 --- a/test/test_utils/test_diagram_editing.py +++ b/test/test_utils/test_diagram_editing.py @@ -195,3 +195,58 @@ def test_a_sideways_wheel_does_not_zoom(self, app): self._wheel(view, 0, 120) assert view.transform().m11() > 1.0 view.deleteLater() + + +class TestThePropertyPanelAndTheCanvas: + def _selected_node(self): + from pybreeze.pybreeze_ui.diagram_editor.diagram_property_panel import DiagramPropertyPanel + + scene = DiagramScene() + node = DiagramNode(x=0, y=0, w=140, h=60, text="A") + scene.addItem(node) + panel = DiagramPropertyPanel(scene) + node.setSelected(True) + return scene, node, panel + + def test_a_height_edit_keeps_a_width_dragged_on_the_canvas(self, app): + # The panel still held the width from before the drag, and set it back + from PySide6.QtCore import QPointF, QRectF + + scene, node, panel = self._selected_node() + with scene.undo_scope("Resize"): + node._apply_resize("r", QPointF(200, 0), QRectF(0, 0, 140, 60), QPointF(0, 0)) + + assert panel._node_w.value() == 340 # the panel follows the canvas + panel._node_h.setValue(80) + + assert (node.node_w, node.node_h) == (340, 80) + + def test_steps_on_one_property_are_one_undo_step(self, app): + # Every arrow click was its own step, each with two whole-scene snapshots + scene, node, panel = self._selected_node() + before = scene.undo_stack.count() + + for size in (11, 12, 13, 14, 15): + panel._node_font.setValue(size) + + assert scene.undo_stack.count() == before + 1 + scene.undo_stack.undo() + restored = [item for item in scene.items() if isinstance(item, DiagramNode)][0] + assert restored._font_size != 15 + + def test_another_property_is_its_own_step(self, app): + scene, node, panel = self._selected_node() + before = scene.undo_stack.count() + + panel._node_font.setValue(12) + panel._node_w.setValue(200) + panel._node_font.setValue(13) + + assert scene.undo_stack.count() == before + 3 + + def test_typing_a_size_applies_it_once(self, app): + _scene, _node, panel = self._selected_node() + + assert not panel._node_w.keyboardTracking() + assert not panel._node_font.keyboardTracking() + From d800ec3d49a209cf3451feddff6f6695a976b718 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:27:18 +0800 Subject: [PATCH 196/312] Keep the diagram's Fit within the zoom range and let zoom always step back toward it --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 14 ++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../diagram_editor/diagram_editor_widget.py | 4 +- .../diagram_editor/diagram_view.py | 35 ++++++++++----- test/test_utils/test_diagram_editing.py | 45 +++++++++++++++++++ 7 files changed, 90 insertions(+), 16 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index df18be3d..a92daa35 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -246,7 +246,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `diagram_items.py` (960) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`add_image()` 把圖放進 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級;位置經 `_coordinate()`:不是有限數字就跳過這一筆,超過 `MAX_COORDINATE`(一百萬)就夾回來;連線建好所有東西之後才掛到兩端節點上 | | `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (453) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單;每記一步 undo(場景的 `recorded`)就重新整理(在畫布上拖把手改大小時選取沒變);寬、高各自只改自己那一邊;數字欄位不追鍵盤、輸入完才套用 | -| `diagram_view.py` (180) | `QGraphicsView`:滾輪縮放(有上下界;橫向滾輪不縮放)、中鍵平移、`drawBackground` 畫格線 | +| `diagram_view.py` (195) | `QGraphicsView`:滾輪與按鈕縮放都經 `_step_zoom()`(有上下界,界外時仍可往界內走;橫向滾輪不縮放),`fit()` 把「符合視窗」夾在上下界內、中鍵平移、`drawBackground` 畫格線 | | `diagram_commands.py` (48) | `DiagramSnapshotCommand(QUndoCommand)` — 快照式 undo,存變更前後完整場景狀態;有 `merge_key` 的連續步驟(同一個項目的同一個屬性:大小、字級、線寬)合併成一步(`id()` / `mergeWith`) | | `diagram_net_utils.py` (108) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗、`_OPENER` 用 `PublicHTTPHandler` / `PublicHTTPSHandler`(連線當下再檢查一次並只連到那個位址)、20 MB 大小上限、15 秒 timeout | @@ -447,7 +447,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 114 個 `test_*.py`、1914 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 114 個 `test_*.py`、1917 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 61ee775c..00a402da 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2747,3 +2747,17 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: diagram tests 97 + 4 passed; 1914 tests in 114 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_property_panel.py`, `diagram_scene.py`, `diagram_commands.py`, `test/test_utils/test_diagram_editing.py`, `progress.md` (#95, #97 removed; #96, #98-#100 added), `architecture_explore.md` §7, §18 - **Open items**: the review's other findings are in progress.md. + +## U-20260923-161 · 2026-09-24 · Keep the diagram's Fit within the zoom range, and let zoom always step back toward it · #fix #diagram #done + +- **What**: progress #96, reproduced by the review. The diagram editor's Fit (`_zoom_fit`) used `fitInView`, which ignores the view's 0.1-5.0 zoom range (`diagram_editor/diagram_view.py`). The wheel refused any step whose result was still outside the range, so from outside it no step was allowed, not even one back toward it. One node in a 1200x800 view fitted to 604%, and then the wheel did nothing either way. Two nodes 50,000 px apart fitted to about 1%, and neither the wheel nor the zoom-out button moved. +- **Fix**: + - New `DiagramView.fit(rect)` fits, then clamps the scale to the range and centres on the content. The editor's Fit uses it, and the zoom label follows through `zoom_changed`. + - The wheel and the zoom buttons go through new `_step_zoom(factor)`: a step is refused only when it ends outside the range and does not move toward it. +- **Tests**: `test_diagram_editing.py` +3: + - Fitting a lone small node stops at the maximum, and zoom out works from there. + - Fitting nodes far apart stops at the minimum. + - From 800%, zoom in is refused and zoom out steps back. +- **Result / numbers**: diagram editing and widget tests 31 + 3 passed; 1917 tests in 114 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_view.py`, `diagram_editor_widget.py`, `test/test_utils/test_diagram_editing.py`, `progress.md` (#96 removed), `architecture_explore.md` §7, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 8e52b44e..36820b24 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-161 | 2026-09-24 | Keep the diagram's Fit within the zoom range, and let zoom always step back toward it | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-160 | 2026-09-24 | Keep the diagram property panel in step with the canvas, set only the side that changed, and make a run of steps on one property one undo step | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-159 | 2026-09-24 | Refuse to open a HAR export or diagram over 100 MB, and report the diagram editor's read errors without the path | #fix #tools #diagram #done | [2026-09](2026-09.md) | | U-20260923-158 | 2026-09-24 | Suggest only the last, Windows-safe part of a server's file name when saving an SFTP download | #fix #security #ssh #done | [2026-09](2026-09.md) | @@ -241,4 +242,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 178 | +| [2026-09.md](2026-09.md) | 2026-09 | 179 | diff --git a/progress.md b/progress.md index a204497e..2093b662 100644 --- a/progress.md +++ b/progress.md @@ -15,7 +15,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. -- **#96** After Fit, the wheel and the zoom buttons cannot zoom when the fitted scale is outside 0.1-5.0: every step is refused, including one back toward the range (`diagram_editor/diagram_view.py`). - **#98** Closing the prompt editor or the diagram editor loses unsaved edits without asking; JEditor's `close_tab` removes the tab even when `closeEvent` refuses, so asking needs a `close_tab` that respects it (`extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `diagram_editor/diagram_editor_widget.py`, `editor_main/main_ui.py`). - **#99** The prompt editor's Create replaces text typed into a not-yet-created template without asking, and a template file that cannot be read leaves the previous template's text on screen under the new name (`prompt_editor_widget.py`). - **#100** The Response Inspector's "Open headers" hands over the text in the input box, not the text that was analysed (`tools_gui/response_inspector_gui.py`). diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index a3a0db5d..a955312d 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -637,5 +637,5 @@ def _zoom_fit(self) -> None: rect = self._scene.itemsBoundingRect() if rect.isNull(): return - self._view.fitInView(rect.marginsAdded(QMarginsF(20, 20, 20, 20)), Qt.AspectRatioMode.KeepAspectRatio) - self._zoom_label.setText(f"{int(self._view.transform().m11() * 100)}%") + # Within the zoom range: fitted outside it, the wheel could not zoom back + self._view.fit(rect.marginsAdded(QMarginsF(20, 20, 20, 20))) diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py index cac38616..c8ff1ef9 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_view.py @@ -124,13 +124,34 @@ def wheelEvent(self, event) -> None: # A sideways wheel or trackpad swipe: not a zoom (it used to zoom out) super().wheelEvent(event) return - factor = _ZOOM_FACTOR if vertical > 0 else 1.0 / _ZOOM_FACTOR + self._step_zoom(_ZOOM_FACTOR if vertical > 0 else 1.0 / _ZOOM_FACTOR) + + def _step_zoom(self, factor: float) -> None: + """Scale by *factor*, unless that ends outside the zoom range and not closer to it. + + A step back toward the range is always taken: after a fit to one node + (604%) or to nodes far apart (1%), every step used to be refused, and + neither the wheel nor the buttons could zoom at all. + """ current = self.transform().m11() - if current * factor < _MIN_SCALE or current * factor > _MAX_SCALE: + wanted = current * factor + inside = _MIN_SCALE <= wanted <= _MAX_SCALE + closer = (current > _MAX_SCALE and factor < 1) or (current < _MIN_SCALE and factor > 1) + if not inside and not closer: return self.scale(factor, factor) self.zoom_changed.emit(int(self.transform().m11() * 100)) + def fit(self, rect) -> None: + """Show *rect* whole, as large as the zoom range allows, centred.""" + self.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio) + current = self.transform().m11() + clamped = max(_MIN_SCALE, min(current, _MAX_SCALE)) + if clamped != current: + self.scale(clamped / current, clamped / current) + self.centerOn(rect.center()) + self.zoom_changed.emit(int(self.transform().m11() * 100)) + def set_zoom(self, percent: int) -> None: factor = percent / 100.0 factor = max(_MIN_SCALE, min(factor, _MAX_SCALE)) @@ -139,16 +160,10 @@ def set_zoom(self, percent: int) -> None: self.zoom_changed.emit(int(factor * 100)) def zoom_in(self) -> None: - current = self.transform().m11() - if current * _ZOOM_FACTOR <= _MAX_SCALE: - self.scale(_ZOOM_FACTOR, _ZOOM_FACTOR) - self.zoom_changed.emit(int(self.transform().m11() * 100)) + self._step_zoom(_ZOOM_FACTOR) def zoom_out(self) -> None: - current = self.transform().m11() - if current / _ZOOM_FACTOR >= _MIN_SCALE: - self.scale(1.0 / _ZOOM_FACTOR, 1.0 / _ZOOM_FACTOR) - self.zoom_changed.emit(int(self.transform().m11() * 100)) + self._step_zoom(1.0 / _ZOOM_FACTOR) # --- middle-button / right-button pan --- diff --git a/test/test_utils/test_diagram_editing.py b/test/test_utils/test_diagram_editing.py index 0ea6b76f..595e071e 100644 --- a/test/test_utils/test_diagram_editing.py +++ b/test/test_utils/test_diagram_editing.py @@ -250,3 +250,48 @@ def test_typing_a_size_applies_it_once(self, app): assert not panel._node_w.keyboardTracking() assert not panel._node_font.keyboardTracking() + +class TestZooming: + """Fit went outside the zoom range, and then no step was allowed either way.""" + + def _view(self, *nodes): + from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView + + scene = DiagramScene() + for node in nodes: + scene.addItem(node) + view = DiagramView(scene) + view.resize(1200, 800) + return scene, view + + def test_fitting_one_node_stays_within_the_range_and_can_zoom_out(self, app): + from pybreeze.pybreeze_ui.diagram_editor import diagram_view + + scene, view = self._view(DiagramNode(x=0, y=0, w=40, h=20)) + view.resize(4000, 3000) + view.fit(scene.itemsBoundingRect()) + + assert view.transform().m11() == pytest.approx(diagram_view._MAX_SCALE) + view.zoom_out() + assert view.transform().m11() < diagram_view._MAX_SCALE + + def test_fitting_nodes_far_apart_stays_within_the_range(self, app): + from pybreeze.pybreeze_ui.diagram_editor import diagram_view + + scene, view = self._view(DiagramNode(x=0, y=0), DiagramNode(x=50000, y=0)) + view.fit(scene.itemsBoundingRect()) + + assert view.transform().m11() == pytest.approx(diagram_view._MIN_SCALE) + + def test_a_scale_outside_the_range_can_step_back_toward_it(self, app): + from pybreeze.pybreeze_ui.diagram_editor import diagram_view + + _scene, view = self._view(DiagramNode(x=0, y=0)) + view.scale(8, 8) # as a fit used to leave it + + view.zoom_in() + assert view.transform().m11() == pytest.approx(8) # further out: refused + view.zoom_out() + assert view.transform().m11() < 8 + assert diagram_view._MAX_SCALE < 8 + From c60ccb91404642d6e8b78f3a675b5d386b221e94 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:28:12 +0800 Subject: [PATCH 197/312] Hand the Response Inspector's analysed headers to the header analyzer --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../pybreeze_ui/tools_gui/response_inspector_gui.py | 7 ++++++- test/test_utils/test_response_inspector_gui.py | 12 ++++++++++++ 6 files changed, 30 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index a92daa35..f4945df3 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -447,7 +447,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 114 個 `test_*.py`、1917 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 114 個 `test_*.py`、1918 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 00a402da..c6c502a2 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2761,3 +2761,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: diagram editing and widget tests 31 + 3 passed; 1917 tests in 114 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_view.py`, `diagram_editor_widget.py`, `test/test_utils/test_diagram_editing.py`, `progress.md` (#96 removed), `architecture_explore.md` §7, §18 - **Open items**: none. + +## U-20260923-162 · 2026-09-24 · Hand the Response Inspector's analysed headers to the header analyzer, not whatever the input box holds now · #fix #tools #done + +- **What**: progress #100. The Response Inspector's "Open headers" (`tools_gui/response_inspector_gui.py`) passed `exact_text(self.input_edit)` to the header analyzer, while its other hand-offs (JWT, status, body) use the stored analysis. After analysing response A and pasting response B without analysing again, the header analyzer got B's headers beside a report still about A. +- **Fix**: `analyze()` keeps the text it analysed (`_analysed_text`), and the headers hand-off passes that. +- **Tests**: `test_response_inspector_gui.py` +1: with other text pasted after the analysis, the header analyzer reports the analysed headers and not the new ones. +- **Result / numbers**: response inspector GUI tests 25 passed; 1918 tests in 114 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py`, `test/test_utils/test_response_inspector_gui.py`, `progress.md` (#100 removed), `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 36820b24..266c284c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-162 | 2026-09-24 | Hand the Response Inspector's analysed headers to the header analyzer, not whatever the input box holds now | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-161 | 2026-09-24 | Keep the diagram's Fit within the zoom range, and let zoom always step back toward it | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-160 | 2026-09-24 | Keep the diagram property panel in step with the canvas, set only the side that changed, and make a run of steps on one property one undo step | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-159 | 2026-09-24 | Refuse to open a HAR export or diagram over 100 MB, and report the diagram editor's read errors without the path | #fix #tools #diagram #done | [2026-09](2026-09.md) | @@ -242,4 +243,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 179 | +| [2026-09.md](2026-09.md) | 2026-09 | 180 | diff --git a/progress.md b/progress.md index 2093b662..12577431 100644 --- a/progress.md +++ b/progress.md @@ -17,4 +17,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. - **#98** Closing the prompt editor or the diagram editor loses unsaved edits without asking; JEditor's `close_tab` removes the tab even when `closeEvent` refuses, so asking needs a `close_tab` that respects it (`extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `diagram_editor/diagram_editor_widget.py`, `editor_main/main_ui.py`). - **#99** The prompt editor's Create replaces text typed into a not-yet-created template without asking, and a template file that cannot be read leaves the previous template's text on screen under the new name (`prompt_editor_widget.py`). -- **#100** The Response Inspector's "Open headers" hands over the text in the input box, not the text that was analysed (`tools_gui/response_inspector_gui.py`). diff --git a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py index 9b0b7ca3..87fc8d49 100644 --- a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py @@ -85,6 +85,8 @@ def __init__(self, main_window=None) -> None: super().__init__() self._main_window = main_window self._analysis: ResponseAnalysis | None = None + # The text that analysis is of: the input may have changed since + self._analysed_text = "" word = language_wrapper.language_word_dict self.input_label = QLabel(word.get("response_input_label")) @@ -145,6 +147,7 @@ def analyze(self) -> None: self.output_edit.setPlainText(word.get("response_empty_hint")) return self._analysis = analyze_response(text) + self._analysed_text = exact_text(self.input_edit) self.output_edit.setPlainText(build_report_text(self._analysis)) self._set_cross_tool_enabled( jwt=bool(self._analysis.jwt_findings), @@ -193,7 +196,9 @@ def open_headers_in_analyzer(self) -> QWidget | None: self._main_window, HeaderAnalyzerGUI( main_window=self._main_window, - initial_headers=exact_text(self.input_edit)), + # What was analysed, as the other hand-offs use: the input box + # may hold another response by now + initial_headers=self._analysed_text), "extend_tools_menu_header_analyzer_tab_label") def open_body_in_json_format(self) -> QWidget | None: diff --git a/test/test_utils/test_response_inspector_gui.py b/test/test_utils/test_response_inspector_gui.py index c2ae1a87..1ba8ad2d 100644 --- a/test/test_utils/test_response_inspector_gui.py +++ b/test/test_utils/test_response_inspector_gui.py @@ -197,6 +197,18 @@ def test_repeated_headers_survive_the_hand_off(self, widget_with_window): opened = window.tab_widget.added[0][0] assert "set-cookie × 2" in opened.output_edit.toPlainText() + def test_the_analysed_headers_are_handed_over_not_newer_input(self, widget_with_window): + # The other buttons used the analysis; this one read the input box again + gui, window = widget_with_window + gui.input_edit.setPlainText("HTTP/1.1 200 OK\nServer: analysed/1\n\n{}") + gui.analyze() + gui.input_edit.setPlainText("HTTP/1.1 200 OK\nServer: pasted-later/2\n\n{}") + + gui.open_headers_in_analyzer() + + report = window.tab_widget.added[0][0].output_edit.toPlainText() + assert "analysed/1" in report and "pasted-later/2" not in report + def test_open_headers_before_analyze_is_noop(self, widget_with_window): gui, window = widget_with_window assert gui.open_headers_in_analyzer() is None From 9461a12c4ab5913aa53680fd816f0000f9f1cf62 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:29:43 +0800 Subject: [PATCH 198/312] Ask before the prompt editor's Create replaces typed text and empty the editor when a template cannot be read --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 15 ++++++ docs/updates/README.md | 3 +- progress.md | 1 - .../extend_multi_language/extend_english.py | 4 ++ .../extend_traditional_chinese.py | 4 ++ .../prompt_edit_gui/prompt_editor_widget.py | 9 ++++ test/test_utils/test_prompt_store.py | 54 +++++++++++++++++++ 8 files changed, 89 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index f4945df3..593d2e05 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -447,7 +447,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 114 個 `test_*.py`、1918 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 114 個 `test_*.py`、1920 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index c6c502a2..5aa00153 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2770,3 +2770,18 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: response inspector GUI tests 25 passed; 1918 tests in 114 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py`, `test/test_utils/test_response_inspector_gui.py`, `progress.md` (#100 removed), `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-163 · 2026-09-24 · Ask before the prompt editor's Create replaces typed text, and empty the editor when a template cannot be read · #fix #ai #done + +- **What**: progress #99, the prompt editor (`extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`). + - While a template's file does not exist yet, the edit area can be typed into. Create then wrote the built-in template and loaded it over what had been typed, without the question that switching and reloading ask. + - When a template's file could not be read (locked, no permission), the editor forgot the file but left the previous template's text and edited state on screen under the new name. Save then said no file was selected, and the next switch asked about discarding edits to a file named "". +- **Fix**: + - Create asks first when the edit area holds unsaved text (new language key `prompt_editor_create_over_edits`). + - An unreadable file empties the edit area and shows why as the placeholder (new key `prompt_editor_unreadable`, with the OS's reason, not the path). +- **Tests**: `test_prompt_store.py` +2: + - Create with typed text asks and, on No, creates nothing and keeps the text. + - A locked file leaves an empty editor, no current file, and the reason as the placeholder. +- **Result / numbers**: prompt tests 42 passed; 1920 tests in 114 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py`, `test/test_utils/test_prompt_store.py`, `progress.md` (#99 removed), `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 266c284c..89eb075e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-163 | 2026-09-24 | Ask before the prompt editor's Create replaces typed text, and empty the editor when a template cannot be read | #fix #ai #done | [2026-09](2026-09.md) | | U-20260923-162 | 2026-09-24 | Hand the Response Inspector's analysed headers to the header analyzer, not whatever the input box holds now | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-161 | 2026-09-24 | Keep the diagram's Fit within the zoom range, and let zoom always step back toward it | #fix #diagram #done | [2026-09](2026-09.md) | | U-20260923-160 | 2026-09-24 | Keep the diagram property panel in step with the canvas, set only the side that changed, and make a run of steps on one property one undo step | #fix #diagram #done | [2026-09](2026-09.md) | @@ -243,4 +244,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 180 | +| [2026-09.md](2026-09.md) | 2026-09 | 181 | diff --git a/progress.md b/progress.md index 12577431..50e57372 100644 --- a/progress.md +++ b/progress.md @@ -16,4 +16,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. - **#98** Closing the prompt editor or the diagram editor loses unsaved edits without asking; JEditor's `close_tab` removes the tab even when `closeEvent` refuses, so asking needs a `close_tab` that respects it (`extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `diagram_editor/diagram_editor_widget.py`, `editor_main/main_ui.py`). -- **#99** The prompt editor's Create replaces text typed into a not-yet-created template without asking, and a template file that cannot be read leaves the previous template's text on screen under the new name (`prompt_editor_widget.py`). diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index a60f6890..cdc19aec 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -213,6 +213,10 @@ "{filename} has edits that are not saved. Switch templates and lose them?", "prompt_editor_reload_button_over_edits": "{filename} has edits that are not saved. Reload it from disk and lose them?", + "prompt_editor_create_over_edits": + "Create {filename} from its built-in template? What you typed here is replaced.", + "prompt_editor_unreadable": + "{filename} could not be read: {error}", "prompt_editor_reload_over_edits": "{filename} changed on disk. Reload it and lose the edits made here?", "ssh_state_shell_and_files": "Connected: shell and files", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 2dde23bb..61c93354 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -213,6 +213,10 @@ "{filename} 還有沒存的編輯。要切換模板並捨棄它們嗎?", "prompt_editor_reload_button_over_edits": "{filename} 還有沒存的編輯。要從磁碟重新載入並捨棄它們嗎?", + "prompt_editor_create_over_edits": + "要用內建模板建立 {filename} 嗎?這裡輸入的內容會被取代。", + "prompt_editor_unreadable": + "{filename} 讀不出來:{error}", "prompt_editor_reload_over_edits": "{filename} 在磁碟上被改過了。要重新載入並捨棄這裡還沒存的編輯嗎?", "ssh_state_shell_and_files": "已連線:終端與檔案", diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py index ebbf797c..e3287a1e 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py @@ -174,7 +174,13 @@ def _show_file(self, path: Path) -> None: except OSError as error: pybreeze_logger.error("Prompt file %s could not be opened: %r", path.name, error) # Nothing was shown, so there is nothing a save may write back. + # The edit area is emptied: it kept the previous template's text, + # shown under this template's name self.current_file = None + self.middle_editor.setPlaceholderText( + language_wrapper.language_word_dict.get("prompt_editor_unreadable").format( + filename=path.name, error=error.strerror or type(error).__name__)) + self._show_text("") return QMessageBox.information( self, language_wrapper.language_word_dict.get(self._labels.info_title), @@ -198,6 +204,9 @@ def create_file(self) -> None: word.get(self._labels.file_exists).format(filename=self.current_file)) return + # Text typed into the empty editor would be replaced by the template + if not self._may_discard_edits("prompt_editor_create_over_edits", Path(self.current_file).name): + return content = self.templates.get(Path(self.current_file).name, "") if not save_prompt_text( self, self.current_file, content, word.get(self._labels.error_title)): diff --git a/test/test_utils/test_prompt_store.py b/test/test_utils/test_prompt_store.py index 0a9b313d..21b39225 100644 --- a/test/test_utils/test_prompt_store.py +++ b/test/test_utils/test_prompt_store.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +from pathlib import Path os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -464,3 +465,56 @@ def test_escaped_braces_and_a_format_spec_still_work(self, prompts): prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) assert "{literal}" in prompt and CODE in prompt + + +def _prompt_editor(): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import CoTPromptEditor + + QApplication.instance() or QApplication([]) + update_language_dict() + return CoTPromptEditor() + + +class TestThePromptEditorKeepsWhatWasTyped: + def test_create_asks_before_replacing_typed_text(self, prompts, monkeypatch): + # Create wrote the built-in template over it without asking + from PySide6.QtWidgets import QMessageBox + + editor = _prompt_editor() + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + editor.middle_editor.setPlainText("my own prompt") + editor.middle_editor.document().setModified(True) + asked: list = [] + monkeypatch.setattr(QMessageBox, "question", + staticmethod(lambda *args: asked.append(args[2]) or QMessageBox.StandardButton.No)) + + editor.create_file() + + assert asked + assert not (prompts / "linter.md").exists() + assert editor.middle_editor.toPlainText() == "my own prompt" + editor.deleteLater() + + def test_a_file_that_cannot_be_read_does_not_show_the_previous_text(self, prompts, monkeypatch): + # The previous template's text stayed on screen under this one's name + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui import prompt_editor_widget + + write(prompts, "linter.md", "the linter prompt") + editor = _prompt_editor() + editor.middle_editor.setPlainText("the previous template") + monkeypatch.setattr(prompt_editor_widget, "read_prompt_file", lambda _path: None) + + def locked(_path): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(Path, "read_bytes", locked) + editor.load_file_content(editor.prompt_files.index("linter.md")) + + assert editor.middle_editor.toPlainText() == "" + assert editor.current_file is None + assert "Permission denied" in editor.middle_editor.placeholderText() + editor.deleteLater() + From 7d130b1f03b48fb3df4386f33efa20af00f923ba Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:35:15 +0800 Subject: [PATCH 199/312] Ask before closing a prompt or diagram with unsaved changes, from its tab or from closing the IDE --- architecture.md | 3 +- architecture_explore.md | 3 +- docs/updates/2026-09.md | 23 ++++ docs/updates/README.md | 3 +- progress.md | 1 - .../extend_multi_language/extend_english.py | 5 + .../extend_traditional_chinese.py | 5 + .../diagram_editor/diagram_editor_widget.py | 22 +++- pybreeze/pybreeze_ui/editor_main/main_ui.py | 37 ++++++ .../prompt_edit_gui/prompt_editor_widget.py | 8 ++ test/test_utils/test_jeditor_contract.py | 7 ++ test/test_utils/test_unsaved_on_close.py | 110 ++++++++++++++++++ 12 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 test/test_utils/test_unsaved_on_close.py diff --git a/architecture.md b/architecture.md index 606a0fe9..84347db0 100644 --- a/architecture.md +++ b/architecture.md @@ -148,7 +148,8 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() PyBreeze also relies on `EditorWidget`'s `current_file`, `code_edit`, `file_encoding`, `line_ending`, `mark_ignore_next_file_change()` and `mark_saved()`, on its private `_file_watcher`, `_ignore_next_change`, `_is_modified` and `_on_text_changed()` (a rename puts the unsaved mark - back after `rename_self_tab()` clears it) and on `CodeEditor`'s `reset_highlighter()`, `load_git_baseline()` and + back after `rename_self_tab()` clears it), on `EditorMain.close_tab(index)` (overridden to ask a + tab's `may_close()` first) and on `CodeEditor`'s `reset_highlighter()`, `load_git_baseline()` and `start_language_server()` (a rename moves the tab the way `open_an_file` does), and on `language_wrapper`'s `choose_language_dict` serving English and Traditional Chinese from the exported dict objects themselves. Having je_editor export the names in the table is workspace X-17. It diff --git a/architecture_explore.md b/architecture_explore.md index 593d2e05..1a710892 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -74,6 +74,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE - `syntax_extend_package()` — 註冊 `.json` / `.yml` / `.yaml` 自動化關鍵字高亮 - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁(`_add_extend_tabs()`:每一個分頁各自建,建不起來的只記 log,不會讓整個 IDE 起不來) - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器(`rename_self_tab()` 會清掉「未儲存」標記,有未存的編輯就用 `_on_text_changed()` 放回去,否則改名後五秒內關分頁會直接丟掉編輯);Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。新增與改名的名稱不能帶磁碟代號、根目錄、`..` 或 `:`,也不能解析到資料夾外(`_inside()`;改名只能是單一名稱)。刪除資料夾用 `remove_folder()`(唯讀檔清掉唯讀屬性再刪,git 的物件檔就是唯讀),符號連結與 junction 只刪連結本身。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開 + - `close_tab()` 覆寫 JEditor 的:分頁有 `may_close()` 就先問(提示詞編輯器、架構圖編輯器有未存的變更時會問),關閉 IDE 時也先問過每個分頁與 dock,有一個說不就取消關閉 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) 3. `apply_stylesheet()` 套 qt_material 主題(預設 `dark_amber.xml`) 4. `showMaximized()` → `startup_setting()` → `app.exec()` @@ -447,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 114 個 `test_*.py`、1920 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1928 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 5aa00153..78fcd7be 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2785,3 +2785,26 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: prompt tests 42 passed; 1920 tests in 114 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py`, `test/test_utils/test_prompt_store.py`, `progress.md` (#99 removed), `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-164 · 2026-09-24 · Ask before closing a prompt or diagram with unsaved changes, from its tab or from closing the IDE · #fix #ai #diagram #done + +- **What**: progress #98. JEditor's `close_tab` asks about unsaved edits in its own editor tabs only, and closes any other tab, whatever that tab's `closeEvent` does. Closing the IDE closed PyBreeze's tabs and docks without asking either. Edits in the prompt editor (`extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, which asks before switching or reloading) were lost on close, and the diagram editor had no notion of unsaved changes, so an unsaved diagram was lost too. +- **Fix**: + - A tab or docked widget may offer `may_close()`. `PyBreezeMainWindow.close_tab` asks it before JEditor's `close_tab` runs. The IDE's `closeEvent` asks every tab and dock before stopping anything, and a No cancels the close with everything as it was. + - `_may_close()` treats a widget without the method as a yes, and one whose question raises (a third-party tab) as a logged yes, so it cannot keep the IDE open. + - The prompt editor's `may_close` asks when its document is modified (new key `prompt_editor_close_over_edits`). + - The diagram editor's `may_close` asks unless the undo stack is clean (new keys `diagram_editor_close_over_edits`, `unsaved_close_title`). A successful save marks it clean; opening or starting a diagram already cleared it. + - The diagram save's error box shows the error's reason through `as_text`, and its log line uses `%` formatting. +- **Tests**: + - New `test_unsaved_on_close.py`, 7 tests: + - Diagram: an unchanged diagram closes unasked, an unsaved change is asked about (No keeps it, Yes lets it go), and a saved one closes unasked. + - Prompt editor: unsaved edits are asked about. + - `_may_close`: a widget without the method, one that raises, and one that says No. + - `test_jeditor_contract.py` +1: JEditor's `close_tab(index)` still closes the widget. +- **Result / numbers**: close, contract and diagram widget tests 53 passed; child-IDE close, menu and language tests 15 passed; both startup tests exit 0. 1928 tests in 115 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_unsaved_on_close.py`, `test_jeditor_contract.py` + - `progress.md` (#98 removed), `architecture.md` §6, `architecture_explore.md` §3, §18 +- **Open items**: a docked prompt or diagram editor closed by its own dock button still closes without asking (the dock is JEditor's `DestroyDock`); closing the IDE does ask. diff --git a/docs/updates/README.md b/docs/updates/README.md index 89eb075e..dab79a88 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-164 | 2026-09-24 | Ask before closing a prompt or diagram with unsaved changes, from its tab or from closing the IDE | #fix #ai #diagram #done | [2026-09](2026-09.md) | | U-20260923-163 | 2026-09-24 | Ask before the prompt editor's Create replaces typed text, and empty the editor when a template cannot be read | #fix #ai #done | [2026-09](2026-09.md) | | U-20260923-162 | 2026-09-24 | Hand the Response Inspector's analysed headers to the header analyzer, not whatever the input box holds now | #fix #tools #done | [2026-09](2026-09.md) | | U-20260923-161 | 2026-09-24 | Keep the diagram's Fit within the zoom range, and let zoom always step back toward it | #fix #diagram #done | [2026-09](2026-09.md) | @@ -244,4 +245,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 181 | +| [2026-09.md](2026-09.md) | 2026-09 | 182 | diff --git a/progress.md b/progress.md index 50e57372..e45e002b 100644 --- a/progress.md +++ b/progress.md @@ -15,4 +15,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. -- **#98** Closing the prompt editor or the diagram editor loses unsaved edits without asking; JEditor's `close_tab` removes the tab even when `closeEvent` refuses, so asking needs a `close_tab` that respects it (`extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `diagram_editor/diagram_editor_widget.py`, `editor_main/main_ui.py`). diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index cdc19aec..f02ea3f7 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -217,6 +217,11 @@ "Create {filename} from its built-in template? What you typed here is replaced.", "prompt_editor_unreadable": "{filename} could not be read: {error}", + "prompt_editor_close_over_edits": + "{filename} has edits that are not saved. Close and lose them?", + "diagram_editor_close_over_edits": + "The diagram has changes that are not saved. Close and lose them?", + "unsaved_close_title": "Unsaved changes", "prompt_editor_reload_over_edits": "{filename} changed on disk. Reload it and lose the edits made here?", "ssh_state_shell_and_files": "Connected: shell and files", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 61c93354..8ec77c60 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -217,6 +217,11 @@ "要用內建模板建立 {filename} 嗎?這裡輸入的內容會被取代。", "prompt_editor_unreadable": "{filename} 讀不出來:{error}", + "prompt_editor_close_over_edits": + "{filename} 還有沒存的編輯。要關閉並捨棄它們嗎?", + "diagram_editor_close_over_edits": + "架構圖還有沒存的變更。要關閉並捨棄它們嗎?", + "unsaved_close_title": "尚未儲存的變更", "prompt_editor_reload_over_edits": "{filename} 在磁碟上被改過了。要重新載入並捨棄這裡還沒存的編輯嗎?", "ssh_state_shell_and_files": "已連線:終端與檔案", diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index a955312d..3e99e640 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -471,6 +471,20 @@ def _import_mermaid(self) -> None: str(e), ) + def may_close(self) -> bool: + """Whether the editor may close: the diagram is as last saved or opened, or the user lets it go. + + Asked by the main window before it closes this tab or the IDE: an + unsaved diagram was lost on close without a word. + """ + if self._scene.undo_stack.isClean(): + return True + reply = QMessageBox.question( + self, _lang("unsaved_close_title", "Unsaved changes"), + _lang("diagram_editor_close_over_edits", "The diagram has changes that are not saved. Close and lose them?"), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) + return reply == QMessageBox.StandardButton.Yes + def closeEvent(self, event) -> None: """Let the image fetches still going run out, cut off from the editor.""" for fetch in tuple(self._url_fetches): @@ -491,8 +505,12 @@ def _write_json(self, path: Path) -> None: data = self._scene.to_dict() replace_text(path, json.dumps(data, indent=2, ensure_ascii=False)) except (OSError, TypeError, ValueError) as e: - pybreeze_logger.error(f"Save diagram failed: {e}") - QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), str(e)) + pybreeze_logger.error("Save diagram failed: %r", e) + reason = e.strerror if isinstance(e, OSError) and e.strerror else str(e) + QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), as_text(reason)) + return + # What is on the canvas is now what is on disk + self._scene.undo_stack.setClean() # ------------------------------------------------------------------ # Export diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index f0858dfb..610a13e9 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -113,7 +113,28 @@ def _add_extend_tabs(self) -> None: except Exception as error: # noqa: BLE001 — a registered third-party tab may raise anything; the IDE must still start pybreeze_logger.error("Extend tab %r could not be built: %r", widget_name, error) + def close_tab(self, index: int) -> None: + """Close the tab at *index*, unless it has unsaved work the user keeps. + + JEditor asks about its own editor tabs only, and closes any other tab + whatever its ``closeEvent`` says: a prompt or a diagram being edited + was lost. A tab with a ``may_close()`` is asked first. + """ + if not _may_close(self.tab_widget.widget(index)): + return + super().close_tab(index) + + def _tool_tabs_may_close(self) -> bool: + """Whether every PyBreeze tab and docked widget agrees to close (each may ask).""" + widgets = [self.tab_widget.widget(index) for index in range(self.tab_widget.count())] + widgets += [dock.widget() for dock in self.findChildren(DestroyDock)] + return all(_may_close(widget) for widget in widgets if not isinstance(widget, EditorWidget)) + def closeEvent(self, event) -> None: + # Asked before anything is stopped: a No keeps the IDE open as it was + if not self._tool_tabs_may_close(): + event.ignore() + return # A run's child outlives the IDE unless stopped here: it is a separate # process, and without a console nobody would see it still running. # Over a copy: a window that closes drops itself from the list. @@ -150,6 +171,22 @@ def debug_close(self) -> None: app.quit() +def _may_close(widget) -> bool: + """Ask *widget* whether it may close, if it has a ``may_close()``; otherwise yes. + + A widget whose question raises (a third-party tab) counts as a yes, logged: + it must not keep the IDE from closing. + """ + ask = getattr(widget, "may_close", None) + if not callable(ask): + return True + try: + return bool(ask()) + except Exception as error: # noqa: BLE001 — a third-party tab may raise anything; closing must go on + pybreeze_logger.error("%s could not be asked whether it may close: %r", type(widget).__name__, error) + return True + + def start_editor(debug_mode: bool = False, theme: str = "dark_amber.xml", **kwargs) -> None: """ Start editor instance diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py index e3287a1e..82fd5f1e 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py @@ -220,6 +220,14 @@ def create_file(self) -> None: word.get(self._labels.file_created).format(filename=self.current_file)) self.load_file_content(self.file_selector.currentIndex()) + def may_close(self) -> bool: + """Whether the editor may close: nothing unsaved, or the user lets it go. + + Asked by the main window before it closes this tab or the IDE: closing + lost unsaved edits without a word. + """ + return self._may_discard_edits("prompt_editor_close_over_edits", self._shown_name()) + def closeEvent(self, event) -> None: """Stop watching the prompt files once the editor closes. diff --git a/test/test_utils/test_jeditor_contract.py b/test/test_utils/test_jeditor_contract.py index 00b79b0c..6225c6b3 100644 --- a/test/test_utils/test_jeditor_contract.py +++ b/test/test_utils/test_jeditor_contract.py @@ -154,6 +154,13 @@ def test_a_rename_can_move_what_an_editor_tab_follows_its_file_with(self): for method in ("reset_highlighter", "load_git_baseline", "start_language_server"): assert callable(getattr(code_editor, method, None)), method + def test_a_tab_closes_through_close_tab(self): + # PyBreeze overrides it to ask a tab's may_close() first + from je_editor import EditorMain + + assert _parameters(EditorMain.close_tab) == ["index"] + assert "widget.close()" in inspect.getsource(EditorMain.close_tab) + def test_a_docked_editor_writes_to_the_file_it_names_when_it_closes(self): # A rename re-points current_file; the dock saves nothing else docked = _internal("je_editor.pyside_ui.main_ui.editor.editor_widget_dock", "FullEditorWidget") diff --git a/test/test_utils/test_unsaved_on_close.py b/test/test_utils/test_unsaved_on_close.py new file mode 100644 index 00000000..5c3fa19b --- /dev/null +++ b/test/test_utils/test_unsaved_on_close.py @@ -0,0 +1,110 @@ +"""Closing a tab or the IDE asks before unsaved prompt or diagram edits are lost. + +JEditor's close_tab asked about its own editor tabs only and closed every other +tab whatever it said, so a prompt or a diagram being edited was lost unasked. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def answers(monkeypatch): + """Answer every question with ``answers["reply"]`` and count them.""" + state = {"reply": QMessageBox.StandardButton.No, "asked": 0} + + def question(*_args, **_kwargs): + state["asked"] += 1 + return state["reply"] + + monkeypatch.setattr(QMessageBox, "question", staticmethod(question)) + return state + + +class TestTheDiagramEditor: + def _editor(self): + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode + + editor = DiagramEditorWidget() + with editor._scene.undo_scope("Add"): + editor._scene.addItem(DiagramNode(x=0, y=0)) + return editor + + def test_an_unchanged_diagram_closes_without_a_question(self, app, answers): + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + + assert DiagramEditorWidget().may_close() is True + assert answers["asked"] == 0 + + def test_an_unsaved_change_is_asked_about(self, app, answers): + editor = self._editor() + + assert editor.may_close() is False + answers["reply"] = QMessageBox.StandardButton.Yes + assert editor.may_close() is True + assert answers["asked"] == 2 + + def test_a_saved_diagram_closes_without_a_question(self, app, answers, tmp_path): + editor = self._editor() + editor._write_json(tmp_path / "saved.diagram.json") + + assert editor.may_close() is True + assert answers["asked"] == 0 + + +class TestThePromptEditor: + def test_unsaved_edits_are_asked_about(self, app, answers, tmp_path, monkeypatch): + from pybreeze.pybreeze_ui.extend_ai_gui import prompt_store + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import CoTPromptEditor + + monkeypatch.setattr(prompt_store, "pybreeze_data_path", lambda: tmp_path) + editor = CoTPromptEditor() + assert editor.may_close() is True + + editor.middle_editor.document().setModified(True) + + assert editor.may_close() is False + assert answers["asked"] == 1 + + +class TestTheMainWindowsQuestion: + def test_a_widget_without_may_close_may_close(self): + from pybreeze.pybreeze_ui.editor_main.main_ui import _may_close + + assert _may_close(object()) is True + assert _may_close(None) is True + + def test_a_widget_whose_question_raises_does_not_keep_the_ide_open(self): + from pybreeze.pybreeze_ui.editor_main.main_ui import _may_close + + class Broken: + @staticmethod + def may_close() -> bool: + raise RuntimeError("third-party tab") + + assert _may_close(Broken()) is True + + def test_a_no_is_a_no(self): + from pybreeze.pybreeze_ui.editor_main.main_ui import _may_close + + class Keeps: + @staticmethod + def may_close() -> bool: + return False + + assert _may_close(Keeps()) is False From 3c54162d802f2a7a7d1a2e1e00b7ff03d7dbac0f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:35:26 +0800 Subject: [PATCH 200/312] Record the docked editors' close without asking in progress --- progress.md | 1 + 1 file changed, 1 insertion(+) diff --git a/progress.md b/progress.md index e45e002b..c5e69555 100644 --- a/progress.md +++ b/progress.md @@ -15,3 +15,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. +- **#101** A prompt or diagram editor docked with the Dock menu closes from its dock's own close button without asking about unsaved changes: the dock is JEditor's `DestroyDock`, whose close does not consult the widget's `may_close()` (tab close and IDE close do, U-20260923-164). Wrap the docked widget, or have the dock menu build a dock that asks. From 79b7cb504490d527a256c4fe3f9c18f7bbe92281 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:38:55 +0800 Subject: [PATCH 201/312] Ask before a docked prompt or diagram editor closes from its dock's own button --- CLAUDE.md | 1 + architecture.md | 2 +- architecture_explore.md | 4 +- docs/updates/2026-09.md | 16 +++++ docs/updates/README.md | 3 +- progress.md | 1 - pybreeze/pybreeze_ui/closing.py | 43 ++++++++++++++ pybreeze/pybreeze_ui/editor_main/main_ui.py | 30 ++++------ pybreeze/pybreeze_ui/menu/tools/tools_menu.py | 7 ++- test/test_utils/test_jeditor_contract.py | 2 +- test/test_utils/test_unsaved_on_close.py | 58 ++++++++++++++++--- 11 files changed, 131 insertions(+), 36 deletions(-) create mode 100644 pybreeze/pybreeze_ui/closing.py diff --git a/CLAUDE.md b/CLAUDE.md index 689f37c8..5247e161 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ pybreeze/ │ ├── thread_keeper.py # let_run_out: a worker QThread outlives its closed widget │ ├── gui_thread_gc.py # Garbage collected on a GUI-thread timer, never on a worker │ ├── plain_text.py # as_text: server/file text shown in message boxes as text, not markup +│ ├── closing.py # may_close / AskingDock: tabs and docks with unsaved work are asked first │ ├── dialog/ # prthinker settings dialog │ └── syntax/ # Automation keyword highlighting definitions ├── extend/ diff --git a/architecture.md b/architecture.md index 84347db0..efcc73b2 100644 --- a/architecture.md +++ b/architecture.md @@ -136,7 +136,7 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() | Name | JEditor module | Used in | | --- | --- | --- | | `PluginBrowserWidget` | `pyside_ui.main_ui.plugin_browser.plugin_browser_widget` | `menu/plugin_menu/build_plugin_menu.py` | - | `DestroyDock` | `pyside_ui.main_ui.dock.destroy_dock` | `menu/tools/tools_menu.py`, `editor_main/main_ui.py` | + | `DestroyDock` | `pyside_ui.main_ui.dock.destroy_dock` | `closing.py` (`AskingDock`, which the Dock menu builds), `editor_main/main_ui.py` | | `init_new_auto_save_thread`, `auto_save_manager_dict`, `file_is_open_manager_dict` | `pyside_ui.code.auto_save.auto_save_manager` | `editor_main/file_tree_context_menu.py` (a rename restarts the tab's auto-save on the new path) | | `FullEditorWidget` | `pyside_ui.main_ui.editor.editor_widget_dock` | `editor_main/file_tree_context_menu.py` (a rename re-points a docked editor's `current_file`) | | `check_and_choose_venv` | `utils.venv_check.check_venv` | `extend/process_executor/python_task_process_manager.py` | diff --git a/architecture_explore.md b/architecture_explore.md index 1a710892..fd73f36c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -192,7 +192,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `_WIDGET_FACTORIES: dict[str, Callable]` — widget key → 建構 lambda - `_TAB_ACTIONS: tuple[...]` — (widget key, 主視窗屬性, 選單屬性, action 語言鍵, 分頁標籤鍵) -- `_DOCK_ACTIONS` / `_DOCK_TITLES` — 同一組 widget 也能開成右側 dock +- `_DOCK_ACTIONS` / `_DOCK_TITLES` — 同一組 widget 也能開成右側 dock(`closing.AskingDock`:關 dock 前先問 widget 的 `may_close()`,有未存變更的提示詞與架構圖編輯器不會被 dock 的關閉鈕直接丟掉) `_register_action()` 有一段關鍵註解:QAction 必須 `setattr` 掛回主視窗,否則 Qt 不持有它、被 GC 後選單項就失效。另一種做法是建構時把選單當 parent(自動化選單工廠、插件選單用這種)。`test_started_menus.py` 在子行程啟動真的 IDE、GC 後走訪整條選單列,任何子選單變空就失敗(JEditor 的兩個字型選單除外:offscreen 平台沒有字型)。 @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1928 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1931 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 78fcd7be..5dc1544b 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2808,3 +2808,19 @@ Index and query commands: [README.md](README.md). New entries go at the end. - `test/test_utils/test_unsaved_on_close.py`, `test_jeditor_contract.py` - `progress.md` (#98 removed), `architecture.md` §6, `architecture_explore.md` §3, §18 - **Open items**: a docked prompt or diagram editor closed by its own dock button still closes without asking (the dock is JEditor's `DestroyDock`); closing the IDE does ask. + +## U-20260923-165 · 2026-09-24 · Ask before a docked prompt or diagram editor closes from its dock's own button · #fix #ai #diagram #done + +- **What**: progress #101, left open by U-20260923-164. A prompt or diagram editor docked from the Dock menu closed from its dock's close button without asking about unsaved changes. The dock was JEditor's `DestroyDock`, which closes its widget without consulting it. +- **Fix**: + - New `pybreeze_ui/closing.py` holds `may_close(widget)`, moved from `main_ui.py` so the menu can use it without importing the main window, and new `AskingDock(DestroyDock)`, whose `closeEvent` asks the docked widget first and ignores the close on a No. + - The Dock menu (`add_dock`) builds `AskingDock`s. + - When the IDE closes it asks every tab and dock once, then marks the docks `already_asked` so they do not ask again as they close. +- **Tests**: `test_unsaved_on_close.py` +3: + - A dock whose widget says No stays open, and closes once `already_asked` is set. + - A dock whose widget agrees closes. + - The Dock menu builds `AskingDock`s. + The `may_close` tests now import it from `closing`, and the JEditor contract list records where `DestroyDock` is imported. +- **Result / numbers**: close, contract, dock-menu and IDE-close tests 66 passed; 1931 tests in 115 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/closing.py` (new), `editor_main/main_ui.py`, `menu/tools/tools_menu.py`, `test/test_utils/test_unsaved_on_close.py`, `test_jeditor_contract.py`, `progress.md` (#101 removed), `CLAUDE.md` (tree), `architecture.md` §6, `architecture_explore.md` §5, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index dab79a88..94fcd82f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-165 | 2026-09-24 | Ask before a docked prompt or diagram editor closes from its dock's own button | #fix #ai #diagram #done | [2026-09](2026-09.md) | | U-20260923-164 | 2026-09-24 | Ask before closing a prompt or diagram with unsaved changes, from its tab or from closing the IDE | #fix #ai #diagram #done | [2026-09](2026-09.md) | | U-20260923-163 | 2026-09-24 | Ask before the prompt editor's Create replaces typed text, and empty the editor when a template cannot be read | #fix #ai #done | [2026-09](2026-09.md) | | U-20260923-162 | 2026-09-24 | Hand the Response Inspector's analysed headers to the header analyzer, not whatever the input box holds now | #fix #tools #done | [2026-09](2026-09.md) | @@ -245,4 +246,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 182 | +| [2026-09.md](2026-09.md) | 2026-09 | 183 | diff --git a/progress.md b/progress.md index c5e69555..e45e002b 100644 --- a/progress.md +++ b/progress.md @@ -15,4 +15,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- `: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. -- **#101** A prompt or diagram editor docked with the Dock menu closes from its dock's own close button without asking about unsaved changes: the dock is JEditor's `DestroyDock`, whose close does not consult the widget's `may_close()` (tab close and IDE close do, U-20260923-164). Wrap the docked widget, or have the dock menu build a dock that asks. diff --git a/pybreeze/pybreeze_ui/closing.py b/pybreeze/pybreeze_ui/closing.py new file mode 100644 index 00000000..07ac6190 --- /dev/null +++ b/pybreeze/pybreeze_ui/closing.py @@ -0,0 +1,43 @@ +"""Closing a tab or a dock that may hold unsaved work: ask it first. + +A widget that can lose work (the prompt editor, the diagram editor) offers +``may_close()``, which asks the user when there is something unsaved. JEditor's +tab and dock close without asking anything but its own editor tabs. +""" +from __future__ import annotations + +from je_editor.pyside_ui.main_ui.dock.destroy_dock import DestroyDock + +from pybreeze.utils.logging.logger import pybreeze_logger + + +def may_close(widget) -> bool: + """Ask *widget* whether it may close, if it has a ``may_close()``; otherwise yes. + + A widget whose question raises (a third-party tab) counts as a yes, logged: + it must not keep the IDE from closing. + """ + ask = getattr(widget, "may_close", None) + if not callable(ask): + return True + try: + return bool(ask()) + except Exception as error: # noqa: BLE001 — a third-party tab may raise anything; closing must go on + pybreeze_logger.error("%s could not be asked whether it may close: %r", type(widget).__name__, error) + return True + + +class AskingDock(DestroyDock): + """A ``DestroyDock`` that asks its widget before closing. + + ``already_asked`` is set by the main window when the IDE closes: it asks + every dock once, before stopping anything. + """ + + already_asked = False + + def closeEvent(self, event) -> None: + if not self.already_asked and not may_close(self.widget()): + event.ignore() + return + super().closeEvent(event) diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index 610a13e9..6adf452d 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -15,6 +15,7 @@ from qt_material import apply_stylesheet from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.closing import AskingDock, may_close from pybreeze.pybreeze_ui.editor_main.file_tree_context_menu import setup_file_tree_context_menu from pybreeze.pybreeze_ui.gui_thread_gc import collect_garbage_on_gui_thread from pybreeze.pybreeze_ui.menu.build_menubar import add_menu_to_menubar @@ -120,19 +121,24 @@ def close_tab(self, index: int) -> None: whatever its ``closeEvent`` says: a prompt or a diagram being edited was lost. A tab with a ``may_close()`` is asked first. """ - if not _may_close(self.tab_widget.widget(index)): + if not may_close(self.tab_widget.widget(index)): return super().close_tab(index) - def _tool_tabs_may_close(self) -> bool: + def _tool_tabsmay_close(self) -> bool: """Whether every PyBreeze tab and docked widget agrees to close (each may ask).""" widgets = [self.tab_widget.widget(index) for index in range(self.tab_widget.count())] widgets += [dock.widget() for dock in self.findChildren(DestroyDock)] - return all(_may_close(widget) for widget in widgets if not isinstance(widget, EditorWidget)) + agreed = all(may_close(widget) for widget in widgets if not isinstance(widget, EditorWidget)) + if agreed: + # Asked once here: a dock that asks on close must not ask again + for dock in self.findChildren(AskingDock): + dock.already_asked = True + return agreed def closeEvent(self, event) -> None: # Asked before anything is stopped: a No keeps the IDE open as it was - if not self._tool_tabs_may_close(): + if not self._tool_tabsmay_close(): event.ignore() return # A run's child outlives the IDE unless stopped here: it is a separate @@ -171,22 +177,6 @@ def debug_close(self) -> None: app.quit() -def _may_close(widget) -> bool: - """Ask *widget* whether it may close, if it has a ``may_close()``; otherwise yes. - - A widget whose question raises (a third-party tab) counts as a yes, logged: - it must not keep the IDE from closing. - """ - ask = getattr(widget, "may_close", None) - if not callable(ask): - return True - try: - return bool(ask()) - except Exception as error: # noqa: BLE001 — a third-party tab may raise anything; closing must go on - pybreeze_logger.error("%s could not be asked whether it may close: %r", type(widget).__name__, error) - return True - - def start_editor(debug_mode: bool = False, theme: str = "dark_amber.xml", **kwargs) -> None: """ Start editor instance diff --git a/pybreeze/pybreeze_ui/menu/tools/tools_menu.py b/pybreeze/pybreeze_ui/menu/tools/tools_menu.py index 8b308241..d7c30b9f 100644 --- a/pybreeze/pybreeze_ui/menu/tools/tools_menu.py +++ b/pybreeze/pybreeze_ui/menu/tools/tools_menu.py @@ -5,9 +5,10 @@ from PySide6.QtGui import QAction, Qt from je_editor import language_wrapper -from je_editor.pyside_ui.main_ui.dock.destroy_dock import DestroyDock + from je_editor import jeditor_logger +from pybreeze.pybreeze_ui.closing import AskingDock from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_main_widget import SSHMainWidget from pybreeze.pybreeze_ui.connect_gui.url.ai_code_review_gui import AICodeReviewClient from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget @@ -270,7 +271,9 @@ def add_dock(ui_we_want_to_set: PyBreezeMainWindow, widget_type: str | None = No # 建立一個可銷毀的 Dock 容器 # Create a destroyable dock container - dock_widget = DestroyDock() + # One that asks its widget first: a docked prompt or diagram editor with + # unsaved changes closed from the dock's button without a word + dock_widget = AskingDock() title_key = _DOCK_TITLES.get(widget_type) if title_key is not None: diff --git a/test/test_utils/test_jeditor_contract.py b/test/test_utils/test_jeditor_contract.py index 6225c6b3..ea4efefd 100644 --- a/test/test_utils/test_jeditor_contract.py +++ b/test/test_utils/test_jeditor_contract.py @@ -23,7 +23,7 @@ ("je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget", "PluginBrowserWidget", "menu/plugin_menu/build_plugin_menu.py"), ("je_editor.pyside_ui.main_ui.dock.destroy_dock", "DestroyDock", - "menu/tools/tools_menu.py, editor_main/main_ui.py"), + "closing.py, editor_main/main_ui.py"), ("je_editor.pyside_ui.code.auto_save.auto_save_manager", "init_new_auto_save_thread", "editor_main/file_tree_context_menu.py"), ("je_editor.pyside_ui.code.auto_save.auto_save_manager", "auto_save_manager_dict", diff --git a/test/test_utils/test_unsaved_on_close.py b/test/test_utils/test_unsaved_on_close.py index 5c3fa19b..4284603d 100644 --- a/test/test_utils/test_unsaved_on_close.py +++ b/test/test_utils/test_unsaved_on_close.py @@ -83,28 +83,70 @@ def test_unsaved_edits_are_asked_about(self, app, answers, tmp_path, monkeypatch class TestTheMainWindowsQuestion: - def test_a_widget_without_may_close_may_close(self): - from pybreeze.pybreeze_ui.editor_main.main_ui import _may_close + def test_a_widget_without_the_question_may_close(self): + from pybreeze.pybreeze_ui.closing import may_close - assert _may_close(object()) is True - assert _may_close(None) is True + assert may_close(object()) is True + assert may_close(None) is True def test_a_widget_whose_question_raises_does_not_keep_the_ide_open(self): - from pybreeze.pybreeze_ui.editor_main.main_ui import _may_close + from pybreeze.pybreeze_ui.closing import may_close class Broken: @staticmethod def may_close() -> bool: raise RuntimeError("third-party tab") - assert _may_close(Broken()) is True + assert may_close(Broken()) is True def test_a_no_is_a_no(self): - from pybreeze.pybreeze_ui.editor_main.main_ui import _may_close + from pybreeze.pybreeze_ui.closing import may_close class Keeps: @staticmethod def may_close() -> bool: return False - assert _may_close(Keeps()) is False + assert may_close(Keeps()) is False + + +class TestADock: + """A docked editor closed from its dock's own button, without a word.""" + + class _Keeps: + """Stands in for an editor with unsaved work the user keeps.""" + + def __init__(self, answer: bool) -> None: + from PySide6.QtWidgets import QWidget + + self.widget = QWidget() + self.widget.may_close = lambda: answer + + def test_a_dock_whose_widget_says_no_stays_open(self, app): + from pybreeze.pybreeze_ui.closing import AskingDock + + dock = AskingDock() + dock.setWidget(self._Keeps(False).widget) + dock.show() + + assert dock.close() is False + assert dock.isVisible() + dock.already_asked = True + assert dock.close() is True + + def test_a_dock_whose_widget_agrees_closes(self, app): + from pybreeze.pybreeze_ui.closing import AskingDock + + dock = AskingDock() + dock.setWidget(self._Keeps(True).widget) + dock.show() + + assert dock.close() is True + + def test_the_dock_menu_builds_docks_that_ask(self, app): + import inspect + + from pybreeze.pybreeze_ui.menu.tools import tools_menu + + assert "AskingDock()" in inspect.getsource(tools_menu.add_dock) + From dd6ba91f6d94c607c640a0a5ca2042f852a61fe0 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:46:36 +0800 Subject: [PATCH 202/312] Restore the main window's tool-tab close check name --- docs/updates/2026-09.md | 7 +++++++ docs/updates/README.md | 3 ++- pybreeze/pybreeze_ui/editor_main/main_ui.py | 6 +++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 5dc1544b..a3e79581 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2824,3 +2824,10 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: close, contract, dock-menu and IDE-close tests 66 passed; 1931 tests in 115 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/closing.py` (new), `editor_main/main_ui.py`, `menu/tools/tools_menu.py`, `test/test_utils/test_unsaved_on_close.py`, `test_jeditor_contract.py`, `progress.md` (#101 removed), `CLAUDE.md` (tree), `architecture.md` §6, `architecture_explore.md` §5, §18 - **Open items**: none. + +## U-20260923-166 · 2026-09-24 · Restore the main window's tool-tab close check name · #refactor + +- **What**: a text replacement in U-20260923-165 renamed `PyBreezeMainWindow._tool_tabs_may_close` to `_tool_tabsmay_close`. It is renamed back; `start_editor`'s `window =PyBreezeMainWindow(...)` gets its missing space. No behaviour changes. +- **Result / numbers**: `test_unsaved_on_close.py` 10 passed; `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 94fcd82f..e5954734 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-166 | 2026-09-24 | Restore the main window's tool-tab close check name | #refactor | [2026-09](2026-09.md) | | U-20260923-165 | 2026-09-24 | Ask before a docked prompt or diagram editor closes from its dock's own button | #fix #ai #diagram #done | [2026-09](2026-09.md) | | U-20260923-164 | 2026-09-24 | Ask before closing a prompt or diagram with unsaved changes, from its tab or from closing the IDE | #fix #ai #diagram #done | [2026-09](2026-09.md) | | U-20260923-163 | 2026-09-24 | Ask before the prompt editor's Create replaces typed text, and empty the editor when a template cannot be read | #fix #ai #done | [2026-09](2026-09.md) | @@ -246,4 +247,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 183 | +| [2026-09.md](2026-09.md) | 2026-09 | 184 | diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index 6adf452d..3d1373dd 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -125,7 +125,7 @@ def close_tab(self, index: int) -> None: return super().close_tab(index) - def _tool_tabsmay_close(self) -> bool: + def _tool_tabs_may_close(self) -> bool: """Whether every PyBreeze tab and docked widget agrees to close (each may ask).""" widgets = [self.tab_widget.widget(index) for index in range(self.tab_widget.count())] widgets += [dock.widget() for dock in self.findChildren(DestroyDock)] @@ -138,7 +138,7 @@ def _tool_tabsmay_close(self) -> bool: def closeEvent(self, event) -> None: # Asked before anything is stopped: a No keeps the IDE open as it was - if not self._tool_tabsmay_close(): + if not self._tool_tabs_may_close(): event.ignore() return # A run's child outlives the IDE unless stopped here: it is a separate @@ -190,7 +190,7 @@ def start_editor(debug_mode: bool = False, theme: str = "dark_amber.xml", **kwar # Workers allocate enough to trigger a collection, which then destroyed # Qt objects on the worker and crashed the IDE later collect_garbage_on_gui_thread(new_ide) - window =PyBreezeMainWindow(debug_mode=debug_mode, **kwargs) + window = PyBreezeMainWindow(debug_mode=debug_mode, **kwargs) apply_stylesheet(new_ide, theme=theme) window.showMaximized() try: From 7d8d3e43053bcce4bd0b4b9e4a782e7e63ee08f9 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:47:10 +0800 Subject: [PATCH 203/312] Restore blank lines lost in recent edits --- docs/updates/2026-09.md | 7 +++++++ docs/updates/README.md | 3 ++- pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py | 1 + pybreeze/pybreeze_ui/diagram_editor/diagram_items.py | 2 ++ pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py | 1 + test/test_utils/test_mermaid_parser.py | 1 - 6 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index a3e79581..2c921c6a 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2831,3 +2831,10 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: `test_unsaved_on_close.py` 10 passed; `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py` - **Open items**: none. + +## U-20260923-167 · 2026-09-24 · Restore blank lines lost in recent edits · #refactor + +- **What**: recent edits left a module constant block glued to the function above it (`diagram_items.py`, `ai_code_review_gui.py`), a nested function with no blank line before it (`file_tree_context_menu.py`) and three blank lines in `test_mermaid_parser.py`. Found by `ruff check --preview --select E3` over the files changed in the last batches. Whitespace only. +- **Result / numbers**: `ruff check pybreeze/ test/` clean. +- **Files**: `diagram_editor/diagram_items.py`, `connect_gui/url/ai_code_review_gui.py`, `editor_main/file_tree_context_menu.py`, `test/test_utils/test_mermaid_parser.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index e5954734..821bdfe1 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-167 | 2026-09-24 | Restore blank lines lost in recent edits | #refactor | [2026-09](2026-09.md) | | U-20260923-166 | 2026-09-24 | Restore the main window's tool-tab close check name | #refactor | [2026-09](2026-09.md) | | U-20260923-165 | 2026-09-24 | Ask before a docked prompt or diagram editor closes from its dock's own button | #fix #ai #diagram #done | [2026-09](2026-09.md) | | U-20260923-164 | 2026-09-24 | Ask before closing a prompt or diagram with unsaved changes, from its tab or from closing the IDE | #fix #ai #diagram #done | [2026-09](2026-09.md) | @@ -247,4 +248,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 184 | +| [2026-09.md](2026-09.md) | 2026-09 | 185 | 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 64736a2c..78cd0a0a 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 @@ -41,6 +41,7 @@ def looks_like_a_fingerprint(line: str) -> bool: """Whether *line* is already a fingerprint rather than a URL.""" return len(line) == _FINGERPRINT_LENGTH and all(c in "0123456789abcdef" for c in line) + # The methods the panel offers / 面板提供的方法 SUPPORTED_METHODS = ("GET", "POST", "PUT", "DELETE") # The methods that carry the code in a body / 會把程式碼放進 body 的方法 diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index 0765f62b..87da198f 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -129,6 +129,8 @@ def _clamped_font_size(size: int) -> int: return max(MIN_FONT_SIZE, min(int(size), MAX_FONT_SIZE)) except (OverflowError, TypeError, ValueError): return _LABEL_FONT_SIZE + + _NODE_PEN_COLOR = "#455a64" _NODE_BRUSH_COLOR = "#e3f2fd" _NODE_SELECTED_COLOR = "#1565c0" diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index 55478808..602875f8 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -170,6 +170,7 @@ def _action_new_file(tree_view: QTreeView, path: Path | None) -> None: as_text(word.get("file_tree_ctx_already_exists").format(name=str(new_path))), ) return + def _create() -> None: new_path.parent.mkdir(parents=True, exist_ok=True) new_path.touch() diff --git a/test/test_utils/test_mermaid_parser.py b/test/test_utils/test_mermaid_parser.py index 53b750a4..7f33a080 100644 --- a/test/test_utils/test_mermaid_parser.py +++ b/test/test_utils/test_mermaid_parser.py @@ -340,7 +340,6 @@ def test_an_invisible_link_keeps_both_nodes_and_draws_nothing(self): assert result["connections"] == [] - class TestWhatTheReviewFound: def test_capitalised_keywords_are_nodes(self): # "End", "Click", "Style" were taken for keywords and their lines skipped From 3bbb75b91009d9077b9085f39be9a56e80f02390 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:48:47 +0800 Subject: [PATCH 204/312] Remove a leftover fragment of a finished item from progress --- docs/updates/2026-09.md | 7 +++++++ docs/updates/README.md | 3 ++- progress.md | 1 - 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 2c921c6a..7b7261d6 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2838,3 +2838,10 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: `ruff check pybreeze/ test/` clean. - **Files**: `diagram_editor/diagram_items.py`, `connect_gui/url/ai_code_review_gui.py`, `editor_main/file_tree_context_menu.py`, `test/test_utils/test_mermaid_parser.py` - **Open items**: none. + +## U-20260923-168 · 2026-09-24 · Remove a leftover fragment of a finished item from progress · #docs + +- **What**: progress #77 (done in U-20260923-143) quoted a pasted carriage return as a raw character. Removing the item split it there, and its tail (`: Qt turns it into a line break, so Hash and Regex see LF ...`) stayed in `progress.md` as a line of its own, under #54. The fragment is removed; `progress.md` holds only open items again. +- **Result / numbers**: 16 lines, all CRLF, 8 open items. +- **Files**: `progress.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 821bdfe1..037f23a6 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-168 | 2026-09-24 | Remove a leftover fragment of a finished item from progress | #docs | [2026-09](2026-09.md) | | U-20260923-167 | 2026-09-24 | Restore blank lines lost in recent edits | #refactor | [2026-09](2026-09.md) | | U-20260923-166 | 2026-09-24 | Restore the main window's tool-tab close check name | #refactor | [2026-09](2026-09.md) | | U-20260923-165 | 2026-09-24 | Ask before a docked prompt or diagram editor closes from its dock's own button | #fix #ai #diagram #done | [2026-09](2026-09.md) | @@ -248,4 +249,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 185 | +| [2026-09.md](2026-09.md) | 2026-09 | 186 | diff --git a/progress.md b/progress.md index e45e002b..d6ae6eb1 100644 --- a/progress.md +++ b/progress.md @@ -12,6 +12,5 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? -`: Qt turns it into a line break, so Hash and Regex see LF where CRLF was pasted (`tools_gui/exact_text.py`). - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. From b00bdb40489b41c3339fb0013cb36866ff35db78 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:52:51 +0800 Subject: [PATCH 205/312] Refuse a curl command that has no URL --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/curl_import/curl_parser.py | 8 +++++++- test/test_utils/test_curl_import.py | 6 ++++++ 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index fd73f36c..979f7f99 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1931 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1935 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 7b7261d6..27d7a2c3 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2845,3 +2845,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: 16 lines, all CRLF, 8 open items. - **Files**: `progress.md` - **Open items**: none. + +## U-20260923-169 · 2026-09-24 · Refuse a curl command that has no URL · #fix #tools + +- **What**: `parse_curl` accepted a command with no URL (`curl -X POST -d a=1`, `curl -H ...`, or a URL that is only a `#fragment`) and returned a request with `url=""`, so the cURL tab generated a script calling `requests.post("")` that failed only when run. curl itself refuses such a command ("no URL specified"). `exception_tags.no_url_in_curl_error` had been defined for this from the start and never used. +- **Fix**: `parse_curl` raises `CurlParseException(no_url_in_curl_error)` when no URL is left after the query and fragment are split off; the tab shows it like the other parse errors. +- **Tests**: `test_curl_import.py` +4 (a parametrized test over four commands without a URL). +- **Result / numbers**: every test that imports the cURL parser, 343 passed; 1935 tests in 115 files. `ruff check` clean. +- **Files**: `pybreeze/utils/curl_import/curl_parser.py`, `test/test_utils/test_curl_import.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 037f23a6..77c6f07b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-169 | 2026-09-24 | Refuse a curl command that has no URL | #fix #tools | [2026-09](2026-09.md) | | U-20260923-168 | 2026-09-24 | Remove a leftover fragment of a finished item from progress | #docs | [2026-09](2026-09.md) | | U-20260923-167 | 2026-09-24 | Restore blank lines lost in recent edits | #refactor | [2026-09](2026-09.md) | | U-20260923-166 | 2026-09-24 | Restore the main window's tool-tab close check name | #refactor | [2026-09](2026-09.md) | @@ -249,4 +250,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 186 | +| [2026-09.md](2026-09.md) | 2026-09 | 187 | diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index ecbbca32..dbc34b06 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -23,6 +23,7 @@ invalid_http_method_error, malformed_curl_command_error, malformed_url_error, + no_url_in_curl_error, not_a_curl_command_error, ) from pybreeze.utils.exception.exceptions import CurlParseException @@ -577,7 +578,7 @@ def parse_curl(command: str) -> CurlRequest: :return: the structured request :raises CurlParseException: when the command is empty, is not a curl command, cannot be tokenised (for example, unbalanced quotes), or its URL is - malformed + missing or malformed """ normalised = _normalise_command(command) if not normalised: @@ -593,6 +594,11 @@ def parse_curl(command: str) -> CurlRequest: _consume_tokens(_expand_short_flags(tokens[1:]), request) # The URL's own query first: curl appends -G data after it. _split_url_query(request) + # curl refuses a command without one ("no URL specified"); the generated + # script would only fail when run, on requests.get("") + if not request.url: + pybreeze_logger.error(no_url_in_curl_error) + raise CurlParseException(no_url_in_curl_error) if not url_is_well_formed(request.url): pybreeze_logger.error(malformed_url_error) raise CurlParseException(malformed_url_error) diff --git a/test/test_utils/test_curl_import.py b/test/test_utils/test_curl_import.py index 9663f957..ebaef018 100644 --- a/test/test_utils/test_curl_import.py +++ b/test/test_utils/test_curl_import.py @@ -171,6 +171,12 @@ def test_non_curl_command_raises(self): with pytest.raises(CurlParseException): parse_curl("wget https://x") + @pytest.mark.parametrize("command", ["curl", "curl -X POST -d a=1", "curl -H 'Accept: x'", "curl '#top'"]) + def test_a_command_without_a_url_raises(self, command): + # curl says "no URL specified"; a script calling requests.get("") only fails when run + with pytest.raises(CurlParseException, match="no URL"): + parse_curl(command) + def test_unbalanced_quotes_raise(self): with pytest.raises(CurlParseException): parse_curl("curl 'https://x") From 3238ce0484d149525400e5f75de473835d67465d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 04:53:46 +0800 Subject: [PATCH 206/312] Remove an unused upload helper and type the output actions' name arguments --- docs/updates/2026-09.md | 7 +++++++ docs/updates/README.md | 3 ++- pybreeze/pybreeze_ui/tools_gui/output_actions.py | 8 ++++---- pybreeze/utils/curl_import/request_body.py | 8 -------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 27d7a2c3..e8843a37 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2854,3 +2854,10 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: every test that imports the cURL parser, 343 passed; 1935 tests in 115 files. `ruff check` clean. - **Files**: `pybreeze/utils/curl_import/curl_parser.py`, `test/test_utils/test_curl_import.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-170 · 2026-09-24 · Remove an unused upload helper and type the output actions' name arguments · #refactor #tools + +- **What**: found by `vulture`. `curl_import/request_body.file_uploads` had no caller (the generators use `form_entries`) and is removed. `output_actions.StrOrCallable` was a string nothing referred to; it is now the type alias it described, annotating `OutputActions`' `basename` and `extension` and `_resolve`. No behaviour changes. +- **Result / numbers**: cURL and output-action tests 358 passed; the alias evaluates on Python 3.10. `ruff check` clean. +- **Files**: `pybreeze/utils/curl_import/request_body.py`, `pybreeze/pybreeze_ui/tools_gui/output_actions.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 77c6f07b..de831f98 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-170 | 2026-09-24 | Remove an unused upload helper and type the output actions' name arguments | #refactor #tools | [2026-09](2026-09.md) | | U-20260923-169 | 2026-09-24 | Refuse a curl command that has no URL | #fix #tools | [2026-09](2026-09.md) | | U-20260923-168 | 2026-09-24 | Remove a leftover fragment of a finished item from progress | #docs | [2026-09](2026-09.md) | | U-20260923-167 | 2026-09-24 | Restore blank lines lost in recent edits | #refactor | [2026-09](2026-09.md) | @@ -250,4 +251,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 187 | +| [2026-09.md](2026-09.md) | 2026-09 | 188 | diff --git a/pybreeze/pybreeze_ui/tools_gui/output_actions.py b/pybreeze/pybreeze_ui/tools_gui/output_actions.py index 036493fb..dba97739 100644 --- a/pybreeze/pybreeze_ui/tools_gui/output_actions.py +++ b/pybreeze/pybreeze_ui/tools_gui/output_actions.py @@ -22,10 +22,10 @@ from pybreeze.utils.logging.logger import pybreeze_logger # A value that is either fixed or computed on demand (e.g. depends on a selector) -StrOrCallable = "str | Callable[[], str]" +StrOrCallable = str | Callable[[], str] -def _resolve(value) -> str: +def _resolve(value: StrOrCallable) -> str: """Return *value*, calling it first if it is a callable.""" return value() if callable(value) else value @@ -36,8 +36,8 @@ class OutputActions: def __init__( self, parent: QWidget, output_edit: QTextEdit, *, main_window=None, - basename="output", - extension="txt", + basename: StrOrCallable = "output", + extension: StrOrCallable = "txt", is_valid: Callable[[], bool] | None = None) -> None: """ :param parent: the tool widget the file dialog is parented to diff --git a/pybreeze/utils/curl_import/request_body.py b/pybreeze/utils/curl_import/request_body.py index 9d9dc5dd..ae5d6df4 100644 --- a/pybreeze/utils/curl_import/request_body.py +++ b/pybreeze/utils/curl_import/request_body.py @@ -76,14 +76,6 @@ def form_parts(request: CurlRequest) -> FormParts: return data_fields, file_fields -def file_uploads(file_fields: dict[str, str | list[str]]) -> list[tuple[str, str]]: - """Every ``(field, filename)`` upload, a repeated field once per file.""" - return [ - (field, name) for field, names in file_fields.items() - for name in (names if isinstance(names, list) else [names]) - ] - - # One multipart field as sent: its name, whether it uploads a file, and the # file name or the text FormEntry = tuple[str, bool, str] From bf74b6870e882eb4916e641444fb298e5907cc1e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:02:19 +0800 Subject: [PATCH 207/312] Remove fourteen error messages nothing raises --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 7 +++++ docs/updates/README.md | 3 +- pybreeze/utils/exception/exception_tags.py | 25 ---------------- test/test_utils/test_exception_tags.py | 34 ---------------------- 5 files changed, 10 insertions(+), 61 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 979f7f99..6e1c5e6d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1935 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1934 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index e8843a37..60042be1 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2861,3 +2861,10 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: cURL and output-action tests 358 passed; the alias evaluates on Python 3.10. `ruff check` clean. - **Files**: `pybreeze/utils/curl_import/request_body.py`, `pybreeze/pybreeze_ui/tools_gui/output_actions.py` - **Open items**: none. + +## U-20260923-171 · 2026-09-24 · Remove fourteen error messages nothing raises · #refactor + +- **What**: found by `vulture` and checked by name across the package and every other repository under `D:\Codes` (none imports `pybreeze.utils.exception`). Fourteen message constants in `utils/exception/exception_tags.py` had no user but the test that listed them: the add-command, executor, install, test-data, compiler, file-not-found and XML messages carried over from the original template. They are removed with their section comments; the test keeps the messages still in use. The exception classes stay: they form the `ITEException` hierarchy callers catch. +- **Result / numbers**: `exception_tags.py` holds 33 messages, down from 47; `test_exception_tags.py` 2 passed (was 3; the dropped test checked only the removed executor messages). 1934 tests in 115 files. `ruff check` clean. +- **Files**: `pybreeze/utils/exception/exception_tags.py`, `test/test_utils/test_exception_tags.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index de831f98..df2daec8 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-171 | 2026-09-24 | Remove fourteen error messages nothing raises | #refactor | [2026-09](2026-09.md) | | U-20260923-170 | 2026-09-24 | Remove an unused upload helper and type the output actions' name arguments | #refactor #tools | [2026-09](2026-09.md) | | U-20260923-169 | 2026-09-24 | Refuse a curl command that has no URL | #fix #tools | [2026-09](2026-09.md) | | U-20260923-168 | 2026-09-24 | Remove a leftover fragment of a finished item from progress | #docs | [2026-09](2026-09.md) | @@ -251,4 +252,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 188 | +| [2026-09.md](2026-09.md) | 2026-09 | 189 | diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index fa16bab8..5146dab2 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -1,9 +1,5 @@ from __future__ import annotations -# add command exception -add_command_type_exception_tag: str = "command execute_return_value type must be a method or function" -add_command_not_allow_package_exception_tag: str = "chosen command package is not allowed" - # send html report exception send_html_exception_tag: str = """ make sure you have installed je_mail_thunder @@ -12,32 +8,11 @@ or use the file_path function to read """ -# test executor exception -auto_control_process_executor_exception_tag: str = "can't run AutoControl" -api_testka_process_executor_exception_tag: str = "can't run APITestka" -web_runner_process_executor_exception_tag: str = "can't run WebRunner" -load_density_process_executor_exception_tag: str = "can't run LoadDensity" - -# Install -not_install_exception: str = "please install the package first; can't find the package" - -# ui exception -wrong_test_data_format_exception_tag: str = "incorrect test data format" - -exec_error: str = "AutomationEditor execution error" -file_not_fond_error: str = "File not found" -compiler_not_found_error: str = "Compiler not found" -not_install_package_error: str = "required package not installed" - # json exception cant_reformat_json_error: str = "can't reformat JSON: is the type correct?" wrong_json_data_error: str = "can't parse JSON" json_duplicate_key_error: str = "the JSON gives the key {key!r} twice in one object" -# XML -cant_read_xml_error: str = "can't read XML" -xml_type_error: str = "XML type error" - # cURL import empty_curl_command_error: str = "no curl command provided" not_a_curl_command_error: str = "the command does not look like a curl command" diff --git a/test/test_utils/test_exception_tags.py b/test/test_utils/test_exception_tags.py index 58383d7d..28f662ce 100644 --- a/test/test_utils/test_exception_tags.py +++ b/test/test_utils/test_exception_tags.py @@ -1,44 +1,16 @@ 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) @@ -47,9 +19,3 @@ def test_all_tags_are_strings(self): 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 From a956626d4a1e2342191dc3c06776d13c10e04564 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:08:00 +0800 Subject: [PATCH 208/312] Keep a query's order in the URL Builder and refuse a port written in other digits --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 11 +++++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/url_tools/url_convert.py | 24 +++++++++++++++++++----- test/test_utils/test_url_convert.py | 15 +++++++++++++++ 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 6e1c5e6d..befda207 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1934 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1937 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 60042be1..91ae1145 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2868,3 +2868,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: `exception_tags.py` holds 33 messages, down from 47; `test_exception_tags.py` 2 passed (was 3; the dropped test checked only the removed executor messages). 1934 tests in 115 files. `ruff check` clean. - **Files**: `pybreeze/utils/exception/exception_tags.py`, `test/test_utils/test_exception_tags.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-172 · 2026-09-24 · Keep a query's order in the URL Builder and refuse a port written in other digits · #fix #tools + +- **What**: two URL Builder defects. + - `parse_url` grouped a query into a dict whenever decoding and encoding it again gave the same text, but grouping moves a key that repeats after another one: `?a=1&b=2&a=3` was rebuilt as `?a=1&a=3&b=2`, which a signed or order-sensitive URL does not survive. + - `_port_text` took any string whose `isdigit()` holds. `"\u00b2"` passes that and `int()` then raised a `ValueError` the tab does not catch: the slot failed and the previous output stayed savable. `"\u0663"` (Arabic-Indic three) became port 3. +- **Fix**: new `_query_component` keeps the dict form only when `_build_query` rebuilds the exact query text, else the text; a port string must be ASCII digits. +- **Tests**: `test_url_convert.py` +3 (the reordered key; `\u00b2` and `\u0663` refused). +- **Result / numbers**: URL, URL Builder and parser-differential tests pass. 1937 tests in 115 files. `ruff check` clean. +- **Files**: `pybreeze/utils/url_tools/url_convert.py`, `test/test_utils/test_url_convert.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index df2daec8..80d7b8d9 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-172 | 2026-09-24 | Keep a query's order in the URL Builder and refuse a port written in other digits | #fix #tools | [2026-09](2026-09.md) | | U-20260923-171 | 2026-09-24 | Remove fourteen error messages nothing raises | #refactor | [2026-09](2026-09.md) | | U-20260923-170 | 2026-09-24 | Remove an unused upload helper and type the output actions' name arguments | #refactor #tools | [2026-09](2026-09.md) | | U-20260923-169 | 2026-09-24 | Refuse a curl command that has no URL | #fix #tools | [2026-09](2026-09.md) | @@ -252,4 +253,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 189 | +| [2026-09.md](2026-09.md) | 2026-09 | 190 | diff --git a/pybreeze/utils/url_tools/url_convert.py b/pybreeze/utils/url_tools/url_convert.py index bbae71a9..7e3e2dfb 100644 --- a/pybreeze/utils/url_tools/url_convert.py +++ b/pybreeze/utils/url_tools/url_convert.py @@ -46,10 +46,7 @@ def parse_url(url: str) -> dict: "host": split.hostname or "", "port": _safe_port(split), "path": split.path, - # A repeated key keeps every value, as a list, like the query tool. A - # query that decoding and encoding again would change stays the text - # it was: rebuilt, %zz became %25zz and a bare key gained "=". - "query": query_to_dict(split.query) if query_round_trips(split.query) else split.query, + "query": _query_component(split.query), "fragment": split.fragment, } if split.username is not None: @@ -59,6 +56,21 @@ def parse_url(url: str) -> dict: return components +def _query_component(query: str) -> dict | str: + """*query* as a dict of its values, or as its text when the dict would not rebuild it. + + A repeated key keeps every value, as a list, like the query tool. A query + that decoding and encoding again would change stays the text it was: + rebuilt, %zz became %25zz and a bare key gained "=". So does one whose + repeated key comes back in another order: the dict groups ``a=1&b=2&a=3`` + into ``a=1&a=3&b=2``, which a signed URL does not survive. + """ + if not query_round_trips(query): + return query + as_dict = query_to_dict(query) + return as_dict if _build_query(as_dict) == query else query + + def url_to_json(url: str) -> str: """Parse *url* and render its components as pretty-printed JSON. @@ -114,7 +126,9 @@ def _port_text(port: object) -> str: """ if port is None or port == "": return "" - if isinstance(port, str) and port.strip().isdigit(): + # ASCII digits only: "²".isdigit() holds, and int() then raised a + # ValueError past the builder's error handling; "٣" became port 3 + if isinstance(port, str) and port.strip().isascii() and port.strip().isdigit(): port = int(port) if isinstance(port, bool) or not isinstance(port, int) or not 0 <= port <= _MAX_PORT: pybreeze_logger.error(url_port_out_of_range_error) diff --git a/test/test_utils/test_url_convert.py b/test/test_utils/test_url_convert.py index a1b0ad02..33292a52 100644 --- a/test/test_utils/test_url_convert.py +++ b/test/test_utils/test_url_convert.py @@ -119,6 +119,21 @@ def test_the_round_trip_keeps_them(self): assert build_url(parse_url("https://x/?tag=a&tag=b&one=1")) == "https://x/?tag=a&tag=b&one=1" + def test_a_key_that_repeats_after_another_keeps_its_place(self): + # Grouped into a dict, a=1&b=2&a=3 came back as a=1&a=3&b=2: a signed URL breaks + parts = parse_url("https://x/?a=1&b=2&a=3") + + assert parts["query"] == "a=1&b=2&a=3" + assert build_url(parts) == "https://x/?a=1&b=2&a=3" + + +class TestAPortOfOtherDigits: + @pytest.mark.parametrize("port", ["²", "٣"]) + def test_it_is_refused(self, port): + # "²".isdigit() holds: int() raised past the builder, and "٣" became port 3 + with pytest.raises(UrlConvertException): + json_to_url(json.dumps({"scheme": "http", "host": "h", "port": port})) + class TestAPortOutOfRange: def test_it_is_reported_rather_than_dropped(self): From c7d3bd5f17d101ae350242e62264fecfa0babe90 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:08:12 +0800 Subject: [PATCH 209/312] Name every line ending the diff tool compares, and a changed line that starts with dashes --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 11 ++++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/diff_tools/text_diff.py | 17 ++++++++++------ test/test_utils/test_text_diff.py | 28 ++++++++++++++++++++++++-- 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index befda207..9410a1d0 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1937 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1940 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 91ae1145..4d3f54c5 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2879,3 +2879,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: URL, URL Builder and parser-differential tests pass. 1937 tests in 115 files. `ruff check` clean. - **Files**: `pybreeze/utils/url_tools/url_convert.py`, `test/test_utils/test_url_convert.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-173 · 2026-09-24 · Name every line ending the diff tool compares, and a changed line that starts with dashes · #fix #tools + +- **What**: two ways the Diff tab hid the one difference it found. + - Lines are split with `str.splitlines`, which also ends a line at `\v`, `\f`, `\x1c`-`\x1e`, `\x85`, U+2028 and U+2029, but `_shown` took off only `\r` and `\n`. `a\u2028b` against `a\nb` showed `-a` followed by the separator and a false "No newline at end of file"; `a\vb` against `a\fb` showed `-a` and `+a`, identical, each with that false note. The exact text the tab reads keeps U+2028, so this is reachable from the GUI. + - `_shown` told the two header lines apart by their `---`/`+++`, so a removed line `--x` (shown `---x`) or an added `++x` was taken for one and its ending was not named: `--x\r\n` against `--x\n` showed no difference at all. +- **Fix**: `_shown` takes the ending as whatever follows `splitlines()`'s first line, and `compare_texts` passes the first two lines (the header) through as they are, by position. +- **Tests**: `test_text_diff.py` +3 (U+2028; `\v` against `\f`; `--x` with CRLF). The difflib-equivalence property test now leaves the header lines as they are, as `compare_texts` does. +- **Result / numbers**: diff tests pass. 1940 tests in 115 files. `ruff check` clean. +- **Files**: `pybreeze/utils/diff_tools/text_diff.py`, `test/test_utils/test_text_diff.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 80d7b8d9..4bb1581d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-173 | 2026-09-24 | Name every line ending the diff tool compares, and a changed line that starts with dashes | #fix #tools | [2026-09](2026-09.md) | | U-20260923-172 | 2026-09-24 | Keep a query's order in the URL Builder and refuse a port written in other digits | #fix #tools | [2026-09](2026-09.md) | | U-20260923-171 | 2026-09-24 | Remove fourteen error messages nothing raises | #refactor | [2026-09](2026-09.md) | | U-20260923-170 | 2026-09-24 | Remove an unused upload helper and type the output actions' name arguments | #refactor #tools | [2026-09](2026-09.md) | @@ -253,4 +254,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 190 | +| [2026-09.md](2026-09.md) | 2026-09 | 191 | diff --git a/pybreeze/utils/diff_tools/text_diff.py b/pybreeze/utils/diff_tools/text_diff.py index c12f1dde..16e909bb 100644 --- a/pybreeze/utils/diff_tools/text_diff.py +++ b/pybreeze/utils/diff_tools/text_diff.py @@ -14,6 +14,8 @@ _RIGHT_LABEL = "actual" # Lines of unchanged context kept around each change in the unified diff _CONTEXT_LINES = 3 +# The "--- left" and "+++ right" lines a non-empty unified diff starts with +_HEADER_LINES = 2 # diff's own note under a last line that has no newline _NO_NEWLINE_MARK = "\\ No newline at end of file" @@ -47,15 +49,16 @@ def _line_lists(left: str, right: str) -> tuple[list[str], list[str]]: def _shown(diff_line: str) -> str: - """One line of the unified diff, its line ending taken off for display. + """One line of the unified diff's body, its line ending taken off for display. An added or removed line with no newline gets diff's own marker on the - next line; one with another ending (``\\r\\n``, a lone ``\\r``) names it. + next line; one with another ending (``\\r\\n``, a lone ``\\r``, or any other + separator ``str.splitlines`` ends a line at, such as U+2028) names it. Unchanged context lines are shown without comment. """ - content = diff_line.rstrip("\r\n") + content = diff_line.splitlines()[0] if diff_line else "" ending = diff_line[len(content):] - if diff_line[:1] not in ("+", "-") or diff_line.startswith(("+++", "---")) or ending == "\n": + if diff_line[:1] not in ("+", "-") or ending == "\n": return content if not ending: return f"{content}\n{_NO_NEWLINE_MARK}" @@ -126,8 +129,10 @@ def compare_texts( removed += left_end - left_start if tag in ("replace", "insert"): added += right_end - right_start - diff = "\n".join( - _shown(line) for line in _unified_lines(matcher, left_lines, right_lines, (left_label, right_label))) + lines = _unified_lines(matcher, left_lines, right_lines, (left_label, right_label)) + # The two header lines are shown as they are: told apart by position, not + # by their "---"/"+++", which a removed "--x" or an added "++x" also has + diff = "\n".join(lines[:_HEADER_LINES] + [_shown(line) for line in lines[_HEADER_LINES:]]) return Comparison(DiffSummary(added=added, removed=removed, is_equal=left == right), diff) diff --git a/test/test_utils/test_text_diff.py b/test/test_utils/test_text_diff.py index 47572e1b..f4e09d0e 100644 --- a/test/test_utils/test_text_diff.py +++ b/test/test_utils/test_text_diff.py @@ -111,6 +111,30 @@ def test_other_changes_are_counted_as_before(self): assert diff_summary("a", "a\nb").added == 1 +class TestAnotherLineSeparator: + """splitlines also ends a line at U+2028, \\v, \\f, ...: the diff must name it.""" + + def test_a_line_separator_is_named_not_mistaken_for_no_newline(self): + diff = unified_diff("a
b", "a\nb") + + assert "-a (line ends with '\\u2028')" in diff + assert "No newline" not in diff + + def test_two_separators_that_look_alike_are_told_apart(self): + diff = unified_diff("a\x0bb", "a\x0cb") + + assert "-a (line ends with '\\x0b')" in diff + assert "+a (line ends with '\\x0c')" in diff + + +def test_a_removed_line_starting_with_dashes_gets_its_ending_named(): + # "---x" was taken for the diff's header line and left without a note + diff = unified_diff("--x\r\n", "--x\n") + + assert diff.splitlines()[:2] == ["--- expected", "+++ actual"] + assert "---x (line ends with '\\r\\n')" in diff + + _LINES = st.lists(st.sampled_from(["a", "b", "c", "", "d e"]), max_size=25) @@ -125,7 +149,7 @@ def test_the_diff_is_the_one_difflib_writes(left, right): left_text, right_text = "\n".join(left), "\n".join(right) left_lines, right_lines = _line_lists(left_text, right_text) - expected = "\n".join(_shown(line) for line in difflib.unified_diff( - left_lines, right_lines, "expected", "actual", lineterm="", n=3)) + written = list(difflib.unified_diff(left_lines, right_lines, "expected", "actual", lineterm="", n=3)) + expected = "\n".join(written[:2] + [_shown(line) for line in written[2:]]) assert compare_texts(left_text, right_text).diff == expected From 55c00a654cb89090ba6ce1ab56cf51eb8f63fd44 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:09:39 +0800 Subject: [PATCH 210/312] Remove a partial save on any failure, and report a prthinker setting UTF-8 cannot write --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 11 +++++++ docs/updates/README.md | 3 +- .../prthinker_extend/prthinker_setting.py | 4 ++- pybreeze/utils/file_process/replace_file.py | 33 +++++++++++++------ test/test_utils/test_prthinker_setting.py | 10 ++++++ test/test_utils/test_replace_file.py | 28 ++++++++++++++++ 7 files changed, 78 insertions(+), 13 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 9410a1d0..78caf3e7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1940 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1943 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 4d3f54c5..4c00e326 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2890,3 +2890,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: diff tests pass. 1940 tests in 115 files. `ruff check` clean. - **Files**: `pybreeze/utils/diff_tools/text_diff.py`, `test/test_utils/test_text_diff.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-174 · 2026-09-24 · Remove a partial save on any failure, and report a prthinker setting UTF-8 cannot write · #fix #ai + +- **What**: + - `replace_text` and `replace_written` removed the partial `.saving` file only on an `OSError`. Text holding a lone surrogate fails with `UnicodeEncodeError`, and a writer may fail with anything: the partial file stayed beside the target. The target itself was never touched. + - `prthinker_setting.save_setting` caught only `OSError`. A settings file hand-edited to hold `"\ud800"` loads as a lone surrogate, and saving it back (the install menu stores the chosen source folder) raised out of the menu's slot. +- **Fix**: both writers remove the partial file in a `finally` unless the replace went through, and `replace_text`'s docstring names the `UnicodeEncodeError`. `save_setting` treats that error like an `OSError`: logged, `False` returned, the file kept. The diagram save already caught it (a `ValueError`). +- **Tests**: `test_replace_file.py` +2 (an encoding error, a writer failing with `ValueError`); `test_prthinker_setting.py` +1 (a lone surrogate from the file: the save fails, the file stays as it was, nothing beside it). +- **Result / numbers**: 64 passed, 1 skipped (POSIX-only). 1943 tests in 115 files. `ruff check` clean. +- **Files**: `pybreeze/utils/file_process/replace_file.py`, `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_replace_file.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 4bb1581d..9cd3d16a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-174 | 2026-09-24 | Remove a partial save on any failure, and report a prthinker setting UTF-8 cannot write | #fix #ai | [2026-09](2026-09.md) | | U-20260923-173 | 2026-09-24 | Name every line ending the diff tool compares, and a changed line that starts with dashes | #fix #tools | [2026-09](2026-09.md) | | U-20260923-172 | 2026-09-24 | Keep a query's order in the URL Builder and refuse a port written in other digits | #fix #tools | [2026-09](2026-09.md) | | U-20260923-171 | 2026-09-24 | Remove fourteen error messages nothing raises | #refactor | [2026-09](2026-09.md) | @@ -254,4 +255,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 191 | +| [2026-09.md](2026-09.md) | 2026-09 | 192 | diff --git a/pybreeze/extend/prthinker_extend/prthinker_setting.py b/pybreeze/extend/prthinker_extend/prthinker_setting.py index bf270cc5..b0ea333a 100644 --- a/pybreeze/extend/prthinker_extend/prthinker_setting.py +++ b/pybreeze/extend/prthinker_extend/prthinker_setting.py @@ -189,7 +189,9 @@ def save_setting(setting: Dict[str, str]) -> bool: # Replaced in one step, readable by its owner only: written in place, a # failure part-way emptied the file and every key and token in it replace_text(path, json.dumps(to_store, indent=4, ensure_ascii=False), private=True) - except OSError as error: + # A lone surrogate, loaded from a hand-edited "\ud800", cannot be written + # as UTF-8: it raised out of the install menu's slot + except (OSError, UnicodeEncodeError) as error: pybreeze_logger.error("prthinker settings could not be saved: %r", error) return False return True diff --git a/pybreeze/utils/file_process/replace_file.py b/pybreeze/utils/file_process/replace_file.py index 73db6c62..a1f98622 100644 --- a/pybreeze/utils/file_process/replace_file.py +++ b/pybreeze/utils/file_process/replace_file.py @@ -29,10 +29,13 @@ def replace_text(path: Path, text: str, *, private: bool = False) -> None: POSIX; Windows keeps the profile's own access rules). For a file that holds keys or tokens, which ``write_text`` left readable by every local user. - :raises OSError: when it cannot be written; *path* is then unchanged and - the partial file removed + :raises OSError: when it cannot be written + :raises UnicodeEncodeError: when *text* holds a lone surrogate, which + UTF-8 cannot encode + Either way *path* is unchanged and the partial file removed. """ beside = path.with_name(path.name + ".saving") + replaced = False try: # A leftover from an earlier failure would keep its own permissions with contextlib.suppress(FileNotFoundError): @@ -42,10 +45,12 @@ def replace_text(path: Path, text: str, *, private: bool = False) -> None: with os.fdopen(descriptor, "w", encoding="utf-8") as file: file.write(text) os.replace(beside, path) - except OSError: - with contextlib.suppress(OSError): - beside.unlink() - raise + replaced = True + finally: + # Whatever stopped it: an encoding error is no OSError, and left + # .saving behind + if not replaced: + _remove_partial(beside) def replace_written(path: Path, write: Callable[[Path], None]) -> None: @@ -63,12 +68,20 @@ def replace_written(path: Path, write: Callable[[Path], None]) -> None: the partial file removed """ beside = path.with_name(f"{path.stem}.saving{path.suffix}") + replaced = False try: with contextlib.suppress(FileNotFoundError): beside.unlink() write(beside) os.replace(beside, path) - except OSError: - with contextlib.suppress(OSError): - beside.unlink() - raise + replaced = True + finally: + # A writer may fail with more than OSError; its partial file goes too + if not replaced: + _remove_partial(beside) + + +def _remove_partial(beside: Path) -> None: + """Remove the partial file *beside*, if a failed save left one.""" + with contextlib.suppress(OSError): + beside.unlink() diff --git a/test/test_utils/test_prthinker_setting.py b/test/test_utils/test_prthinker_setting.py index ff6d9b15..3b062a99 100644 --- a/test/test_utils/test_prthinker_setting.py +++ b/test/test_utils/test_prthinker_setting.py @@ -81,6 +81,16 @@ def disk_full(*_args, **_kwargs): assert load_setting()["openai_api_key"] == "kept-key" assert [path.name for path in setting_path().parent.iterdir()] == [SETTING_FILE_NAME] + def test_a_lone_surrogate_loaded_from_the_file_fails_the_save_and_keeps_it(self, data_dir): + # The file may hold "\ud800" escaped; UTF-8 cannot write it back, and + # the error escaped the install menu's slot + (data_dir / SETTING_FILE_NAME).write_text('{"repository": "a\\ud800"}', encoding="utf-8") + before = (data_dir / SETTING_FILE_NAME).read_bytes() + + assert save_setting(load_setting()) is False + assert (data_dir / SETTING_FILE_NAME).read_bytes() == before + assert [path.name for path in data_dir.iterdir()] == [SETTING_FILE_NAME] + def test_the_file_is_created_for_its_owner_only(self, data_dir, monkeypatch): from pybreeze.utils.file_process import replace_file diff --git a/test/test_utils/test_replace_file.py b/test/test_utils/test_replace_file.py index 00cd46cc..521e7740 100644 --- a/test/test_utils/test_replace_file.py +++ b/test/test_utils/test_replace_file.py @@ -86,3 +86,31 @@ def write(target): assert old.read_bytes() == b"old" assert list(tmp_path.iterdir()) == [old] + + def test_a_writer_that_fails_otherwise_leaves_nothing_behind_either(self, tmp_path): + from pybreeze.utils.file_process.replace_file import replace_written + + old = tmp_path / "out.svg" + old.write_bytes(b"old") + + def write(target): + target.write_bytes(b"ha") + raise ValueError("not an image") + + with pytest.raises(ValueError): + replace_written(old, write) + + assert old.read_bytes() == b"old" + assert list(tmp_path.iterdir()) == [old] + + +def test_text_utf8_cannot_encode_leaves_the_file_and_nothing_else(tmp_path): + # UnicodeEncodeError is no OSError: the partial .saving stayed + target = tmp_path / "f.txt" + target.write_text("old", encoding="utf-8") + + with pytest.raises(UnicodeEncodeError): + replace_text(target, "x" + chr(0xD800)) + + assert target.read_text(encoding="utf-8") == "old" + assert list(tmp_path.iterdir()) == [target] From 963446b61e03a79845ad91b384f753cbedd0b3ed Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:11:32 +0800 Subject: [PATCH 211/312] Report a mail settings file that cannot be read, and always tell the run window how the report mail went --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 11 ++++++ docs/updates/README.md | 3 +- progress.md | 1 + .../mail_thunder_setting.py | 17 +++++++-- test/test_utils/test_mail_report.py | 36 +++++++++++++++++++ 6 files changed, 66 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 78caf3e7..c5302768 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1943 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1947 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 4c00e326..2e21f16c 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2901,3 +2901,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: 64 passed, 1 skipped (POSIX-only). 1943 tests in 115 files. `ruff check` clean. - **Files**: `pybreeze/utils/file_process/replace_file.py`, `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_replace_file.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-175 · 2026-09-24 · Report a mail settings file that cannot be read, and always tell the run window how the report mail went · #fix #executor + +- **What**: `send_report` looked up the mail user outside its `try`. je_mail_thunder's `read_output_content` opens `mail_thunder_content.json` in the locale's encoding and parses it without a guard, so a broken file, or a UTF-8 file with Chinese text on a cp950 system, raised `JSONDecodeError` / `UnicodeDecodeError` out of the report-mail thread. The thread died with a traceback on stderr: nothing was logged, `on_done` never ran, the run window never said "[Mail] ...", and the `_MailNotice` waiting for it was never deleted. +- **Fix**: + - The user lookup catches `OSError`, `ValueError` (the decode and JSON errors) and `MailThunderException`, logs it, and answers "the mail settings file (mail_thunder_content.json) could not be read"; nothing connects. + - `send_after_test`'s thread catches anything else `send_report` lets through, logs it, and still calls `on_done` with "sending failed ()". +- **Tests**: `test_mail_report.py` +4 (a JSON error, a decode error and an `OSError` from the content file; an unexpected error still reaching `on_done`). +- **Result / numbers**: 24 passed. 1947 tests in 115 files. `ruff check` clean. +- **Files**: `pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py`, `test/test_utils/test_mail_report.py`, `progress.md` (#102 added), `architecture_explore.md` §18 +- **Open items**: #102: `SMTPWrapper` takes no timeout, so a mail server that stalls holds the thread without end; that needs MailThunder. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9cd3d16a..27876fb3 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-175 | 2026-09-24 | Report a mail settings file that cannot be read, and always tell the run window how the report mail went | #fix #executor | [2026-09](2026-09.md) | | U-20260923-174 | 2026-09-24 | Remove a partial save on any failure, and report a prthinker setting UTF-8 cannot write | #fix #ai | [2026-09](2026-09.md) | | U-20260923-173 | 2026-09-24 | Name every line ending the diff tool compares, and a changed line that starts with dashes | #fix #tools | [2026-09](2026-09.md) | | U-20260923-172 | 2026-09-24 | Keep a query's order in the URL Builder and refuse a port written in other digits | #fix #tools | [2026-09](2026-09.md) | @@ -255,4 +256,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 192 | +| [2026-09.md](2026-09.md) | 2026-09 | 193 | diff --git a/progress.md b/progress.md index d6ae6eb1..324c3311 100644 --- a/progress.md +++ b/progress.md @@ -14,3 +14,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. +- **#102** [BLOCKED] A report mail can wait forever: je_mail_thunder's `SMTPWrapper(host, port)` passes no timeout to `SMTP_SSL`, so a mail server that accepts the connection and then stalls holds the report-mail thread (`extend/mail_thunder_extend/mail_thunder_setting.py`, `send_report`) with no end, and the run window never says whether the report went. Needs a `timeout` argument in MailThunder (`je_mail_thunder/smtp/smtp_wrapper.py:20`); then pass 30 s here. diff --git a/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py b/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py index 1b81da46..36d1c4ec 100644 --- a/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py +++ b/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py @@ -33,7 +33,13 @@ def send_after_test( :param on_done: called on the mail thread with ``send_report``'s answer """ def send() -> None: - outcome = send_report(html_report_path, not_before=not_before) + try: + outcome = send_report(html_report_path, not_before=not_before) + # The last stop on this thread: anything je_mail_thunder raises past + # send_report must still reach on_done, or the run window never says + except Exception as error: # noqa: BLE001 — logged, and reported to the run window + pybreeze_logger.error("Sending the report failed: %r", error) + outcome = f"sending failed ({type(error).__name__})" if on_done is not None: on_done(outcome) @@ -65,7 +71,14 @@ def send_report(html_report_path: str | None = None, *, not_before: float | None if problem is not None: pybreeze_logger.error("Report not sent (%s): %s", problem, report_path) return problem - user = _mail_user(read_output_content, get_mail_thunder_os_environ) + try: + user = _mail_user(read_output_content, get_mail_thunder_os_environ) + # je_mail_thunder reads mail_thunder_content.json in the locale's encoding + # and parses it unguarded: a broken file, or UTF-8 text on a cp950 + # system, raised out of the mail thread + except (OSError, ValueError, MailThunderException) as error: + pybreeze_logger.error("The mail settings file could not be read: %r", error) + return "the mail settings file (mail_thunder_content.json) could not be read" if user is None: pybreeze_logger.error("Cannot determine mail user for sending report") return "no mail user is set" diff --git a/test/test_utils/test_mail_report.py b/test/test_utils/test_mail_report.py index 1bbf6411..2cbc4e32 100644 --- a/test/test_utils/test_mail_report.py +++ b/test/test_utils/test_mail_report.py @@ -242,3 +242,39 @@ def test_the_answer_reaches_on_done(self, monkeypatch): assert answered.wait(5) assert answers == ["why"] + + def test_anything_raised_while_sending_still_reaches_on_done(self, monkeypatch, logger): + # The thread died with a traceback, and the run window never said a word + answered = threading.Event() + answers: list = [] + + def breaks(_path, *, not_before=None): + raise RuntimeError("inside je_mail_thunder") + + monkeypatch.setattr(mail, "send_report", breaks) + + mail.send_after_test("report.html", on_done=lambda reason: (answers.append(reason), answered.set())) + + assert answered.wait(5) + assert answers == ["sending failed (RuntimeError)"] + assert "inside je_mail_thunder" in _logged(logger) + + +class TestAMailSettingsFileThatCannotBeRead: + """je_mail_thunder reads mail_thunder_content.json unguarded, in the locale's encoding.""" + + @pytest.mark.parametrize("error", [ + ValueError("Expecting value"), + UnicodeDecodeError("cp950", b"\xe5", 0, 1, "illegal multibyte sequence"), + OSError("locked"), + ]) + def test_it_is_reported_and_nothing_is_sent(self, mail_thunder, logger, report, error): + def unreadable(): + raise error + + mail_thunder.read_output_content = unreadable + + assert mail.send_report(report) == ( + "the mail settings file (mail_thunder_content.json) could not be read") + assert FakeSmtp.instances == [] + assert "could not be read" in _logged(logger) From 0c7ced0f7e344d24254a12a715cd4da5a02f67f2 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:14:39 +0800 Subject: [PATCH 212/312] Translate the diagram export failure, and check every language key the code uses --- docs/updates/2026-09.md | 8 ++++++++ docs/updates/README.md | 3 ++- pybreeze/extend_multi_language/extend_english.py | 1 + .../extend_multi_language/extend_traditional_chinese.py | 1 + test/test_utils/test_language_parity.py | 7 ++++++- 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 2e21f16c..3896ee9b 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2912,3 +2912,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: 24 passed. 1947 tests in 115 files. `ruff check` clean. - **Files**: `pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py`, `test/test_utils/test_mail_report.py`, `progress.md` (#102 added), `architecture_explore.md` §18 - **Open items**: #102: `SMTPWrapper` takes no timeout, so a mail server that stalls holds the thread without end; that needs MailThunder. + +## U-20260923-176 · 2026-09-24 · Translate the diagram export failure, and check every language key the code uses · #fix #diagram #i18n + +- **What**: `diagram_editor_export_failed` was in neither word dict, so a failed PNG or SVG export showed its English fallback in the Traditional Chinese UI. `test_language_parity.py`'s check that every key the code uses is defined matched only a literal `language_word_dict.get("key")`, so it saw 85 keys: not the `word.get(...)` alias most widgets use, nor the diagram editor's `_lang(key, fallback)`, whose fallback hides a missing key. +- **Fix**: the key is added to both dicts. The check also matches `word.get(`, `word_dict.get(` and `_lang(`, and now sees 344 keys, all defined. +- **Result / numbers**: `test_language_parity.py` 8 passed. 1947 tests in 115 files. `ruff check` clean. +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 27876fb3..059007b8 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-176 | 2026-09-24 | Translate the diagram export failure, and check every language key the code uses | #fix #diagram #i18n | [2026-09](2026-09.md) | | U-20260923-175 | 2026-09-24 | Report a mail settings file that cannot be read, and always tell the run window how the report mail went | #fix #executor | [2026-09](2026-09.md) | | U-20260923-174 | 2026-09-24 | Remove a partial save on any failure, and report a prthinker setting UTF-8 cannot write | #fix #ai | [2026-09](2026-09.md) | | U-20260923-173 | 2026-09-24 | Name every line ending the diff tool compares, and a changed line that starts with dashes | #fix #tools | [2026-09](2026-09.md) | @@ -256,4 +257,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 193 | +| [2026-09.md](2026-09.md) | 2026-09 | 194 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index f02ea3f7..c8b9271e 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -683,6 +683,7 @@ "diagram_editor_dialog_export_png": "Export PNG", "diagram_editor_dialog_export_svg": "Export SVG", "diagram_editor_error_title": "Error", + "diagram_editor_export_failed": "Could not export the diagram to:\n{path}", # Diagram Editor — Property Panel "diagram_editor_prop_title": "Properties", "diagram_editor_prop_no_selection": "No selection", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 8ec77c60..7f9027f8 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -656,6 +656,7 @@ "diagram_editor_dialog_export_png": "匯出 PNG", "diagram_editor_dialog_export_svg": "匯出 SVG", "diagram_editor_error_title": "錯誤", + "diagram_editor_export_failed": "無法把架構圖匯出到:\n{path}", # Diagram Editor — 屬性面板 "diagram_editor_prop_title": "屬性", "diagram_editor_prop_no_selection": "未選取", diff --git a/test/test_utils/test_language_parity.py b/test/test_utils/test_language_parity.py index 012109ad..51baf16f 100644 --- a/test/test_utils/test_language_parity.py +++ b/test/test_utils/test_language_parity.py @@ -11,7 +11,12 @@ pybreeze_traditional_chinese_word_dict as ZH, ) -_GET_KEY_RE = re.compile(r'language_word_dict\.get\(\s*["\']([A-Za-z0-9_]+)["\']') +# language_word_dict.get("key"), the same through a local alias +# (word = language_wrapper.language_word_dict; word.get("key")), and the +# diagram editor's _lang("key", fallback), whose English fallback hid a key +# missing from both dicts +_GET_KEY_RE = re.compile( + r'(?:\b(?:language_word_dict|word_dict|word)\.get|\b_lang)\(\s*["\']([A-Za-z0-9_]+)["\']') def _placeholders(text: str) -> set[str]: From 7fd2c99f60cdcf58a96ec154fb28a8604033e932 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:15:49 +0800 Subject: [PATCH 213/312] Check that every header finding and level has a message --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 8 ++++++++ docs/updates/README.md | 3 ++- test/test_utils/test_language_parity.py | 18 +++++++++++++++++- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index c5302768..87a26cf4 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1947 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1948 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 3896ee9b..25f8a100 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2920,3 +2920,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: `test_language_parity.py` 8 passed. 1947 tests in 115 files. `ruff check` clean. - **Files**: `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py` - **Open items**: none. + +## U-20260923-177 · 2026-09-24 · Check that every header finding and level has a message · #test #tools #i18n + +- **What**: the header analyzer's GUI looks its messages up by keys it builds with an f-string (`header_finding_`, `header_analyzer_level_`), which the language-key check cannot see; a finding added without a message would show its bare code. All 18 codes and both levels have one today. +- **Tests**: `test_language_parity.py` +1: collects every code the analyzer reports (each `HeaderFinding("..."` and the missing-security-header table) and both levels, and requires a message for each. `_code_used_keys`' docstring now describes what it matches. +- **Result / numbers**: 9 passed. 1948 tests in 115 files. `ruff check` clean. +- **Files**: `test/test_utils/test_language_parity.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 059007b8..c7bc1998 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-177 | 2026-09-24 | Check that every header finding and level has a message | #test #tools #i18n | [2026-09](2026-09.md) | | U-20260923-176 | 2026-09-24 | Translate the diagram export failure, and check every language key the code uses | #fix #diagram #i18n | [2026-09](2026-09.md) | | U-20260923-175 | 2026-09-24 | Report a mail settings file that cannot be read, and always tell the run window how the report mail went | #fix #executor | [2026-09](2026-09.md) | | U-20260923-174 | 2026-09-24 | Remove a partial save on any failure, and report a prthinker setting UTF-8 cannot write | #fix #ai | [2026-09](2026-09.md) | @@ -257,4 +258,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 194 | +| [2026-09.md](2026-09.md) | 2026-09 | 195 | diff --git a/test/test_utils/test_language_parity.py b/test/test_utils/test_language_parity.py index 51baf16f..70685f08 100644 --- a/test/test_utils/test_language_parity.py +++ b/test/test_utils/test_language_parity.py @@ -24,7 +24,7 @@ def _placeholders(text: str) -> set[str]: def _code_used_keys() -> dict[str, str]: - """Map every literal ``language_word_dict.get("key")`` key to a source file.""" + """Map every literal key the code looks up (see ``_GET_KEY_RE``) to a source file.""" root = pathlib.Path(pybreeze.__file__).parent used: dict[str, str] = {} for path in root.rglob("*.py"): @@ -88,6 +88,22 @@ def test_every_prompt_editor_label_key_exists(self): ] assert not missing, f"Prompt editor label keys missing from the dict: {missing}" + def test_every_header_finding_and_level_has_a_message(self): + # The header analyzer's GUI builds these keys with an f-string, which + # the regex above cannot see; a missing one shows the bare code + from pybreeze.utils.header_tools import header_analyzer + + source = pathlib.Path(header_analyzer.__file__).read_text(encoding="utf-8") + codes = set(re.findall(r'HeaderFinding\(\s*"([a-z0-9_]+)"', source)) + codes |= {code for _name, _canonical, code in header_analyzer._RESPONSE_SECURITY_HEADERS} + levels = {header_analyzer.LEVEL_WARNING, header_analyzer.LEVEL_INFO} + + assert len(codes) > 10 + missing = sorted(f"header_finding_{code}" for code in codes if f"header_finding_{code}" not in EN) + missing += sorted(f"header_analyzer_level_{level}" for level in levels + if f"header_analyzer_level_{level}" not in EN) + assert not missing, f"Header analyzer keys missing from the dict: {missing}" + class TestEveryLanguageServesPyBreezeStrings: def test_each_registered_language_resolves_every_key(self): From 9f213ee7b876af8d1ff6c8e4fb46dcc47558cc5e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:23:26 +0800 Subject: [PATCH 214/312] Write JSON a tool shows so that its text box gives it back as it was --- CLAUDE.md | 2 +- architecture_explore.md | 4 +- docs/updates/2026-09.md | 9 +++ docs/updates/README.md | 3 +- .../pybreeze_ui/tools_gui/jwt_decoder_gui.py | 6 +- .../tools_gui/response_inspector_gui.py | 6 +- pybreeze/utils/curl_import/request_codegen.py | 21 ++---- .../utils/curl_import/script_templates.py | 6 +- pybreeze/utils/har_import/har_codegen.py | 4 +- pybreeze/utils/json_format/json_process.py | 14 ++-- pybreeze/utils/json_format/view_safe.py | 38 ++++++++++ pybreeze/utils/query_tools/query_convert.py | 3 +- .../response_inspector/response_analyzer.py | 3 +- pybreeze/utils/url_tools/url_convert.py | 4 +- test/test_utils/test_view_safe.py | 74 +++++++++++++++++++ 15 files changed, 155 insertions(+), 42 deletions(-) create mode 100644 pybreeze/utils/json_format/view_safe.py create mode 100644 test/test_utils/test_view_safe.py diff --git a/CLAUDE.md b/CLAUDE.md index 5247e161..12529712 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,7 @@ Reference implementations: `utils/network/url_validation.py` (`validate_url`), ` **File I/O** — dialog-chosen paths are trusted; paths loaded from saved data (`.diagram.json`) are not: check `is_file()` and an extension allowlist, or run URLs through SSRF validation. Use `pathlib`, never string concatenation. Write to `~/.pybreeze/` via `app_dirs.pybreeze_data_dir()` with `encoding="utf-8"`; read through `pybreeze_data_path()`, which creates nothing. Replace a file the user would lose through `utils/file_process/replace_file.replace_text` (written beside it, then moved into place), never an in-place `write_text`; a file something else writes (an image, an SVG export) goes through `replace_written`. Resolve symlinks with `Path.resolve(strict=True)` and verify the result stays in bounds. -**Qt** — `QGraphicsTextItem` text interaction must not be on by default (double-click to edit). Text from a server or a file (a remote path, an error message, a host name, a file name) never reaches a `QMessageBox` or `QLabel` as it is: Qt reads markup in it (`Qt::AutoText`) and loads an ``. Pass it through `pybreeze_ui/plain_text.as_text()`, or give the label `Qt.TextFormat.PlainText`. Plugin loading takes only `.py` files, skipping `_`/`.` prefixes. `QWebEngineView.setUrl()` only for localhost or user-confirmed URLs; never `setHtml()` with unsanitised content. +**Qt** — `QGraphicsTextItem` text interaction must not be on by default (double-click to edit). Text from a server or a file (a remote path, an error message, a host name, a file name) never reaches a `QMessageBox` or `QLabel` as it is: Qt reads markup in it (`Qt::AutoText`) and loads an ``. Pass it through `pybreeze_ui/plain_text.as_text()`, or give the label `Qt.TextFormat.PlainText`. JSON (or a generated Python string) a tool shows in a text box is written with `utils/json_format/view_safe.dumps_for_view()` / `escape_for_view()`: Qt gives U+2029, U+FDD0 and U+FDD1 back as newlines and drops a lone surrogate, so Copy and Save wrote something else. Plugin loading takes only `.py` files, skipping `_`/`.` prefixes. `QWebEngineView.setUrl()` only for localhost or user-confirmed URLs; never `setHtml()` with unsanitised content. **Secrets** — SSH passwords and passphrases stay in memory for the session only. A secret that must persist (the prthinker keys and token) is written with `replace_text(..., private=True)` under the `0700` data folder. Password fields use `QLineEdit.EchoMode.Password`. diff --git a/architecture_explore.md b/architecture_explore.md index 87a26cf4..1dfa83a5 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -349,7 +349,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | -| `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯、落單的 surrogate 寫回 `\uXXXX` 跳脫。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN`(`load_json_verbatim()`),`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | +| `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯。`json_format/view_safe.py` 的 `dumps_for_view()` / `escape_for_view()`:工具顯示的 JSON 把文字框還不回原樣的字元(U+2029、U+FDD0、U+FDD1 會變換行,落單的 surrogate 會消失;U+2028 經 `toPlainText()`、U+0085 經 `splitlines()` 也會斷行)寫成 `\uXXXX`,JSON Format、Query/URL 轉 JSON、JWT、Response Inspector、cURL/HAR 產生的 JSON 與 Python 字串都經過它。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN`(`load_json_verbatim()`),`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | | `file_process/read_capped.py` | `read_text_capped(path, encoding, max_bytes=None)`:先看大小,超過 `MAX_OPEN_BYTES`(100 MB)丟 `FileTooLargeError`(`OSError`,`strerror` 說明大小與上限),HAR 分頁與架構圖編輯器開檔都經它,不在 UI 執行緒讀好幾 GB 的檔案 | | `file_process/replace_file.py` | `replace_text(path, text, private=False)`:寫到旁邊的 `<名稱>.saving` 再 `os.replace` 過去,失敗時原檔不動、半成品刪掉;`private` 以 `0600` 建立(存金鑰的檔案用);`replace_written(path, write)` 給別人寫的檔案(圖片、SVG):`write` 拿到旁邊的 `<主檔名>.saving<副檔名>`(副檔名不變,看副檔名決定格式的寫入器照樣用),寫完才換上 | @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 115 個 `test_*.py`、1948 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 116 個 `test_*.py`、1956 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 25f8a100..7a8cf38b 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2928,3 +2928,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: 9 passed. 1948 tests in 115 files. `ruff check` clean. - **Files**: `test/test_utils/test_language_parity.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-178 · 2026-09-24 · Write JSON a tool shows so that its text box gives it back as it was · #fix #tools + +- **What**: the tools write JSON with `ensure_ascii=False` into a text box, and Copy, Save and Open in editor read it back from there. Scanning every BMP character through a `QPlainTextEdit` and `exact_text` shows Qt changes three besides the lone surrogates it drops: U+2029, U+FDD0 and U+FDD1 come back as newlines, and a newline inside a JSON string is not JSON. `{"a": "x\u2029y"}` formatted in JSON Format, or `a=x%E2%80%A9y` in Query to JSON, saved a file `json.loads` refuses. Only JSON Format escaped lone surrogates; the JWT decoder, the Response Inspector, the URL and query converters, and the APITestka action JSON of the cURL and HAR tabs escaped nothing. The code generators escaped U+0085, U+2028 and U+2029 (JEditor runs a tab through `toPlainText()`) but not U+FDD0/U+FDD1. +- **Fix**: new `utils/json_format/view_safe.py`: `escape_for_view` writes U+0085, U+2028, U+2029, U+FDD0, U+FDD1 and lone surrogates as `\uXXXX` escapes (JSON holds them only inside strings, where the escape is the same character, as it is in a Python string literal), and `dumps_for_view` is `json.dumps(..., ensure_ascii=False)` with it applied. JSON Format, Query to JSON, URL to JSON, the Response Inspector's pretty body and JWT section, the JWT decoder, `script_templates` (inline JSON, the APITestka action) and the HAR action list use it; `request_codegen.python_string` uses it in place of its own `escape_line_breaks`, which is removed. +- **Tests**: new `test_view_safe.py` (8): each escaped character is one a view does not keep; escaped JSON read back through a real `QPlainTextEdit` loads to the same value; other text is untouched; the escaped literal means the same to Python; Format, Minify, Query to JSON and URL to JSON output survive the view. +- **Result / numbers**: the JSON, query, URL, cURL, HAR, JWT and Response Inspector tests, 561 passed; 1956 tests in 116 files. `ruff check` clean. +- **Files**: `pybreeze/utils/json_format/view_safe.py` (new), `json_process.py`, `query_tools/query_convert.py`, `url_tools/url_convert.py`, `response_inspector/response_analyzer.py`, `curl_import/request_codegen.py`, `curl_import/script_templates.py`, `har_import/har_codegen.py`, `tools_gui/jwt_decoder_gui.py`, `tools_gui/response_inspector_gui.py`, `test/test_utils/test_view_safe.py` (new), `CLAUDE.md` (Qt rule), `architecture_explore.md` §7, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c7bc1998..8bf99c17 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-178 | 2026-09-24 | Write JSON a tool shows so that its text box gives it back as it was | #fix #tools | [2026-09](2026-09.md) | | U-20260923-177 | 2026-09-24 | Check that every header finding and level has a message | #test #tools #i18n | [2026-09](2026-09.md) | | U-20260923-176 | 2026-09-24 | Translate the diagram export failure, and check every language key the code uses | #fix #diagram #i18n | [2026-09](2026-09.md) | | U-20260923-175 | 2026-09-24 | Report a mail settings file that cannot be read, and always tell the run window how the report mail went | #fix #executor | [2026-09](2026-09.md) | @@ -258,4 +259,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 195 | +| [2026-09.md](2026-09.md) | 2026-09 | 196 | diff --git a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py index 03c6a9f0..d9fc8f51 100644 --- a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py @@ -5,7 +5,6 @@ """ from __future__ import annotations -import json from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget from je_editor import language_wrapper @@ -13,6 +12,7 @@ from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import JwtDecodeException +from pybreeze.utils.json_format.view_safe import dumps_for_view from pybreeze.utils.jwt_tools.jwt_decoder import ( DecodedJwt, decode_jwt, humanized_timestamp_claims ) @@ -28,10 +28,10 @@ def build_decoded_text(decoded: DecodedJwt) -> str: word = language_wrapper.language_word_dict sections = [ word.get("jwt_decoder_header_label"), - json.dumps(decoded.header, indent=4, sort_keys=True, ensure_ascii=False), + dumps_for_view(decoded.header, indent=4, sort_keys=True), "", word.get("jwt_decoder_payload_label"), - json.dumps(decoded.payload, indent=4, sort_keys=True, ensure_ascii=False), + dumps_for_view(decoded.payload, indent=4, sort_keys=True), ] readable = humanized_timestamp_claims(decoded.payload) if readable: diff --git a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py index 87fc8d49..a5415f66 100644 --- a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py @@ -5,7 +5,6 @@ """ from __future__ import annotations -import json from PySide6.QtWidgets import ( QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget @@ -20,6 +19,7 @@ from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.pybreeze_ui.tools_gui.tool_tabs import open_tool_tab from pybreeze.utils.jwt_tools.jwt_decoder import humanized_timestamp_claims +from pybreeze.utils.json_format.view_safe import dumps_for_view from pybreeze.utils.response_inspector.response_analyzer import ( ResponseAnalysis, analyze_response ) @@ -45,8 +45,8 @@ def _jwt_section(analysis: ResponseAnalysis) -> list[str]: word = language_wrapper.language_word_dict lines = [word.get("response_jwt_label")] for finding in analysis.jwt_findings: - lines.append(json.dumps(finding.decoded.header, ensure_ascii=False)) - lines.append(json.dumps(finding.decoded.payload, indent=4, ensure_ascii=False)) + lines.append(dumps_for_view(finding.decoded.header)) + lines.append(dumps_for_view(finding.decoded.payload, indent=4)) for claim, value in humanized_timestamp_claims(finding.decoded.payload).items(): lines.append(f" {claim}: {value}") lines.append("") diff --git a/pybreeze/utils/curl_import/request_codegen.py b/pybreeze/utils/curl_import/request_codegen.py index a54c0a8b..c6624ad2 100644 --- a/pybreeze/utils/curl_import/request_codegen.py +++ b/pybreeze/utils/curl_import/request_codegen.py @@ -6,12 +6,12 @@ """ from __future__ import annotations -import json import math import re from pybreeze.utils.curl_import.curl_parser import CurlRequest from pybreeze.utils.curl_import.request_body import FormEntry, body_kind, form_entries, sent_headers +from pybreeze.utils.json_format.view_safe import dumps_for_view # Keyword argument passing the request body to ``requests.request``. _DATA_KWARG = "data=data" @@ -103,23 +103,14 @@ def python_string(text: str) -> str: them. Written as itself it is one character to both. A lone surrogate, which cannot be written to a UTF-8 file, falls back to ``repr``. - The line breaks JSON leaves as they are (U+0085, U+2028, U+2029) are - escaped: JEditor saves and runs a tab through ``toPlainText()``, which makes - them real line breaks, so in a comment the text after one became code. + The characters JSON leaves as they are but a Qt view turns into line + breaks (U+0085, U+2028, U+2029, U+FDD0, U+FDD1) are escaped: JEditor saves + and runs a tab through ``toPlainText()``, so in a comment the text after + one became code (``view_safe.escape_for_view``). """ if _SURROGATE.search(text): return repr(text) - return escape_line_breaks(json.dumps(text, ensure_ascii=False)) - - -# Line breaks outside ASCII: JSON does not escape them, and a Qt editor turns -# them into real ones -_UNICODE_LINE_BREAKS = re.compile("[\x85\u2028\u2029]") - - -def escape_line_breaks(text: str) -> str: - """*text* with every U+0085, U+2028 and U+2029 written as its ``\\uXXXX`` escape.""" - return _UNICODE_LINE_BREAKS.sub(lambda match: f"\\u{ord(match.group()):04x}", text) + return dumps_for_view(text) def python_literal(value: object, *, inline: bool = False, level: int = 0) -> str: diff --git a/pybreeze/utils/curl_import/script_templates.py b/pybreeze/utils/curl_import/script_templates.py index 487eaf5f..4e9a1f24 100644 --- a/pybreeze/utils/curl_import/script_templates.py +++ b/pybreeze/utils/curl_import/script_templates.py @@ -12,7 +12,6 @@ """ from __future__ import annotations -import json import re from urllib.parse import urlparse @@ -20,6 +19,7 @@ from pybreeze.utils.curl_import.request_body import body_kind, form_entries, sent_headers from pybreeze.utils.exception.exception_tags import action_cannot_read_files_error from pybreeze.utils.exception.exceptions import CurlParseException +from pybreeze.utils.json_format.view_safe import dumps_for_view from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.curl_import.request_codegen import ( REQUESTS_IMPORT, cookie_file_notes, data_from_file_expr, form_has_repeats, form_value_expr, python_literal, @@ -39,7 +39,7 @@ def _inline_json(value: object) -> str: """Render *value* as a compact one-line JSON literal for inline code.""" - return json.dumps(value, ensure_ascii=False) + return dumps_for_view(value) def _inline_form(request: CurlRequest) -> str: @@ -212,7 +212,7 @@ def to_apitestka_action_json(request: CurlRequest) -> str: :param request: the parsed curl request :return: a formatted JSON action list """ - return json.dumps([to_apitestka_action(request)], indent=4, ensure_ascii=False) + "\n" + return dumps_for_view([to_apitestka_action(request)], indent=4) + "\n" def test_function_name(request: CurlRequest) -> str: diff --git a/pybreeze/utils/har_import/har_codegen.py b/pybreeze/utils/har_import/har_codegen.py index 53ad9d19..48aa90d3 100644 --- a/pybreeze/utils/har_import/har_codegen.py +++ b/pybreeze/utils/har_import/har_codegen.py @@ -10,12 +10,12 @@ """ from __future__ import annotations -import json import re from collections.abc import Callable from pybreeze.utils.curl_import.curl_parser import CurlRequest from pybreeze.utils.curl_import.request_codegen import REQUESTS_IMPORT, request_statements +from pybreeze.utils.json_format.view_safe import dumps_for_view from pybreeze.utils.curl_import.script_templates import ( APITESTKA_IMPORT, LOADDENSITY_IMPORT, @@ -98,7 +98,7 @@ def _apitestka_python_script(requests: list[CurlRequest]) -> str: def _apitestka_action_script(requests: list[CurlRequest]) -> str: """Collect every request into one action list, replayed in capture order.""" actions = [to_apitestka_action(request) for request in requests] - return json.dumps(actions, indent=4, ensure_ascii=False) + "\n" + return dumps_for_view(actions, indent=4) + "\n" def _loaddensity_script(requests: list[CurlRequest]) -> str: diff --git a/pybreeze/utils/json_format/json_process.py b/pybreeze/utils/json_format/json_process.py index 50a3e991..666d9b84 100644 --- a/pybreeze/utils/json_format/json_process.py +++ b/pybreeze/utils/json_format/json_process.py @@ -16,6 +16,7 @@ from pybreeze.utils.exception.exception_tags import json_duplicate_key_error from pybreeze.utils.exception.exception_tags import wrong_json_data_error from pybreeze.utils.exception.exceptions import ITEJsonException +from pybreeze.utils.json_format.view_safe import escape_for_view from pybreeze.utils.logging.logger import pybreeze_logger @@ -43,18 +44,15 @@ def hold(self, text: str) -> str: def restore(self, serialised: str) -> str: """*serialised* with every placeholder replaced by its number's text. - A lone surrogate (``"\\ud83d"`` in the input) is written back as its - escape: with ``ensure_ascii=False`` it went out raw, and the text view - dropped it, so ``{"a": "\\ud83d"}`` came out as ``{"a": ""}``. + A character the text view would not give back -- a lone surrogate + (``"\\ud83d"`` in the input), U+2029 -- is written back as its escape: + with ``ensure_ascii=False`` it went out raw, and ``{"a": "\\ud83d"}`` + came out as ``{"a": ""}`` (see ``view_safe``). """ # How dumps writes a placeholder: quoted, the NUL escaped placeholder = re.compile(rf'"\\u0000{self._marker}:(\d+)\\u0000"') restored = placeholder.sub(lambda match: self._texts[int(match.group(1))], serialised) - return _LONE_SURROGATE.sub(lambda match: f"\\u{ord(match.group()):04x}", restored) - - -# Outside a string JSON holds no such character, so every one is inside a string -_LONE_SURROGATE = re.compile("[\ud800-\udfff]") + return escape_for_view(restored) def _refuse_constant(name: str) -> None: diff --git a/pybreeze/utils/json_format/view_safe.py b/pybreeze/utils/json_format/view_safe.py new file mode 100644 index 00000000..97468d27 --- /dev/null +++ b/pybreeze/utils/json_format/view_safe.py @@ -0,0 +1,38 @@ +"""JSON written so that a Qt text view gives it back as it was. + +A tool shows its JSON in a text box, and Copy, Save and Open in editor read it +back from there. Written with ``ensure_ascii=False``, a few characters did not +survive the trip: + +- U+2029, U+FDD0 and U+FDD1 are paragraph and frame markers to Qt: they come + back as a newline, and a newline inside a JSON string is not JSON; +- U+2028 comes back as a newline from ``toPlainText()``, which JEditor saves + and runs a tab through, and U+0085 is a line break to ``str.splitlines()``; +- a lone surrogate (``"\\ud83d"`` in the input) is dropped. + +Written as a ``\\uXXXX`` escape each one means the same character to JSON, and +to a Python string literal, and survives any view. +""" +from __future__ import annotations + +import json +import re + +# JSON holds none of them outside a string, so each one found is inside one +_NOT_KEPT_BY_A_VIEW = re.compile( + "[" + "".join(chr(code) for code in (0x85, 0x2028, 0x2029, 0xFDD0, 0xFDD1)) + + chr(0xD800) + "-" + chr(0xDFFF) + "]") + + +def escape_for_view(json_text: str) -> str: + """*json_text* with every character a text view would not give back written as its ``\\uXXXX`` escape. + + Only for JSON text, or Python string literals written as JSON, where the + escape stands for the character itself. + """ + return _NOT_KEPT_BY_A_VIEW.sub(lambda match: f"\\u{ord(match.group()):04x}", json_text) + + +def dumps_for_view(value: object, **kwargs) -> str: + """``json.dumps(value, ensure_ascii=False, **kwargs)``, with :func:`escape_for_view` applied.""" + return escape_for_view(json.dumps(value, ensure_ascii=False, **kwargs)) diff --git a/pybreeze/utils/query_tools/query_convert.py b/pybreeze/utils/query_tools/query_convert.py index c75e77a1..8f9dfd32 100644 --- a/pybreeze/utils/query_tools/query_convert.py +++ b/pybreeze/utils/query_tools/query_convert.py @@ -20,6 +20,7 @@ unencodable_text_error, ) from pybreeze.utils.exception.exceptions import QueryConvertException +from pybreeze.utils.json_format.view_safe import dumps_for_view from pybreeze.utils.logging.logger import pybreeze_logger @@ -66,7 +67,7 @@ def query_to_json(query: str) -> str: :param query: the query string to convert :return: a formatted JSON object string """ - return json.dumps(query_to_dict(query), indent=4, ensure_ascii=False, sort_keys=True) + return dumps_for_view(query_to_dict(query), indent=4, sort_keys=True) def coerce_scalar(value: object) -> str: diff --git a/pybreeze/utils/response_inspector/response_analyzer.py b/pybreeze/utils/response_inspector/response_analyzer.py index b828bb04..6014b2dc 100644 --- a/pybreeze/utils/response_inspector/response_analyzer.py +++ b/pybreeze/utils/response_inspector/response_analyzer.py @@ -19,6 +19,7 @@ from pybreeze.utils.http_reference.status_codes import StatusInfo, lookup from pybreeze.utils.jwt_tools.jwt_decoder import DecodedJwt, decode_jwt, find_tokens from pybreeze.utils.exception.exceptions import JwtDecodeException +from pybreeze.utils.json_format.view_safe import dumps_for_view # Matches the response status line, e.g. "HTTP/1.1 200 OK" _STATUS_LINE_RE = re.compile(r"^HTTP/\d(?:\.\d)?\s+(\d{3})\b") @@ -142,7 +143,7 @@ def _continue_value(headers: dict[str, str | list[str]], name: str, more: str) - def _pretty_json(body: str) -> str | None: """Return *body* pretty-printed if it is JSON, else ``None`` (key order kept).""" try: - return json.dumps(json.loads(body), indent=4, ensure_ascii=False) + return dumps_for_view(json.loads(body), indent=4) # RecursionError: a body nested past the recursion limit except (ValueError, TypeError, RecursionError): return None diff --git a/pybreeze/utils/url_tools/url_convert.py b/pybreeze/utils/url_tools/url_convert.py index 7e3e2dfb..25fb88e9 100644 --- a/pybreeze/utils/url_tools/url_convert.py +++ b/pybreeze/utils/url_tools/url_convert.py @@ -6,7 +6,6 @@ """ from __future__ import annotations -import json from urllib.parse import SplitResult, urlsplit, urlunsplit from pybreeze.utils.exception.exception_tags import ( @@ -16,6 +15,7 @@ url_port_out_of_range_error, ) from pybreeze.utils.exception.exceptions import QueryConvertException, UrlConvertException +from pybreeze.utils.json_format.view_safe import dumps_for_view from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.query_tools.query_convert import ( coerce_scalar, encode_pairs, load_json_verbatim, query_round_trips, query_to_dict, @@ -90,7 +90,7 @@ def url_to_json(url: str) -> str: # from these parts would silently lose it. pybreeze_logger.error(url_port_out_of_range_error) raise UrlConvertException(url_port_out_of_range_error) - return json.dumps(components, indent=4, ensure_ascii=False) + return dumps_for_view(components, indent=4) def _has_port_text(url: str) -> bool: diff --git a/test/test_utils/test_view_safe.py b/test/test_utils/test_view_safe.py new file mode 100644 index 00000000..69322298 --- /dev/null +++ b/test/test_utils/test_view_safe.py @@ -0,0 +1,74 @@ +"""JSON a tool shows comes back from its text box as the same JSON.""" +from __future__ import annotations + +import json +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication, QPlainTextEdit # noqa: E402 — the platform must be set first + +from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text # noqa: E402 +from pybreeze.utils.json_format.view_safe import dumps_for_view, escape_for_view # noqa: E402 + +# What a Qt text view does not give back: U+2029, U+FDD0 and U+FDD1 come back +# as newlines, a lone surrogate not at all; U+2028 turns into a newline in +# toPlainText(), which JEditor saves and runs a tab through; U+0085 is a line +# break to str.splitlines() +NOT_KEPT = "a" + "".join(chr(code) for code in (0x85, 0x2028, 0x2029, 0xFDD0, 0xFDD1, 0xD83D)) + "b" + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def _through_a_view(text: str) -> str: + view = QPlainTextEdit() + view.setPlainText(text) + return exact_text(view) + + +def test_every_escaped_character_is_one_a_view_does_not_keep(app): + # So the list holds nothing a view would have kept anyway; U+0085, which + # Qt keeps, is escaped as the line break str.splitlines() takes it for + for character in NOT_KEPT[1:-1].replace(chr(0x85), ""): + view = QPlainTextEdit() + view.setPlainText(f"x{character}y") + assert (exact_text(view), view.toPlainText()) != (f"x{character}y",) * 2, hex(ord(character)) + + +def test_escaped_json_survives_a_view_and_means_the_same(app): + shown = dumps_for_view({"k": NOT_KEPT}, indent=4) + + assert json.loads(_through_a_view(shown)) == {"k": NOT_KEPT} + + +def test_other_text_is_left_as_it_is(): + assert escape_for_view('{"k": "café \U0001f600  "}') == '{"k": "café \U0001f600  "}' + + +def test_as_a_python_string_literal_it_means_the_same(): + assert eval(dumps_for_view(NOT_KEPT)) == NOT_KEPT # noqa: S307 — the literal this test just wrote + + +@pytest.mark.parametrize("convert", ["reformat", "minify", "query", "url"]) +def test_each_json_tool_output_survives_its_view(app, convert): + from pybreeze.utils.json_format.json_process import minify_json, reformat_json + from pybreeze.utils.query_tools.query_convert import query_to_json + from pybreeze.utils.url_tools.url_convert import url_to_json + + text = NOT_KEPT.replace(chr(0xD83D), "") + escaped = json.dumps({"k": text}) + output = { + "reformat": lambda: reformat_json(escaped), + "minify": lambda: minify_json(escaped), + "query": lambda: query_to_json("k=" + "".join(f"%{byte:02X}" for byte in text.encode("utf-8"))), + # The fragment is shown as it was written + "url": lambda: url_to_json("http://h/p#" + text), + }[convert]() + + loaded = json.loads(_through_a_view(output)) + assert (loaded["fragment"] if convert == "url" else loaded["k"]) == text From 30bd996f21a4745dffaae19835205832e0cb327a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:27:00 +0800 Subject: [PATCH 215/312] Lay out the Response Inspector's body and a JWT's segments as JSON Format does --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 12 +++++++ docs/updates/README.md | 3 +- .../pybreeze_ui/tools_gui/jwt_decoder_gui.py | 7 ++-- .../tools_gui/response_inspector_gui.py | 4 +-- pybreeze/utils/json_format/json_process.py | 36 +++++++++++++++++-- pybreeze/utils/jwt_tools/jwt_decoder.py | 33 +++++++++++++---- .../response_inspector/response_analyzer.py | 15 ++------ test/test_utils/test_jwt_decoder.py | 36 +++++++++++++++++++ test/test_utils/test_response_analyzer.py | 14 ++++++++ 10 files changed, 133 insertions(+), 31 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 1dfa83a5..c12145fc 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -349,7 +349,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | -| `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯。`json_format/view_safe.py` 的 `dumps_for_view()` / `escape_for_view()`:工具顯示的 JSON 把文字框還不回原樣的字元(U+2029、U+FDD0、U+FDD1 會變換行,落單的 surrogate 會消失;U+2028 經 `toPlainText()`、U+0085 經 `splitlines()` 也會斷行)寫成 `\uXXXX`,JSON Format、Query/URL 轉 JSON、JWT、Response Inspector、cURL/HAR 產生的 JSON 與 Python 字串都經過它。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN`(`load_json_verbatim()`),`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | +| `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯;`pretty_json_or_none()` 是同一套解析、不記 log 的版本,Response Inspector 的 body 與 JWT 各段(`jwt_decoder.shown_json()`)用它排版。`json_format/view_safe.py` 的 `dumps_for_view()` / `escape_for_view()`:工具顯示的 JSON 把文字框還不回原樣的字元(U+2029、U+FDD0、U+FDD1 會變換行,落單的 surrogate 會消失;U+2028 經 `toPlainText()`、U+0085 經 `splitlines()` 也會斷行)寫成 `\uXXXX`,JSON Format、Query/URL 轉 JSON、JWT、Response Inspector、cURL/HAR 產生的 JSON 與 Python 字串都經過它。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN`(`load_json_verbatim()`),`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | | `file_process/read_capped.py` | `read_text_capped(path, encoding, max_bytes=None)`:先看大小,超過 `MAX_OPEN_BYTES`(100 MB)丟 `FileTooLargeError`(`OSError`,`strerror` 說明大小與上限),HAR 分頁與架構圖編輯器開檔都經它,不在 UI 執行緒讀好幾 GB 的檔案 | | `file_process/replace_file.py` | `replace_text(path, text, private=False)`:寫到旁邊的 `<名稱>.saving` 再 `os.replace` 過去,失敗時原檔不動、半成品刪掉;`private` 以 `0600` 建立(存金鑰的檔案用);`replace_written(path, write)` 給別人寫的檔案(圖片、SVG):`write` 拿到旁邊的 `<主檔名>.saving<副檔名>`(副檔名不變,看副檔名決定格式的寫入器照樣用),寫完才換上 | @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 116 個 `test_*.py`、1956 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 116 個 `test_*.py`、1961 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 7a8cf38b..168a1fa9 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2937,3 +2937,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: the JSON, query, URL, cURL, HAR, JWT and Response Inspector tests, 561 passed; 1956 tests in 116 files. `ruff check` clean. - **Files**: `pybreeze/utils/json_format/view_safe.py` (new), `json_process.py`, `query_tools/query_convert.py`, `url_tools/url_convert.py`, `response_inspector/response_analyzer.py`, `curl_import/request_codegen.py`, `curl_import/script_templates.py`, `har_import/har_codegen.py`, `tools_gui/jwt_decoder_gui.py`, `tools_gui/response_inspector_gui.py`, `test/test_utils/test_view_safe.py` (new), `CLAUDE.md` (Qt rule), `architecture_explore.md` §7, §18 - **Open items**: none. + +## U-20260923-179 · 2026-09-24 · Lay out the Response Inspector's body and a JWT's segments as JSON Format does · #fix #tools + +- **What**: the Response Inspector and the JWT decoder laid JSON out through `json.loads` and `json.dumps`, the round trip JSON Format stopped using: `1e400` became `Infinity`, which is not JSON, a long number was rounded (`12345678901234567890.5` as `1.2345678901234567e+19`), and a key repeated in one object kept its last value without a word. The Inspector then called such a body JSON while "Open body in JSON Format" refused it. +- **Fix**: + - `json_process.pretty_json_or_none(text, sort_keys=False)`: JSON Format's parse (numbers held as their text, a repeated key, `NaN` and `Infinity` refused) and the view-safe output, or `None` without logging, for text that may be anything. `_parse` is split out of `_load` for it. + - The Inspector's pretty body uses it (key order kept); a body with a repeated key is no longer taken for JSON. + - `DecodedJwt` keeps each segment's JSON text (`header_json`, `payload_json`), and `jwt_decoder.shown_json(text, value, sort_keys=True)` lays it out verbatim, falling back to the decoded value when JSON Format would refuse the text (a repeated claim, which RFC 7519 lets a reader resolve to the last). The JWT tab and the Inspector's JWT section use it. +- **Tests**: `test_response_analyzer.py` +2 (numbers as written; a repeated key is not JSON), `test_jwt_decoder.py` +3 (numbers as written; a repeated claim falls back; sorted unless asked not to). +- **Result / numbers**: JWT, Response Inspector, JSON, view-safe and fuzz tests, 165 passed. 1961 tests in 116 files. `ruff check` clean. +- **Files**: `pybreeze/utils/json_format/json_process.py`, `utils/jwt_tools/jwt_decoder.py`, `utils/response_inspector/response_analyzer.py`, `tools_gui/jwt_decoder_gui.py`, `tools_gui/response_inspector_gui.py`, `test/test_utils/test_response_analyzer.py`, `test/test_utils/test_jwt_decoder.py`, `architecture_explore.md` §7, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 8bf99c17..48fdedb3 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-179 | 2026-09-24 | Lay out the Response Inspector's body and a JWT's segments as JSON Format does | #fix #tools | [2026-09](2026-09.md) | | U-20260923-178 | 2026-09-24 | Write JSON a tool shows so that its text box gives it back as it was | #fix #tools | [2026-09](2026-09.md) | | U-20260923-177 | 2026-09-24 | Check that every header finding and level has a message | #test #tools #i18n | [2026-09](2026-09.md) | | U-20260923-176 | 2026-09-24 | Translate the diagram export failure, and check every language key the code uses | #fix #diagram #i18n | [2026-09](2026-09.md) | @@ -259,4 +260,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 196 | +| [2026-09.md](2026-09.md) | 2026-09 | 197 | diff --git a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py index d9fc8f51..c56d8143 100644 --- a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py @@ -12,9 +12,8 @@ from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import JwtDecodeException -from pybreeze.utils.json_format.view_safe import dumps_for_view from pybreeze.utils.jwt_tools.jwt_decoder import ( - DecodedJwt, decode_jwt, humanized_timestamp_claims + DecodedJwt, decode_jwt, humanized_timestamp_claims, shown_json ) from pybreeze.utils.logging.logger import pybreeze_logger @@ -28,10 +27,10 @@ def build_decoded_text(decoded: DecodedJwt) -> str: word = language_wrapper.language_word_dict sections = [ word.get("jwt_decoder_header_label"), - dumps_for_view(decoded.header, indent=4, sort_keys=True), + shown_json(decoded.header_json, decoded.header), "", word.get("jwt_decoder_payload_label"), - dumps_for_view(decoded.payload, indent=4, sort_keys=True), + shown_json(decoded.payload_json, decoded.payload), ] readable = humanized_timestamp_claims(decoded.payload) if readable: diff --git a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py index a5415f66..93943df0 100644 --- a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py @@ -18,7 +18,7 @@ from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.pybreeze_ui.tools_gui.tool_tabs import open_tool_tab -from pybreeze.utils.jwt_tools.jwt_decoder import humanized_timestamp_claims +from pybreeze.utils.jwt_tools.jwt_decoder import humanized_timestamp_claims, shown_json from pybreeze.utils.json_format.view_safe import dumps_for_view from pybreeze.utils.response_inspector.response_analyzer import ( ResponseAnalysis, analyze_response @@ -46,7 +46,7 @@ def _jwt_section(analysis: ResponseAnalysis) -> list[str]: lines = [word.get("response_jwt_label")] for finding in analysis.jwt_findings: lines.append(dumps_for_view(finding.decoded.header)) - lines.append(dumps_for_view(finding.decoded.payload, indent=4)) + lines.append(shown_json(finding.decoded.payload_json, finding.decoded.payload, sort_keys=False)) for claim, value in humanized_timestamp_claims(finding.decoded.payload).items(): lines.append(f" {claim}: {value}") lines.append("") diff --git a/pybreeze/utils/json_format/json_process.py b/pybreeze/utils/json_format/json_process.py index 666d9b84..69a2e381 100644 --- a/pybreeze/utils/json_format/json_process.py +++ b/pybreeze/utils/json_format/json_process.py @@ -70,12 +70,22 @@ def _unique_pairs(pairs: list[tuple[str, object]]) -> dict: return members +def _parse(json_string: str, numbers: _Numbers) -> object: + """Parse *json_string*, numbers held as their text. + + :raises ValueError: when it is not JSON, repeats a key in one object + (``_DuplicateKey``), or uses ``NaN`` or ``Infinity`` + :raises RecursionError: when it is nested past the recursion limit + """ + return loads( + json_string, parse_float=numbers.hold, parse_int=numbers.hold, + parse_constant=_refuse_constant, object_pairs_hook=_unique_pairs) + + def _load(json_string: str, numbers: _Numbers) -> object: """Parse *json_string*, numbers held as their text; raise ``ITEJsonException`` when it is not JSON.""" try: - return loads( - json_string, parse_float=numbers.hold, parse_int=numbers.hold, - parse_constant=_refuse_constant, object_pairs_hook=_unique_pairs) + return _parse(json_string, numbers) except _DuplicateKey as error: message = json_duplicate_key_error.format(key=error.args[0]) pybreeze_logger.error(message) @@ -115,6 +125,26 @@ def reformat_json(json_string: str, **kwargs) -> str: raise ITEJsonException(f"{cant_reformat_json_error} ({err})") from err +def pretty_json_or_none(json_string: str, *, sort_keys: bool = False) -> str | None: + """*json_string* indented by four, numbers and characters as written; ``None`` when it is not JSON. + + For text that may be anything -- a response body, a token's segment -- so + nothing is logged when it is not JSON. What JSON Format refuses is refused + here too: a key repeated in one object (``json.loads`` kept its last value + without a word), ``NaN`` and ``Infinity``. A number keeps its text, where + ``float`` made ``1e400`` the ``Infinity`` that is not JSON. + + :param json_string: the text to lay out + :param sort_keys: sort each object's keys; otherwise they keep their order + """ + numbers = _Numbers() + try: + value = _parse(json_string, numbers) + return numbers.restore(dumps(value, indent=4, sort_keys=sort_keys, ensure_ascii=False)) + except (ValueError, RecursionError): + return None + + # Compact separators for minified JSON: no spaces after ',' or ':'. _MINIFY_SEPARATORS = (",", ":") diff --git a/pybreeze/utils/jwt_tools/jwt_decoder.py b/pybreeze/utils/jwt_tools/jwt_decoder.py index d174e534..f179fcff 100644 --- a/pybreeze/utils/jwt_tools/jwt_decoder.py +++ b/pybreeze/utils/jwt_tools/jwt_decoder.py @@ -21,6 +21,8 @@ malformed_jwt_error, ) from pybreeze.utils.exception.exceptions import JwtDecodeException +from pybreeze.utils.json_format.json_process import pretty_json_or_none +from pybreeze.utils.json_format.view_safe import dumps_for_view from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.timestamp_tools.timestamp_converter import utc_from_epoch_seconds @@ -40,18 +42,33 @@ class DecodedJwt: :param header: decoded JOSE header (algorithm, type, ...) :param payload: decoded claims set :param signature: the raw (still-encoded) signature segment + :param header_json: the header's JSON text as the token carries it + :param payload_json: the payload's JSON text as the token carries it """ header: dict payload: dict signature: str + header_json: str = "" + payload_json: str = "" -def _decode_segment(segment: str) -> dict: +def shown_json(text: str, value: dict, *, sort_keys: bool = True) -> str: + """A decoded segment laid out for display: its own *text*, numbers as written. + + ``json.dumps`` of the decoded *value* wrote ``1e400`` as ``Infinity``, which + is not JSON, and a long number rounded. The decoded value stands in when + the text is not one JSON Format accepts (a claim repeated, ``NaN``), or + was not kept. + """ + return pretty_json_or_none(text, sort_keys=sort_keys) or dumps_for_view(value, indent=4, sort_keys=sort_keys) + + +def _decode_segment(segment: str) -> tuple[dict, str]: """Base64url-decode one JWT segment into a JSON object. :param segment: a single base64url-encoded segment - :return: the decoded JSON object + :return: the decoded JSON object, and its JSON text :raises JwtDecodeException: when the segment is not valid base64url/JSON or does not decode to a JSON object """ @@ -62,7 +79,8 @@ def _decode_segment(segment: str) -> dict: # the padding was worked out from a length that counted them, so a # stray quote decoded or failed depending on the segment's length raw = base64.b64decode(segment + padding, altchars=b"-_", validate=True) - decoded = json.loads(raw.decode("utf-8")) + text = raw.decode("utf-8") + decoded = json.loads(text) # binascii.Error and UnicodeDecodeError both derive from ValueError; a # payload nested past the recursion limit raises RecursionError. except (ValueError, RecursionError) as error: @@ -70,7 +88,7 @@ def _decode_segment(segment: str) -> dict: raise JwtDecodeException(jwt_segment_decode_error) from error if not isinstance(decoded, dict): raise JwtDecodeException(jwt_segment_decode_error) - return decoded + return decoded, text def decode_jwt(token: str) -> DecodedJwt: @@ -92,9 +110,10 @@ def decode_jwt(token: str) -> DecodedJwt: pybreeze_logger.error(malformed_jwt_error) raise JwtDecodeException(malformed_jwt_error) - header = _decode_segment(segments[0]) - payload = _decode_segment(segments[1]) - return DecodedJwt(header=header, payload=payload, signature=segments[2]) + header, header_json = _decode_segment(segments[0]) + payload, payload_json = _decode_segment(segments[1]) + return DecodedJwt(header=header, payload=payload, signature=segments[2], + header_json=header_json, payload_json=payload_json) def _the_token(text: str) -> str: diff --git a/pybreeze/utils/response_inspector/response_analyzer.py b/pybreeze/utils/response_inspector/response_analyzer.py index 6014b2dc..3941e818 100644 --- a/pybreeze/utils/response_inspector/response_analyzer.py +++ b/pybreeze/utils/response_inspector/response_analyzer.py @@ -10,7 +10,6 @@ """ from __future__ import annotations -import json import re from dataclasses import dataclass, field @@ -19,7 +18,7 @@ from pybreeze.utils.http_reference.status_codes import StatusInfo, lookup from pybreeze.utils.jwt_tools.jwt_decoder import DecodedJwt, decode_jwt, find_tokens from pybreeze.utils.exception.exceptions import JwtDecodeException -from pybreeze.utils.json_format.view_safe import dumps_for_view +from pybreeze.utils.json_format.json_process import pretty_json_or_none # Matches the response status line, e.g. "HTTP/1.1 200 OK" _STATUS_LINE_RE = re.compile(r"^HTTP/\d(?:\.\d)?\s+(\d{3})\b") @@ -140,15 +139,6 @@ def _continue_value(headers: dict[str, str | list[str]], name: str, more: str) - headers[name] = f"{value} {more}".strip() -def _pretty_json(body: str) -> str | None: - """Return *body* pretty-printed if it is JSON, else ``None`` (key order kept).""" - try: - return dumps_for_view(json.loads(body), indent=4) - # RecursionError: a body nested past the recursion limit - except (ValueError, TypeError, RecursionError): - return None - - def _find_jwts(text: str) -> list[JwtFinding]: """Find and decode every JWT-looking token in *text*.""" findings: list[JwtFinding] = [] @@ -168,7 +158,8 @@ def analyze_response(text: str) -> ResponseAnalysis: :return: the structured analysis """ status_code, headers, body = _parse_head_and_body(text) - pretty_body = _pretty_json(body) + # Laid out as JSON Format would: numbers as written, a repeated key refused + pretty_body = pretty_json_or_none(body) return ResponseAnalysis( status=lookup(status_code) if status_code is not None else None, headers=headers, diff --git a/test/test_utils/test_jwt_decoder.py b/test/test_utils/test_jwt_decoder.py index 16cff2d7..86dea9a4 100644 --- a/test/test_utils/test_jwt_decoder.py +++ b/test/test_utils/test_jwt_decoder.py @@ -154,3 +154,39 @@ def test_a_segment_with_characters_outside_base64url_is_refused(self): with pytest.raises(JwtDecodeException): decode_jwt(f"{header[:4]}!{header[4:]}.{payload}.{signature}") + + +class TestTheSegmentsAsShown: + """The decoder keeps each segment's JSON text, and shows it as written.""" + + @staticmethod + def _raw_jwt(payload_text: str) -> str: + encoded = base64.urlsafe_b64encode(payload_text.encode("utf-8")).decode("ascii").rstrip("=") + return f"{_segment({'alg': 'none'})}.{encoded}.sig" + + def test_numbers_keep_their_text(self): + from pybreeze.utils.jwt_tools.jwt_decoder import shown_json + + decoded = decode_jwt(self._raw_jwt('{"n": 1e400, "big": 12345678901234567890123.5}')) + shown = shown_json(decoded.payload_json, decoded.payload) + + # json.dumps wrote Infinity, which is not JSON, and rounded the long number + assert '"n": 1e400' in shown + assert '"big": 12345678901234567890123.5' in shown + + def test_a_repeated_claim_falls_back_to_the_decoded_value(self): + from pybreeze.utils.jwt_tools.jwt_decoder import shown_json + + decoded = decode_jwt(self._raw_jwt('{"a": 1, "a": 2}')) + + assert json.loads(shown_json(decoded.payload_json, decoded.payload)) == {"a": 2} + + def test_the_keys_are_sorted_unless_asked_not_to(self): + from pybreeze.utils.jwt_tools.jwt_decoder import shown_json + + decoded = decode_jwt(self._raw_jwt('{"z": 1, "a": 2}')) + + assert shown_json(decoded.payload_json, decoded.payload).index('"a"') < \ + shown_json(decoded.payload_json, decoded.payload).index('"z"') + unsorted = shown_json(decoded.payload_json, decoded.payload, sort_keys=False) + assert unsorted.index('"z"') < unsorted.index('"a"') diff --git a/test/test_utils/test_response_analyzer.py b/test/test_utils/test_response_analyzer.py index 351a4e7a..81bea9ab 100644 --- a/test/test_utils/test_response_analyzer.py +++ b/test/test_utils/test_response_analyzer.py @@ -80,6 +80,20 @@ def test_json_key_order_preserved(self): # Keys should appear in original order, not sorted. assert analysis.pretty_body.index('"z"') < analysis.pretty_body.index('"a"') + def test_numbers_keep_their_text(self): + # json.loads made 1e400 the Infinity that is not JSON, and rounded the long one + analysis = analyze_response('{"n": 1e400, "big": 12345678901234567890.5}') + + assert '"n": 1e400' in analysis.pretty_body + assert '"big": 12345678901234567890.5' in analysis.pretty_body + + def test_a_repeated_key_is_not_laid_out_as_json(self): + # It kept the last value without a word, and JSON Format then refused the body + analysis = analyze_response('{"a": 1, "a": 2}') + + assert not analysis.is_json_body + assert analysis.pretty_body is None + def test_body_without_blank_line(self): # Headers then a JSON body with no separating blank line. text = "HTTP/1.1 200 OK\nContent-Type: application/json\n{\"a\": 1}" From 62ada9da9b517af7fe6001a34137b8169907f62e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:28:54 +0800 Subject: [PATCH 216/312] End a pasted JWT where it ends, and find one whose header is not written eyJ --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 14 ++++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/jwt_tools/jwt_decoder.py | 34 ++++++++++++++++++++----- test/test_utils/test_jwt_decoder.py | 27 ++++++++++++++++++++ 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index c12145fc..362b7799 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 116 個 `test_*.py`、1961 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 116 個 `test_*.py`、1966 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 168a1fa9..b696d1df 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2949,3 +2949,17 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: JWT, Response Inspector, JSON, view-safe and fuzz tests, 165 passed. 1961 tests in 116 files. `ruff check` clean. - **Files**: `pybreeze/utils/json_format/json_process.py`, `utils/jwt_tools/jwt_decoder.py`, `utils/response_inspector/response_analyzer.py`, `tools_gui/jwt_decoder_gui.py`, `tools_gui/response_inspector_gui.py`, `test/test_utils/test_response_analyzer.py`, `test/test_utils/test_jwt_decoder.py`, `architecture_explore.md` §7, §18 - **Open items**: none. + +## U-20260923-180 · 2026-09-24 · End a pasted JWT where it ends, and find one whose header is not written eyJ · #fix #tools + +- **What**: three ways the JWT decoder read the wrong token from pasted text. + - `_the_token` removed all whitespace before matching, so the text after a token became part of its signature: `\nnext line` decoded with the signature `signextline`, shown and saved as the token's. + - `h.p.sig.extra`, four segments and not a JWT, decoded as `h.p.sig`. + - Only headers encoded as `eyJ` (`{"`) were looked for. A header written `{ "alg"` encodes to `eyA`, one with a line break after the brace to `ewo`: with `Bearer ` in front it failed, although the bare token decoded. +- **Fix**: + - A line is joined to the one above only when it is a single word (`_unwrapped`): a token wrapped across lines has no space inside, a line of other text does. Quoted and indented wrapped tokens still join. + - `JWT_TOKEN_RE` starts with `e[wy]` and may neither start inside a word or another token (its payload starts `ey` too) nor end before a fourth segment; a sentence's closing full stop is still fine. Four segments are refused as "a JWT must have three dot-separated parts". +- **Tests**: `test_jwt_decoder.py` +5 (the next line; a quoted wrapped token; four segments; a closing full stop; a `{ "alg"` header after `Bearer`). +- **Result / numbers**: every test that mentions JWTs, 136 passed before the new ones; `test_jwt_decoder.py` 44 passed. 1966 tests in 116 files. `ruff check` clean. +- **Files**: `pybreeze/utils/jwt_tools/jwt_decoder.py`, `test/test_utils/test_jwt_decoder.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 48fdedb3..b4aaea26 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-180 | 2026-09-24 | End a pasted JWT where it ends, and find one whose header is not written eyJ | #fix #tools | [2026-09](2026-09.md) | | U-20260923-179 | 2026-09-24 | Lay out the Response Inspector's body and a JWT's segments as JSON Format does | #fix #tools | [2026-09](2026-09.md) | | U-20260923-178 | 2026-09-24 | Write JSON a tool shows so that its text box gives it back as it was | #fix #tools | [2026-09](2026-09.md) | | U-20260923-177 | 2026-09-24 | Check that every header finding and level has a message | #test #tools #i18n | [2026-09](2026-09.md) | @@ -260,4 +261,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 197 | +| [2026-09.md](2026-09.md) | 2026-09 | 198 | diff --git a/pybreeze/utils/jwt_tools/jwt_decoder.py b/pybreeze/utils/jwt_tools/jwt_decoder.py index f179fcff..6adc8387 100644 --- a/pybreeze/utils/jwt_tools/jwt_decoder.py +++ b/pybreeze/utils/jwt_tools/jwt_decoder.py @@ -32,7 +32,14 @@ _TIMESTAMP_CLAIMS = ("exp", "iat", "nbf", "auth_time") # Matches a JWT-looking token anywhere in a larger text, such as the value of an # ``Authorization: Bearer ...`` header. The signature segment may be empty. -JWT_TOKEN_RE = re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*") +# A header is a JSON object, so its encoding starts "ey" ('{"') or "ew" ('{' +# then a space, tab or line break), not always "eyJ". The token must start and +# end where the match does: not inside a word or another token (its payload +# starts "ey" too), and not before a fourth segment (a sentence's closing full +# stop is fine). +JWT_TOKEN_RE = re.compile( + r"(? str: ``Authorization`` header, the quotes of a JSON string, line breaks where it wrapped. Those used to reach the decoder, and ``Bearer eyJ...`` always failed. """ - compact = "".join(text.split()) - if JWT_TOKEN_RE.fullmatch(compact): - return compact - found = find_tokens(compact) - return found[0] if found else compact + unwrapped = _unwrapped(text) + if JWT_TOKEN_RE.fullmatch(unwrapped): + return unwrapped + found = find_tokens(unwrapped) + return found[0] if found else unwrapped + + +def _unwrapped(text: str) -> str: + """*text* with the line breaks a wrapped token was cut at taken out. + + A line joins the one above when it is a single word: a token wrapped + across lines has no space inside, while a line of other text does. All + whitespace used to go, so the text after a token (``next line``) became + part of its signature. + """ + lines = [line.strip() for line in text.splitlines() if line.strip()] + joined = lines[0] if lines else "" + for line in lines[1:]: + joined += line if len(line.split()) == 1 else f"\n{line}" + return joined def find_tokens(text: str) -> list[str]: diff --git a/test/test_utils/test_jwt_decoder.py b/test/test_utils/test_jwt_decoder.py index 86dea9a4..abab5859 100644 --- a/test/test_utils/test_jwt_decoder.py +++ b/test/test_utils/test_jwt_decoder.py @@ -190,3 +190,30 @@ def test_the_keys_are_sorted_unless_asked_not_to(self): shown_json(decoded.payload_json, decoded.payload).index('"z"') unsorted = shown_json(decoded.payload_json, decoded.payload, sort_keys=False) assert unsorted.index('"z"') < unsorted.index('"a"') + + +class TestWhereTheTokenEnds: + TOKEN = _make_jwt({"alg": "none"}, {"a": 1}) + + def test_the_line_after_it_is_not_part_of_the_signature(self): + # All whitespace went: the signature read "signextline" + assert decode_jwt(self.TOKEN + "\nnext line").signature == "sig" + + def test_a_token_wrapped_across_lines_is_joined(self): + wrapped = f'"{self.TOKEN[:20]}\n {self.TOKEN[20:]}",' + assert decode_jwt(wrapped).payload == {"a": 1} + + def test_a_fourth_segment_is_refused_not_dropped(self): + with pytest.raises(JwtDecodeException, match="three"): + decode_jwt(self.TOKEN + ".extra") + + def test_a_closing_full_stop_is_not_a_segment(self): + assert decode_jwt(f"The token is {self.TOKEN}.").signature == "sig" + + +def test_a_header_whose_encoding_is_not_eyj_is_found_after_bearer(): + # '{ "alg"' encodes to "eyAi", '{\n' to "ewo": only "eyJ" was looked for + header = base64.urlsafe_b64encode(b'{ "alg": "none"}').decode("ascii").rstrip("=") + token = f"{header}.{_segment({'a': 1})}.s" + + assert decode_jwt(f"Bearer {token}").header == {"alg": "none"} From 4d3a38bd92bfc0c66d452ad67dd10a314cef1743 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:30:19 +0800 Subject: [PATCH 217/312] Show a response's status when its code is not a registered one --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++ docs/updates/README.md | 3 +- pybreeze/utils/http_reference/status_codes.py | 13 +++++++ .../response_inspector/response_analyzer.py | 38 +++++++++++-------- test/test_utils/test_response_analyzer.py | 16 +++++++- 6 files changed, 61 insertions(+), 20 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 362b7799..c0580bfa 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 116 個 `test_*.py`、1966 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 116 個 `test_*.py`、1968 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index b696d1df..4e460b0d 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2963,3 +2963,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: every test that mentions JWTs, 136 passed before the new ones; `test_jwt_decoder.py` 44 passed. 1966 tests in 116 files. `ruff check` clean. - **Files**: `pybreeze/utils/jwt_tools/jwt_decoder.py`, `test/test_utils/test_jwt_decoder.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-181 · 2026-09-24 · Show a response's status when its code is not a registered one · #fix #tools + +- **What**: the Response Inspector looked the status code up in `HTTPStatus` and kept nothing when it was not there. A real status line with a code nobody registered (`HTTP/1.1 299 Custom`, `:status: 599`) lost its status section, and Open status was greyed out. +- **Fix**: `status_codes.status_of(code, phrase)` returns the registered status, or one built from the code's class with the reason phrase the response gave. The analyzer carries each status line's reason phrase with its code (`_Status`) and uses it; a registered code keeps its registered phrase. +- **Tests**: `test_response_analyzer.py`: the test that expected no status for `299` now expects `299 Weird [Success]`; +2 (a `:status: 599` pseudo-header; a registered code keeps its phrase). +- **Result / numbers**: Response Inspector and HTTP status tests, 108 passed. 1968 tests in 116 files. `ruff check` clean. +- **Files**: `pybreeze/utils/http_reference/status_codes.py`, `utils/response_inspector/response_analyzer.py`, `test/test_utils/test_response_analyzer.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index b4aaea26..730a3263 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-181 | 2026-09-24 | Show a response's status when its code is not a registered one | #fix #tools | [2026-09](2026-09.md) | | U-20260923-180 | 2026-09-24 | End a pasted JWT where it ends, and find one whose header is not written eyJ | #fix #tools | [2026-09](2026-09.md) | | U-20260923-179 | 2026-09-24 | Lay out the Response Inspector's body and a JWT's segments as JSON Format does | #fix #tools | [2026-09](2026-09.md) | | U-20260923-178 | 2026-09-24 | Write JSON a tool shows so that its text box gives it back as it was | #fix #tools | [2026-09](2026-09.md) | @@ -261,4 +262,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 198 | +| [2026-09.md](2026-09.md) | 2026-09 | 199 | diff --git a/pybreeze/utils/http_reference/status_codes.py b/pybreeze/utils/http_reference/status_codes.py index a07cd309..042926f7 100644 --- a/pybreeze/utils/http_reference/status_codes.py +++ b/pybreeze/utils/http_reference/status_codes.py @@ -58,6 +58,19 @@ def all_statuses() -> list[StatusInfo]: return [_to_info(status) for status in sorted(HTTPStatus, key=int)] +def status_of(code: int, phrase: str = "") -> StatusInfo: + """The status for *code*: the registered one, else one built from its class. + + A server may send a code nobody registered (``299``, ``599``); what it + means is still its class, and *phrase* -- the reason phrase from its + status line -- is what it called it. + + :param code: the numeric status code + :param phrase: the reason phrase the response gave, if any + """ + return lookup(code) or StatusInfo(code=code, phrase=phrase, description="", category=_category_for(code)) + + def lookup(code: int) -> StatusInfo | None: """Return the status for an exact *code*, or ``None`` if unknown. diff --git a/pybreeze/utils/response_inspector/response_analyzer.py b/pybreeze/utils/response_inspector/response_analyzer.py index 3941e818..63f1fa55 100644 --- a/pybreeze/utils/response_inspector/response_analyzer.py +++ b/pybreeze/utils/response_inspector/response_analyzer.py @@ -15,7 +15,7 @@ from pybreeze.utils.curl_import.curl_parser import add_repeated_value from pybreeze.utils.header_tools.header_analyzer import FOLDED_LINE_START, HEADER_LINE_RE -from pybreeze.utils.http_reference.status_codes import StatusInfo, lookup +from pybreeze.utils.http_reference.status_codes import StatusInfo, status_of from pybreeze.utils.jwt_tools.jwt_decoder import DecodedJwt, decode_jwt, find_tokens from pybreeze.utils.exception.exceptions import JwtDecodeException from pybreeze.utils.json_format.json_process import pretty_json_or_none @@ -64,8 +64,12 @@ def _normalise(text: str) -> str: return text.replace("\r\n", "\n").replace("\r", "\n") -def _parse_head_and_body(text: str) -> tuple[int | None, dict[str, str], str]: - """Split *text* into the final response's status code, headers and body. +# A response's status: its code and the reason phrase its status line gave +_Status = tuple[int, str] + + +def _parse_head_and_body(text: str) -> tuple[_Status | None, dict[str, str], str]: + """Split *text* into the final response's status, headers and body. ``curl -i`` prints every response it read: ``100 Continue``, a proxy's ``200 Connection established``, each redirect with ``-L``. The first status @@ -77,22 +81,22 @@ def _parse_head_and_body(text: str) -> tuple[int | None, dict[str, str], str]: # One line and no status line is a body: "Error: invalid token" is # not a header return None, {}, text.strip() - status_code, headers, body = _parse_one_response(lines) + status, headers, body = _parse_one_response(lines) while _STATUS_LINE_RE.match(body): - status_code, headers, body = _parse_one_response(body.split("\n")) - return status_code, headers, body + status, headers, body = _parse_one_response(body.split("\n")) + return status, headers, body -def _parse_one_response(lines: list[str]) -> tuple[int | None, dict[str, str], str]: - """Split *lines* into a status code, headers and body. +def _parse_one_response(lines: list[str]) -> tuple[_Status | None, dict[str, str], str]: + """Split *lines* into a status, headers and body. Headers run from an optional status line until a blank line or the first line that is not a ``Name: Value`` header; everything after is the body. """ index = 0 - status_code: int | None = None - if lines and _STATUS_LINE_RE.match(lines[0]): - status_code = int(_STATUS_LINE_RE.match(lines[0]).group(1)) + status: _Status | None = None + if lines and (status_line := _STATUS_LINE_RE.match(lines[0])) is not None: + status = (int(status_line.group(1)), lines[0][status_line.end():].strip()) index = 1 headers: dict[str, str | list[str]] = {} @@ -108,8 +112,8 @@ def _parse_one_response(lines: list[str]) -> tuple[int | None, dict[str, str], s _continue_value(headers, last_name, line.strip()) elif (pseudo := _PSEUDO_HEADER_RE.match(line)) is not None: # It ended the headers, and the rest was read as the body - if pseudo.group(1) == "status" and status_code is None and pseudo.group(2).strip().isdigit(): - status_code = int(pseudo.group(2).strip()) + if pseudo.group(1) == "status" and status is None and pseudo.group(2).strip().isdigit(): + status = (int(pseudo.group(2).strip()), "") elif (match := HEADER_LINE_RE.match(line)) is not None: last_name = _same_name(headers, match.group(1)) add_repeated_value(headers, last_name, match.group(2).strip()) @@ -118,7 +122,7 @@ def _parse_one_response(lines: list[str]) -> tuple[int | None, dict[str, str], s index += 1 body = "\n".join(lines[index:]).strip() - return status_code, headers, body + return status, headers, body def _same_name(headers: dict[str, str | list[str]], name: str) -> str: @@ -157,11 +161,13 @@ def analyze_response(text: str) -> ResponseAnalysis: :param text: the raw response text (status line + headers + body, or just a body) :return: the structured analysis """ - status_code, headers, body = _parse_head_and_body(text) + status, headers, body = _parse_head_and_body(text) # Laid out as JSON Format would: numbers as written, a repeated key refused pretty_body = pretty_json_or_none(body) return ResponseAnalysis( - status=lookup(status_code) if status_code is not None else None, + # A code nobody registered (299, 599) is still shown: lookup found + # nothing, and the status section and Open status went with it + status=status_of(*status) if status is not None else None, headers=headers, body=body, pretty_body=pretty_body, diff --git a/test/test_utils/test_response_analyzer.py b/test/test_utils/test_response_analyzer.py index 81bea9ab..8e6ebbb5 100644 --- a/test/test_utils/test_response_analyzer.py +++ b/test/test_utils/test_response_analyzer.py @@ -31,9 +31,21 @@ def test_no_status_line(self): analysis = analyze_response('{"a": 1}') assert analysis.status is None - def test_unknown_status_code(self): + def test_an_unregistered_code_is_shown_with_its_class_and_own_phrase(self): + # It was dropped: no status section, and Open status greyed out analysis = analyze_response("HTTP/1.1 299 Weird\n\n") - assert analysis.status is None # 299 is not a known code + + assert (analysis.status.code, analysis.status.phrase, analysis.status.category) == ( + 299, "Weird", "Success") + + def test_an_unregistered_pseudo_header_status_has_no_phrase(self): + analysis = analyze_response(":status: 599\ncontent-type: text/plain\n\nx") + + assert (analysis.status.code, analysis.status.phrase, analysis.status.category) == ( + 599, "", "Server Error") + + def test_a_registered_code_keeps_its_registered_phrase(self): + assert analyze_response("HTTP/1.1 404 Nope\n\n").status.phrase == "Not Found" class TestHeaderParsing: From 214c60b369703b392a772900441a5df8c70cfd74 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:32:23 +0800 Subject: [PATCH 218/312] Refuse a query byte that is not UTF-8 and a JSON key given twice instead of losing them --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 13 ++++++++ docs/updates/README.md | 3 +- .../pybreeze_ui/tools_gui/query_json_gui.py | 10 ++++++- pybreeze/utils/exception/exception_tags.py | 2 ++ pybreeze/utils/json_format/json_process.py | 21 ++++++++----- pybreeze/utils/query_tools/query_convert.py | 30 +++++++++++++++---- pybreeze/utils/url_tools/url_convert.py | 6 ++++ test/test_utils/test_query_convert.py | 19 ++++++++++++ test/test_utils/test_query_json_gui.py | 11 +++++++ 10 files changed, 102 insertions(+), 17 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index c0580bfa..41bad7f6 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -349,7 +349,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | -| `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯;`pretty_json_or_none()` 是同一套解析、不記 log 的版本,Response Inspector 的 body 與 JWT 各段(`jwt_decoder.shown_json()`)用它排版。`json_format/view_safe.py` 的 `dumps_for_view()` / `escape_for_view()`:工具顯示的 JSON 把文字框還不回原樣的字元(U+2029、U+FDD0、U+FDD1 會變換行,落單的 surrogate 會消失;U+2028 經 `toPlainText()`、U+0085 經 `splitlines()` 也會斷行)寫成 `\uXXXX`,JSON Format、Query/URL 轉 JSON、JWT、Response Inspector、cURL/HAR 產生的 JSON 與 Python 字串都經過它。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN`(`load_json_verbatim()`),`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | +| `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯;`pretty_json_or_none()` 是同一套解析、不記 log 的版本,Response Inspector 的 body 與 JWT 各段(`jwt_decoder.shown_json()`)用它排版。`json_format/view_safe.py` 的 `dumps_for_view()` / `escape_for_view()`:工具顯示的 JSON 把文字框還不回原樣的字元(U+2029、U+FDD0、U+FDD1 會變換行,落單的 surrogate 會消失;U+2028 經 `toPlainText()`、U+0085 經 `splitlines()` 也會斷行)寫成 `\uXXXX`,JSON Format、Query/URL 轉 JSON、JWT、Response Inspector、cURL/HAR 產生的 JSON 與 Python 字串都經過它。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN` 與同一物件重複的 key(`load_json_verbatim()`,用 `json_process.unique_pairs`),Query 轉 JSON 遇到不是 UTF-8 的 percent-escape 報錯而不換成 U+FFFD,`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | | `file_process/read_capped.py` | `read_text_capped(path, encoding, max_bytes=None)`:先看大小,超過 `MAX_OPEN_BYTES`(100 MB)丟 `FileTooLargeError`(`OSError`,`strerror` 說明大小與上限),HAR 分頁與架構圖編輯器開檔都經它,不在 UI 執行緒讀好幾 GB 的檔案 | | `file_process/replace_file.py` | `replace_text(path, text, private=False)`:寫到旁邊的 `<名稱>.saving` 再 `os.replace` 過去,失敗時原檔不動、半成品刪掉;`private` 以 `0600` 建立(存金鑰的檔案用);`replace_written(path, write)` 給別人寫的檔案(圖片、SVG):`write` 拿到旁邊的 `<主檔名>.saving<副檔名>`(副檔名不變,看副檔名決定格式的寫入器照樣用),寫完才換上 | @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 116 個 `test_*.py`、1968 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 116 個 `test_*.py`、1972 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 4e460b0d..ab84283b 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2972,3 +2972,16 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: Response Inspector and HTTP status tests, 108 passed. 1968 tests in 116 files. `ruff check` clean. - **Files**: `pybreeze/utils/http_reference/status_codes.py`, `utils/response_inspector/response_analyzer.py`, `test/test_utils/test_response_analyzer.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-182 · 2026-09-24 · Refuse a query byte that is not UTF-8 and a JSON key given twice instead of losing them · #fix #tools + +- **What**: two conversions lost data without a word. + - Query to JSON decoded a percent-escape that is not UTF-8 (`a=%B0`) as U+FFFD: the JSON said `"a": "\ufffd"` and the byte was gone. The URL Builder and the cURL import already keep such a query as its text (`query_round_trips`). + - JSON to Query and JSON to URL read a key given twice in one object as its last value: `{"a": 1, "a": 2}` became `a=2`, where JSON Format refuses the same input. A repeated query key is written as a list. +- **Fix**: + - `query_to_dict` decodes strictly (`parse_qsl(errors="strict")`); `query_to_json` reports a `UnicodeDecodeError` as `QueryConvertException(query_not_utf8_error)`, and the Query tab now catches it like the other direction: the message is shown and the output cannot be saved. + - `load_json_verbatim` refuses a repeated key with JSON Format's hook, now public as `json_process.unique_pairs` / `DuplicateKeyError`; JSON to Query and JSON to URL report it with JSON Format's message, naming the key. +- **Tests**: `test_query_convert.py` +3 (`%B0`; a repeated key to a query; a repeated URL part), `test_query_json_gui.py` +1 (the message is shown and the output is not savable). +- **Result / numbers**: query, URL, JSON, cURL, HAR, view-safe and fuzz tests, 478 passed before the new ones. 1972 tests in 116 files. `ruff check` clean. +- **Files**: `pybreeze/utils/query_tools/query_convert.py`, `utils/url_tools/url_convert.py`, `utils/json_format/json_process.py`, `utils/exception/exception_tags.py`, `tools_gui/query_json_gui.py`, `test/test_utils/test_query_convert.py`, `test/test_utils/test_query_json_gui.py`, `architecture_explore.md` §7, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 730a3263..4b9f8688 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-182 | 2026-09-24 | Refuse a query byte that is not UTF-8 and a JSON key given twice instead of losing them | #fix #tools | [2026-09](2026-09.md) | | U-20260923-181 | 2026-09-24 | Show a response's status when its code is not a registered one | #fix #tools | [2026-09](2026-09.md) | | U-20260923-180 | 2026-09-24 | End a pasted JWT where it ends, and find one whose header is not written eyJ | #fix #tools | [2026-09](2026-09.md) | | U-20260923-179 | 2026-09-24 | Lay out the Response Inspector's body and a JWT's segments as JSON Format does | #fix #tools | [2026-09](2026-09.md) | @@ -262,4 +263,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 199 | +| [2026-09.md](2026-09.md) | 2026-09 | 200 | diff --git a/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py b/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py index bc25e662..71ad3021 100644 --- a/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py @@ -63,8 +63,16 @@ def convert_to_json(self) -> None: self.output_edit.setPlainText( language_wrapper.language_word_dict.get("query_json_empty_hint")) return + try: + result = query_to_json(text) + except QueryConvertException as error: + pybreeze_logger.info("query_json_gui.py to-json failed: %r", error) + self._valid_output = False + self.output_edit.setPlainText( + language_wrapper.language_word_dict.get("query_json_error").format(error=str(error))) + return self._valid_output = True - self.output_edit.setPlainText(query_to_json(text)) + self.output_edit.setPlainText(result) def convert_to_query(self) -> None: """Convert the input JSON to a query string.""" diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index 5146dab2..9fa43e68 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -48,6 +48,8 @@ nested_query_value_error: str = ( "a query value must be a string, number, true/false or null, or a list of them") invalid_json_for_query_error: str = "can't parse the input as JSON" +query_not_utf8_error: str = ( + "a percent-escape in the query is not UTF-8 text (for example %B0), so it has no JSON form") unencodable_text_error: str = ( "a value holds a character that cannot be written in a URL (a lone surrogate such as \\ud83d)" ) diff --git a/pybreeze/utils/json_format/json_process.py b/pybreeze/utils/json_format/json_process.py index 69a2e381..0699a4dd 100644 --- a/pybreeze/utils/json_format/json_process.py +++ b/pybreeze/utils/json_format/json_process.py @@ -20,8 +20,8 @@ from pybreeze.utils.logging.logger import pybreeze_logger -class _DuplicateKey(ValueError): - """A key given twice in one object.""" +class DuplicateKeyError(ValueError): + """A key given twice in one JSON object; ``args[0]`` is the key.""" class _Numbers: @@ -60,12 +60,17 @@ def _refuse_constant(name: str) -> None: raise ValueError(f"{name} is not JSON") -def _unique_pairs(pairs: list[tuple[str, object]]) -> dict: - """An object's members as a dict, refusing a key given twice.""" +def unique_pairs(pairs: list[tuple[str, object]]) -> dict: + """An object's members as a dict, refusing a key given twice (``object_pairs_hook``). + + ``json.loads`` keeps a repeated key's last value without a word. + + :raises DuplicateKeyError: for a key given twice + """ members: dict = {} for key, value in pairs: if key in members: - raise _DuplicateKey(key) + raise DuplicateKeyError(key) members[key] = value return members @@ -74,19 +79,19 @@ def _parse(json_string: str, numbers: _Numbers) -> object: """Parse *json_string*, numbers held as their text. :raises ValueError: when it is not JSON, repeats a key in one object - (``_DuplicateKey``), or uses ``NaN`` or ``Infinity`` + (``DuplicateKeyError``), or uses ``NaN`` or ``Infinity`` :raises RecursionError: when it is nested past the recursion limit """ return loads( json_string, parse_float=numbers.hold, parse_int=numbers.hold, - parse_constant=_refuse_constant, object_pairs_hook=_unique_pairs) + parse_constant=_refuse_constant, object_pairs_hook=unique_pairs) def _load(json_string: str, numbers: _Numbers) -> object: """Parse *json_string*, numbers held as their text; raise ``ITEJsonException`` when it is not JSON.""" try: return _parse(json_string, numbers) - except _DuplicateKey as error: + except DuplicateKeyError as error: message = json_duplicate_key_error.format(key=error.args[0]) pybreeze_logger.error(message) raise ITEJsonException(message) from error diff --git a/pybreeze/utils/query_tools/query_convert.py b/pybreeze/utils/query_tools/query_convert.py index 8f9dfd32..e5cb1d5d 100644 --- a/pybreeze/utils/query_tools/query_convert.py +++ b/pybreeze/utils/query_tools/query_convert.py @@ -16,10 +16,13 @@ from pybreeze.utils.exception.exception_tags import ( invalid_json_for_query_error, invalid_json_object_error, + json_duplicate_key_error, nested_query_value_error, + query_not_utf8_error, unencodable_text_error, ) from pybreeze.utils.exception.exceptions import QueryConvertException +from pybreeze.utils.json_format.json_process import DuplicateKeyError, unique_pairs from pybreeze.utils.json_format.view_safe import dumps_for_view from pybreeze.utils.logging.logger import pybreeze_logger @@ -32,9 +35,11 @@ def query_to_dict(query: str) -> dict[str, str | list[str]]: :param query: a query string such as ``a=1&b=2`` (a leading ``?`` is ignored) :return: the parsed mapping + :raises UnicodeDecodeError: when a percent-escape is not UTF-8 (``%B0``): + decoded, it became U+FFFD and the byte was lost without a word """ stripped = query.strip().lstrip("?") - pairs = parse_qsl(stripped, keep_blank_values=True) + pairs = parse_qsl(stripped, keep_blank_values=True, errors="strict") result: dict[str, str | list[str]] = {} for key, value in pairs: if key in result: @@ -66,8 +71,14 @@ def query_to_json(query: str) -> str: :param query: the query string to convert :return: a formatted JSON object string + :raises QueryConvertException: when a percent-escape is not UTF-8 text """ - return dumps_for_view(query_to_dict(query), indent=4, sort_keys=True) + try: + as_dict = query_to_dict(query) + except UnicodeDecodeError as error: + pybreeze_logger.error(query_not_utf8_error) + raise QueryConvertException(query_not_utf8_error) from error + return dumps_for_view(as_dict, indent=4, sort_keys=True) def coerce_scalar(value: object) -> str: @@ -96,10 +107,15 @@ def json_to_query(json_text: str) -> str: :param json_text: a JSON object of key/value (or key/list) pairs :return: the URL-encoded query string - :raises QueryConvertException: when the input is not valid JSON or not an object + :raises QueryConvertException: when the input is not valid JSON or not an + object, or repeats a key (a repeated key is written as a list) """ try: parsed = load_json_verbatim(json_text) + except DuplicateKeyError as error: + message = json_duplicate_key_error.format(key=error.args[0]) + pybreeze_logger.error(message) + raise QueryConvertException(message) from error # json.JSONDecodeError derives from ValueError; RecursionError is JSON # nested deeper than the parser goes, which escaped the tab's slot except (ValueError, RecursionError) as error: @@ -142,9 +158,13 @@ def load_json_verbatim(json_text: str) -> object: Through ``float`` a query value changed: ``1E3`` became ``1000.0``, a long integer written with a fraction lost its digits, ``1e400`` became ``inf``, - and ``NaN`` was accepted. ``NaN`` and ``Infinity`` are refused. + and ``NaN`` was accepted. ``NaN`` and ``Infinity`` are refused, and so is + a key repeated in one object, which kept only its last value. + :raises DuplicateKeyError: when one object repeats a key :raises ValueError: when it is not JSON :raises RecursionError: when it is nested deeper than the parser goes """ - return json.loads(json_text, parse_float=str, parse_int=str, parse_constant=_refuse_constant) + return json.loads( + json_text, parse_float=str, parse_int=str, parse_constant=_refuse_constant, + object_pairs_hook=unique_pairs) diff --git a/pybreeze/utils/url_tools/url_convert.py b/pybreeze/utils/url_tools/url_convert.py index 25fb88e9..6133f0e8 100644 --- a/pybreeze/utils/url_tools/url_convert.py +++ b/pybreeze/utils/url_tools/url_convert.py @@ -11,10 +11,12 @@ from pybreeze.utils.exception.exception_tags import ( invalid_json_for_url_error, invalid_url_components_error, + json_duplicate_key_error, unreadable_url_error, url_port_out_of_range_error, ) from pybreeze.utils.exception.exceptions import QueryConvertException, UrlConvertException +from pybreeze.utils.json_format.json_process import DuplicateKeyError from pybreeze.utils.json_format.view_safe import dumps_for_view from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.query_tools.query_convert import ( @@ -191,6 +193,10 @@ def json_to_url(json_text: str) -> str: try: # Numbers as written: a query value 1E3 went into the URL as 1000.0 components = load_json_verbatim(json_text) + except DuplicateKeyError as error: + message = json_duplicate_key_error.format(key=error.args[0]) + pybreeze_logger.error(message) + raise UrlConvertException(message) from error # RecursionError: JSON nested deeper than the parser goes, which escaped # the tab's slot except (ValueError, RecursionError) as error: diff --git a/test/test_utils/test_query_convert.py b/test/test_utils/test_query_convert.py index 718d6805..76054891 100644 --- a/test/test_utils/test_query_convert.py +++ b/test/test_utils/test_query_convert.py @@ -119,3 +119,22 @@ def test_a_lone_surrogate_is_refused_not_raised(self): # The UnicodeEncodeError escaped the tab's slot with pytest.raises(QueryConvertException): json_to_query('{"q": "\ud83d"}') + + +class TestNothingIsLostWithoutAWord: + def test_a_percent_escape_that_is_not_utf8_is_refused(self): + # It became U+FFFD and the byte was gone + with pytest.raises(QueryConvertException, match="not UTF-8"): + query_to_json("a=%B0") + + def test_a_repeated_json_key_is_refused(self): + # {"a": 1, "a": 2} went out as a=2; a list is how a key repeats + with pytest.raises(QueryConvertException, match="twice"): + json_to_query('{"a": 1, "a": 2}') + + def test_a_repeated_url_part_is_refused(self): + from pybreeze.utils.exception.exceptions import UrlConvertException + from pybreeze.utils.url_tools.url_convert import json_to_url + + with pytest.raises(UrlConvertException, match="twice"): + json_to_url('{"scheme": "http", "host": "a", "host": "b"}') diff --git a/test/test_utils/test_query_json_gui.py b/test/test_utils/test_query_json_gui.py index f86852b9..5406f48d 100644 --- a/test/test_utils/test_query_json_gui.py +++ b/test/test_utils/test_query_json_gui.py @@ -60,3 +60,14 @@ def test_copy_output(self, app, widget): widget.convert_to_json() widget.actions.copy() assert "a" in QApplication.clipboard().text() + + +def test_a_query_with_no_json_form_says_so_and_cannot_be_saved(widget): + widget.input_edit.setPlainText("a=1") + widget.convert_to_json() + widget.input_edit.setPlainText("a=%B0") + + widget.convert_to_json() + + assert "UTF-8" in widget.output_edit.toPlainText() + assert widget._valid_output is False From de41ec56ed692810405ffbe2166b1ad2cd2a8eb3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:33:56 +0800 Subject: [PATCH 219/312] Free a run window once the main window lets go of it --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../process_executor_utils.py | 15 ++++++++++++-- test/test_utils/test_build_task_process.py | 20 +++++++++++++++++++ 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 41bad7f6..29204b71 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 116 個 `test_*.py`、1972 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 116 個 `test_*.py`、1973 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ab84283b..79cff2f8 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2985,3 +2985,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: query, URL, JSON, cURL, HAR, view-safe and fuzz tests, 478 passed before the new ones. 1972 tests in 116 files. `ruff check` clean. - **Files**: `pybreeze/utils/query_tools/query_convert.py`, `utils/url_tools/url_convert.py`, `utils/json_format/json_process.py`, `utils/exception/exception_tags.py`, `tools_gui/query_json_gui.py`, `test/test_utils/test_query_convert.py`, `test/test_utils/test_query_json_gui.py`, `architecture_explore.md` §7, §18 - **Open items**: none. + +## U-20260923-183 · 2026-09-24 · Free a run window once the main window lets go of it · #fix #executor + +- **What**: `open_run_window` connected the window's `finished_and_closed` to a lambda holding the window. The connection belongs to the window, so the window kept itself alive through Qt, a cycle Python's collector cannot see. Every run window closed after its run stayed in memory for as long as the IDE ran, with up to 10,000 lines of output, its executor, queues and timer: five windows opened, closed and dropped from the list were all still alive after `gc.collect()`. +- **Fix**: the slot holds a weak reference to the window (`_forget_if_alive`); forgetting it then leaves nothing holding it, and it is freed on the GUI thread's collection. +- **Tests**: `test_build_task_process.py` +1 (a closed run window, dropped from the list, is freed). +- **Result / numbers**: the five-window probe: 5 alive before, 0 after. `test_build_task_process.py` 14 passed. 1973 tests in 116 files. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `test/test_utils/test_build_task_process.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 4b9f8688..662a4098 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-183 | 2026-09-24 | Free a run window once the main window lets go of it | #fix #executor | [2026-09](2026-09.md) | | U-20260923-182 | 2026-09-24 | Refuse a query byte that is not UTF-8 and a JSON key given twice instead of losing them | #fix #tools | [2026-09](2026-09.md) | | U-20260923-181 | 2026-09-24 | Show a response's status when its code is not a registered one | #fix #tools | [2026-09](2026-09.md) | | U-20260923-180 | 2026-09-24 | End a pasted JWT where it ends, and find one whose header is not written eyJ | #fix #tools | [2026-09](2026-09.md) | @@ -263,4 +264,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 200 | +| [2026-09.md](2026-09.md) | 2026-09 | 201 | diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index df2459a6..ad50a006 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -2,6 +2,7 @@ import os import time +import weakref from collections.abc import Callable from typing import TYPE_CHECKING @@ -173,11 +174,21 @@ def open_run_window(main_window: PyBreezeMainWindow, title: str = "") -> CodeWin if title: code_window.setWindowTitle(title) main_window.current_run_code_window.append(code_window) - code_window.finished_and_closed.connect( - lambda: forget_run_window(main_window, code_window)) + # Weakly: the connection belongs to the window, so a slot holding the + # window itself kept every closed run window, with its output and its + # executor, alive for as long as the IDE ran + window_ref = weakref.ref(code_window) + code_window.finished_and_closed.connect(lambda: _forget_if_alive(main_window, window_ref)) return code_window +def _forget_if_alive(main_window: PyBreezeMainWindow, window_ref: weakref.ref) -> None: + """``forget_run_window`` for the window *window_ref* points at, if it still exists.""" + code_window = window_ref() + if code_window is not None: + forget_run_window(main_window, code_window) + + def forget_run_window(main_window: PyBreezeMainWindow, code_window: CodeWindow) -> None: """Drop *code_window* from the main window's list, if it is still there.""" if code_window in main_window.current_run_code_window: diff --git a/test/test_utils/test_build_task_process.py b/test/test_utils/test_build_task_process.py index 17190b24..6368d4b7 100644 --- a/test/test_utils/test_build_task_process.py +++ b/test/test_utils/test_build_task_process.py @@ -221,6 +221,26 @@ def test_closing_one_window_does_not_skip_the_others(self, qt_app): assert main_window.current_run_code_window == [] assert all(not window.isVisible() for window in windows) + def test_a_window_let_go_of_is_freed(self, qt_app): + # The slot that let go of it held the window, and the connection + # belongs to the window: every closed run window, its output and its + # executor stayed alive for as long as the IDE ran + import gc + import weakref + + from pybreeze.extend.process_executor.process_executor_utils import open_run_window + + main_window = MainWindow() + run_window = open_run_window(main_window) + watch = weakref.ref(run_window) + + run_window.close() + del run_window + gc.collect() + + assert main_window.current_run_code_window == [] + assert watch() is None + def test_the_mail_notice_is_freed_on_the_gui_thread(qt_app, monkeypatch): # Its last reference was the mail thread's: with the run window gone, the From bbf78856bcb549d70b0ea464cad5d55c2da1d034 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:37:21 +0800 Subject: [PATCH 220/312] Delete a tool tab once it is closed --- architecture.md | 2 +- architecture_explore.md | 4 +- docs/updates/2026-09.md | 9 +++++ docs/updates/README.md | 3 +- pybreeze/pybreeze_ui/editor_main/main_ui.py | 11 +++++- test/test_utils/test_closed_tool_tabs.py | 41 +++++++++++++++++++++ 6 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 test/test_utils/test_closed_tool_tabs.py diff --git a/architecture.md b/architecture.md index efcc73b2..c2fe3be2 100644 --- a/architecture.md +++ b/architecture.md @@ -149,7 +149,7 @@ Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() `line_ending`, `mark_ignore_next_file_change()` and `mark_saved()`, on its private `_file_watcher`, `_ignore_next_change`, `_is_modified` and `_on_text_changed()` (a rename puts the unsaved mark back after `rename_self_tab()` clears it), on `EditorMain.close_tab(index)` (overridden to ask a - tab's `may_close()` first) and on `CodeEditor`'s `reset_highlighter()`, `load_git_baseline()` and + tab's `may_close()` first, and to delete a closed tool tab, which its `removeTab` keeps) and on `CodeEditor`'s `reset_highlighter()`, `load_git_baseline()` and `start_language_server()` (a rename moves the tab the way `open_an_file` does), and on `language_wrapper`'s `choose_language_dict` serving English and Traditional Chinese from the exported dict objects themselves. Having je_editor export the names in the table is workspace X-17. It diff --git a/architecture_explore.md b/architecture_explore.md index 29204b71..3908d9a2 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -74,7 +74,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE - `syntax_extend_package()` — 註冊 `.json` / `.yml` / `.yaml` 自動化關鍵字高亮 - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁(`_add_extend_tabs()`:每一個分頁各自建,建不起來的只記 log,不會讓整個 IDE 起不來) - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器(`rename_self_tab()` 會清掉「未儲存」標記,有未存的編輯就用 `_on_text_changed()` 放回去,否則改名後五秒內關分頁會直接丟掉編輯);Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。新增與改名的名稱不能帶磁碟代號、根目錄、`..` 或 `:`,也不能解析到資料夾外(`_inside()`;改名只能是單一名稱)。刪除資料夾用 `remove_folder()`(唯讀檔清掉唯讀屬性再刪,git 的物件檔就是唯讀),符號連結與 junction 只刪連結本身。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開 - - `close_tab()` 覆寫 JEditor 的:分頁有 `may_close()` 就先問(提示詞編輯器、架構圖編輯器有未存的變更時會問),關閉 IDE 時也先問過每個分頁與 dock,有一個說不就取消關閉 + - `close_tab()` 覆寫 JEditor 的:分頁有 `may_close()` 就先問(提示詞編輯器、架構圖編輯器有未存的變更時會問);關掉的工具分頁 `deleteLater()`(JEditor 的 `removeTab` 不刪 widget,關過的工具分頁會留到 IDE 結束),JEditor 自己的編輯器分頁不動,關閉 IDE 時也先問過每個分頁與 dock,有一個說不就取消關閉 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) 3. `apply_stylesheet()` 套 qt_material 主題(預設 `dark_amber.xml`) 4. `showMaximized()` → `startup_setting()` → `app.exec()` @@ -448,7 +448,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 116 個 `test_*.py`、1973 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 117 個 `test_*.py`、1974 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 79cff2f8..7a61fbd9 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -2994,3 +2994,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: the five-window probe: 5 alive before, 0 after. `test_build_task_process.py` 14 passed. 1973 tests in 116 files. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `test/test_utils/test_build_task_process.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-184 · 2026-09-24 · Delete a tool tab once it is closed · #fix #ui + +- **What**: JEditor's `close_tab` closes the page and removes the tab, and `removeTab` keeps the page: it stays a child of the tab widget's stack. Every tool tab ever closed (a diagram with its scene, an SSH panel, the AI panels, every developer tool) stayed in memory until the IDE exited. Only the JupyterLab tab deleted itself (`WA_DeleteOnClose`). A docked tool is already deleted as its dock closes (`DestroyDock` sets `WA_DeleteOnClose`), so the widgets already expect it: those with a worker thread hand it to `let_run_out` in `closeEvent`. +- **Fix**: `PyBreezeMainWindow.close_tab` deletes the page (`deleteLater`) once JEditor has removed its tab. JEditor's own editor tabs are left to JEditor. +- **Tests**: new `test_closed_tool_tabs.py` (1, in a started IDE): a Hash tab and a diagram tab closed through `close_tab` are destroyed, and an editor tab is not. It fails with the delete taken out. +- **Result / numbers**: close, contract, JupyterLab, SSH teardown, IDE close, AI panel lifecycle and the new test, 89 passed; both IDE startup tests exit 0. 1974 tests in 117 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/test_closed_tool_tabs.py` (new), `architecture.md` §6, `architecture_explore.md` §4, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 662a4098..e6faec8c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-184 | 2026-09-24 | Delete a tool tab once it is closed | #fix #ui | [2026-09](2026-09.md) | | U-20260923-183 | 2026-09-24 | Free a run window once the main window lets go of it | #fix #executor | [2026-09](2026-09.md) | | U-20260923-182 | 2026-09-24 | Refuse a query byte that is not UTF-8 and a JSON key given twice instead of losing them | #fix #tools | [2026-09](2026-09.md) | | U-20260923-181 | 2026-09-24 | Show a response's status when its code is not a registered one | #fix #tools | [2026-09](2026-09.md) | @@ -264,4 +265,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 201 | +| [2026-09.md](2026-09.md) | 2026-09 | 202 | diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index 3d1373dd..d8e5ab63 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -120,10 +120,19 @@ def close_tab(self, index: int) -> None: JEditor asks about its own editor tabs only, and closes any other tab whatever its ``closeEvent`` says: a prompt or a diagram being edited was lost. A tab with a ``may_close()`` is asked first. + + A tool tab is deleted once it is closed. ``removeTab`` keeps the page, + so every tool tab ever closed stayed in memory until the IDE exited, + a diagram with its scene and an SSH panel among them; a docked one is + already deleted as its dock closes. JEditor's own editor tabs are left + to JEditor. """ - if not may_close(self.tab_widget.widget(index)): + widget = self.tab_widget.widget(index) + if not may_close(widget): return super().close_tab(index) + if widget is not None and not isinstance(widget, EditorWidget) and self.tab_widget.indexOf(widget) == -1: + widget.deleteLater() def _tool_tabs_may_close(self) -> bool: """Whether every PyBreeze tab and docked widget agrees to close (each may ask).""" diff --git a/test/test_utils/test_closed_tool_tabs.py b/test/test_utils/test_closed_tool_tabs.py new file mode 100644 index 00000000..892ca22c --- /dev/null +++ b/test/test_utils/test_closed_tool_tabs.py @@ -0,0 +1,41 @@ +"""A closed tool tab is deleted, not kept until the IDE exits. + +JEditor's close_tab closes the page and removes the tab; ``removeTab`` keeps +the page, so every tool tab ever closed -- a diagram and its scene, an SSH +panel -- stayed in memory for the rest of the session. +""" +from __future__ import annotations + +from test_utils.started_window import run_started_window + +_CLOSE_TABS = """ +from PySide6.QtCore import QCoreApplication, QEvent +from je_editor import EditorWidget +from pybreeze.pybreeze_ui.tools_gui.hash_gui import HashGUI +from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + +gone = [] +tabs = {"hash": HashGUI(window), "diagram": DiagramEditorWidget()} +for name, tab in tabs.items(): + window.tab_widget.addTab(tab, name) + tab.destroyed.connect(lambda *_, name=name: gone.append(name)) +editor = next(window.tab_widget.widget(i) for i in range(window.tab_widget.count()) + if isinstance(window.tab_widget.widget(i), EditorWidget)) +editor_gone = [] +editor.destroyed.connect(lambda *_: editor_gone.append(True)) + +for tab in tabs.values(): + window.close_tab(window.tab_widget.indexOf(tab)) + # Only this page's delete: others posted by the window's start are not ours + QCoreApplication.sendPostedEvents(tab, QEvent.Type.DeferredDelete) +app.processEvents() + +result = {"gone": sorted(gone), "editor_gone": editor_gone} +""" + + +def test_a_closed_tool_tab_is_deleted_and_an_editor_tab_is_left_to_jeditor(tmp_path): + seen = run_started_window(tmp_path, _CLOSE_TABS) + + assert seen["gone"] == ["diagram", "hash"] + assert seen["editor_gone"] == [] From 147753a98422ac98bdc1021b13c0c11917a6a03b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:39:32 +0800 Subject: [PATCH 221/312] Move the terminal escape handling out of the SSH widget into utils --- CLAUDE.md | 1 + architecture.md | 2 +- architecture_explore.md | 3 +- docs/updates/2026-09.md | 8 +++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_command_widget.py | 46 +------------- pybreeze/utils/terminal_text.py | 61 +++++++++++++++++++ test/test_utils/test_ansi_escape.py | 4 +- 8 files changed, 79 insertions(+), 49 deletions(-) create mode 100644 pybreeze/utils/terminal_text.py diff --git a/CLAUDE.md b/CLAUDE.md index 12529712..03a54469 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ pybreeze/ ├── network/ # url_validation (SSRF), public_http (pinned connections), http_client (capped reads) ├── exception/ # ITEException hierarchy ├── logging/ file_process/ app_dirs.py / subprocess_util.py + ├── terminal_text.py # Escape sequences and controls stripped from terminal output (SSH, run window) └── manager/package_manager/ # PackageManager — holds syntax_check_list ``` diff --git a/architecture.md b/architecture.md index c2fe3be2..007211b4 100644 --- a/architecture.md +++ b/architecture.md @@ -37,7 +37,7 @@ their output reaches the UI through Queue + QTimer. | `pybreeze/extend/process_executor/` | Subprocess isolation layer: `TaskProcessManager`, `process_executor_utils.py`, `FileRunnerProcess`, `queue_pump.py`, one sub-package per automation package, plus `test_pioneer/` and `prthinker/` | | `pybreeze/extend/mail_thunder_extend/`, `prthinker_extend/` | Post-test email hook; prthinker settings and argument assembly (pure logic) | | `pybreeze/extend_multi_language/` | PyBreeze's English and Traditional Chinese strings, merged into JEditor's dictionaries | -| `pybreeze/utils/` | Pure logic, no Qt or JEditor (`test_utils_has_no_qt.py` guards it): request parsing and codegen, HTTP tools, `network/` SSRF validation, pinned connections and capped reads, exceptions, logging, `app_dirs.py`, `subprocess_util.py` | +| `pybreeze/utils/` | Pure logic, no Qt or JEditor (`test_utils_has_no_qt.py` guards it): request parsing and codegen, HTTP tools, `network/` SSRF validation, pinned connections and capped reads, exceptions, logging, `app_dirs.py`, `subprocess_util.py`, `terminal_text.py` (terminal escapes stripped for the SSH terminal and the run window) | | `test/test_utils/` | Unit tests (pure logic and headless widgets). `test/unit_test/start_automation/` holds the launch tests | | `pyproject.toml`, `dev.toml` | Stable packaging (CI bumps and publishes it) and the unpublished dev packaging (keep its dependencies identical) | | `.github/workflows/` | `dev.yml`, `stable.yml` (unit tests on a Windows matrix, then SonarCloud) | diff --git a/architecture_explore.md b/architecture_explore.md index 3908d9a2..ce083c28 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | -| `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`strip_terminal_controls()`);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | +| `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | | `ssh_file_viewer_widget.py` (643) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | @@ -340,6 +340,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | 套件 | 內容 | |---|---| | `app_dirs.py` | `pybreeze_data_dir()` → `~/.pybreeze`,所有持久化資料的單一位置,建立時為 `0700`(`DATA_DIR_MODE`);`pybreeze_data_path()` 只給路徑、不建立 | +| `terminal_text.py` | 終端輸出的 escape 與控制字元:`strip_terminal_controls()`(CSI、OSC/DCS 等控制字串、nF、兩位元組 escape 剝除,backspace 套用,其餘 C0 丟掉)、`split_incomplete_escape()`(讀取切斷在 escape 中間時把尾巴留給下一次)。SSH terminal 與執行視窗共用 | | `subprocess_util.py` | `utf8_subprocess_env()`(釘 `PYTHONIOENCODING`,解 Windows cp950 亂碼)、`no_window_creationflags()`(`CREATE_NO_WINDOW`,避免 GUI 程式彈出黑窗) | | `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 7a61fbd9..8340eeee 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3003,3 +3003,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: close, contract, JupyterLab, SSH teardown, IDE close, AI panel lifecycle and the new test, 89 passed; both IDE startup tests exit 0. 1974 tests in 117 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/test_closed_tool_tabs.py` (new), `architecture.md` §6, `architecture_explore.md` §4, §18 - **Open items**: none. + +## U-20260923-185 · 2026-09-24 · Move the terminal escape handling out of the SSH widget into utils · #refactor #ssh + +- **What**: refactor, no behaviour change. The SSH terminal's escape-sequence patterns, `strip_terminal_controls` and the hold-back of an escape cut off at the end of a read move to the new pure module `pybreeze/utils/terminal_text.py`, so the run window can use the same handling (next entry) instead of its own, weaker copy. The hold-back is now a function, `split_incomplete_escape(text) -> (shown, pending)`; `TerminalDecoder` stays in the SSH widget (it also decodes bytes) and calls it. +- **Tests**: `test_ansi_escape.py` imports the pattern and `strip_terminal_controls` from the new module. +- **Result / numbers**: SSH and escape tests, 95 passed. 1974 tests in 117 files. `ruff check` clean. +- **Files**: `pybreeze/utils/terminal_text.py` (new), `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_ansi_escape.py`, `CLAUDE.md` (tree), `architecture.md` §2, `architecture_explore.md` §5, §7 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index e6faec8c..5d094b98 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-185 | 2026-09-24 | Move the terminal escape handling out of the SSH widget into utils | #refactor #ssh | [2026-09](2026-09.md) | | U-20260923-184 | 2026-09-24 | Delete a tool tab once it is closed | #fix #ui | [2026-09](2026-09.md) | | U-20260923-183 | 2026-09-24 | Free a run window once the main window lets go of it | #fix #executor | [2026-09](2026-09.md) | | U-20260923-182 | 2026-09-24 | Refuse a query byte that is not UTF-8 and a JSON key given twice instead of losing them | #fix #tools | [2026-09](2026-09.md) | @@ -265,4 +266,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 202 | +| [2026-09.md](2026-09.md) | 2026-09 | 203 | 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 b8ba3e44..9b090118 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -2,7 +2,6 @@ import codecs import os -import re import paramiko from PySide6.QtCore import QThread, Signal @@ -24,47 +23,11 @@ from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.utils.terminal_text import split_incomplete_escape, strip_terminal_controls # What closing a channel or a client can raise on a connection already broken CLOSE_ERRORS = (OSError, EOFError, paramiko.SSHException) -ANSI_ESCAPE_PATTERN = re.compile( - r'\x1B(?:' - # A control string -- OSC (titles, links), DCS, SOS, PM, APC -- ended by - # BEL or ST, or implicitly by the next ESC / the end - r'[\]PX^_][^\x07\x1B]*(?:\x07|\x1B\\)?' - r'|\[[0-?]*[ -/]*[@-~]' # CSI (colours, cursor movement) - r'|[ -/]+[0-~]' # nF: character sets (ESC ( B, from tput sgr0), ESC # 8 - r'|[0-~]' # Fp, Fe, Fs: ESC 7 / ESC 8, ESC = / ESC >, ESC M, ESC c - r')' -) - -# Control characters the view cannot show, once the sequences are gone: BEL -# and the rest of C0 but tab, newline and carriage return, and DEL. -# Backspace is applied first (_BACKSPACED) -_CONTROL_CHARACTER = re.compile('[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') -# A character and the backspace that takes it back (not across a line break) -_BACKSPACED = re.compile('[^\n\x08]\x08') - -# The end of a read that stops inside an escape sequence: a lone ESC, a CSI -# still waiting for its final byte, a control string still waiting for its -# terminator (or the second byte of ST), or a character-set escape still -# waiting for its final byte -_INCOMPLETE_ESCAPE = re.compile(r'\x1B(?:\[[0-?]*[ -/]*|[\]PX^_][^\x07\x1B]*\x1B?|[ -/]+)?\Z') -# Longest such tail held back for the next read; anything longer is shown as is -_MAX_PENDING_ESCAPE = 256 - - -def strip_terminal_controls(text: str) -> str: - """*text* without escape sequences, with backspaces applied and other controls dropped.""" - text = ANSI_ESCAPE_PATTERN.sub('', text) - while True: - applied = _BACKSPACED.sub('', text) - if applied == text: - break - text = applied - return _CONTROL_CHARACTER.sub('', text) - class TerminalDecoder: """Turn what the shell sends into text to show, one read at a time. @@ -86,12 +49,7 @@ def reset(self) -> None: def feed(self, data: bytes) -> str: """Return the text *data* completes, escape sequences removed.""" - text = self._pending + self._decoder.decode(data) - self._pending = "" - tail = _INCOMPLETE_ESCAPE.search(text) - if tail is not None and len(text) - tail.start() <= _MAX_PENDING_ESCAPE: - self._pending = text[tail.start():] - text = text[:tail.start()] + text, self._pending = split_incomplete_escape(self._pending + self._decoder.decode(data)) return strip_terminal_controls(text) diff --git a/pybreeze/utils/terminal_text.py b/pybreeze/utils/terminal_text.py new file mode 100644 index 00000000..e9a3269c --- /dev/null +++ b/pybreeze/utils/terminal_text.py @@ -0,0 +1,61 @@ +"""Terminal output as text a view can show. + +A program writing to a terminal colours its output and moves the cursor with +escape sequences, and rubs characters out with backspaces. A text view shows +them as ``\u2190[31m`` and boxes. Both the SSH terminal and the run window read +such output, in pieces that can end inside a sequence. +""" +from __future__ import annotations + +import re + +ANSI_ESCAPE_PATTERN = re.compile( + r'\x1B(?:' + # A control string -- OSC (titles, links), DCS, SOS, PM, APC -- ended by + # BEL or ST, or implicitly by the next ESC / the end + r'[\]PX^_][^\x07\x1B]*(?:\x07|\x1B\\)?' + r'|\[[0-?]*[ -/]*[@-~]' # CSI (colours, cursor movement) + r'|[ -/]+[0-~]' # nF: character sets (ESC ( B, from tput sgr0), ESC # 8 + r'|[0-~]' # Fp, Fe, Fs: ESC 7 / ESC 8, ESC = / ESC >, ESC M, ESC c + r')' +) + +# Control characters the view cannot show, once the sequences are gone: BEL +# and the rest of C0 but tab, newline and carriage return, and DEL. +# Backspace is applied first (_BACKSPACED) +_CONTROL_CHARACTER = re.compile('[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') +# A character and the backspace that takes it back (not across a line break) +_BACKSPACED = re.compile('[^\n\x08]\x08') + +# The end of a read that stops inside an escape sequence: a lone ESC, a CSI +# still waiting for its final byte, a control string still waiting for its +# terminator (or the second byte of ST), or a character-set escape still +# waiting for its final byte +_INCOMPLETE_ESCAPE = re.compile(r'\x1B(?:\[[0-?]*[ -/]*|[\]PX^_][^\x07\x1B]*\x1B?|[ -/]+)?\Z') +# Longest such tail held back for the next read; anything longer is shown as is +_MAX_PENDING_ESCAPE = 256 + + +def strip_terminal_controls(text: str) -> str: + """*text* without escape sequences, with backspaces applied and other controls dropped.""" + text = ANSI_ESCAPE_PATTERN.sub('', text) + while True: + applied = _BACKSPACED.sub('', text) + if applied == text: + break + text = applied + return _CONTROL_CHARACTER.sub('', text) + + +def split_incomplete_escape(text: str) -> tuple[str, str]: + """*text* as what can be shown now and the unfinished escape sequence at its end. + + A read ends wherever a buffer did, so it can stop inside a sequence; + stripped on its own, the sequence's tail showed as text. The unfinished + part, if any and no longer than ``_MAX_PENDING_ESCAPE``, is returned to be + put in front of the next read. + """ + tail = _INCOMPLETE_ESCAPE.search(text) + if tail is not None and len(text) - tail.start() <= _MAX_PENDING_ESCAPE: + return text[:tail.start()], text[tail.start():] + return text, "" diff --git a/test/test_utils/test_ansi_escape.py b/test/test_utils/test_ansi_escape.py index e1db6084..59a50734 100644 --- a/test/test_utils/test_ansi_escape.py +++ b/test/test_utils/test_ansi_escape.py @@ -2,7 +2,7 @@ import pytest -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import ANSI_ESCAPE_PATTERN +from pybreeze.utils.terminal_text import ANSI_ESCAPE_PATTERN def _strip(text: str) -> str: @@ -61,6 +61,6 @@ class TestControlCharacters: ("x\x00y\x7fz", "xyz"), ]) def test_controls_are_applied_or_dropped(self, raw, expected): - from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import strip_terminal_controls + from pybreeze.utils.terminal_text import strip_terminal_controls assert strip_terminal_controls(raw) == expected From 8a866e92072464aa11edaca88866253c3dfe0ddc Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:43:57 +0800 Subject: [PATCH 222/312] Strip escape sequences a read cuts in two, and every two-byte escape, from the run window --- architecture_explore.md | 4 +-- docs/updates/2026-09.md | 11 ++++++++ docs/updates/README.md | 3 ++- .../extend/process_executor/queue_pump.py | 9 ++++--- .../show_code_window/code_window.py | 25 +++-------------- test/test_utils/test_code_window.py | 27 ++++++++++++++++++- 6 files changed, 50 insertions(+), 29 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index ce083c28..eba2643b 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -158,7 +158,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 - `output_queue()` — 每條管線的 queue 最多 `MAX_QUEUED_MESSAGES`(10,000)則;滿了 reader 就等(每 0.2 秒看一次 `keep_reading`),子行程寫管線也跟著等,跑得跟視窗顯示一樣快,像終端機;以前不設上限,印個不停的腳本會一直吃記憶體,按 Stop 後再一口氣全倒進視窗 - `ReaderGrace` / `any_alive()` / `OUTPUT_STILL_HELD_NOTE` — 子行程結束後 reader 還能讀多久(`READER_GRACE_SECONDS = 2.0`,從結束後第一個 tick 起算)。管線要等最後一個握著它的行程結束才會 EOF,子行程開的行程沒轉向輸出時會一直握著;以前兩個執行器在 UI 執行緒上各 join 2 秒,IDE 卡 4 秒還是丟掉之後的輸出。現在由 pump 逐 tick 詢問,時間到就結束執行並在視窗註明 -- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。終端機控制碼(CSI 顏色與游標移動、OSC 標題與超連結)和 tab、換行、`\r` 以外的控制字元先拿掉(`strip_terminal_codes()`),`\r\n` 是換行,單獨的 `\r` 像終端機一樣回到行首、由後面的文字取代這一行(`_insert_rewinding()`;結尾的 `\r` 等下一段來才回捲,跑完的進度條不會被清掉);換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 +- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。終端機控制碼(CSI 顏色與游標移動、OSC 等控制字串、`ESC ( B` 這類 nF 與其他兩位元組 escape)先拿掉、backspace 套用到前一個字元、tab、換行、`\r` 以外的控制字元丟掉(與 SSH terminal 共用 `utils/terminal_text.strip_terminal_controls()`;讀取切斷在 escape 中間時,reader 把尾巴留給下一段,`queue_pump` 的 `split_incomplete_escape()`),`\r\n` 是換行,單獨的 `\r` 像終端機一樣回到行首、由後面的文字取代這一行(`_insert_rewinding()`;結尾的 `\r` 等下一段來才回捲,跑完的進度條不會被清掉);換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 --- @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 117 個 `test_*.py`、1974 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 117 個 `test_*.py`、1976 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 8340eeee..8405f06b 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3011,3 +3011,14 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: SSH and escape tests, 95 passed. 1974 tests in 117 files. `ruff check` clean. - **Files**: `pybreeze/utils/terminal_text.py` (new), `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_ansi_escape.py`, `CLAUDE.md` (tree), `architecture.md` §2, `architecture_explore.md` §5, §7 - **Open items**: none. + +## U-20260923-186 · 2026-09-24 · Strip escape sequences a read cuts in two, and every two-byte escape, from the run window · #fix #executor + +- **What**: the run window stripped escape sequences one piece of output at a time, and the reader hands the output over in pieces of up to 1024 bytes. A colour code cut at a piece boundary (`\x1b[3` + `2m`) matched nothing; the control-character pass removed only the ESC and `[32m` appeared in the window. Out of 300 lines of `pytest --color=yes`-style output read in 7-byte pieces, 86 showed such junk. The window's own pattern also missed two-byte escapes outside `@`-`_`: `tput sgr0`'s `ESC ( B`, and `ESC 7` / `ESC 8` (save and restore the cursor) came out as `(B`, `7`, `8`. The SSH terminal already handled all of this. +- **Fix**: + - The run window strips with `utils/terminal_text.strip_terminal_controls` (moved there in U-20260923-185) instead of its own pattern: every escape the SSH terminal strips, and a backspace takes back the character before it, as in a terminal (it used to be dropped, leaving both characters). + - `read_stream_into_queue` holds an escape cut off at the end of a piece for the next one (`split_incomplete_escape`), as it holds a trailing `\r`. +- **Tests**: `test_code_window.py`: the backspace case now expects the character taken back; +2 (two-byte escapes; 300 coloured lines read in 7-byte pieces show no escape text). +- **Result / numbers**: every test that touches the run window or the reader, 197 passed. 1976 tests in 117 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/extend/process_executor/queue_pump.py`, `test/test_utils/test_code_window.py`, `architecture_explore.md` §5, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 5d094b98..4ac3ac57 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-186 | 2026-09-24 | Strip escape sequences a read cuts in two, and every two-byte escape, from the run window | #fix #executor | [2026-09](2026-09.md) | | U-20260923-185 | 2026-09-24 | Move the terminal escape handling out of the SSH widget into utils | #refactor #ssh | [2026-09](2026-09.md) | | U-20260923-184 | 2026-09-24 | Delete a tool tab once it is closed | #fix #ui | [2026-09](2026-09.md) | | U-20260923-183 | 2026-09-24 | Free a run window once the main window lets go of it | #fix #executor | [2026-09](2026-09.md) | @@ -266,4 +267,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 203 | +| [2026-09.md](2026-09.md) | 2026-09 | 204 | diff --git a/pybreeze/extend/process_executor/queue_pump.py b/pybreeze/extend/process_executor/queue_pump.py index 9c7ca8bf..4d08d94b 100644 --- a/pybreeze/extend/process_executor/queue_pump.py +++ b/pybreeze/extend/process_executor/queue_pump.py @@ -17,6 +17,7 @@ from typing import IO from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.utils.terminal_text import split_incomplete_escape # Drain up to this many messages per timer tick. Draining one line per ~100 ms # tick caps throughput at ~10 lines/s and makes chatty scripts crawl; batching @@ -86,7 +87,7 @@ def read_stream_into_queue( ending. The character is carried over by an incremental decoder, and a trailing ``\\r`` is held for the next piece: decoded on its own, the character showed as replacement marks, and the split line ending as a - blank line. + blank line. An escape sequence cut off at the end is held the same way. """ decoder = _decoder_for(encoding) held = "" @@ -102,8 +103,10 @@ def read_stream_into_queue( break if isinstance(line, bytes): line = decoder.decode(line) - line, held = held + line, "" - if line.endswith("\r"): + # An escape sequence cut off at the end waits for the rest: stripped + # in two pieces, "\x1b[3" + "2m" showed "[32m" in the window + line, held = split_incomplete_escape(held + line) + if not held and line.endswith("\r"): line, held = line[:-1], "\r" if line and not _put(target_queue, line, keep_reading): return diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index 3e06758e..80ee6df9 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -1,6 +1,5 @@ from __future__ import annotations -import re from typing import TYPE_CHECKING from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict @@ -8,6 +7,8 @@ from PySide6.QtGui import QGuiApplication, QTextCharFormat, QTextCursor from PySide6.QtWidgets import QWidget, QGridLayout, QPlainTextEdit, QScrollArea +from pybreeze.utils.terminal_text import strip_terminal_controls + if TYPE_CHECKING: from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess from pybreeze.extend.process_executor.python_task_process_manager import TaskProcessManager @@ -21,26 +22,6 @@ MAX_OUTPUT_BLOCKS = 10000 -# A terminal's control sequences: CSI (colours, cursor moves: ESC [ ... final -# byte), OSC (titles, links: ESC ] ... BEL or ESC \), and any other two-byte -# escape -_TERMINAL_SEQUENCE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]") -# Control characters a text view cannot show: all of C0 but tab, newline and -# carriage return (which _insert_rewinding handles), and DEL -_CONTROL_CHARACTER = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") - - -def strip_terminal_codes(text: str) -> str: - """*text* without the terminal's control sequences and control characters. - - A script that colours its output (``colorama``, ``rich``, ``pytest - --color=yes``, ``FORCE_COLOR``) filled the window with ``←[31m``; a - backspace or form feed showed as a box. Carriage returns are left for - :func:`_insert_rewinding`. - """ - return _CONTROL_CHARACTER.sub("", _TERMINAL_SEQUENCE.sub("", text)) - - def _insert_rewinding(cursor: QTextCursor, text: str, text_format: QTextCharFormat) -> None: """Insert *text* at *cursor*, a lone ``\\r`` going back to the start of the line. @@ -157,6 +138,6 @@ def append_output(self, text: str, is_error: bool = False, *, own_line: bool = F text_format = QTextCharFormat() color_key = "error_output_color" if is_error else "normal_output_color" text_format.setForeground(actually_color_dict.get(color_key)) - _insert_rewinding(cursor, strip_terminal_codes(text), text_format) + _insert_rewinding(cursor, strip_terminal_controls(text), text_format) if follow_output: scroll_bar.setValue(scroll_bar.maximum()) diff --git a/test/test_utils/test_code_window.py b/test/test_utils/test_code_window.py index c749b7de..9e232325 100644 --- a/test/test_utils/test_code_window.py +++ b/test/test_utils/test_code_window.py @@ -289,7 +289,10 @@ class TestTerminalCodes: ("\x1b]0;window title\x07after\n", "after\n"), ("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\\n", "link\n"), ("\x1b[2K\x1b[1Gprogress 50%\n", "progress 50%\n"), - ("a\bb\x0cc\x00\tt\n", "abc\tt\n"), + # A backspace takes the character before it back, as in a terminal + ("a\bb\x0cc\x00\tt\n", "bc\tt\n"), + # Two-byte escapes: tput sgr0's character-set reset, save/restore cursor + ("a\x1b(Bb\x1b7c\x1b8d\n", "abcd\n"), ("\u4e2d\u6587 stays\n", "\u4e2d\u6587 stays\n"), ]) def test_what_the_window_shows(self, qt_app, written, shown): @@ -300,6 +303,28 @@ def test_what_the_window_shows(self, qt_app, written, shown): assert window.code_result.toPlainText() == shown + def test_a_colour_code_cut_between_two_reads_is_not_shown(self, qt_app): + # The reader hands output over in pieces; stripped one by one, + # "\x1b[3" + "2m" showed "[32m" + import io + from queue import Queue + + from pybreeze.extend.process_executor.queue_pump import read_stream_into_queue + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + written = b"".join(b"\x1b[32mPASSED test_%d\x1b[0m\n" % number for number in range(300)) + pieces: Queue = Queue() + read_stream_into_queue( + io.BufferedReader(io.BytesIO(written), buffer_size=7), pieces, + buffer_size=7, encoding="utf-8", keep_reading=lambda: True) + window = CodeWindow() + while not pieces.empty(): + window.append_output(pieces.get()) + + shown = window.code_result.toPlainText() + assert "[" not in shown + assert shown.splitlines() == [f"PASSED test_{number}" for number in range(300)] + def test_a_coloured_progress_bar_still_redraws_its_line(self, qt_app): from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow From 50c80aacddafe8ebce97298d4d393a8c6ca6a8d3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:45:33 +0800 Subject: [PATCH 223/312] Show a plugin's About text as text, in a box that belongs to the main window --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../menu/plugin_menu/build_plugin_menu.py | 17 +++++++++++------ test/test_utils/test_plugin_menu.py | 16 +++++++++++++++- 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index eba2643b..8b14efb7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 117 個 `test_*.py`、1976 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 117 個 `test_*.py`、1977 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 8405f06b..a787e47d 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3022,3 +3022,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: every test that touches the run window or the reader, 197 passed. 1976 tests in 117 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/extend/process_executor/queue_pump.py`, `test/test_utils/test_code_window.py`, `architecture_explore.md` §5, §18 - **Open items**: none. + +## U-20260923-187 · 2026-09-24 · Show a plugin's About text as text, in a box that belongs to the main window · #fix #plugin + +- **What**: the plugin menu's About box put the plugin's name, version and author into `QMessageBox.setText` as they were, so Qt read markup in them (a plugin named `Go` showed bold, an `` was loaded), against the Security › Qt rule for text from a file. The box also had no parent and could open behind the IDE. +- **Fix**: the text goes through `plain_text.as_text()`, and `_make_about_callback` takes the main window as the box's parent. +- **Tests**: `test_plugin_menu.py` +1 (markup in the name and author is shown as text); the existing About test passes the parent. +- **Result / numbers**: plugin menu and JEditor contract tests, 82 passed. 1977 tests in 117 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py`, `test/test_utils/test_plugin_menu.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 4ac3ac57..8c020e74 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-187 | 2026-09-24 | Show a plugin's About text as text, in a box that belongs to the main window | #fix #plugin | [2026-09](2026-09.md) | | U-20260923-186 | 2026-09-24 | Strip escape sequences a read cuts in two, and every two-byte escape, from the run window | #fix #executor | [2026-09](2026-09.md) | | U-20260923-185 | 2026-09-24 | Move the terminal escape handling out of the SSH widget into utils | #refactor #ssh | [2026-09](2026-09.md) | | U-20260923-184 | 2026-09-24 | Delete a tool tab once it is closed | #fix #ui | [2026-09](2026-09.md) | @@ -267,4 +268,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 204 | +| [2026-09.md](2026-09.md) | 2026-09 | 205 | diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py index 986a75bf..5e96dd7c 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py @@ -11,6 +11,7 @@ from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import ( plugin_text, run_config_suffixes, run_current_file_with, ) +from pybreeze.pybreeze_ui.plain_text import as_text from pybreeze.utils.logging.logger import pybreeze_logger if TYPE_CHECKING: @@ -68,7 +69,7 @@ def _add_plugin_entry(ui_we_want_to_set: PyBreezeMainWindow, meta: dict) -> None # Plugins without run config (e.g. translation), show about only about_action = QAction(plugin_name, ui_we_want_to_set.plugin_menu) about_action.triggered.connect( - _make_about_callback(plugin_name, plugin_version, plugin_author) + _make_about_callback(ui_we_want_to_set, plugin_name, plugin_version, plugin_author) ) ui_we_want_to_set.plugin_menu.addAction(about_action) return @@ -82,7 +83,7 @@ def _add_plugin_entry(ui_we_want_to_set: PyBreezeMainWindow, meta: dict) -> None sub_menu, ) about_action.triggered.connect( - _make_about_callback(plugin_name, plugin_version, plugin_author) + _make_about_callback(ui_we_want_to_set, plugin_name, plugin_version, plugin_author) ) sub_menu.addAction(about_action) sub_menu.addSeparator() @@ -118,19 +119,23 @@ def _open_plugin_browser(ui_we_want_to_set: PyBreezeMainWindow) -> None: ) -def _make_about_callback(name: str, version: str, author: str): +def _make_about_callback(parent: PyBreezeMainWindow, name: str, version: str, author: str): """ 建立顯示插件資訊的回呼函式。 Create a callback to show plugin info dialog. + + The plugin's own name, version and author are shown as text: Qt read + markup in them. The box belongs to the main window; with no parent it + could open behind it. """ def callback(): - message_box = QMessageBox() + message_box = QMessageBox(parent) message_box.setWindowTitle(name) - message_box.setText( + message_box.setText(as_text( f"{name}\n" f"Version: {version}\n" f"Author: {author}" - ) + )) message_box.exec() return callback diff --git a/test/test_utils/test_plugin_menu.py b/test/test_utils/test_plugin_menu.py index dc67a159..5b288667 100644 --- a/test/test_utils/test_plugin_menu.py +++ b/test/test_utils/test_plugin_menu.py @@ -262,10 +262,24 @@ def test_the_about_dialog_names_version_and_author(self, app, monkeypatch): shown: list[str] = [] monkeypatch.setattr( plugin_menu.QMessageBox, "exec", lambda self: shown.append(self.text())) - plugin_menu._make_about_callback("Go", "2.1", "someone")() + plugin_menu._make_about_callback(None, "Go", "2.1", "someone")() assert "2.1" in shown[0] assert "someone" in shown[0] + def test_the_about_dialog_shows_markup_as_text(self, app, monkeypatch): + # A plugin's name or author went to the box as markup, and all + from PySide6.QtGui import QTextDocument + + shown: list[str] = [] + monkeypatch.setattr( + plugin_menu.QMessageBox, "exec", lambda self: shown.append(self.text())) + plugin_menu._make_about_callback(None, "Go", "1", "")() + + document = QTextDocument() + document.setHtml(shown[0]) + assert "Go" in document.toPlainText() + assert "" in document.toPlainText() + def test_a_run_callback_ignores_a_non_editor_tab(self, window, monkeypatch): window.tab_widget.addTab(QWidget(), "not an editor") window.tab_widget.setCurrentIndex(0) From dbc78ebb98b057b6da6ad8674dfebfaa58624b22 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:46:12 +0800 Subject: [PATCH 224/312] Record that JSON and YAML keyword highlighting waits on JEditor --- progress.md | 1 + 1 file changed, 1 insertion(+) diff --git a/progress.md b/progress.md index 324c3311..bffed28e 100644 --- a/progress.md +++ b/progress.md @@ -15,3 +15,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. - **#102** [BLOCKED] A report mail can wait forever: je_mail_thunder's `SMTPWrapper(host, port)` passes no timeout to `SMTP_SSL`, so a mail server that accepts the connection and then stalls holds the report-mail thread (`extend/mail_thunder_extend/mail_thunder_setting.py`, `send_report`) with no end, and the run window never says whether the report went. Needs a `timeout` argument in MailThunder (`je_mail_thunder/smtp/smtp_wrapper.py:20`); then pass 30 s here. +- **#103** [BLOCKED] PyBreeze's automation keywords are never highlighted: `syntax_extend_package` registers them for `.json`, `.yml` and `.yaml` (`pybreeze_ui/syntax/syntax_extend.py`), but JEditor has built-in rules for those suffixes, so `CodeEditor.reset_highlighter` gets a `GenericHighlighter` from `highlighter_for`, and only `PythonHighlighter` reads the plugin registry (`je_editor/pyside_ui/code/syntax/generic_syntax.py:124`, `code_edit_plaintext.py:423-425` in `D:\Codes\JEDITOR` at d372c35). Needs JEditor's generic highlighter to add a registered language's words for the suffix; the `architecture.md` §6 contract changes with it. From 60d376501473fee15b944c73758362e0f130e673 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:48:52 +0800 Subject: [PATCH 225/312] Delete message boxes and the prthinker settings dialog once they are answered --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_host_key_policy.py | 1 + .../prthinker_menu/build_prthinker_menu.py | 3 + .../build_test_pioneer_menu.py | 2 + .../build_automation_install_menu.py | 2 + .../menu/plugin_menu/build_plugin_menu.py | 2 + .../menu/plugin_menu/build_run_with_menu.py | 2 + .../test_answered_boxes_are_deleted.py | 61 +++++++++++++++++++ 10 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 test/test_utils/test_answered_boxes_are_deleted.py diff --git a/architecture_explore.md b/architecture_explore.md index 8b14efb7..237e4796 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 117 個 `test_*.py`、1977 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 118 個 `test_*.py`、1979 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index a787e47d..05c71e25 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3031,3 +3031,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: plugin menu and JEditor contract tests, 82 passed. 1977 tests in 117 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py`, `test/test_utils/test_plugin_menu.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-188 · 2026-09-24 · Delete message boxes and the prthinker settings dialog once they are answered · #fix #ui + +- **What**: the message boxes the menus build as objects (plugin About, Run with's suffix mismatch, prthinker's notices, TestPioneer's "choose a YAML file", the prthinker install's source-folder notice, the SSH host-key question) and the prthinker settings dialog were created with the main window (or the SSH panel) as their parent and never deleted. Each one shown stayed a child of the window until the IDE exited. +- **Fix**: each is given `WA_DeleteOnClose`; Qt deletes it once `exec()` returns (checked on Qt 6 for `QMessageBox` and `QDialog`). Nothing reads the box after `exec()`: the host-key question takes its answer from `exec()`'s return value. +- **Tests**: new `test_answered_boxes_are_deleted.py` (2): every `QMessageBox(...)` or `PRThinkerSettingDialog(...)` the package builds sets the attribute on the next lines, and an answered About box leaves no child behind (it fails with the attribute taken out). +- **Result / numbers**: plugin, install, SSH host-key, prthinker and TestPioneer tests, 163 passed, 14 skipped. 1979 tests in 118 files. `ruff check` clean. +- **Files**: `connect_gui/ssh/ssh_host_key_policy.py`, `menu/automation_menu/prthinker_menu/build_prthinker_menu.py`, `menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py`, `menu/install_menu/automation_menu/build_automation_install_menu.py`, `menu/plugin_menu/build_plugin_menu.py`, `menu/plugin_menu/build_run_with_menu.py`, `test/test_utils/test_answered_boxes_are_deleted.py` (new), `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 8c020e74..10b348e7 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-188 | 2026-09-24 | Delete message boxes and the prthinker settings dialog once they are answered | #fix #ui | [2026-09](2026-09.md) | | U-20260923-187 | 2026-09-24 | Show a plugin's About text as text, in a box that belongs to the main window | #fix #plugin | [2026-09](2026-09.md) | | U-20260923-186 | 2026-09-24 | Strip escape sequences a read cuts in two, and every two-byte escape, from the run window | #fix #executor | [2026-09](2026-09.md) | | U-20260923-185 | 2026-09-24 | Move the terminal escape handling out of the SSH widget into utils | #refactor #ssh | [2026-09](2026-09.md) | @@ -268,4 +269,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 205 | +| [2026-09.md](2026-09.md) | 2026-09 | 206 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py index a8d9b664..0e3bd4ec 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py @@ -189,6 +189,7 @@ def _show(self, parent: QWidget | None, title: str, message: str) -> None: pybreeze_logger.warning("SSH host key question dropped: its panel was closed") return box = QMessageBox(parent) + box.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) box.setIcon(QMessageBox.Icon.Warning) box.setWindowTitle(title) box.setText(message) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/prthinker_menu/build_prthinker_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/prthinker_menu/build_prthinker_menu.py index 0eeaffcc..aae5bf18 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/prthinker_menu/build_prthinker_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/prthinker_menu/build_prthinker_menu.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING +from PySide6.QtCore import Qt from PySide6.QtGui import QAction from PySide6.QtWidgets import QInputDialog, QMessageBox from je_editor import EditorWidget, language_wrapper @@ -111,6 +112,7 @@ def _review_pull_request(ui_we_want_to_set: PyBreezeMainWindow) -> None: def _open_setting(ui_we_want_to_set: PyBreezeMainWindow) -> None: """開設定視窗 / Open the settings window.""" dialog = PRThinkerSettingDialog(ui_we_want_to_set) + dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) dialog.exec() @@ -118,6 +120,7 @@ def _tell(ui_we_want_to_set: PyBreezeMainWindow, message_key: str) -> None: """把一句話說給使用者聽 / Put one sentence in front of the user.""" lang = language_wrapper.language_word_dict messagebox = QMessageBox(ui_we_want_to_set) + messagebox.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) messagebox.setWindowTitle(lang.get("prthinker_menu_label")) messagebox.setText(lang.get(message_key)) messagebox.exec() diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py index 1f87e78f..94b47eff 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import TYPE_CHECKING +from PySide6.QtCore import Qt from PySide6.QtGui import QAction from PySide6.QtWidgets import QFileDialog, QMessageBox from je_editor import language_wrapper @@ -99,6 +100,7 @@ def check_file(ui_we_want_to_set: PyBreezeMainWindow): show_messagebox = True if show_messagebox: messagebox = QMessageBox(ui_we_want_to_set) + messagebox.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) messagebox.setWindowTitle(language_wrapper.language_word_dict.get("test_pioneer_not_choose_yaml")) messagebox.setText(language_wrapper.language_word_dict.get("test_pioneer_not_choose_yaml")) messagebox.exec() diff --git a/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py b/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py index 0243d5c3..05ea8358 100644 --- a/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py +++ b/pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from PySide6.QtCore import Qt from PySide6.QtGui import QAction from PySide6.QtWidgets import QFileDialog, QMessageBox from je_editor import language_wrapper @@ -110,6 +111,7 @@ def install_prthinker(ui_we_want_to_set: PyBreezeMainWindow) -> None: target = install_target(chosen or "") if not target: messagebox = QMessageBox(ui_we_want_to_set) + messagebox.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) messagebox.setWindowTitle( language_wrapper.language_word_dict.get("install_menu_prthinker")) messagebox.setText( diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py index 5e96dd7c..3a4fefc9 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from PySide6.QtCore import Qt from PySide6.QtGui import QAction from PySide6.QtWidgets import QMessageBox @@ -130,6 +131,7 @@ def _make_about_callback(parent: PyBreezeMainWindow, name: str, version: str, au """ def callback(): message_box = QMessageBox(parent) + message_box.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) message_box.setWindowTitle(name) message_box.setText(as_text( f"{name}\n" diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index 688cfc9a..caac811b 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import TYPE_CHECKING +from PySide6.QtCore import Qt from PySide6.QtGui import QAction from PySide6.QtWidgets import QMessageBox @@ -128,6 +129,7 @@ def run_current_file_with(main_window: PyBreezeMainWindow, run_config: dict) -> supported = run_config_suffixes(run_config) if supported and suffix not in supported: msg = QMessageBox(main_window) + msg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) msg.setWindowTitle(language_wrapper.language_word_dict.get("run_with_menu_label")) msg.setText( language_wrapper.language_word_dict.get("run_with_suffix_mismatch").format( diff --git a/test/test_utils/test_answered_boxes_are_deleted.py b/test/test_utils/test_answered_boxes_are_deleted.py new file mode 100644 index 00000000..a1b521bf --- /dev/null +++ b/test/test_utils/test_answered_boxes_are_deleted.py @@ -0,0 +1,61 @@ +"""A message box or dialog made for the main window is deleted once answered. + +Built with the main window as its parent and never deleted, each one stayed a +child of the window until the IDE exited, one more every time it was shown. +""" +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest # noqa: E402 + +import pybreeze # noqa: E402 + +_BOX = re.compile(r"^(\s*)(\w+) = (?:QMessageBox|PRThinkerSettingDialog)\(", re.M) + + +@pytest.fixture(scope="module") +def app(): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def test_every_box_built_in_the_package_is_deleted_on_close(): + missing = [] + for path in Path(pybreeze.__file__).parent.rglob("*.py"): + text = path.read_text(encoding="utf-8") + for match in _BOX.finditer(text): + following = text[match.end():].split("\n", 3)[:3] + wanted = f"{match.group(2)}.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)" + if not any(wanted in line for line in following): + missing.append(f"{path.name}: {match.group(2)}") + assert not missing, f"Boxes kept after they are answered: {missing}" + + +def test_an_answered_about_box_is_gone(app): + from PySide6.QtCore import QCoreApplication, QEvent, QTimer + from PySide6.QtWidgets import QApplication, QWidget + + from pybreeze.pybreeze_ui.menu.plugin_menu import build_plugin_menu + + window = QWidget() + + def answer() -> None: + QApplication.activeModalWidget().accept() + + QTimer.singleShot(50, answer) + build_plugin_menu._make_about_callback(window, "Go", "1", "someone")() + # Only the box's delete: others still posted belong to earlier tests + for child in window.children(): + QCoreApplication.sendPostedEvents(child, QEvent.Type.DeferredDelete) + + assert window.children() == [] From fde8c38378d6873a07c1d8bf5e2b26851ccb9cc3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 06:54:19 +0800 Subject: [PATCH 226/312] Let the TestPioneer menu's stand-in message box take the delete-on-close attribute --- docs/updates/2026-09.md | 8 ++++++++ docs/updates/README.md | 3 ++- test/test_utils/test_testpioneer_menu.py | 3 +++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 05c71e25..25cdc96c 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3040,3 +3040,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: plugin, install, SSH host-key, prthinker and TestPioneer tests, 163 passed, 14 skipped. 1979 tests in 118 files. `ruff check` clean. - **Files**: `connect_gui/ssh/ssh_host_key_policy.py`, `menu/automation_menu/prthinker_menu/build_prthinker_menu.py`, `menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py`, `menu/install_menu/automation_menu/build_automation_install_menu.py`, `menu/plugin_menu/build_plugin_menu.py`, `menu/plugin_menu/build_run_with_menu.py`, `test/test_utils/test_answered_boxes_are_deleted.py` (new), `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-189 · 2026-09-24 · Let the TestPioneer menu's stand-in message box take the delete-on-close attribute · #test + +- **What**: U-20260923-188 gave the TestPioneer menu's message box `WA_DeleteOnClose`, and `test_testpioneer_menu.py` replaces `QMessageBox` with a stand-in that had no `setAttribute`, so `test_another_file_is_refused` failed with `AttributeError`. That file was not among the tests run for U-20260923-188 (its name does not match `test_test_pioneer*`); the full suite found it. +- **Fix**: the stand-in accepts `setAttribute`. Nothing else in the suite replaces `QMessageBox` wholesale. +- **Result / numbers**: `test_testpioneer_menu.py` 6 passed. Full suite before this fix: first half 1083 passed; second half 880 passed, 15 skipped, 1 failed (this test). 1979 tests in 118 files. +- **Files**: `test/test_utils/test_testpioneer_menu.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 10b348e7..91398efd 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-189 | 2026-09-24 | Let the TestPioneer menu's stand-in message box take the delete-on-close attribute | #test | [2026-09](2026-09.md) | | U-20260923-188 | 2026-09-24 | Delete message boxes and the prthinker settings dialog once they are answered | #fix #ui | [2026-09](2026-09.md) | | U-20260923-187 | 2026-09-24 | Show a plugin's About text as text, in a box that belongs to the main window | #fix #plugin | [2026-09](2026-09.md) | | U-20260923-186 | 2026-09-24 | Strip escape sequences a read cuts in two, and every two-byte escape, from the run window | #fix #executor | [2026-09](2026-09.md) | @@ -269,4 +270,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 206 | +| [2026-09.md](2026-09.md) | 2026-09 | 207 | diff --git a/test/test_utils/test_testpioneer_menu.py b/test/test_utils/test_testpioneer_menu.py index 15f25f3e..9d7a42a0 100644 --- a/test/test_utils/test_testpioneer_menu.py +++ b/test/test_utils/test_testpioneer_menu.py @@ -38,6 +38,9 @@ def setWindowTitle(self, _title) -> None: def setText(self, _text) -> None: """Text is not checked.""" + def setAttribute(self, _attribute) -> None: + """Deleted once answered (test_answered_boxes_are_deleted.py checks it).""" + def exec(self) -> None: state["told"] += 1 From 44ee94daf85b76a5d171eae7554f9954bb0a7734 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:01:07 +0800 Subject: [PATCH 227/312] Free the CoT review, Skill send, Diff and SSH panels once they are closed --- CLAUDE.md | 3 +- architecture_explore.md | 4 +- docs/updates/2026-09.md | 16 +++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_command_widget.py | 12 +- .../connect_gui/ssh/ssh_connect_thread.py | 4 +- .../connect_gui/ssh/ssh_host_key_policy.py | 15 ++- .../code_review/cot_code_review_gui.py | 11 +- .../extend_ai_gui/skills/skills_send_gui.py | 11 +- pybreeze/pybreeze_ui/thread_keeper.py | 21 ++++ pybreeze/pybreeze_ui/tools_gui/diff_gui.py | 7 +- .../test_closed_panels_are_freed.py | 112 ++++++++++++++++++ 12 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 test/test_utils/test_closed_panels_are_freed.py diff --git a/CLAUDE.md b/CLAUDE.md index 03a54469..bb8e9bab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ pybreeze/ │ ├── connect_gui/ # ssh/ (terminal + SFTP tree), url/ (AI review client) │ ├── jupyter_lab_gui/ # JupyterLab tab (QWebEngineView) │ ├── show_code_window/ # CodeWindow — subprocess output display -│ ├── thread_keeper.py # let_run_out: a worker QThread outlives its closed widget +│ ├── thread_keeper.py # let_run_out: a worker QThread outlives its closed widget; if_alive: weak slots │ ├── gui_thread_gc.py # Garbage collected on a GUI-thread timer, never on a worker │ ├── plain_text.py # as_text: server/file text shown in message boxes as text, not markup │ ├── closing.py # may_close / AskingDock: tabs and docks with unsaved work are asked first @@ -87,6 +87,7 @@ ruff check pybreeze/ # before committing non-trivia - Python 3.10+: `X | Y` unions, `from __future__ import annotations`, `TYPE_CHECKING` guard for hint-only imports - **Never update UI from a worker thread** — Queue + QTimer (see `TaskProcessManager`) or Qt Signal/Slot +- A slot on a thread (or any object) the widget keeps must not hold the widget: connect a bound method, or `thread_keeper.if_alive(weakref.ref(self), ...)`. A lambda capturing `self` there is a cycle through Qt that Python's collector cannot see, and the closed widget is never freed - Automatic garbage collection is off in the IDE: `start_editor()` collects on a GUI-thread timer (`gui_thread_gc.py`), because a collection on a worker destroys Qt objects there. Never call `gc.enable()` - Custom exceptions inherit from `ITEException`; log via `pybreeze_logger` (lazy `%s` formatting, never `print()`) - Plugin API: `register_programming_language()` / `register_natural_language()` from `je_editor.plugins` diff --git a/architecture_explore.md b/architecture_explore.md index 237e4796..76f99838 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -425,7 +425,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 垃圾回收只在 UI 執行緒跑:`start_editor()` 裝上 `gui_thread_gc.GuiThreadGarbageCollector`,關掉自動回收,改由 UI 執行緒上每秒一次的 QTimer 照直譯器自己的門檻回收(單元測試由 `test/test_utils/conftest.py` 做同樣的事)。自動回收會在任何配置超過門檻的執行緒跑,worker 上的一次回收曾把 UI 執行緒建立的 Qt 物件在 worker 上銷毀,計時器停不掉,之後 UI 執行緒把計時器事件送給已釋放的物件而崩潰。不要在 IDE 裡呼叫 `gc.enable()`。 -鐵律:worker thread 一律不碰 UI。普通執行緒走 Queue + QTimer,`QThread` 走 Signal/Slot。分頁或視窗關閉時還在跑的 `QThread` 交給 `thread_keeper.let_run_out()`,不等它、也不讓它在執行中被銷毀。 +鐵律:worker thread 一律不碰 UI。普通執行緒走 Queue + QTimer,`QThread` 走 Signal/Slot。分頁或視窗關閉時還在跑的 `QThread` 交給 `thread_keeper.let_run_out()`,不等它、也不讓它在執行中被銷毀。widget 留著的 thread(或其他物件)上接的 slot 不能抓住 widget 本身:接 bound method,或用 `thread_keeper.if_alive(weakref.ref(self), ...)`;lambda 抓 `self` 是經過 Qt 的循環參照,Python 的 GC 看不到,關掉的 widget 永遠不會釋放(`test_closed_panels_are_freed.py`)。 執行器的壽命:QTimer 接到執行器的 `pull_text()` / `_pull_text()` 這條連線**不會**讓執行器活著;reader 執行緒一結束,沒人持有的執行器就會被 GC,剩下的輸出和結束那一行都不會出現。所以執行器一律掛在它寫入的 `CodeWindow.runner` 上,跟視窗同壽;視窗又掛在主視窗的 `current_run_code_window`。 @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 118 個 `test_*.py`、1979 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 119 個 `test_*.py`、1983 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 25cdc96c..b04dcb90 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3048,3 +3048,19 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: `test_testpioneer_menu.py` 6 passed. Full suite before this fix: first half 1083 passed; second half 880 passed, 15 skipped, 1 failed (this test). 1979 tests in 118 files. - **Files**: `test/test_utils/test_testpioneer_menu.py` - **Open items**: none. + +## U-20260923-190 · 2026-09-24 · Free the CoT review, Skill send, Diff and SSH panels once they are closed · #fix #ai #ssh #tools + +- **What**: four panels stayed in memory after they were closed and deleted, once they had run a worker thread. Each kept its thread (`self.thread`, `self._compare_thread`, `self._connecting`), and a slot connected to that thread held the panel: a cycle through Qt that Python's collector cannot see. + - CoT review, Skill send, Diff: `finished` was connected to a lambda holding the panel (found by review; the AI review client, which connects a bound method, was freed). + - SSH shell: the `connected` / `failed` lambdas held the panel, and also the paramiko client, whose host-key policy held the panel as its dialog parent. + The Skill panel's API URL, which may carry a token, stayed in memory with it. +- **Fix**: + - CoT review, Skill send and Diff connect `finished` to a bound method (`_enable_send`, `_enable_compare`). + - New `thread_keeper.if_alive(ref, act)` for a slot that needs more than a bound method can carry; the SSH shell's connect slots use it over a weak reference. + - `InteractiveHostKeyPolicy` holds its parent weakly; a parent that has gone answers No, as a deleted one already did. + - `SshConnectThread` logs a failed connect's text rather than the exception, whose traceback held the panel for any handler that keeps records. +- **Tests**: new `test_closed_panels_are_freed.py` (4): each panel, after its worker ran to the end, is gone once closed, deleted and collected. +- **Result / numbers**: SSH, SFTP, AI panel, diff, plain-text and thread tests, 254 passed. 1983 tests in 119 files. `ruff check` clean. +- **Files**: `extend_ai_gui/code_review/cot_code_review_gui.py`, `extend_ai_gui/skills/skills_send_gui.py`, `tools_gui/diff_gui.py`, `connect_gui/ssh/ssh_command_widget.py`, `connect_gui/ssh/ssh_host_key_policy.py`, `connect_gui/ssh/ssh_connect_thread.py`, `pybreeze_ui/thread_keeper.py`, `test/test_utils/test_closed_panels_are_freed.py` (new), `CLAUDE.md` (tree, Conventions), `architecture_explore.md` §5, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 91398efd..4a4ae268 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-190 | 2026-09-24 | Free the CoT review, Skill send, Diff and SSH panels once they are closed | #fix #ai #ssh #tools | [2026-09](2026-09.md) | | U-20260923-189 | 2026-09-24 | Let the TestPioneer menu's stand-in message box take the delete-on-close attribute | #test | [2026-09](2026-09.md) | | U-20260923-188 | 2026-09-24 | Delete message boxes and the prthinker settings dialog once they are answered | #fix #ui | [2026-09](2026-09.md) | | U-20260923-187 | 2026-09-24 | Show a plugin's About text as text, in a box that belongs to the main window | #fix #plugin | [2026-09](2026-09.md) | @@ -270,4 +271,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 207 | +| [2026-09.md](2026-09.md) | 2026-09 | 208 | 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 9b090118..ff14e957 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -2,6 +2,7 @@ import codecs import os +import weakref import paramiko from PySide6.QtCore import QThread, Signal @@ -21,7 +22,7 @@ ) from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget -from pybreeze.pybreeze_ui.thread_keeper import let_run_out +from pybreeze.pybreeze_ui.thread_keeper import if_alive, let_run_out from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.terminal_text import split_incomplete_escape, strip_terminal_controls @@ -286,8 +287,13 @@ def connect() -> None: # timeouts together, and a server gone quiet after auth held it while # the channel waited to open. thread = SshConnectThread(connect) - thread.connected.connect(lambda: self._on_connected(client, opened["channel"], host, port, user)) - thread.failed.connect(lambda message: self._on_connect_failed(client, message)) + # Weakly: this widget keeps the thread, so slots holding the widget + # kept the closed and deleted widget alive with it + me = weakref.ref(self) + thread.connected.connect(lambda: if_alive( + me, lambda widget: widget._on_connected(client, opened["channel"], host, port, user))) + thread.failed.connect(lambda message: if_alive( + me, lambda widget: widget._on_connect_failed(client, message))) self._connecting = thread thread.start() diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_connect_thread.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_connect_thread.py index 3b98229b..fa302445 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_connect_thread.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_connect_thread.py @@ -52,7 +52,9 @@ def run(self) -> None: try: self._connect() except CONNECT_ERRORS as error: - pybreeze_logger.info("SSH connect failed: %r", error) + # Its text, not the exception: a handler that keeps records kept the + # traceback, and through its frames the panel that connected + pybreeze_logger.info("SSH connect failed: %s", repr(error)) self.failed.emit(str(error)) else: self.connected.emit() diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py index 0e3bd4ec..4000d83a 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py @@ -12,6 +12,7 @@ import hashlib import threading import time +import weakref from pathlib import Path from typing import TYPE_CHECKING @@ -100,9 +101,18 @@ class InteractiveHostKeyPolicy(paramiko.MissingHostKeyPolicy): def __init__(self, parent: QWidget | None = None) -> None: super().__init__() - self._parent = parent + # Weakly: the client holding this policy is held by the connect's slots + # through Qt, so a strong parent kept the closed SSH panel alive + self._parent = weakref.ref(parent) if parent is not None else None self._word_dict = language_wrapper.language_word_dict + def _asked(self, title: str, message: str) -> bool: + """Whether the user trusts the key; No when the panel that asked has gone.""" + if self._parent is None: + return host_key_asker().ask(None, title, message) + parent = self._parent() + return parent is not None and host_key_asker().ask(parent, title, message) + def missing_host_key( self, client: paramiko.SSHClient, @@ -133,8 +143,7 @@ def missing_host_key( if _is_trusted_on_disk(hostname, key): client.get_host_keys().add(hostname, key_type, key) return - if _declined_just_now(hostname, fingerprint) or not host_key_asker().ask( - self._parent, title, message): + if _declined_just_now(hostname, fingerprint) or not self._asked(title, message): _RECENT_DECLINES[(hostname, fingerprint)] = time.monotonic() pybreeze_logger.warning( "SSH host key for %s rejected by user (%s)", hostname, fingerprint 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 ff4a4b70..43168680 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 @@ -89,9 +89,18 @@ def start_sending(self): self.send_button.setEnabled(False) self.thread = SenderThread(files=self.files, code=self.code_paste_area.toPlainText(), url=url) self.thread.update_response.connect(self.handle_response) - self.thread.finished.connect(lambda: self.send_button.setEnabled(True)) + self.thread.finished.connect(self._enable_send) self.thread.start() + def _enable_send(self) -> None: + """Let the next request be sent, however this one ended. + + A bound method, not a lambda: the thread's connection holding a lambda + that held the panel kept both alive after the panel was closed and + deleted, a cycle through Qt that Python's collector cannot see. + """ + self.send_button.setEnabled(True) + def handle_response(self, filename, response): self.responses[filename] = response if self.response_selector.findText(filename) == -1: 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 b87f042f..392b611a 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 @@ -209,9 +209,18 @@ def send_prompt(self): self.thread.error.connect(self.on_error) # However run() ends -- including an exception outside its handler -- # the button comes back. - self.thread.finished.connect(lambda: self.send_button.setEnabled(True)) + self.thread.finished.connect(self._enable_send) self.thread.start() + def _enable_send(self) -> None: + """Let the next request be sent, however this one ended. + + A bound method, not a lambda: the thread's connection holding a lambda + that held the panel kept both alive after the panel was closed and + deleted, a cycle through Qt that Python's collector cannot see. + """ + self.send_button.setEnabled(True) + def on_finished(self, result): self.response_output.setPlainText(result) self.send_button.setEnabled(True) diff --git a/pybreeze/pybreeze_ui/thread_keeper.py b/pybreeze/pybreeze_ui/thread_keeper.py index b16b1c3b..a9184416 100644 --- a/pybreeze/pybreeze_ui/thread_keeper.py +++ b/pybreeze/pybreeze_ui/thread_keeper.py @@ -1,5 +1,7 @@ """Let a worker QThread run out after the widget that started it has closed. +``if_alive`` lets a worker's signal reach its widget without keeping it alive. + A panel that closes while its request is still in flight has two bad options: destroy the running QThread with it (Qt aborts the process), or wait for it on the UI thread (the IDE freezes for as long as the request takes, up to its read @@ -9,11 +11,16 @@ from __future__ import annotations import warnings +import weakref +from collections.abc import Callable +from typing import TypeVar from PySide6.QtCore import QThread from pybreeze.utils.logging.logger import pybreeze_logger +_Widget = TypeVar("_Widget") + # Threads whose widget has gone, each kept until its finished signal _OUTLIVING_THEIR_WIDGET: set[QThread] = set() @@ -40,3 +47,17 @@ def let_run_out(thread: QThread, *signals) -> None: def is_kept(thread: QThread) -> bool: """Whether *thread* is being kept until it ends.""" return thread in _OUTLIVING_THEIR_WIDGET + + +def if_alive(widget_ref: weakref.ref, act: Callable[[_Widget], None]) -> None: + """Call *act* with the widget *widget_ref* points at, if it still exists. + + For a worker's signal whose slot needs more than a bound method can carry. + A lambda holding the widget itself, connected to a thread the widget keeps, + is a cycle through Qt that Python's collector cannot see: the closed and + deleted widget stayed in memory with the thread. Connect + ``lambda: if_alive(ref, lambda widget: ...)`` over a weak reference instead. + """ + widget = widget_ref() + if widget is not None: + act(widget) diff --git a/pybreeze/pybreeze_ui/tools_gui/diff_gui.py b/pybreeze/pybreeze_ui/tools_gui/diff_gui.py index 96ed572a..9e6f4c1b 100644 --- a/pybreeze/pybreeze_ui/tools_gui/diff_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/diff_gui.py @@ -98,10 +98,15 @@ def compare(self) -> None: self.summary_label.setText(language_wrapper.language_word_dict.get("diff_comparing")) thread = DiffThread(exact_text(self.left_edit), exact_text(self.right_edit)) thread.compared.connect(self._show) - thread.finished.connect(lambda: self.compare_button.setEnabled(True)) + thread.finished.connect(self._enable_compare) self._compare_thread = thread thread.start() + def _enable_compare(self) -> None: + """Let the next comparison start. A bound method: a lambda holding the + tab, on the thread the tab keeps, kept the closed tab alive.""" + self.compare_button.setEnabled(True) + def _show(self, comparison: Comparison) -> None: self.summary_label.setText(build_summary_line(comparison.summary)) self.output_edit.setPlainText(comparison.diff) diff --git a/test/test_utils/test_closed_panels_are_freed.py b/test/test_utils/test_closed_panels_are_freed.py new file mode 100644 index 00000000..5aac6556 --- /dev/null +++ b/test/test_utils/test_closed_panels_are_freed.py @@ -0,0 +1,112 @@ +"""A panel that ran a worker thread is freed once it is closed and deleted. + +A slot holding the panel, connected to a thread the panel keeps, is a cycle +through Qt that Python's collector cannot see: every such panel opened and +closed stayed in memory with its thread for the rest of the session. +""" +from __future__ import annotations + +import gc +import os +import time +import weakref + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest # noqa: E402 + + +@pytest.fixture(scope="module") +def app(): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _wait_for(app, thread, seconds: float = 10) -> None: + deadline = time.monotonic() + seconds + while thread.isRunning() and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.01) + app.processEvents() + assert not thread.isRunning() + + +def _freed_after_a_run(app, build, drive, thread_of) -> bool: + """Build a panel, run its worker to the end, close and delete it: is it gone? + + Built here so no caller holds it. + """ + from PySide6.QtCore import QCoreApplication, QEvent + + widget = build() + drive(widget) + _wait_for(app, thread_of(widget)) + watch = weakref.ref(widget) + widget.close() + widget.deleteLater() + QCoreApplication.sendPostedEvents(widget, QEvent.Type.DeferredDelete) + del widget + gc.collect() + return watch() is None + + +def _cot(widget) -> None: + # A URL the panel refuses to send to: the thread ends at once, with no network + widget.url_input.setText("ftp://example.invalid/") + widget.code_paste_area.setPlainText("x") + widget.start_sending() + + +def _skills(widget) -> None: + widget.api_url_input.setText("ftp://example.invalid/") + widget.prompt_input.setPlainText("x") + widget.send_prompt() + + +def _diff(widget) -> None: + widget.left_edit.setPlainText("a") + widget.right_edit.setPlainText("b") + widget.compare() + + +def test_the_cot_review_panel(app): + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI + + assert _freed_after_a_run(app, CoTCodeReviewGUI, _cot, lambda widget: widget.thread) + + +def test_the_skill_send_panel(app): + from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import SkillsSendGUI + + assert _freed_after_a_run(app, SkillsSendGUI, _skills, lambda widget: widget.thread) + + +def test_the_diff_tab(app): + from pybreeze.pybreeze_ui.tools_gui.diff_gui import DiffGUI + + assert _freed_after_a_run(app, DiffGUI, _diff, lambda widget: widget._compare_thread) + + +def test_the_ssh_shell_after_a_failed_connect(app, monkeypatch): + import paramiko + + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_command_widget + + def refuse(*_args, **_kwargs): + raise OSError("refused") + + def connect(widget) -> None: + widget.login_widget.host_edit.setText("h") + widget.login_widget.user_edit.setText("u") + widget.connect_ssh() + + monkeypatch.setattr(paramiko.SSHClient, "connect", refuse) + + # The client's host-key policy held the panel as its dialog parent + assert _freed_after_a_run( + app, ssh_command_widget.SSHCommandWidget, connect, lambda widget: widget._connecting) From edc31bb42e2d1128c003b1f856515f96c5695e32 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:03:59 +0800 Subject: [PATCH 228/312] Cut a trickling AI answer off at its deadline, not only between chunks --- architecture_explore.md | 4 +- docs/updates/2026-09.md | 9 +++++ docs/updates/README.md | 3 +- progress.md | 1 + pybreeze/utils/network/http_client.py | 53 ++++++++++++++++++++++++--- test/test_utils/test_http_client.py | 41 +++++++++++++++++++++ 6 files changed, 103 insertions(+), 8 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 76f99838..db788b87 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -346,7 +346,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | -| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),每來一塊檢查一次,超過丟 `ReadTimeout`:讀取逾時只管每一塊之間,一秒一個位元組的伺服器可以一直拖下去)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | +| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),時間到由 `_Watchdog` 關掉連線(urllib3 的 `HTTPResponse.shutdown()` 能中斷別的執行緒上正在等的讀取),丟 `ReadTimeout`:讀取逾時只管每一塊之間,一次送一個位元組的伺服器可以一直拖下去;狀態列與標頭在 watchdog 開始前就讀完,還沒涵蓋,progress #104)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | | `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 119 個 `test_*.py`、1983 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 119 個 `test_*.py`、1984 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index b04dcb90..5b7afb2b 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3064,3 +3064,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: SSH, SFTP, AI panel, diff, plain-text and thread tests, 254 passed. 1983 tests in 119 files. `ruff check` clean. - **Files**: `extend_ai_gui/code_review/cot_code_review_gui.py`, `extend_ai_gui/skills/skills_send_gui.py`, `tools_gui/diff_gui.py`, `connect_gui/ssh/ssh_command_widget.py`, `connect_gui/ssh/ssh_host_key_policy.py`, `connect_gui/ssh/ssh_connect_thread.py`, `pybreeze_ui/thread_keeper.py`, `test/test_utils/test_closed_panels_are_freed.py` (new), `CLAUDE.md` (tree, Conventions), `architecture_explore.md` §5, §18 - **Open items**: none. + +## U-20260923-191 · 2026-09-24 · Cut a trickling AI answer off at its deadline, not only between chunks · #fix #ai #security + +- **What**: `read_capped_text` looked at its 300 s deadline only as each 64 KB chunk arrived. With a `Content-Length` announced, one chunk's read waits for 65,536 bytes, and each byte restarts the 30 s read timeout: a server sending a byte every 25 s held one read for about 19 days, and the AI panel's Send stayed greyed out. A local server announcing 100,000 bytes and sending one every 0.25 s was not stopped by `max_seconds=2`: the read ended after 11 s, when the server closed, with `ChunkedEncodingError` instead of the promised `ReadTimeout`. Found by review. +- **Fix**: a watchdog (`_Watchdog`, a daemon `threading.Timer`) shuts the response's connection down at the deadline: urllib3's `HTTPResponse.shutdown()` (2.3+) ends a read blocked on another thread; closing the response is the fallback. Whatever the cut-off read raises is reported as `ReadTimeout`, and the watchdog is cancelled when the read ends first. +- **Tests**: `test_http_client.py` +1: a loopback server announcing 100,000 bytes and sending one every 0.2 s is cut off with `ReadTimeout` within 4 s of a 1 s limit. +- **Result / numbers**: the reviewer's trickle probe now ends at 2.3 s with `ReadTimeout` (was 11 s, `ChunkedEncodingError`); a deflate bomb still stops at the 16 MB cap in 0.1 s. HTTP client, AI client and pinned-connection tests pass. 1984 tests in 119 files. `ruff check` clean. +- **Files**: `pybreeze/utils/network/http_client.py`, `test/test_utils/test_http_client.py`, `progress.md` (#104 added), `architecture_explore.md` §7, §18 +- **Open items**: #104: the status line and headers arrive before the watchdog starts, so a server trickling them is not cut off. diff --git a/docs/updates/README.md b/docs/updates/README.md index 4a4ae268..3a728d16 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-191 | 2026-09-24 | Cut a trickling AI answer off at its deadline, not only between chunks | #fix #ai #security | [2026-09](2026-09.md) | | U-20260923-190 | 2026-09-24 | Free the CoT review, Skill send, Diff and SSH panels once they are closed | #fix #ai #ssh #tools | [2026-09](2026-09.md) | | U-20260923-189 | 2026-09-24 | Let the TestPioneer menu's stand-in message box take the delete-on-close attribute | #test | [2026-09](2026-09.md) | | U-20260923-188 | 2026-09-24 | Delete message boxes and the prthinker settings dialog once they are answered | #fix #ui | [2026-09](2026-09.md) | @@ -271,4 +272,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 208 | +| [2026-09.md](2026-09.md) | 2026-09 | 209 | diff --git a/progress.md b/progress.md index bffed28e..9016518f 100644 --- a/progress.md +++ b/progress.md @@ -16,3 +16,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. - **#102** [BLOCKED] A report mail can wait forever: je_mail_thunder's `SMTPWrapper(host, port)` passes no timeout to `SMTP_SSL`, so a mail server that accepts the connection and then stalls holds the report-mail thread (`extend/mail_thunder_extend/mail_thunder_setting.py`, `send_report`) with no end, and the run window never says whether the report went. Needs a `timeout` argument in MailThunder (`je_mail_thunder/smtp/smtp_wrapper.py:20`); then pass 30 s here. - **#103** [BLOCKED] PyBreeze's automation keywords are never highlighted: `syntax_extend_package` registers them for `.json`, `.yml` and `.yaml` (`pybreeze_ui/syntax/syntax_extend.py`), but JEditor has built-in rules for those suffixes, so `CodeEditor.reset_highlighter` gets a `GenericHighlighter` from `highlighter_for`, and only `PythonHighlighter` reads the plugin registry (`je_editor/pyside_ui/code/syntax/generic_syntax.py:124`, `code_edit_plaintext.py:423-425` in `D:\Codes\JEDITOR` at d372c35). Needs JEditor's generic highlighter to add a registered language's words for the suffix; the `architecture.md` §6 contract changes with it. +- **#104** The AI requests' 300 s limit covers the body only: `read_capped_text` starts its watchdog once `session.request(..., stream=True)` has returned (`utils/network/http_client.py`), so a server that sends its status line and headers a byte at a time, each inside the 30 s read timeout, holds the request, and the panel's greyed-out Send, for as long as it likes (`connect_gui/url/ai_code_review_gui.py:76`, `extend_ai_gui/skills/skills_send_gui.py:50`, `extend_ai_gui/code_review/code_review_thread.py:73`). Needs a deadline that can reach the socket before a response exists, e.g. in `public_http`'s pinned connection. diff --git a/pybreeze/utils/network/http_client.py b/pybreeze/utils/network/http_client.py index 30ddb4c8..1a759f48 100644 --- a/pybreeze/utils/network/http_client.py +++ b/pybreeze/utils/network/http_client.py @@ -8,11 +8,13 @@ """ from __future__ import annotations +import threading import time from email.message import Message import requests +from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.url_validation import UnsafeURLError DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024 # 16 MB @@ -50,17 +52,19 @@ def read_capped_text( in its ``Content-Type`` is used when Python knows it, and *default_encoding* otherwise: a server can name anything, and an unknown one would raise ``LookupError`` here. Raises ``requests.exceptions.ReadTimeout`` once the - body has taken more than *max_seconds* (checked as each chunk arrives, so - the read timeout is the most it can overrun by). + body has taken more than *max_seconds*: a watchdog shuts the connection + down then, which ends a read that is still waiting. Checked only as each + chunk arrived, a server sending a byte every 25 s held a single chunk's + read for weeks, each byte restarting the read timeout. """ total = 0 chunks: list[bytes] = [] deadline = time.monotonic() + max_seconds + watchdog = _Watchdog(response, max_seconds) try: for chunk in response.iter_content(chunk_size=_CHUNK_SIZE): - if time.monotonic() > deadline: - raise requests.exceptions.ReadTimeout( - f"The response took more than {max_seconds:g} seconds.") + if watchdog.fired or time.monotonic() > deadline: + break if not chunk: continue total += len(chunk) @@ -69,8 +73,15 @@ def read_capped_text( f"Response body exceeds the {max_bytes}-byte limit." ) chunks.append(chunk) + # What a read cut off by the watchdog raises depends on where it was + except (requests.exceptions.RequestException, OSError, ValueError, AttributeError): + if not watchdog.fired: + raise finally: + watchdog.cancel() response.close() + if watchdog.fired or time.monotonic() > deadline: + raise requests.exceptions.ReadTimeout(f"The response took more than {max_seconds:g} seconds.") body = b"".join(chunks) try: return body.decode(named_charset(response) or default_encoding, "replace") @@ -78,6 +89,38 @@ def read_capped_text( return body.decode(default_encoding, "replace") +class _Watchdog: + """Shuts *response*'s connection down after *seconds*, unless cancelled first.""" + + def __init__(self, response: requests.Response, seconds: float) -> None: + self._response = response + self._lock = threading.Lock() + self._cancelled = False + self.fired = False + self._timer = threading.Timer(seconds, self._fire) + self._timer.daemon = True + self._timer.start() + + def _fire(self) -> None: + with self._lock: + if self._cancelled: + return + self.fired = True + # urllib3 2.3+: ends a read blocked on another thread. Closing is the + # fallback: it ends the next read, if not the one waiting now + shutdown = getattr(self._response.raw, "shutdown", None) + try: + (shutdown or self._response.close)() + except (OSError, AttributeError) as error: + pybreeze_logger.debug("Response watchdog could not shut the connection down: %r", error) + + def cancel(self) -> None: + """Stop the watchdog; after this it does nothing.""" + with self._lock: + self._cancelled = True + self._timer.cancel() + + def named_charset(response: requests.Response) -> str | None: """The charset *response*'s ``Content-Type`` names, or ``None`` when it names none. diff --git a/test/test_utils/test_http_client.py b/test/test_utils/test_http_client.py index 49f20842..d83a3722 100644 --- a/test/test_utils/test_http_client.py +++ b/test/test_utils/test_http_client.py @@ -138,6 +138,47 @@ def test_a_trickle_is_cut_off_at_the_deadline(self, monkeypatch): read_capped_text(resp, max_seconds=300) assert resp.closed + def test_a_byte_now_and_then_inside_one_chunk_is_cut_off_too(self): + # A server announcing a long body and sending a byte every so often held + # one chunk's read for as long as it liked: each byte restarted the read + # timeout, and the deadline was only looked at between chunks + import socket + import threading + import time + + import requests + + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + stop = threading.Event() + + def trickle() -> None: + connection, _ = listener.accept() + with connection: + connection.recv(65536) + connection.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 100000\r\n\r\n") + while not stop.is_set(): + try: + connection.sendall(b"x") + except OSError: + return + stop.wait(0.2) + + server = threading.Thread(target=trickle, daemon=True) + server.start() + try: + response = requests.get( # noqa: S113 — a loopback server this test started; timeout given + f"http://127.0.0.1:{listener.getsockname()[1]}/", stream=True, timeout=(5, 5)) + started = time.monotonic() + with pytest.raises(requests.exceptions.ReadTimeout): + read_capped_text(response, max_seconds=1) + assert time.monotonic() - started < 4 + finally: + stop.set() + listener.close() + server.join(5) + def test_a_timely_answer_is_read_whole(self): assert read_capped_text(FakeResponse(b"x" * 100, chunk=1), max_seconds=300) == "x" * 100 From 6bf93e9f845b600ac72f1e42da73554113e348a0 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:07:39 +0800 Subject: [PATCH 229/312] Bound a whole AI request, its status line and headers included --- CLAUDE.md | 2 +- architecture_explore.md | 6 +- docs/updates/2026-09.md | 9 ++ docs/updates/README.md | 3 +- progress.md | 1 - .../connect_gui/url/ai_code_review_gui.py | 8 +- .../code_review/code_review_thread.py | 19 ++-- .../extend_ai_gui/skills/skills_send_gui.py | 8 +- pybreeze/utils/network/public_http.py | 87 +++++++++++++++++++ test/test_utils/test_public_http.py | 54 ++++++++++++ 10 files changed, 177 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb8e9bab..5a4d97ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ ruff check pybreeze/ # before committing non-trivia **Network (SSRF)** — every outbound request to a user-supplied URL must first pass validation: 1. `http://` / `https://` only — block `file://`, `ftp://`, `data:`, `gopher://` 2. Resolve the hostname and reject private / loopback / link-local / reserved IPs -3. Enforce timeouts (15 s downloads, 30 s API calls) and response size caps (20 MB binary) +3. Enforce timeouts (15 s downloads, 30 s API calls) and response size caps (20 MB binary). A read timeout restarts with every byte, so a request that may run long also gets an overall bound: `public_http.overall_deadline()` around the request and `read_capped_text`'s `max_seconds` 4. `allow_redirects=False`, or re-validate every redirect target 5. Connect only to the address checked: send through `public_session()` (requests) or `PublicHTTPHandler` / `PublicHTTPSHandler` (urllib) from `utils/network/public_http.py`, which check again as they connect and connect only to the addresses checked (each in turn), so a name that resolves differently after validation (DNS rebinding) gets nowhere private. `test_http_goes_through_public_connections.py` fails on a direct `requests.*` or `urlopen` call diff --git a/architecture_explore.md b/architecture_explore.md index db788b87..b7be8583 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -345,8 +345,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | -| `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | -| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),時間到由 `_Watchdog` 關掉連線(urllib3 的 `HTTPResponse.shutdown()` 能中斷別的執行緒上正在等的讀取),丟 `ReadTimeout`:讀取逾時只管每一塊之間,一次送一個位元組的伺服器可以一直拖下去;狀態列與標頭在 watchdog 開始前就讀完,還沒涵蓋,progress #104)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | +| `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`overall_deadline(seconds)`:這個執行緒在區塊內的請求總共最多這麼久,釘住的連線等回應時把 socket 登記上去,時間到就 shutdown,丟 `ReadTimeout`(讀取逾時每來一個位元組就重算,慢慢送標頭的伺服器原本可以一直拖);AI 審查、Skill、CoT 每一步都包在 `overall_deadline(DEFAULT_MAX_READ_SECONDS)` 裡。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | +| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),時間到由 `_Watchdog` 關掉連線(urllib3 的 `HTTPResponse.shutdown()` 能中斷別的執行緒上正在等的讀取),丟 `ReadTimeout`:讀取逾時只管每一塊之間,一次送一個位元組的伺服器可以一直拖下去;狀態列與標頭由呼叫端的 `public_http.overall_deadline()` 涵蓋)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | | `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 119 個 `test_*.py`、1984 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 119 個 `test_*.py`、1986 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 5b7afb2b..3efead16 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3073,3 +3073,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: the reviewer's trickle probe now ends at 2.3 s with `ReadTimeout` (was 11 s, `ChunkedEncodingError`); a deflate bomb still stops at the 16 MB cap in 0.1 s. HTTP client, AI client and pinned-connection tests pass. 1984 tests in 119 files. `ruff check` clean. - **Files**: `pybreeze/utils/network/http_client.py`, `test/test_utils/test_http_client.py`, `progress.md` (#104 added), `architecture_explore.md` §7, §18 - **Open items**: #104: the status line and headers arrive before the watchdog starts, so a server trickling them is not cut off. + +## U-20260923-192 · 2026-09-24 · Bound a whole AI request, its status line and headers included · #fix #ai #security #done + +- **What**: progress #104, left open by U-20260923-191. `read_capped_text`'s watchdog starts once `session.request(..., stream=True)` has returned, so a server sending its status line and headers a byte at a time, each inside the 30 s read timeout, held an AI request, and the panel's greyed-out Send, for as long as it liked. +- **Fix**: new `public_http.overall_deadline(seconds)`, a context manager for the calling thread. The pinned connections register their socket with it as they wait for a response (`getresponse`), and when the time is up a timer shuts every registered socket down; the failure that causes is raised as `ReadTimeout`. It nests (the outer deadline comes back on exit) and does nothing once the block has ended. The AI review client, the Skill send panel and each CoT step run under `overall_deadline(DEFAULT_MAX_READ_SECONDS)`. Through a proxy the connection is not a pinned one and is not covered, like the pinning itself. +- **Tests**: `test_public_http.py` +2 (headers trickled a byte every 0.2 s are cut off by a 1 s deadline within 4 s; a timely answer is untouched). +- **Result / numbers**: a probe trickling headers ends at 1.7 s with `ReadTimeout` under a 1.5 s deadline. AI panel, CoT, Skill, HTTP client, pinned-connection and freed-panel tests, 153 passed before the new ones. 1986 tests in 119 files. `ruff check` clean. +- **Files**: `pybreeze/utils/network/public_http.py`, `connect_gui/url/ai_code_review_gui.py`, `extend_ai_gui/skills/skills_send_gui.py`, `extend_ai_gui/code_review/code_review_thread.py`, `test/test_utils/test_public_http.py`, `progress.md` (#104 removed), `CLAUDE.md` (Network rule 3), `architecture_explore.md` §7, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 3a728d16..8d204f80 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-192 | 2026-09-24 | Bound a whole AI request, its status line and headers included | #fix #ai #security #done | [2026-09](2026-09.md) | | U-20260923-191 | 2026-09-24 | Cut a trickling AI answer off at its deadline, not only between chunks | #fix #ai #security | [2026-09](2026-09.md) | | U-20260923-190 | 2026-09-24 | Free the CoT review, Skill send, Diff and SSH panels once they are closed | #fix #ai #ssh #tools | [2026-09](2026-09.md) | | U-20260923-189 | 2026-09-24 | Let the TestPioneer menu's stand-in message box take the delete-on-close attribute | #test | [2026-09](2026-09.md) | @@ -272,4 +273,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 209 | +| [2026-09.md](2026-09.md) | 2026-09 | 210 | diff --git a/progress.md b/progress.md index 9016518f..bffed28e 100644 --- a/progress.md +++ b/progress.md @@ -16,4 +16,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. - **#102** [BLOCKED] A report mail can wait forever: je_mail_thunder's `SMTPWrapper(host, port)` passes no timeout to `SMTP_SSL`, so a mail server that accepts the connection and then stalls holds the report-mail thread (`extend/mail_thunder_extend/mail_thunder_setting.py`, `send_report`) with no end, and the run window never says whether the report went. Needs a `timeout` argument in MailThunder (`je_mail_thunder/smtp/smtp_wrapper.py:20`); then pass 30 s here. - **#103** [BLOCKED] PyBreeze's automation keywords are never highlighted: `syntax_extend_package` registers them for `.json`, `.yml` and `.yaml` (`pybreeze_ui/syntax/syntax_extend.py`), but JEditor has built-in rules for those suffixes, so `CodeEditor.reset_highlighter` gets a `GenericHighlighter` from `highlighter_for`, and only `PythonHighlighter` reads the plugin registry (`je_editor/pyside_ui/code/syntax/generic_syntax.py:124`, `code_edit_plaintext.py:423-425` in `D:\Codes\JEDITOR` at d372c35). Needs JEditor's generic highlighter to add a registered language's words for the suffix; the `architecture.md` §6 contract changes with it. -- **#104** The AI requests' 300 s limit covers the body only: `read_capped_text` starts its watchdog once `session.request(..., stream=True)` has returned (`utils/network/http_client.py`), so a server that sends its status line and headers a byte at a time, each inside the 30 s read timeout, holds the request, and the panel's greyed-out Send, for as long as it likes (`connect_gui/url/ai_code_review_gui.py:76`, `extend_ai_gui/skills/skills_send_gui.py:50`, `extend_ai_gui/code_review/code_review_thread.py:73`). Needs a deadline that can reach the socket before a response exists, e.g. in `public_http`'s pinned connection. 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 78cd0a0a..9365bed7 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 @@ -18,10 +18,11 @@ from pybreeze.utils.hash_tools.hash_text import hash_text from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( - ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, describe_request_error, succeeded, + DEFAULT_MAX_READ_SECONDS, ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, + describe_request_error, succeeded, truncate_for_display, ) -from pybreeze.utils.network.public_http import public_session +from pybreeze.utils.network.public_http import overall_deadline, public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -71,7 +72,8 @@ def __init__(self, method: str, url: str, code_text: str) -> None: def run(self) -> None: try: validate_url(self._url) - with public_session() as session: + # The whole request, headers included: a read timeout restarts with every byte + with overall_deadline(DEFAULT_MAX_READ_SECONDS), public_session() as session: response = self._send(session) body = read_capped_text(response) if succeeded(response): 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 c49e009a..8d065edc 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 @@ -10,10 +10,11 @@ ) from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( - ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, describe_request_error, succeeded, + DEFAULT_MAX_READ_SECONDS, ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, + describe_request_error, succeeded, truncate_for_display, ) -from pybreeze.utils.network.public_http import public_session +from pybreeze.utils.network.public_http import overall_deadline, public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -65,12 +66,14 @@ def _run_templates(self, session: requests.Session, code: str) -> None: def _ask(self, session: requests.Session, file: str, prompt: str) -> tuple[str, bool]: """Send one step's prompt; return its answer and whether it arrived.""" try: - # 傳送到指定 URL(重用 session 連線) - resp = session.post( - self.url, json={"prompt": prompt}, - timeout=(CONNECT_TIMEOUT, 30), allow_redirects=False, stream=True, - ) - body = read_capped_text(resp) + # 傳送到指定 URL(重用 session 連線); the whole step bounded, headers + # included: a read timeout restarts with every byte + with overall_deadline(DEFAULT_MAX_READ_SECONDS): + resp = session.post( + self.url, json={"prompt": prompt}, + timeout=(CONNECT_TIMEOUT, 30), allow_redirects=False, stream=True, + ) + body = read_capped_text(resp) except (requests.RequestException, ResponseTooLargeError) as error: # Not %r: a requests error carries the whole URL, which may hold a token. pybreeze_logger.error( 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 392b611a..6eafe50c 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 @@ -17,10 +17,11 @@ from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( - ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, describe_request_error, succeeded, + DEFAULT_MAX_READ_SECONDS, ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, + describe_request_error, succeeded, truncate_for_display, ) -from pybreeze.utils.network.public_http import public_session +from pybreeze.utils.network.public_http import overall_deadline, public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -42,7 +43,8 @@ def __init__(self, api_url, code_text): def run(self): try: validate_url(self.api_url) - with public_session() as session: + # The whole request, headers included: a read timeout restarts with every byte + with overall_deadline(DEFAULT_MAX_READ_SECONDS), public_session() as session: response = session.post( self.api_url, json={"code": self.code_text}, timeout=(CONNECT_TIMEOUT, 30), allow_redirects=False, stream=True, diff --git a/pybreeze/utils/network/public_http.py b/pybreeze/utils/network/public_http.py index d262fa7b..72451ad8 100644 --- a/pybreeze/utils/network/public_http.py +++ b/pybreeze/utils/network/public_http.py @@ -11,12 +11,18 @@ Only direct connections are pinned. Through a proxy it is the proxy that resolves and connects, and the connection made here is to the proxy, which may well be on the local network. + +``overall_deadline`` bounds a whole request, the wait for the status line and +headers included, which a per-read timeout does not: every byte restarts it. """ from __future__ import annotations +import contextlib import http.client import socket +import threading import urllib.request +from collections.abc import Iterator from typing import Any import requests @@ -28,6 +34,80 @@ from pybreeze.utils.network.url_validation import UnsafeURLError, public_addresses +# The overall deadline of the request each thread is making, if it set one +_DEADLINES = threading.local() + + +class _Deadline: + """Shuts every socket it watches down once *seconds* have passed.""" + + def __init__(self, seconds: float) -> None: + self._lock = threading.Lock() + self._sockets: list[socket.socket] = [] + self._over = False + self.passed = False + self._timer = threading.Timer(seconds, self._pass) + self._timer.daemon = True + self._timer.start() + + def watch(self, sock: socket.socket | None) -> None: + """Shut *sock* down when the deadline passes, or now if it has.""" + if sock is None: + return + with self._lock: + if not self.passed: + self._sockets.append(sock) + return + _shut(sock) + + def _pass(self) -> None: + with self._lock: + if self._over: + return + self.passed = True + watched, self._sockets = self._sockets, [] + for sock in watched: + _shut(sock) + + def end(self) -> None: + """The request is over: the timer stops, and nothing is shut down after this.""" + with self._lock: + self._over = True + self._sockets.clear() + self._timer.cancel() + + +def _shut(sock: socket.socket) -> None: + """End every read and write on *sock*, whichever thread is waiting in one.""" + with contextlib.suppress(OSError): + sock.shutdown(socket.SHUT_RDWR) + + +@contextlib.contextmanager +def overall_deadline(seconds: float) -> Iterator[None]: + """Bound the requests this thread makes in the block to *seconds* in all. + + A read timeout bounds each wait for data, and every byte restarts it: a + server sending its status line and headers a byte at a time held a request + for as long as it liked. A connection made here that waits for a response + within the block has its socket shut down when the time is up, and the + failure that causes is raised as ``requests.exceptions.ReadTimeout``. + """ + deadline = _Deadline(seconds) + outer = getattr(_DEADLINES, "current", None) + _DEADLINES.current = deadline + try: + yield + except (requests.exceptions.RequestException, OSError) as error: + if deadline.passed and not isinstance(error, requests.exceptions.ReadTimeout): + raise requests.exceptions.ReadTimeout( + f"The request took more than {seconds:g} seconds.") from error + raise + finally: + deadline.end() + _DEADLINES.current = outer + + class AddressNotPublicError(OSError): """The host resolved to a blocked address when the connection was made.""" @@ -61,6 +141,13 @@ def _new_conn(self) -> socket.socket: self._dns_host = name raise failure # type: ignore[misc] # public_addresses never returns none + def getresponse(self, *args: Any, **kwargs: Any) -> Any: + """Wait for the response under the calling thread's ``overall_deadline``, if any.""" + deadline = getattr(_DEADLINES, "current", None) + if deadline is not None: + deadline.watch(self.sock) # type: ignore[attr-defined] + return super().getresponse(*args, **kwargs) # type: ignore[misc] + class _PinnedHTTPConnection(_PinnedConnectionMixin, HTTPConnection): pass diff --git a/test/test_utils/test_public_http.py b/test/test_utils/test_public_http.py index 9bbce252..3ab272cd 100644 --- a/test/test_utils/test_public_http.py +++ b/test/test_utils/test_public_http.py @@ -249,3 +249,57 @@ def getaddrinfo(host, port=None, *args, **kwargs): assert validate_url("http://straße.de/") == "http://straße.de/" assert looked_up == ["xn--strae-oqa.de"] + + +class TestTheOverallDeadline: + """A read timeout restarts with every byte; the deadline bounds the whole request.""" + + @staticmethod + def _trickling_headers(stop: threading.Event) -> tuple[socket.socket, threading.Thread]: + server = socket.socket() + server.bind(("127.0.0.1", 0)) + server.listen(1) + + def serve() -> None: + try: + connection, _address = server.accept() + except OSError: + return + with connection: + connection.recv(65536) + for byte in b"HTTP/1.1 200 OK\r\nX-Slow: " + b"y" * 1000: + if stop.wait(0.2): + return + try: + connection.sendall(bytes([byte])) + except OSError: + return + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + return server, thread + + def test_headers_sent_a_byte_at_a_time_are_cut_off(self, dns, loopback_allowed): + import time + + from pybreeze.utils.network.public_http import overall_deadline + + stop = threading.Event() + server, thread = self._trickling_headers(stop) + started = time.monotonic() + try: + with pytest.raises(requests.exceptions.ReadTimeout), overall_deadline(1), _session() as session: + session.get(f"http://service.test:{server.getsockname()[1]}/", timeout=(3, 3), stream=True) + assert time.monotonic() - started < 4 + finally: + stop.set() + server.close() + thread.join(5) + + def test_a_timely_answer_is_not_touched(self, dns, loopback_allowed, listener): + from pybreeze.utils.network.public_http import overall_deadline + + with overall_deadline(5), _session() as session: + response = session.get(f"http://service.test:{listener.port}/", timeout=(3, 3)) + + assert response.text == "ok" From 54115381935081cfa391062400810e5d4940b8d7 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:10:19 +0800 Subject: [PATCH 230/312] Bound a whole diagram image download, and a body cut short by the deadline --- CLAUDE.md | 2 +- architecture_explore.md | 4 +- docs/updates/2026-09.md | 12 +++++ docs/updates/README.md | 3 +- .../diagram_editor/diagram_net_utils.py | 8 ++- .../diagram_editor/diagram_scene.py | 4 +- pybreeze/utils/network/public_http.py | 36 ++++++++----- test/test_utils/test_public_http.py | 54 +++++++++++++++++++ 8 files changed, 101 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a4d97ff..5d2ff535 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ ruff check pybreeze/ # before committing non-trivia **Network (SSRF)** — every outbound request to a user-supplied URL must first pass validation: 1. `http://` / `https://` only — block `file://`, `ftp://`, `data:`, `gopher://` 2. Resolve the hostname and reject private / loopback / link-local / reserved IPs -3. Enforce timeouts (15 s downloads, 30 s API calls) and response size caps (20 MB binary). A read timeout restarts with every byte, so a request that may run long also gets an overall bound: `public_http.overall_deadline()` around the request and `read_capped_text`'s `max_seconds` +3. Enforce timeouts (15 s downloads, 30 s API calls) and response size caps (20 MB binary). A read timeout restarts with every byte, so a request that may run long also gets an overall bound: `public_http.overall_deadline()` around the request (image downloads: 120 s) and `read_capped_text`'s `max_seconds` 4. `allow_redirects=False`, or re-validate every redirect target 5. Connect only to the address checked: send through `public_session()` (requests) or `PublicHTTPHandler` / `PublicHTTPSHandler` (urllib) from `utils/network/public_http.py`, which check again as they connect and connect only to the addresses checked (each in turn), so a name that resolves differently after validation (DNS rebinding) gets nowhere private. `test_http_goes_through_public_connections.py` fails on a direct `requests.*` or `urlopen` call diff --git a/architecture_explore.md b/architecture_explore.md index b7be8583..27f03659 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -249,7 +249,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `diagram_property_panel.py` (453) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單;每記一步 undo(場景的 `recorded`)就重新整理(在畫布上拖把手改大小時選取沒變);寬、高各自只改自己那一邊;數字欄位不追鍵盤、輸入完才套用 | | `diagram_view.py` (195) | `QGraphicsView`:滾輪與按鈕縮放都經 `_step_zoom()`(有上下界,界外時仍可往界內走;橫向滾輪不縮放),`fit()` 把「符合視窗」夾在上下界內、中鍵平移、`drawBackground` 畫格線 | | `diagram_commands.py` (48) | `DiagramSnapshotCommand(QUndoCommand)` — 快照式 undo,存變更前後完整場景狀態;有 `merge_key` 的連續步驟(同一個項目的同一個屬性:大小、字級、線寬)合併成一步(`id()` / `mergeWith`) | -| `diagram_net_utils.py` (108) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗、`_OPENER` 用 `PublicHTTPHandler` / `PublicHTTPSHandler`(連線當下再檢查一次並只連到那個位址)、20 MB 大小上限、15 秒 timeout | +| `diagram_net_utils.py` (112) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗、`_OPENER` 用 `PublicHTTPHandler` / `PublicHTTPSHandler`(連線當下再檢查一次並只連到那個位址)、20 MB 大小上限、每次等資料 15 秒 timeout,整個下載另有 `overall_deadline(DOWNLOAD_DEADLINE_SECONDS)` 120 秒上限(慢慢送位元組的伺服器原本可以一直卡住下載執行緒) | `diagram_net_utils` 是 CLAUDE.md 指定的網路安全參考實作,其他 HTTP 呼叫端則統一走 `utils/network/url_validation.py` 驗證、經 `utils/network/public_http.py` 的 `public_session()` 送出。 @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 119 個 `test_*.py`、1986 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 119 個 `test_*.py`、1988 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 3efead16..a2caf6ef 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3082,3 +3082,15 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: a probe trickling headers ends at 1.7 s with `ReadTimeout` under a 1.5 s deadline. AI panel, CoT, Skill, HTTP client, pinned-connection and freed-panel tests, 153 passed before the new ones. 1986 tests in 119 files. `ruff check` clean. - **Files**: `pybreeze/utils/network/public_http.py`, `connect_gui/url/ai_code_review_gui.py`, `extend_ai_gui/skills/skills_send_gui.py`, `extend_ai_gui/code_review/code_review_thread.py`, `test/test_utils/test_public_http.py`, `progress.md` (#104 removed), `CLAUDE.md` (Network rule 3), `architecture_explore.md` §7, §18 - **Open items**: none. + +## U-20260923-193 · 2026-09-24 · Bound a whole diagram image download, and a body cut short by the deadline · #fix #diagram #security + +- **What**: the diagram editor's image download (`safe_download_image`) read through `urllib` with a 15 s timeout, which bounds each wait for data and restarts with every byte: a server sending a byte now and then held the download thread for good, the same gap U-20260923-191 and -192 closed for the AI requests. `overall_deadline` covered only the urllib3 connections `requests` uses. +- **Fix**: + - The registration with the thread's deadline moves into a shared `_UnderDeadlineMixin`, now also on the `http.client` connections `PublicHTTPHandler` / `PublicHTTPSHandler` open. + - `overall_deadline` also turns an `http.client.HTTPException` (`IncompleteRead`) raised after the deadline into `ReadTimeout`, and raises it when the block returns after the deadline: a read shut down where no length was announced ends as if the body had, and would have passed as a whole image. + - `safe_download_image` runs under `overall_deadline(DOWNLOAD_DEADLINE_SECONDS)`, 120 s. The canvas already catches `OSError`, which `ReadTimeout` is. +- **Tests**: `test_public_http.py` +2 (the urllib opener with headers trickled is cut off; a body with no length cut off is not taken as whole). +- **Result / numbers**: pinned-connection tests 17 passed; diagram, network and URL validation tests 181 passed. 1988 tests in 119 files. `ruff check` clean. +- **Files**: `pybreeze/utils/network/public_http.py`, `pybreeze_ui/diagram_editor/diagram_net_utils.py`, `pybreeze_ui/diagram_editor/diagram_scene.py` (docstring), `test/test_utils/test_public_http.py`, `CLAUDE.md` (Network rule 3), `architecture_explore.md` §6, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 8d204f80..f7b722d9 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-193 | 2026-09-24 | Bound a whole diagram image download, and a body cut short by the deadline | #fix #diagram #security | [2026-09](2026-09.md) | | U-20260923-192 | 2026-09-24 | Bound a whole AI request, its status line and headers included | #fix #ai #security #done | [2026-09](2026-09.md) | | U-20260923-191 | 2026-09-24 | Cut a trickling AI answer off at its deadline, not only between chunks | #fix #ai #security | [2026-09](2026-09.md) | | U-20260923-190 | 2026-09-24 | Free the CoT review, Skill send, Diff and SSH panels once they are closed | #fix #ai #ssh #tools | [2026-09](2026-09.md) | @@ -273,4 +274,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 210 | +| [2026-09.md](2026-09.md) | 2026-09 | 211 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py index 037be7d0..6e836bf1 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py @@ -11,11 +11,13 @@ from urllib.request import HTTPRedirectHandler, Request, build_opener -from pybreeze.utils.network.public_http import PublicHTTPHandler, PublicHTTPSHandler +from pybreeze.utils.network.public_http import PublicHTTPHandler, PublicHTTPSHandler, overall_deadline from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024 # 20 MB TIMEOUT_SECONDS = 15 +# The longest a whole download may take, however steadily its bytes come +DOWNLOAD_DEADLINE_SECONDS = 120 class ImageDownloadError(Exception): @@ -85,7 +87,9 @@ def safe_download_image(url: str) -> bytes: # URL + every redirect hop are validated by _ValidatingRedirectHandler, so the # scheme can only ever be http/https by the time the request is opened. req = Request(url, headers={"User-Agent": "PyBreeze-DiagramEditor/1.0"}) # nosec B310 # noqa: S310 - with _OPENER.open(req, timeout=TIMEOUT_SECONDS) as resp: + # TIMEOUT_SECONDS bounds each wait for data, and every byte restarts it: a + # server sending a byte now and then held the download thread for good + with overall_deadline(DOWNLOAD_DEADLINE_SECONDS), _OPENER.open(req, timeout=TIMEOUT_SECONDS) as resp: content_type = resp.headers.get("Content-Type", "") if _is_text_content_type(content_type): raise ImageDownloadError( diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 95f7b2ea..36363e62 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -70,8 +70,8 @@ class ToolMode(Enum): class ImageDownloadThread(QThread): """Fetch one image for the canvas, off the UI thread. - A download is bounded by ``safe_download_image``'s own 15 s timeout, which is - 15 s the IDE would otherwise spend frozen -- once per image, and again on + A download is bounded by ``safe_download_image``: 15 s for each wait, 120 s + in all, time the IDE would otherwise spend frozen -- once per image, and again on every undo, because an undo rebuilds every item from the saved dictionary. Only the two signals reach the UI. """ diff --git a/pybreeze/utils/network/public_http.py b/pybreeze/utils/network/public_http.py index 72451ad8..afb456b1 100644 --- a/pybreeze/utils/network/public_http.py +++ b/pybreeze/utils/network/public_http.py @@ -91,28 +91,43 @@ def overall_deadline(seconds: float) -> Iterator[None]: server sending its status line and headers a byte at a time held a request for as long as it liked. A connection made here that waits for a response within the block has its socket shut down when the time is up, and the - failure that causes is raised as ``requests.exceptions.ReadTimeout``. + failure that causes is raised as ``requests.exceptions.ReadTimeout`` (an + ``OSError``). So is a block that returned after the time was up: a read cut + off where no length was announced ends as if the body had. """ deadline = _Deadline(seconds) outer = getattr(_DEADLINES, "current", None) _DEADLINES.current = deadline + too_late = requests.exceptions.ReadTimeout(f"The request took more than {seconds:g} seconds.") try: yield - except (requests.exceptions.RequestException, OSError) as error: + except (requests.exceptions.RequestException, OSError, http.client.HTTPException) as error: if deadline.passed and not isinstance(error, requests.exceptions.ReadTimeout): - raise requests.exceptions.ReadTimeout( - f"The request took more than {seconds:g} seconds.") from error + raise too_late from error raise finally: deadline.end() _DEADLINES.current = outer + if deadline.passed: + raise too_late class AddressNotPublicError(OSError): """The host resolved to a blocked address when the connection was made.""" -class _PinnedConnectionMixin: +class _UnderDeadlineMixin: + """A connection whose wait for a response, and the reads after it, the thread's deadline bounds.""" + + def getresponse(self, *args: Any, **kwargs: Any) -> Any: + """Wait for the response under the calling thread's ``overall_deadline``, if any.""" + deadline = getattr(_DEADLINES, "current", None) + if deadline is not None: + deadline.watch(self.sock) # type: ignore[attr-defined] + return super().getresponse(*args, **kwargs) # type: ignore[misc] + + +class _PinnedConnectionMixin(_UnderDeadlineMixin): """A urllib3 connection that opens its socket to an address ``public_addresses`` returns. urllib3 connects to ``_dns_host`` and takes ``host`` (TLS server name, @@ -141,13 +156,6 @@ def _new_conn(self) -> socket.socket: self._dns_host = name raise failure # type: ignore[misc] # public_addresses never returns none - def getresponse(self, *args: Any, **kwargs: Any) -> Any: - """Wait for the response under the calling thread's ``overall_deadline``, if any.""" - deadline = getattr(_DEADLINES, "current", None) - if deadline is not None: - deadline.watch(self.sock) # type: ignore[attr-defined] - return super().getresponse(*args, **kwargs) # type: ignore[misc] - class _PinnedHTTPConnection(_PinnedConnectionMixin, HTTPConnection): pass @@ -202,13 +210,13 @@ def _create_public_connection(address: tuple[str, int], *args: Any, **kwargs: An raise failure # type: ignore[misc] # public_addresses never returns none -class _PinnedHTTPClientConnection(http.client.HTTPConnection): +class _PinnedHTTPClientConnection(_UnderDeadlineMixin, http.client.HTTPConnection): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._create_connection = _create_public_connection -class _PinnedHTTPSClientConnection(http.client.HTTPSConnection): +class _PinnedHTTPSClientConnection(_UnderDeadlineMixin, http.client.HTTPSConnection): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._create_connection = _create_public_connection diff --git a/test/test_utils/test_public_http.py b/test/test_utils/test_public_http.py index 3ab272cd..aac01ba4 100644 --- a/test/test_utils/test_public_http.py +++ b/test/test_utils/test_public_http.py @@ -303,3 +303,57 @@ def test_a_timely_answer_is_not_touched(self, dns, loopback_allowed, listener): response = session.get(f"http://service.test:{listener.port}/", timeout=(3, 3)) assert response.text == "ok" + + def test_the_urllib_opener_is_cut_off_too(self, dns, loopback_allowed): + # The diagram editor's image downloads: 15 s per wait, restarted by every byte + import time + + from pybreeze.utils.network.public_http import overall_deadline + + stop = threading.Event() + server, thread = self._trickling_headers(stop) + opener = urllib.request.build_opener(PublicHTTPHandler()) + started = time.monotonic() + try: + with pytest.raises(requests.exceptions.ReadTimeout), overall_deadline(1): + opener.open(f"http://service.test:{server.getsockname()[1]}/", timeout=3).read() + assert time.monotonic() - started < 4 + finally: + stop.set() + server.close() + thread.join(5) + + def test_a_body_cut_short_without_a_length_is_not_taken_as_whole(self, dns, loopback_allowed): + # Shut down mid-body with no Content-Length, the read ends as if the body had + import time + + from pybreeze.utils.network.public_http import overall_deadline + + server = socket.socket() + server.bind(("127.0.0.1", 0)) + server.listen(1) + stop = threading.Event() + + def serve() -> None: + connection, _address = server.accept() + with connection: + connection.recv(65536) + connection.sendall(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n") + while not stop.wait(0.2): + try: + connection.sendall(b"x") + except OSError: + return + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + opener = urllib.request.build_opener(PublicHTTPHandler()) + started = time.monotonic() + try: + with pytest.raises(requests.exceptions.ReadTimeout), overall_deadline(1): + opener.open(f"http://service.test:{server.getsockname()[1]}/", timeout=3).read() + assert time.monotonic() - started < 4 + finally: + stop.set() + server.close() + thread.join(5) From f5bd54350e62cc23a03347325e2692444e5c3396 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:11:00 +0800 Subject: [PATCH 231/312] Wrap the three code lines past 120 characters --- docs/updates/2026-09.md | 7 +++++++ docs/updates/README.md | 3 ++- .../pybreeze_ui/diagram_editor/diagram_editor_widget.py | 3 ++- pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py | 2 +- test/test_utils/test_file_tree_context_menu.py | 3 ++- 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index a2caf6ef..10fddf46 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3094,3 +3094,10 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: pinned-connection tests 17 passed; diagram, network and URL validation tests 181 passed. 1988 tests in 119 files. `ruff check` clean. - **Files**: `pybreeze/utils/network/public_http.py`, `pybreeze_ui/diagram_editor/diagram_net_utils.py`, `pybreeze_ui/diagram_editor/diagram_scene.py` (docstring), `test/test_utils/test_public_http.py`, `CLAUDE.md` (Network rule 3), `architecture_explore.md` §6, §18 - **Open items**: none. + +## U-20260923-194 · 2026-09-24 · Wrap the three code lines past 120 characters · #refactor + +- **What**: `ruff --select E501 --line-length 120` over the package and tests found three code lines past the workspace's 120-character limit: the diagram editor's close question, the canvas's image-scheme check (its `NOSONAR` reason, which must stay on the line, is shortened) and a test's parametrize list. They are wrapped; no behaviour changes. The other 22 long lines are prompt-template and word-dict text. +- **Result / numbers**: file-tree and unsaved-close tests 58 passed. 1988 tests in 119 files. `ruff check` clean. +- **Files**: `diagram_editor/diagram_editor_widget.py`, `diagram_editor/diagram_scene.py`, `test/test_utils/test_file_tree_context_menu.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index f7b722d9..0d8829f0 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-194 | 2026-09-24 | Wrap the three code lines past 120 characters | #refactor | [2026-09](2026-09.md) | | U-20260923-193 | 2026-09-24 | Bound a whole diagram image download, and a body cut short by the deadline | #fix #diagram #security | [2026-09](2026-09.md) | | U-20260923-192 | 2026-09-24 | Bound a whole AI request, its status line and headers included | #fix #ai #security #done | [2026-09](2026-09.md) | | U-20260923-191 | 2026-09-24 | Cut a trickling AI answer off at its deadline, not only between chunks | #fix #ai #security | [2026-09](2026-09.md) | @@ -274,4 +275,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 211 | +| [2026-09.md](2026-09.md) | 2026-09 | 212 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 3e99e640..0abf1872 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -481,7 +481,8 @@ def may_close(self) -> bool: return True reply = QMessageBox.question( self, _lang("unsaved_close_title", "Unsaved changes"), - _lang("diagram_editor_close_over_edits", "The diagram has changes that are not saved. Close and lose them?"), + _lang("diagram_editor_close_over_edits", + "The diagram has changes that are not saved. Close and lose them?"), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) return reply == QMessageBox.StandardButton.Yes diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 36363e62..39e7a8d3 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -828,7 +828,7 @@ def _try_load_image_source(self, img: DiagramImage, source: str) -> None: self._pixmap_cache[source] = pix img.set_pixmap(pix, source) return - if source.startswith(("http://", "https://")): # NOSONAR S5332 — scheme detection; actual fetch goes through safe_download_image with SSRF validation + if source.startswith(("http://", "https://")): # NOSONAR S5332 — scheme check; fetched via safe_download_image self._start_image_download(source) def _start_image_download(self, source: str) -> None: diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py index a9d15dc8..87ba2440 100644 --- a/test/test_utils/test_file_tree_context_menu.py +++ b/test/test_utils/test_file_tree_context_menu.py @@ -438,7 +438,8 @@ def test_setup_leaves_later_tabs_working(self, app): class TestANameStaysInItsFolder: """A drive, a root, '..' or ':' put the file elsewhere: /tmp/notes.py became C:\\tmp\\notes.py.""" - @pytest.mark.parametrize("name", ["/tmp/notes.py", "C:\\x.py", "C:x.py", "..\\up.py", "a/../../up.py", "notes.py:stream"]) + @pytest.mark.parametrize( + "name", ["/tmp/notes.py", "C:\\x.py", "C:x.py", "..\\up.py", "a/../../up.py", "notes.py:stream"]) def test_a_new_file_outside_the_folder_is_refused(self, tree, tmp_path, monkeypatch, warnings, name): folder = tmp_path / "project" folder.mkdir() From 63d95f69bc581b66dc96b0ab3fa49cb8cd3c59d6 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:21:16 +0800 Subject: [PATCH 232/312] Show file names, paths, hosts and errors in message boxes as text, and check every box --- CLAUDE.md | 2 +- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 ++++ docs/updates/README.md | 3 +- .../process_executor_utils.py | 3 +- .../connect_gui/ssh/ssh_host_key_policy.py | 3 +- .../diagram_editor/diagram_editor_widget.py | 10 ++-- .../dialog/prthinker_setting_dialog.py | 3 +- .../prompt_edit_gui/prompt_editor_widget.py | 13 ++--- .../prompt_edit_gui/prompt_file_io.py | 3 +- .../extend_ai_gui/skills/skills_send_gui.py | 3 +- .../automation_menu_factory.py | 9 ++-- .../build_test_pioneer_menu.py | 7 +-- .../menu/plugin_menu/build_run_with_menu.py | 5 +- .../pybreeze_ui/tools_gui/output_actions.py | 5 +- test/test_utils/test_diagram_images.py | 5 +- .../test_message_boxes_show_text.py | 48 +++++++++++++++++++ 17 files changed, 103 insertions(+), 30 deletions(-) create mode 100644 test/test_utils/test_message_boxes_show_text.py diff --git a/CLAUDE.md b/CLAUDE.md index 5d2ff535..7070ecba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,7 +119,7 @@ Reference implementations: `utils/network/url_validation.py` (`validate_url`), ` **File I/O** — dialog-chosen paths are trusted; paths loaded from saved data (`.diagram.json`) are not: check `is_file()` and an extension allowlist, or run URLs through SSRF validation. Use `pathlib`, never string concatenation. Write to `~/.pybreeze/` via `app_dirs.pybreeze_data_dir()` with `encoding="utf-8"`; read through `pybreeze_data_path()`, which creates nothing. Replace a file the user would lose through `utils/file_process/replace_file.replace_text` (written beside it, then moved into place), never an in-place `write_text`; a file something else writes (an image, an SVG export) goes through `replace_written`. Resolve symlinks with `Path.resolve(strict=True)` and verify the result stays in bounds. -**Qt** — `QGraphicsTextItem` text interaction must not be on by default (double-click to edit). Text from a server or a file (a remote path, an error message, a host name, a file name) never reaches a `QMessageBox` or `QLabel` as it is: Qt reads markup in it (`Qt::AutoText`) and loads an ``. Pass it through `pybreeze_ui/plain_text.as_text()`, or give the label `Qt.TextFormat.PlainText`. JSON (or a generated Python string) a tool shows in a text box is written with `utils/json_format/view_safe.dumps_for_view()` / `escape_for_view()`: Qt gives U+2029, U+FDD0 and U+FDD1 back as newlines and drops a lone surrogate, so Copy and Save wrote something else. Plugin loading takes only `.py` files, skipping `_`/`.` prefixes. `QWebEngineView.setUrl()` only for localhost or user-confirmed URLs; never `setHtml()` with unsanitised content. +**Qt** — `QGraphicsTextItem` text interaction must not be on by default (double-click to edit). Text from a server or a file (a remote path, an error message, a host name, a file name) never reaches a `QMessageBox` or `QLabel` as it is: Qt reads markup in it (`Qt::AutoText`) and loads an ``. Pass it through `pybreeze_ui/plain_text.as_text()`, or give the label `Qt.TextFormat.PlainText`; `test_message_boxes_show_text.py` fails on a `QMessageBox` text that is neither a literal, a word-dict entry as it is, nor `as_text(...)`. JSON (or a generated Python string) a tool shows in a text box is written with `utils/json_format/view_safe.dumps_for_view()` / `escape_for_view()`: Qt gives U+2029, U+FDD0 and U+FDD1 back as newlines and drops a lone surrogate, so Copy and Save wrote something else. Plugin loading takes only `.py` files, skipping `_`/`.` prefixes. `QWebEngineView.setUrl()` only for localhost or user-confirmed URLs; never `setHtml()` with unsanitised content. **Secrets** — SSH passwords and passphrases stay in memory for the session only. A secret that must persist (the prthinker keys and token) is written with `replace_text(..., private=True)` under the `0700` data folder. Password fields use `QLineEdit.EchoMode.Password`. diff --git a/architecture_explore.md b/architecture_explore.md index 27f03659..61f45e41 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 119 個 `test_*.py`、1988 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 120 個 `test_*.py`、1989 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 10fddf46..ac2033a8 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3101,3 +3101,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: file-tree and unsaved-close tests 58 passed. 1988 tests in 119 files. `ruff check` clean. - **Files**: `diagram_editor/diagram_editor_widget.py`, `diagram_editor/diagram_scene.py`, `test/test_utils/test_file_tree_context_menu.py` - **Open items**: none. + +## U-20260923-195 · 2026-09-24 · Show file names, paths, hosts and errors in message boxes as text, and check every box · #fix #security #ui + +- **What**: the diagram editor's URL-image failure put the download error into `QMessageBox.warning` as it was. That text quotes the server (an HTTP reason phrase, a `Content-Type`), and Qt reads markup in a message box: a 127.0.0.1 server answering `404 ` made the box load an image from a UNC path, which on Windows starts SMB authentication with that host (found by review). A scan of every `QMessageBox` call found 20 more boxes whose text interpolates what the Security › Qt rule names, a file name, a path, a host or an error message (a file name may hold `<` on Linux and macOS): the prompt editors, Create Project, the TestPioneer template, Run with, output Save, the prthinker settings, the Mermaid import error, the SSH host-key question, the run-folder notice. +- **Fix**: each of those texts goes through `plain_text.as_text()`. New `test_message_boxes_show_text.py` walks every module's syntax tree and fails on a `QMessageBox` static call or a box's `setText` / `setInformativeText` / `setDetailedText` whose text is not a literal, a word-dict entry as it is, or `as_text(...)`. +- **Tests**: +1 (the guard). `test_diagram_images.py`'s failed-fetch test now expects the message through `as_text`. +- **Result / numbers**: full suite: 1087 passed and 886 passed, 15 skipped, with the one expectation then updated (14 passed). 1989 tests in 120 files. `ruff check` clean. +- **Files**: `diagram_editor/diagram_editor_widget.py`, `extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `extend_ai_gui/prompt_edit_gui/prompt_file_io.py`, `extend_ai_gui/skills/skills_send_gui.py`, `menu/automation_menu/automation_menu_factory.py`, `menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py`, `menu/plugin_menu/build_run_with_menu.py`, `tools_gui/output_actions.py`, `dialog/prthinker_setting_dialog.py`, `connect_gui/ssh/ssh_host_key_policy.py`, `extend/process_executor/process_executor_utils.py`, `test/test_utils/test_message_boxes_show_text.py` (new), `test/test_utils/test_diagram_images.py`, `CLAUDE.md` (Security › Qt), `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 0d8829f0..b9912fd4 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-195 | 2026-09-24 | Show file names, paths, hosts and errors in message boxes as text, and check every box | #fix #security #ui | [2026-09](2026-09.md) | | U-20260923-194 | 2026-09-24 | Wrap the three code lines past 120 characters | #refactor | [2026-09](2026-09.md) | | U-20260923-193 | 2026-09-24 | Bound a whole diagram image download, and a body cut short by the deadline | #fix #diagram #security | [2026-09](2026-09.md) | | U-20260923-192 | 2026-09-24 | Bound a whole AI request, its status line and headers included | #fix #ai #security #done | [2026-09](2026-09.md) | @@ -275,4 +276,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 212 | +| [2026-09.md](2026-09.md) | 2026-09 | 213 | diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index ad50a006..47bb4a5a 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -15,6 +15,7 @@ from pybreeze.extend.process_executor.python_task_process_manager import TaskProcessManager from pybreeze.utils.file_process.get_dir_file_list import get_dir_files_as_list from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.plain_text import as_text if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -156,7 +157,7 @@ def _ask_for_action_files(main_window: PyBreezeMainWindow) -> list[str]: lang = language_wrapper.language_word_dict QMessageBox.information( main_window, lang.get("run_folder_title"), - lang.get("run_folder_no_action_files").format(folder=folder)) + as_text(lang.get("run_folder_no_action_files").format(folder=folder))) return files diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py index 4000d83a..9312e59d 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py @@ -25,6 +25,7 @@ from pybreeze.utils.app_dirs import pybreeze_data_dir from pybreeze.utils.file_process.replace_file import replace_written from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.plain_text import as_text if TYPE_CHECKING: from PySide6.QtWidgets import QWidget @@ -201,7 +202,7 @@ def _show(self, parent: QWidget | None, title: str, message: str) -> None: box.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) box.setIcon(QMessageBox.Icon.Warning) box.setWindowTitle(title) - box.setText(message) + box.setText(as_text(message)) box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) box.setDefaultButton(QMessageBox.StandardButton.No) self._answer = box.exec() == QMessageBox.StandardButton.Yes diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 0abf1872..bb858ae7 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -468,7 +468,7 @@ def _import_mermaid(self) -> None: QMessageBox.warning( self, _lang("diagram_editor_import_error", "Parse Error"), - str(e), + as_text(str(e)), ) def may_close(self) -> bool: @@ -526,8 +526,8 @@ def _warn_export_failed(self, path: str, reason: str) -> None: QMessageBox.warning( self, _lang("diagram_editor_error_title", "Error"), - _lang("diagram_editor_export_failed", "Could not export the diagram to:\n{path}") - .format(path=path), + as_text(_lang("diagram_editor_export_failed", "Could not export the diagram to:\n{path}") + .format(path=path)), ) def _export_png(self) -> None: @@ -646,7 +646,9 @@ def _on_url_image_fetched(self, url: str, data: bytes) -> None: def _on_url_image_failed(self, url: str, message: str) -> None: """Say why the image at *url* could not be added. UI thread.""" pybreeze_logger.error("URL image load failed: %s", message) - QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), message) + # As text: the message quotes the server (its reason phrase, its + # Content-Type), and Qt read markup in it, an and all + QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), as_text(message)) # ------------------------------------------------------------------ # View helpers diff --git a/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py b/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py index adf40c98..f7b57437 100644 --- a/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py +++ b/pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py @@ -22,6 +22,7 @@ from pybreeze.extend.prthinker_extend.prthinker_setting import ( BACKENDS, PLATFORMS, RAG_MODES, load_setting, read_extra_arguments, save_setting, setting_path ) +from pybreeze.pybreeze_ui.plain_text import as_text # 以圓點顯示的欄位 / The fields shown as dots SECRET_FIELDS = ( @@ -133,4 +134,4 @@ def save(self) -> None: # It used to go to the log only: the window stayed open with no reason given QMessageBox.warning( self, self.word_dict.get("prthinker_setting_dialog_title"), - self.word_dict.get("prthinker_setting_save_failed").format(path=setting_path())) + as_text(self.word_dict.get("prthinker_setting_save_failed").format(path=setting_path()))) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py index 82fd5f1e..22743334 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py @@ -26,6 +26,7 @@ prompt_dir, prompt_path, read_prompt_file ) from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.plain_text import as_text @dataclass(frozen=True) @@ -141,7 +142,7 @@ def _may_discard_edits(self, question_key: str, filename: str) -> bool: word = language_wrapper.language_word_dict reply = QMessageBox.question( self, word.get(self._labels.info_title), - word.get(question_key).format(filename=filename), + as_text(word.get(question_key).format(filename=filename)), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) return reply == QMessageBox.StandardButton.Yes @@ -184,8 +185,8 @@ def _show_file(self, path: Path) -> None: return QMessageBox.information( self, language_wrapper.language_word_dict.get(self._labels.info_title), - language_wrapper.language_word_dict.get("prompt_editor_not_utf8").format( - filename=path.name)) + as_text(language_wrapper.language_word_dict.get("prompt_editor_not_utf8").format( + filename=path.name))) self._show_text(text) def _show_text(self, text: str) -> None: @@ -201,7 +202,7 @@ def create_file(self) -> None: if Path(self.current_file).is_file(): QMessageBox.information( self, word.get(self._labels.info_title), - word.get(self._labels.file_exists).format(filename=self.current_file)) + as_text(word.get(self._labels.file_exists).format(filename=self.current_file))) return # Text typed into the empty editor would be replaced by the template @@ -217,7 +218,7 @@ def create_file(self) -> None: self.watcher.addPath(self.current_file) QMessageBox.information( self, word.get(self._labels.success_title), - word.get(self._labels.file_created).format(filename=self.current_file)) + as_text(word.get(self._labels.file_created).format(filename=self.current_file))) self.load_file_content(self.file_selector.currentIndex()) def may_close(self) -> bool: @@ -270,4 +271,4 @@ def save_file(self) -> None: self.middle_editor.document().setModified(False) QMessageBox.information( self, word.get(self._labels.success_title), - word.get(self._labels.file_saved).format(filename=self.current_file)) + as_text(word.get(self._labels.file_saved).format(filename=self.current_file))) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py index 7636b272..39252aeb 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py @@ -12,6 +12,7 @@ from pybreeze.utils.file_process.replace_file import replace_text from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.plain_text import as_text def save_prompt_text(parent: QWidget, path: str, content: str, error_title: str) -> bool: @@ -32,5 +33,5 @@ def save_prompt_text(parent: QWidget, path: str, content: str, error_title: str) return True except OSError as error: pybreeze_logger.error("Prompt file write failed for %s: %r", path, error) - QMessageBox.warning(parent, error_title, error.strerror or type(error).__name__) + QMessageBox.warning(parent, error_title, as_text(error.strerror or type(error).__name__)) return False 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 6eafe50c..5c6bfb31 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 @@ -23,6 +23,7 @@ ) from pybreeze.utils.network.public_http import overall_deadline, public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url +from pybreeze.pybreeze_ui.plain_text import as_text # Where a skill template wants the code; the user puts it there before sending @@ -177,7 +178,7 @@ def _may_replace_edits(self, name: str) -> bool: """ reply = QMessageBox.question( self, language_wrapper.language_word_dict.get("skills_prompt_select_label"), - language_wrapper.language_word_dict.get("skills_switch_over_edits").format(name=name), + as_text(language_wrapper.language_word_dict.get("skills_switch_over_edits").format(name=name)), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) return reply == QMessageBox.StandardButton.Yes diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py index dd3e2bd0..8bbc38d5 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py @@ -12,6 +12,7 @@ from pybreeze.pybreeze_ui.menu.menu_utils import open_web_browser from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.plain_text import as_text if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -129,7 +130,7 @@ def _create() -> None: except ImportError as error: pybreeze_logger.error("Failed to import %s: %r", import_name, error) QMessageBox.warning( - ui, title, word.get("create_project_not_installed").format(package=import_name, error=error)) + ui, title, as_text(word.get("create_project_not_installed").format(package=import_name, error=error))) return project = Path(getattr(ui, "working_dir", None) or Path.cwd()) target = project / _project_folder_name(package.create_project_dir) @@ -139,9 +140,9 @@ def _create() -> None: package.create_project_dir(project_path=str(project)) except Exception as error: # noqa: BLE001 — each package raises its own exception types for a write it could not make; it is logged and reported pybreeze_logger.error("%s project not created in %s: %r", import_name, project, error) - QMessageBox.warning(ui, title, word.get("create_project_failed").format(path=target, error=error)) + QMessageBox.warning(ui, title, as_text(word.get("create_project_failed").format(path=target, error=error))) return - QMessageBox.information(ui, title, word.get("create_project_created").format(path=target)) + QMessageBox.information(ui, title, as_text(word.get("create_project_created").format(path=target))) return _create @@ -156,7 +157,7 @@ def _project_folder_name(create_project_dir: Callable) -> str: def _may_replace(ui: PyBreezeMainWindow, title: str, target: Path) -> bool: """Ask whether the template files under *target* may be written over.""" reply = QMessageBox.question( - ui, title, language_wrapper.language_word_dict.get("create_project_exists").format(path=target), + ui, title, as_text(language_wrapper.language_word_dict.get("create_project_exists").format(path=target)), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) return reply == QMessageBox.StandardButton.Yes diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py index 94b47eff..3d6956b5 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py @@ -13,6 +13,7 @@ init_and_start_test_pioneer_process from pybreeze.pybreeze_ui.syntax.syntax_keyword import TEST_PIONEER_SUFFIXES from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.plain_text import as_text if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -67,7 +68,7 @@ def create_template(ui_we_want_to_set: PyBreezeMainWindow) -> None: if template.exists(): reply = QMessageBox.question( ui_we_want_to_set, title, - word.get("test_pioneer_template_exists").format(path=template), + as_text(word.get("test_pioneer_template_exists").format(path=template)), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) if reply != QMessageBox.StandardButton.Yes: @@ -78,10 +79,10 @@ def create_template(ui_we_want_to_set: PyBreezeMainWindow) -> None: pybreeze_logger.error("TestPioneer template not created in %s: %r", project, error) QMessageBox.warning( ui_we_want_to_set, title, - word.get("test_pioneer_template_failed").format(path=template, error=error)) + as_text(word.get("test_pioneer_template_failed").format(path=template, error=error))) return QMessageBox.information( - ui_we_want_to_set, title, word.get("test_pioneer_template_created").format(path=template)) + ui_we_want_to_set, title, as_text(word.get("test_pioneer_template_created").format(path=template))) def check_file(ui_we_want_to_set: PyBreezeMainWindow): diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index caac811b..fad72650 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -25,6 +25,7 @@ from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess from pybreeze.extend.process_executor.process_executor_utils import open_run_window from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.plain_text import as_text if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -93,9 +94,9 @@ def save_current_file_for_run(main_window: PyBreezeMainWindow) -> str | None: pybreeze_logger.error("Save before run failed: %r", error.__cause__ or error) QMessageBox.warning( main_window, language_wrapper.language_word_dict.get("run_with_menu_label"), - language_wrapper.language_word_dict.get("run_with_save_failed").format( + as_text(language_wrapper.language_word_dict.get("run_with_save_failed").format( file=Path(str(widget.current_file or "")).name, - error=error.__cause__ or error)) + error=error.__cause__ or error))) return None diff --git a/pybreeze/pybreeze_ui/tools_gui/output_actions.py b/pybreeze/pybreeze_ui/tools_gui/output_actions.py index dba97739..f88c2e4f 100644 --- a/pybreeze/pybreeze_ui/tools_gui/output_actions.py +++ b/pybreeze/pybreeze_ui/tools_gui/output_actions.py @@ -20,6 +20,7 @@ from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text from pybreeze.utils.file_process.replace_file import replace_text from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.plain_text import as_text # A value that is either fixed or computed on demand (e.g. depends on a selector) StrOrCallable = str | Callable[[], str] @@ -135,7 +136,7 @@ def save_to_file(self) -> str | None: word = language_wrapper.language_word_dict QMessageBox.warning( self._parent, word.get("output_actions_save_failed_title"), - word.get("output_actions_save_failed_message").format( - file=Path(path).name, error=error.strerror or error)) + as_text(word.get("output_actions_save_failed_message").format( + file=Path(path).name, error=error.strerror or error))) return None return path diff --git a/test/test_utils/test_diagram_images.py b/test/test_utils/test_diagram_images.py index 88c4b086..bb7334dc 100644 --- a/test/test_utils/test_diagram_images.py +++ b/test/test_utils/test_diagram_images.py @@ -217,7 +217,10 @@ def refuse(_source: str) -> bytes: editor._add_image_from_url() _wait_until(app, lambda: warned) - assert warned == ["nothing answered"] + # As text: the message quotes the server, and Qt read markup in it + from pybreeze.pybreeze_ui.plain_text import as_text + + assert warned == [as_text("nothing answered")] assert editor._scene.get_all_images() == [] editor.close() diff --git a/test/test_utils/test_message_boxes_show_text.py b/test/test_utils/test_message_boxes_show_text.py new file mode 100644 index 00000000..27506748 --- /dev/null +++ b/test/test_utils/test_message_boxes_show_text.py @@ -0,0 +1,48 @@ +"""A message box shows a file name, a path, a host or an error as text, never as markup. + +Qt reads markup in a message box's text (``Qt::AutoText``) and loads an +```` in it. Text that comes from a server or a file -- a reason phrase, a +file name, an ``OSError``'s message -- reached the boxes as it was. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +import pybreeze + +_STATIC_BOXES = {"warning", "critical", "information", "question", "about"} +_BOX_TEXT_SETTERS = {"setText", "setInformativeText", "setDetailedText"} + + +def _is_plain(node: ast.AST) -> bool: + """A literal, a word-dict entry as it is, or text already passed through ``as_text``.""" + if isinstance(node, ast.Constant): + return True + if not isinstance(node, ast.Call): + return False + func = node.func + if isinstance(func, ast.Name): + return func.id in ("as_text", "_lang") + return isinstance(func, ast.Attribute) and func.attr == "get" + + +def _box_texts(tree: ast.AST): + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + func = node.func + if (func.attr in _STATIC_BOXES and isinstance(func.value, ast.Name) + and func.value.id == "QMessageBox" and len(node.args) >= 3): + yield node.args[2] + elif func.attr in _BOX_TEXT_SETTERS and "box" in ast.unparse(func.value).lower() and node.args: + yield node.args[0] + + +def test_every_message_box_text_is_plain_or_goes_through_as_text(): + unsafe = [] + for path in Path(pybreeze.__file__).parent.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + unsafe += [f"{path.name}:{text.lineno}: {ast.unparse(text)[:80]}" + for text in _box_texts(tree) if not _is_plain(text)] + assert not unsafe, "Message box text not passed through as_text():\n" + "\n".join(unsafe) From 1a6329df95f40bbdd27f6ed69d6e6e17c7e69cd5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:22:57 +0800 Subject: [PATCH 233/312] Drop a half-made connection when the diagram is rebuilt --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++ docs/updates/README.md | 3 +- .../diagram_editor/diagram_editor_widget.py | 4 ++- .../diagram_editor/diagram_scene.py | 9 ++++- test/test_utils/test_diagram_undo_fidelity.py | 33 +++++++++++++++++++ 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 61f45e41..50e069c1 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 120 個 `test_*.py`、1989 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 120 個 `test_*.py`、1991 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index ac2033a8..777e045f 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3110,3 +3110,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: full suite: 1087 passed and 886 passed, 15 skipped, with the one expectation then updated (14 passed). 1989 tests in 120 files. `ruff check` clean. - **Files**: `diagram_editor/diagram_editor_widget.py`, `extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `extend_ai_gui/prompt_edit_gui/prompt_file_io.py`, `extend_ai_gui/skills/skills_send_gui.py`, `menu/automation_menu/automation_menu_factory.py`, `menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py`, `menu/plugin_menu/build_run_with_menu.py`, `tools_gui/output_actions.py`, `dialog/prthinker_setting_dialog.py`, `connect_gui/ssh/ssh_host_key_policy.py`, `extend/process_executor/process_executor_utils.py`, `test/test_utils/test_message_boxes_show_text.py` (new), `test/test_utils/test_diagram_images.py`, `CLAUDE.md` (Security › Qt), `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-196 · 2026-09-24 · Drop a half-made connection when the diagram is rebuilt · #fix #diagram + +- **What**: with the Connection tool, clicking a node starts a connection from it. An undo or redo, Open, New or a Mermaid import rebuilds every item, but the scene kept the old node as the connection's source. Clicking a second node then finished a connection from a node no longer on the canvas: `to_dict` raised `KeyError` out of the mouse press, and from then on every snapshot did, so Save raised out of its slot with no file written and no message, and Delete failed too (found by review). +- **Fix**: `_clear_items`, which every rebuild goes through, cancels a half-made connection first. `_write_json` also catches `KeyError` and reports it like the other save failures. +- **Tests**: `test_diagram_undo_fidelity.py` +2 (a connection started before an undo and redo, and before a load, is not finished from the replaced node, and the scene still snapshots). +- **Result / numbers**: every diagram test and the unsaved-close tests, 116 passed. 1991 tests in 120 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_diagram_undo_fidelity.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index b9912fd4..2b05e688 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-196 | 2026-09-24 | Drop a half-made connection when the diagram is rebuilt | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-195 | 2026-09-24 | Show file names, paths, hosts and errors in message boxes as text, and check every box | #fix #security #ui | [2026-09](2026-09.md) | | U-20260923-194 | 2026-09-24 | Wrap the three code lines past 120 characters | #refactor | [2026-09](2026-09.md) | | U-20260923-193 | 2026-09-24 | Bound a whole diagram image download, and a body cut short by the deadline | #fix #diagram #security | [2026-09](2026-09.md) | @@ -276,4 +277,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 213 | +| [2026-09.md](2026-09.md) | 2026-09 | 214 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index bb858ae7..2732f8b7 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -505,7 +505,9 @@ def _write_json(self, path: Path) -> None: try: data = self._scene.to_dict() replace_text(path, json.dumps(data, indent=2, ensure_ascii=False)) - except (OSError, TypeError, ValueError) as e: + # KeyError: a connection whose node is not on the canvas; it escaped the + # slot, and Save did nothing without a word + except (OSError, TypeError, ValueError, KeyError) as e: pybreeze_logger.error("Save diagram failed: %r", e) reason = e.strerror if isinstance(e, OSError) and e.strerror else str(e) QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), as_text(reason)) diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 39e7a8d3..1ab3fa42 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -716,7 +716,14 @@ def _restore_stacking(self) -> None: self.addItem(item) def _clear_items(self) -> None: - """Remove all diagram items without clearing the scene entirely.""" + """Remove all diagram items without clearing the scene entirely. + + A connection half made (its first node clicked) is dropped too: every + rebuild (undo, redo, Open, New, a Mermaid import) comes through here, + and a connection finished from a node no longer on the canvas made + every later snapshot, and so Save, fail with ``KeyError``. + """ + self._cancel_connection() # snapshot: removeItem() mutates scene items during iteration for item in tuple(self.items()): if isinstance(item, (DiagramNode, DiagramConnection, DiagramImage)): diff --git a/test/test_utils/test_diagram_undo_fidelity.py b/test/test_utils/test_diagram_undo_fidelity.py index 2b55d162..14f6c19f 100644 --- a/test/test_utils/test_diagram_undo_fidelity.py +++ b/test/test_utils/test_diagram_undo_fidelity.py @@ -102,3 +102,36 @@ def test_a_mouse_move_is_still_one_step(app): scene.end_undo() assert scene.undo_stack.count() == 1 + + +def _click(scene: DiagramScene, x: float, y: float) -> None: + press = QGraphicsSceneMouseEvent(QEvent.Type.GraphicsSceneMousePress) + press.setScenePos(QPointF(x, y)) + press.setButton(Qt.MouseButton.LeftButton) + press.setButtons(Qt.MouseButton.LeftButton) + scene.mousePressEvent(press) + + +@pytest.mark.parametrize("rebuild", ["undo and redo", "load"]) +def test_a_half_made_connection_does_not_outlive_a_rebuild(app, rebuild): + # Its first node was replaced by the rebuild; finished, the connection + # pointed at a node no longer on the canvas, and every snapshot (so Save, + # and Delete) raised KeyError from then on + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import ToolMode + + scene = DiagramScene() + with scene.undo_scope("Add"): + scene.addItem(DiagramNode(x=0, y=0, w=100, h=60, text="A")) + scene.addItem(DiagramNode(x=300, y=0, w=100, h=60, text="B")) + scene.mode = ToolMode.ADD_CONNECTION + _click(scene, 50, 30) + if rebuild == "load": + scene.load_from_dict(scene.to_dict()) + else: + scene.undo_stack.undo() + scene.undo_stack.redo() + + _click(scene, 350, 30) + + assert scene.get_all_connections() == [] + scene.to_dict() From b71a06a411dd085d7c706cdbd5c3e0c1884552c5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:24:39 +0800 Subject: [PATCH 234/312] Free the diagram editor after it has fetched an image from a URL --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++ docs/updates/README.md | 3 +- .../diagram_editor/diagram_editor_widget.py | 13 ++++++- .../test_closed_panels_are_freed.py | 36 +++++++++++++++++++ 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 50e069c1..d49c293e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 120 個 `test_*.py`、1991 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 120 個 `test_*.py`、1992 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 777e045f..416eab03 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3119,3 +3119,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: every diagram test and the unsaved-close tests, 116 passed. 1991 tests in 120 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_diagram_undo_fidelity.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-197 · 2026-09-24 · Free the diagram editor after it has fetched an image from a URL · #fix #diagram + +- **What**: Add Image from URL connected the fetch's `finished` to a lambda holding the editor and the fetch. Once the fetch had ended and been dropped from `_url_fetches`, that connection kept the fetch and the editor alive through Qt: the closed and deleted editor, its scene and its image cache (up to 20 MB an image) stayed until the IDE exited (found by review; closing during the fetch, the scene's own downloads and an editor with no fetch were already freed). +- **Fix**: `finished` goes to a bound method, `_forget_fetch`, which takes the fetch from `sender()`, waits for it to end (`finished` is emitted just before the thread does, and a `QThread` freed while running makes Qt abort) and drops it. +- **Tests**: `test_closed_panels_are_freed.py` +1 (the editor is freed after an image fetched from a URL; fails with the lambda put back). +- **Result / numbers**: the reviewer's probe: the editor and its scene are freed after a fetch, when closed mid-fetch, after a URL image loaded from a file, and with no fetch. Diagram and freed-panel tests, 111 passed. 1992 tests in 120 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_closed_panels_are_freed.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 2b05e688..b6551b42 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-197 | 2026-09-24 | Free the diagram editor after it has fetched an image from a URL | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-196 | 2026-09-24 | Drop a half-made connection when the diagram is rebuilt | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-195 | 2026-09-24 | Show file names, paths, hosts and errors in message boxes as text, and check every box | #fix #security #ui | [2026-09](2026-09.md) | | U-20260923-194 | 2026-09-24 | Wrap the three code lines past 120 characters | #refactor | [2026-09](2026-09.md) | @@ -277,4 +278,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 214 | +| [2026-09.md](2026-09.md) | 2026-09 | 215 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 2732f8b7..177b6f06 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -632,10 +632,21 @@ def _add_image_from_url(self) -> None: fetch = ImageDownloadThread(url.strip()) fetch.fetched.connect(self._on_url_image_fetched) fetch.failed.connect(self._on_url_image_failed) - fetch.finished.connect(lambda: self._url_fetches.discard(fetch)) + # A bound method: a lambda holding this editor and the fetch, on the + # fetch, kept the closed editor, its scene and every image alive + fetch.finished.connect(self._forget_fetch) self._url_fetches.add(fetch) fetch.start() + def _forget_fetch(self) -> None: + """Let go of the fetch that just finished. UI thread.""" + fetch = self.sender() + if isinstance(fetch, ImageDownloadThread): + # finished is emitted just before the thread ends; freed while it + # still runs, Qt would abort + fetch.wait() + self._url_fetches.discard(fetch) + def _on_url_image_fetched(self, url: str, data: bytes) -> None: """Put a fetched image on the canvas, or say it was not one. UI thread.""" pix = QPixmap() diff --git a/test/test_utils/test_closed_panels_are_freed.py b/test/test_utils/test_closed_panels_are_freed.py index 5aac6556..516b4dcc 100644 --- a/test/test_utils/test_closed_panels_are_freed.py +++ b/test/test_utils/test_closed_panels_are_freed.py @@ -110,3 +110,39 @@ def connect(widget) -> None: # The client's host-key policy held the panel as its dialog parent assert _freed_after_a_run( app, ssh_command_widget.SSHCommandWidget, connect, lambda widget: widget._connecting) + + +def test_the_diagram_editor_after_an_image_from_a_url(app, monkeypatch): + # The fetch's finished held a lambda holding the editor: the closed editor, + # its scene and every image in it stayed + from PySide6.QtCore import QBuffer, QByteArray, QIODevice + from PySide6.QtGui import QColor, QImage + from PySide6.QtWidgets import QInputDialog + + from pybreeze.pybreeze_ui.diagram_editor import diagram_scene + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + + image = QImage(4, 4, QImage.Format.Format_ARGB32) + image.fill(QColor("red")) + png = QByteArray() + buffer = QBuffer(png) + buffer.open(QIODevice.OpenModeFlag.WriteOnly) + image.save(buffer, "PNG") + monkeypatch.setattr(diagram_scene, "safe_download_image", lambda _url: bytes(png.data())) + monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *_args, **_kwargs: ("http://x.test/a.png", True))) + + def add_image(widget) -> None: + widget._add_image_from_url() + (fetch,) = widget._url_fetches + _wait_for(app, fetch) + deadline = time.monotonic() + 5 + while widget._url_fetches and time.monotonic() < deadline: + app.processEvents() + assert len(widget._scene.get_all_images()) == 1 + + class _Done: + @staticmethod + def isRunning() -> bool: + return False + + assert _freed_after_a_run(app, DiagramEditorWidget, add_image, lambda _widget: _Done) From 48665c52f407f2835f779835e51d8f47af1475db Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:26:24 +0800 Subject: [PATCH 235/312] Free the SFTP tree after its listings and menu requests --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 +++++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_file_viewer_widget.py | 20 ++++++++-- .../test_closed_panels_are_freed.py | 40 +++++++++++++++++++ 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index d49c293e..735ac5c0 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 120 個 `test_*.py`、1992 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 120 個 `test_*.py`、1993 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 416eab03..59cd2e15 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3128,3 +3128,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: the reviewer's probe: the editor and its scene are freed after a fetch, when closed mid-fetch, after a URL image loaded from a file, and with no fetch. Diagram and freed-panel tests, 111 passed. 1992 tests in 120 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_closed_panels_are_freed.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-198 · 2026-09-24 · Free the SFTP tree after its listings and menu requests · #fix #ssh + +- **What**: the SFTP tree dropped a finished listing or menu request from its set with a lambda connected to that worker's own `finished`, holding the worker. The worker then kept itself alive through Qt, and its other slots (the rows, the failure, the follow-up), which hold the tree, kept the closed tree alive with it: the same pattern U-20260923-197 fixed in the diagram editor. +- **Fix**: both go to a bound method, `_forget_worker`, which takes the worker from `sender()`, waits for it to end and drops it from whichever set holds it. +- **Tests**: `test_closed_panels_are_freed.py` +1 (the tree is freed after a listing; fails with the lambda put back). +- **Result / numbers**: SSH and SFTP tests, 135 passed; freed-panel tests 6 passed. 1993 tests in 120 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_closed_panels_are_freed.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index b6551b42..6f082eec 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-198 | 2026-09-24 | Free the SFTP tree after its listings and menu requests | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-197 | 2026-09-24 | Free the diagram editor after it has fetched an image from a URL | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-196 | 2026-09-24 | Drop a half-made connection when the diagram is rebuilt | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-195 | 2026-09-24 | Show file names, paths, hosts and errors in message boxes as text, and check every box | #fix #security #ui | [2026-09](2026-09.md) | @@ -278,4 +279,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 215 | +| [2026-09.md](2026-09.md) | 2026-09 | 216 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 737dc31c..7614e591 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -11,7 +11,7 @@ import stat from collections.abc import Callable -from PySide6.QtCore import Qt, QEvent, Signal +from PySide6.QtCore import Qt, QEvent, QThread, Signal from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QLineEdit, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu, QFileDialog, QMessageBox, QSplitter, QInputDialog, QStyle @@ -328,7 +328,7 @@ def populate_children(self, parent_item: QTreeWidgetItem): lambda rows: self._listing_done(parent_item, generation, serial, rows)) listing.failed.connect( lambda message: self._listing_failed(parent_item, generation, serial, message)) - listing.finished.connect(lambda: self._listings.discard(listing)) + listing.finished.connect(self._forget_worker) self._listings.add(listing) listing.start() @@ -452,10 +452,24 @@ def _in_background(self, call: Callable[[], None], after: Callable[[], None]) -> thread = SftpCallThread(call) thread.done.connect(lambda: after() if generation == self._tree_generation else None) thread.failed.connect(self._operation_failed) - thread.finished.connect(lambda: self._calls.discard(thread)) + thread.finished.connect(self._forget_worker) self._calls.add(thread) thread.start() + def _forget_worker(self) -> None: + """Let go of the listing or call that just finished. UI thread. + + A bound method: a lambda holding the worker, on the worker, kept it, + and through its other slots this tree, alive after the tree closed. + """ + worker = self.sender() + if isinstance(worker, QThread): + # finished is emitted just before the thread ends; freed while it + # still runs, Qt would abort + worker.wait() + self._listings.discard(worker) + self._calls.discard(worker) + def _operation_failed(self, message: str) -> None: """Say that a menu request failed, and why. UI thread.""" QMessageBox.critical( diff --git a/test/test_utils/test_closed_panels_are_freed.py b/test/test_utils/test_closed_panels_are_freed.py index 516b4dcc..42bd0d9a 100644 --- a/test/test_utils/test_closed_panels_are_freed.py +++ b/test/test_utils/test_closed_panels_are_freed.py @@ -146,3 +146,43 @@ def isRunning() -> bool: return False assert _freed_after_a_run(app, DiagramEditorWidget, add_image, lambda _widget: _Done) + + +def test_the_sftp_tree_after_a_listing(app): + # The listing's finished held a lambda holding the listing: the listing + # stayed, and its other slots kept the closed tree alive + import stat + from types import SimpleNamespace + + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SSHFileTreeManager + + class Client: + connected = True + + @staticmethod + def list_dir(_path: str): + return [SimpleNamespace(filename="a.txt", st_mode=stat.S_IFREG, st_size=1)] + + @staticmethod + def close() -> None: + """Nothing to close.""" + + def build() -> SSHFileTreeManager: + tree = SSHFileTreeManager() + tree.client = Client() + return tree + + def list_root(tree) -> None: + tree.load_root("/") + deadline = time.monotonic() + 5 + while tree._listings and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.01) + assert not tree._listings + + class _Done: + @staticmethod + def isRunning() -> bool: + return False + + assert _freed_after_a_run(app, build, list_root, lambda _tree: _Done) From e90b3952d5da7986b37c76d8257ef3cedf4db6fa Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:28:49 +0800 Subject: [PATCH 236/312] Free the Regex tab and the SFTP tree after a match or a transfer --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 9 ++++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_file_viewer_widget.py | 14 ++++++ pybreeze/pybreeze_ui/tools_gui/regex_gui.py | 7 ++- .../test_closed_panels_are_freed.py | 44 +++++++++++++++++++ test/test_utils/test_sftp_upload.py | 12 +++-- 7 files changed, 85 insertions(+), 6 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 735ac5c0..11044de0 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 120 個 `test_*.py`、1993 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 120 個 `test_*.py`、1995 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 59cd2e15..4a5b62e2 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3137,3 +3137,12 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: SSH and SFTP tests, 135 passed; freed-panel tests 6 passed. 1993 tests in 120 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_closed_panels_are_freed.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-199 · 2026-09-24 · Free the Regex tab and the SFTP tree after a match or a transfer · #fix #tools #ssh + +- **What**: two more panels kept a finished worker whose slots held the panel, so the closed panel stayed in memory: the Regex tab (`_match_thread`, whose `finished` re-enabled Test through a lambda holding the tab) and the SFTP tree (`_transfer`, whose done / exists slots hold the tree and the follow-up it was given). Found by searching for the pattern after U-20260923-190, -197 and -198. +- **Fix**: the Regex tab re-enables Test from a bound method (`_enable_test`). The SFTP tree lets go of a finished transfer (`_forget_transfer`: waits for the thread to end, then sets `_transfer` to `None`), which frees its slots with it; `_ask_to_replace` and the busy check already allowed `None`. +- **Tests**: `test_closed_panels_are_freed.py` +2 (the Regex tab after a match; the SFTP tree after an upload; both fail on the old code). `test_sftp_upload.py`'s helper treats a transfer let go of as finished. +- **Result / numbers**: SSH, SFTP and Regex tests, 174 passed; freed-panel tests 8 passed. 1995 tests in 120 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/regex_gui.py`, `connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_closed_panels_are_freed.py`, `test/test_utils/test_sftp_upload.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 6f082eec..484400c6 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-199 | 2026-09-24 | Free the Regex tab and the SFTP tree after a match or a transfer | #fix #tools #ssh | [2026-09](2026-09.md) | | U-20260923-198 | 2026-09-24 | Free the SFTP tree after its listings and menu requests | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-197 | 2026-09-24 | Free the diagram editor after it has fetched an image from a URL | #fix #diagram | [2026-09](2026-09.md) | | U-20260923-196 | 2026-09-24 | Drop a half-made connection when the diagram is rebuilt | #fix #diagram | [2026-09](2026-09.md) | @@ -279,4 +280,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 216 | +| [2026-09.md](2026-09.md) | 2026-09 | 217 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 7614e591..c6d50448 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -609,12 +609,26 @@ def _start_transfer(self, *, downloading: bool, remote_path: str, local_path: st self._transfer.done.connect( lambda path: self._transfer_done(title, message, path, after)) self._transfer.failed.connect(self._transfer_failed) + self._transfer.finished.connect(self._forget_transfer) self._transfer.exists.connect(lambda path: self._ask_to_replace(path, lambda: self._start_transfer( downloading=False, remote_path=remote_path, local_path=local_path, title=title, message=message, after=after, replace=True))) self._transfer.start() return True + def _forget_transfer(self) -> None: + """Let go of the transfer that just finished. UI thread. + + Its slots hold this tree (and the follow-up it was given): kept after + it ended, it kept the closed tree alive. + """ + transfer = self.sender() + if isinstance(transfer, QThread) and transfer is self._transfer: + # finished is emitted just before the thread ends; freed while it + # still runs, Qt would abort + transfer.wait() + self._transfer = None + def _refused_while_transferring(self) -> bool: """Say a transfer is going and return ``True`` if one is; ``False`` otherwise. UI thread.""" if self._transfer is None or not self._transfer.isRunning(): diff --git a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py index 65fbeb6b..ca7d3234 100644 --- a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py @@ -136,10 +136,15 @@ def test(self) -> None: self.pattern_edit.text(), exact_text(self.text_edit), self.selected_flags()) thread.matched.connect(self._show_matches) thread.failed.connect(self._show_error) - thread.finished.connect(lambda: self.test_button.setEnabled(True)) + thread.finished.connect(self._enable_test) self._match_thread = thread thread.start() + def _enable_test(self) -> None: + """Let the next match start. A bound method: a lambda holding the tab, + on the thread the tab keeps, kept the closed tab alive.""" + self.test_button.setEnabled(True) + def _show_matches(self, matches: list) -> None: self._valid_output = True self.output_edit.setPlainText( diff --git a/test/test_utils/test_closed_panels_are_freed.py b/test/test_utils/test_closed_panels_are_freed.py index 42bd0d9a..f32daa84 100644 --- a/test/test_utils/test_closed_panels_are_freed.py +++ b/test/test_utils/test_closed_panels_are_freed.py @@ -186,3 +186,47 @@ def isRunning() -> bool: return False assert _freed_after_a_run(app, build, list_root, lambda _tree: _Done) + + +def test_the_regex_tab(app): + from pybreeze.pybreeze_ui.tools_gui.regex_gui import RegexGUI + + def match(widget) -> None: + widget.pattern_edit.setText("a") + widget.text_edit.setPlainText("banana") + widget.test() + + assert _freed_after_a_run(app, RegexGUI, match, lambda widget: widget._match_thread) + + +def test_the_sftp_tree_after_an_upload(app, monkeypatch, tmp_path): + # The finished transfer stayed on the tree with its slots, which hold the tree + from PySide6.QtWidgets import QMessageBox + + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SSHFileTreeManager + from test_utils.test_sftp_upload import FakeSftp, _wrapper + + local_file = tmp_path / "a.txt" + local_file.write_bytes(b"x") + monkeypatch.setattr(QMessageBox, "information", lambda *_args: None) + + def build() -> SSHFileTreeManager: + tree = SSHFileTreeManager() + tree.client = _wrapper(FakeSftp()) + return tree + + def upload(tree) -> None: + assert tree._start_transfer(downloading=False, remote_path="/a.txt", local_path=str(local_file), + title="Uploaded", message="Uploaded to") + deadline = time.monotonic() + 5 + while tree._transfer is not None and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.01) + assert tree._transfer is None + + class _Done: + @staticmethod + def isRunning() -> bool: + return False + + assert _freed_after_a_run(app, build, upload, lambda _tree: _Done) diff --git a/test/test_utils/test_sftp_upload.py b/test/test_utils/test_sftp_upload.py index 3a3a9a8c..3452b083 100644 --- a/test/test_utils/test_sftp_upload.py +++ b/test/test_utils/test_sftp_upload.py @@ -142,13 +142,19 @@ def question(*args, **_kwargs): widget._start_transfer(downloading=False, remote_path="/config.yaml", local_path=local_file, title="Uploaded", message="Uploaded to") deadline = time.monotonic() + WAIT_SECONDS - while widget._transfer.isRunning() or (asked and answer == QMessageBox.StandardButton.Yes - and sftp.files.get("/config.yaml") != b"new config"): + + def transferring() -> bool: + # A finished transfer is let go of (it is then None) + return widget._transfer is not None and widget._transfer.isRunning() + + while transferring() or (asked and answer == QMessageBox.StandardButton.Yes + and sftp.files.get("/config.yaml") != b"new config"): assert time.monotonic() < deadline, "timed out" app.processEvents() time.sleep(0.01) app.processEvents() - widget._transfer.wait(1000) + if widget._transfer is not None: + widget._transfer.wait(1000) widget.close() return asked From 58cbe7af6c4f1d309ff31c3b6772f45ecd62871f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:33:01 +0800 Subject: [PATCH 237/312] Check that every tool tab opened and closed is freed --- architecture_explore.md | 2 +- docs/updates/2026-09.md | 8 +++ docs/updates/README.md | 3 +- .../test_closed_panels_are_freed.py | 67 ++++++++++++++----- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 11044de0..e9dc9792 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -449,7 +449,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 120 個 `test_*.py`、1995 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 120 個 `test_*.py`、2015 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 4a5b62e2..84a607a5 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -3146,3 +3146,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Result / numbers**: SSH, SFTP and Regex tests, 174 passed; freed-panel tests 8 passed. 1995 tests in 120 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/tools_gui/regex_gui.py`, `connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_closed_panels_are_freed.py`, `test/test_utils/test_sftp_upload.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-200 · 2026-09-24 · Check that every tool tab opened and closed is freed · #test + +- **What**: a guard for the leak class fixed in U-20260923-190 and -197 to -199. `test_closed_panels_are_freed.py` now also builds each of the 20 tool tabs the Tools menu offers (`tools_menu._WIDGET_FACTORIES`), closes and deletes it, and requires it to be gone after a collection. All 20 are. +- **Found while writing it**: the AI review client stayed alive under pytest only. Its constructor logs a missing stats file with the `FileNotFoundError` as the record's argument; pytest's capture handler keeps every record, the exception keeps its traceback and the frames keep the panel. The IDE's own handler writes a line to a file and keeps nothing, so only the test clears the captured records before collecting (`clear()`, which empties pytest's list in place, not `reset()`). The four `_Done` stand-ins became one `_NothingRunning`. +- **Result / numbers**: `test_closed_panels_are_freed.py` 28 passed. 2015 tests in 120 files. `ruff check` clean. +- **Files**: `test/test_utils/test_closed_panels_are_freed.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 484400c6..7dac62f1 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-200 | 2026-09-24 | Check that every tool tab opened and closed is freed | #test | [2026-09](2026-09.md) | | U-20260923-199 | 2026-09-24 | Free the Regex tab and the SFTP tree after a match or a transfer | #fix #tools #ssh | [2026-09](2026-09.md) | | U-20260923-198 | 2026-09-24 | Free the SFTP tree after its listings and menu requests | #fix #ssh | [2026-09](2026-09.md) | | U-20260923-197 | 2026-09-24 | Free the diagram editor after it has fetched an image from a URL | #fix #diagram | [2026-09](2026-09.md) | @@ -280,4 +281,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-09.md](2026-09.md) | 2026-09 | 217 | +| [2026-09.md](2026-09.md) | 2026-09 | 218 | diff --git a/test/test_utils/test_closed_panels_are_freed.py b/test/test_utils/test_closed_panels_are_freed.py index f32daa84..5f9be74b 100644 --- a/test/test_utils/test_closed_panels_are_freed.py +++ b/test/test_utils/test_closed_panels_are_freed.py @@ -27,6 +27,14 @@ def app(): return instance +class _NothingRunning: + """For a panel whose worker has already been waited for (and let go of).""" + + @staticmethod + def isRunning() -> bool: + return False + + def _wait_for(app, thread, seconds: float = 10) -> None: deadline = time.monotonic() + seconds while thread.isRunning() and time.monotonic() < deadline: @@ -51,10 +59,27 @@ def _freed_after_a_run(app, build, drive, thread_of) -> bool: widget.deleteLater() QCoreApplication.sendPostedEvents(widget, QEvent.Type.DeferredDelete) del widget + _forget_captured_log_records() gc.collect() return watch() is None +def _forget_captured_log_records() -> None: + """Drop the records pytest has captured so far. + + A record logged with an exception as its argument keeps the exception's + traceback, and through its frames the panel that logged it. The IDE's own + handler writes a line to a file and keeps nothing; pytest's keeps them all. + """ + import logging + + for handler in logging.getLogger().handlers: + # clear(), not reset(): pytest keeps the list reset() replaces + forget = getattr(handler, "clear", None) + if callable(forget): + forget() + + def _cot(widget) -> None: # A URL the panel refuses to send to: the thread ends at once, with no network widget.url_input.setText("ftp://example.invalid/") @@ -129,7 +154,8 @@ def test_the_diagram_editor_after_an_image_from_a_url(app, monkeypatch): buffer.open(QIODevice.OpenModeFlag.WriteOnly) image.save(buffer, "PNG") monkeypatch.setattr(diagram_scene, "safe_download_image", lambda _url: bytes(png.data())) - monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *_args, **_kwargs: ("http://x.test/a.png", True))) + monkeypatch.setattr( + QInputDialog, "getText", staticmethod(lambda *_args, **_kwargs: ("http://x.test/a.png", True))) def add_image(widget) -> None: widget._add_image_from_url() @@ -140,12 +166,7 @@ def add_image(widget) -> None: app.processEvents() assert len(widget._scene.get_all_images()) == 1 - class _Done: - @staticmethod - def isRunning() -> bool: - return False - - assert _freed_after_a_run(app, DiagramEditorWidget, add_image, lambda _widget: _Done) + assert _freed_after_a_run(app, DiagramEditorWidget, add_image, lambda _widget: _NothingRunning) def test_the_sftp_tree_after_a_listing(app): @@ -180,12 +201,7 @@ def list_root(tree) -> None: time.sleep(0.01) assert not tree._listings - class _Done: - @staticmethod - def isRunning() -> bool: - return False - - assert _freed_after_a_run(app, build, list_root, lambda _tree: _Done) + assert _freed_after_a_run(app, build, list_root, lambda _tree: _NothingRunning) def test_the_regex_tab(app): @@ -224,9 +240,24 @@ def upload(tree) -> None: time.sleep(0.01) assert tree._transfer is None - class _Done: - @staticmethod - def isRunning() -> bool: - return False + assert _freed_after_a_run(app, build, upload, lambda _tree: _NothingRunning) + + +def _tool_keys() -> list[str]: + from pybreeze.pybreeze_ui.menu.tools import tools_menu + + return list(tools_menu._WIDGET_FACTORIES) - assert _freed_after_a_run(app, build, upload, lambda _tree: _Done) + +@pytest.mark.parametrize("key", _tool_keys()) +def test_every_tool_tab_opened_and_closed_is_freed(app, key): + from PySide6.QtWidgets import QTabWidget + + from pybreeze.pybreeze_ui.menu.tools import tools_menu + + class Main: + tab_widget = QTabWidget() + current_run_code_window: list = [] + + assert _freed_after_a_run( + app, lambda: tools_menu._WIDGET_FACTORIES[key](Main()), lambda _widget: None, lambda _widget: _NothingRunning) From 47adfc4e3a3f8c41eaade1385245e6c728f55d08 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:33:52 +0800 Subject: [PATCH 238/312] Continue the September update log in 2026-09-b.md --- docs/updates/2026-09-b.md | 11 +++++++++++ docs/updates/2026-09.md | 2 +- docs/updates/README.md | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 docs/updates/2026-09-b.md diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md new file mode 100644 index 00000000..5031c929 --- /dev/null +++ b/docs/updates/2026-09-b.md @@ -0,0 +1,11 @@ +# 2026-09 update log, continued + +Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and query commands: [README.md](README.md). New entries go at the end. + +--- + +## U-20260923-201 · 2026-09-24 · Continue the September update log in 2026-09-b.md · #docs + +- **What**: batch rule 2 (`docs/updates/README.md`) says a month's file continues in `YYYY-MM-b.md` past about 800 lines; `2026-09.md` had reached 3156 lines and 218 entries without it. New entries now go to `2026-09-b.md`, listed in the Batches table. Recorded entries stay where they are (rule 5), and their index rows keep pointing at `2026-09.md`; its header now says where it continues. +- **Files**: `docs/updates/2026-09-b.md` (new), `docs/updates/2026-09.md` (header), `docs/updates/README.md` (Batches, index) +- **Open items**: none. diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md index 84a607a5..716783d0 100644 --- a/docs/updates/2026-09.md +++ b/docs/updates/2026-09.md @@ -1,6 +1,6 @@ # 2026-09 update log -Index and query commands: [README.md](README.md). New entries go at the end. +Index and query commands: [README.md](README.md). Continued in [2026-09-b.md](2026-09-b.md): new entries go there. --- diff --git a/docs/updates/README.md b/docs/updates/README.md index 7dac62f1..fc272fa5 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-201 | 2026-09-24 | Continue the September update log in 2026-09-b.md | #docs | [2026-09-b](2026-09-b.md) | | U-20260923-200 | 2026-09-24 | Check that every tool tab opened and closed is freed | #test | [2026-09](2026-09.md) | | U-20260923-199 | 2026-09-24 | Free the Regex tab and the SFTP tree after a match or a transfer | #fix #tools #ssh | [2026-09](2026-09.md) | | U-20260923-198 | 2026-09-24 | Free the SFTP tree after its listings and menu requests | #fix #ssh | [2026-09](2026-09.md) | @@ -282,3 +283,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 1 | From babf3bea1efe5b6715bc2fadae66093c87b927ae Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:43:25 +0800 Subject: [PATCH 239/312] Bring the architecture map, README and CLAUDE.md back in line with the code --- CLAUDE.md | 6 ++++-- README.md | 10 +++++----- architecture_explore.md | 34 ++++++++++++++++++---------------- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- 5 files changed, 38 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7070ecba..dad1efe7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,9 +9,11 @@ Automation-first Python IDE built on PySide6 + JEditor, integrating Web/API/GUI/ ``` pybreeze/ ├── __init__.py # Facade: start_editor, PyBreezeMainWindow, EDITOR_EXTEND_TAB +├── __main__.py # python -m pybreeze ├── pybreeze_ui/ # Presentation layer (PySide6) │ ├── editor_main/ # Main window (extends JEditor) + file tree context menu -│ ├── menu/ # Menu builders: automation / install / tools / plugin / dock +│ ├── menu/ # Menu builders: automation / install / tools (tabs and docks) / plugin, +│ │ # menu_utils, extend_jeditor_tab_menu (the JupyterLab tab entry) │ ├── tools_gui/ # Tool tabs: cURL, HAR, JWT, diff, regex, headers, … │ ├── diagram_editor/ # WYSIWYG diagram editor (QGraphicsScene, Mermaid import) │ ├── extend_ai_gui/ # CoT code review, prompt editors, skill send @@ -111,7 +113,7 @@ ruff check pybreeze/ # before committing non-trivia Reference implementations: `utils/network/url_validation.py` (`validate_url`), `utils/network/public_http.py` (`public_session`), `utils/network/http_client.py` (`read_capped_text`), `diagram_editor/diagram_net_utils.py` (`safe_download_image`). Never pass a user URL to `urlopen()` / `requests.*` unvalidated, and never set `verify=False`. -**SSH** — never `paramiko.AutoAddPolicy()` or `WarningPolicy()`. Use `apply_host_key_policy(client, parent_widget)` from `connect_gui/ssh/ssh_host_key_policy.py`: it shows the SHA256 fingerprint for confirmation on first connect and persists to `~/.pybreeze/ssh_known_hosts`. Every `connect()` passes `disabled_algorithms=SHA1_ALGORITHMS` (`connect_gui/ssh/ssh_connect_thread.py`): `requirements.txt` does not pin paramiko, and paramiko 4 still offers SHA-1 signatures and key exchanges (CVE-2026-44405). +**SSH** — never `paramiko.AutoAddPolicy()` or `WarningPolicy()`. Use `apply_host_key_policy(client, parent)` from `connect_gui/ssh/ssh_host_key_policy.py`: it shows the SHA256 fingerprint for confirmation on first connect and persists to `~/.pybreeze/ssh_known_hosts`. Every `connect()` passes `disabled_algorithms=SHA1_ALGORITHMS` (`connect_gui/ssh/ssh_connect_thread.py`): `requirements.txt` does not pin paramiko, and paramiko 4 still offers SHA-1 signatures and key exchanges (CVE-2026-44405). **Subprocess** — always argument lists, explicit `shell=False`, `timeout` on every `subprocess.run()`. Never interpolate user input into a command string. Secrets travel as `env`, never argv (see `prthinker_setting.environment_for`). The IDE intentionally runs user-authored scripts — this hardening guards against accidental shell injection, not against malicious local files. diff --git a/README.md b/README.md index d569799b..75c8f983 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Each module gets the same menu shape: **Run** (single script, batch directory, w - **Automation-aware syntax highlighting** — the `AT_*` / GUI / Web / Load keyword sets are registered for `.json`, and TestPioneer's schema for `.yml` and `.yaml`, on top of JEditor's language support - **Code editor** — built on [JEditor](https://github.com/Integration-Automation/JEDITOR): tabs, project tree, format checker, debugger, terminal, variable inspector and a git client pane -- **Script execution** — single or batch; large action files are passed by path, never through the command line, so Windows' ~32 KB argv limit is never a factor +- **Script execution** — single or batch; a batch or file run passes each action file by path, so Windows' ~32 KB argv limit does not apply to it (running the tab in front passes its script on the command line) - **Report generation** — HTML / JSON / XML after a run, with optional email delivery - **Integrated JupyterLab** — launches as a tab, installing JupyterLab into the project venv if it is missing - **Virtual environment awareness** — `venv/` and `.venv/` are detected and used automatically @@ -154,7 +154,7 @@ Password or private-key authentication, an interactive shell with ANSI handling ### And also - **File Tree Context Menu** — right-click to create, rename, delete, copy absolute or relative paths, or reveal the item in your platform file manager. Renaming or deleting a file open in an editor tab keeps the tab in sync. -- **Package Manager** — install automation modules and build tools from the menu, output in the shell pane. +- **Package Manager** — install automation modules and build tools from the menu, output in a run window. - **Integrated Documentation** — each module's docs and GitHub page open as in-IDE browser tabs. --- @@ -202,7 +202,7 @@ PyBreeze inherits JEditor's plugin architecture, auto-discovered from a `jeditor - **Run configurations** — "Run with…" for interpreted (`go run main.go`) and compiled (`gcc main.c -o main` then run) languages, executed through PyBreeze's `FileRunnerProcess` with the compiled artifact cleaned up afterwards - **Plugin Browser** — browse and install plugins from remote repositories inside the IDE -Loaded plugins appear under their own **Plugins** menu with an About entry and one run action per supported suffix. See [PLUGIN_GUIDE.md](PLUGIN_GUIDE.md) for the full API and worked examples (C, C++, Go, Java, Rust, and a French translation). +Loaded plugins appear under their own **Plugins** menu with an About entry and one run action, labelled with the suffixes it runs. [PLUGIN_GUIDE.md](PLUGIN_GUIDE.md) covers what PyBreeze adds and links JEditor's guide, which has the full API and worked examples (C, C++, Go, Java, Rust, and a French translation). --- @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 571 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 643 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- @@ -415,7 +415,7 @@ PyBreeze/ ### Development -`build`, `twine`, `sphinx`, `sphinx-rtd-theme`, `auto-py-to-exe`, `pytest`, `hypothesis` +`build`, `twine`, `sphinx`, `sphinx-rtd-theme`, `auto-py-to-exe`, `pytest`, `pytest-cov`, `hypothesis`, `ruff` --- diff --git a/architecture_explore.md b/architecture_explore.md index e9dc9792..a623769d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -1,6 +1,6 @@ # PyBreeze 架構探勘 / Architecture Exploration -> 掃描範圍:`pybreeze/`(189 個 `.py`、約 16,300 行)+ `test/`、`exe/`、`docs/`、CI 設定 +> 掃描範圍:`pybreeze/`(201 個 `.py`、約 22,200 行,不含空行與註解約 17,300 行)+ `test/`、`exe/`、`docs/`、CI 設定 > 對應版本:`pyproject.toml` 1.0.21(stable)/`dev.toml` 1.0.14(dev),分支 `dev` --- @@ -30,7 +30,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE │ 表現層 Presentation pybreeze/pybreeze_ui/ │ │ editor_main 主視窗(繼承 EditorMain)+ 檔案樹右鍵選單 │ │ menu 選單建構器:automation / install / tools / plugin / dock │ - │ tools_gui 19 個工具分頁(curl、HAR、JWT、diff、regex…) │ + │ tools_gui 13 個工具 widget(curl、HAR、JWT、diff、regex…) │ │ diagram_editor 架構圖 WYSIWYG 編輯器(QGraphicsScene) │ │ extend_ai_gui CoT 程式碼審查、Prompt 編輯器、Skills 發送 │ │ connect_gui SSH 終端 + SFTP 檔案樹、AI Code Review HTTP client │ @@ -43,14 +43,14 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 執行層 Execution pybreeze/extend/ │ │ process_executor/ 子行程隔離層(Strategy + Template Method) │ - │ mail_thunder_extend/ 測試後寄報告 hook │ + │ mail_thunder_extend/ 測試後寄報告 hook(mail_thunder_setting.py) │ │ prthinker_extend/ prthinker 設定與指令組裝(純邏輯) │ └────────────────────────────────┬────────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ - │ pybreeze/utils/ 14 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 571 鍵) │ + │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 643 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -62,11 +62,11 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ## 3. 啟動流程 -`pybreeze/pybreeze_ui/editor_main/main_ui.py:100` 的 `start_editor()`: +`pybreeze/pybreeze_ui/editor_main/main_ui.py:189` 的 `start_editor()`: 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 571 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 643 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -164,7 +164,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ## 5. 選單層 `pybreeze_ui/menu/` -`build_menubar.py:add_menu_to_menubar()` 是唯一入口,依序建構 14 個選單建構器。 +`build_menubar.py:add_menu_to_menubar()` 是唯一入口,依序建構 15 個選單建構器。`menu_utils.py` 的 `open_web_browser()` 讓各選單的 Help 連結開成內嵌瀏覽器分頁;`extend_jeditor_tab_menu/jupyter_lab_tab.py` 的 `extend_tab_tools_menu()` 把 JupyterLab 分頁加進 JEditor 的分頁選單。 ### 5.1 `automation_menu_factory.py` — 選單工廠 @@ -238,13 +238,13 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,760 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,963 行,最大子系統) | 檔案 | 職責 | |---|---| -| `diagram_editor_widget.py` (636) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔經 `replace_text()` 先寫 `.saving` 再換上去,存檔失敗不會毀掉上一份 | +| `diagram_editor_widget.py` (675) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔經 `replace_text()` 先寫 `.saving` 再換上去,存檔失敗不會毀掉上一份 | | `diagram_scene.py` (888) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點與圖片之上/之下;`to_dict()` 給每個節點與圖片記下它在兩者之間由下往上的位置(`stack`),載入後 `_restore_stacking()` 照這個順序重新加回場景(同 z 值時後加的在上面),圖片也存 `z`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | -| `diagram_items.py` (960) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`add_image()` 把圖放進 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級;位置經 `_coordinate()`:不是有限數字就跳過這一筆,超過 `MAX_COORDINATE`(一百萬)就夾回來;連線建好所有東西之後才掛到兩端節點上 | +| `diagram_items.py` (960) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`DiagramScene.add_image()`(`diagram_scene.py`)把圖放進 scene 的 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級;位置經 `_coordinate()`:不是有限數字就跳過這一筆,超過 `MAX_COORDINATE`(一百萬)就夾回來;連線建好所有東西之後才掛到兩端節點上 | | `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | | `diagram_property_panel.py` (453) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單;每記一步 undo(場景的 `recorded`)就重新整理(在畫布上拖把手改大小時選取沒變);寬、高各自只改自己那一邊;數字欄位不追鍵盤、輸入完才套用 | | `diagram_view.py` (195) | `QGraphicsView`:滾輪與按鈕縮放都經 `_step_zoom()`(有上下界,界外時仍可往界內走;橫向滾輪不縮放),`fit()` 把「符合視窗」夾在上下界內、中鍵平移、`drawBackground` 畫格線 | @@ -293,14 +293,14 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,740 行) +### `ssh/`(2,035 行) | 檔案 | 職責 | |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (643) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (691) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | @@ -335,7 +335,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 --- -## 12. `pybreeze/utils/` — 基礎工具(14 個子套件) +## 12. `pybreeze/utils/` — 基礎工具(18 個子套件) | 套件 | 內容 | |---|---| @@ -347,7 +347,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`overall_deadline(seconds)`:這個執行緒在區塊內的請求總共最多這麼久,釘住的連線等回應時把 socket 登記上去,時間到就 shutdown,丟 `ReadTimeout`(讀取逾時每來一個位元組就重算,慢慢送標頭的伺服器原本可以一直拖);AI 審查、Skill、CoT 每一步都包在 `overall_deadline(DEFAULT_MAX_READ_SECONDS)` 裡。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),時間到由 `_Watchdog` 關掉連線(urllib3 的 `HTTPResponse.shutdown()` 能中斷別的執行緒上正在等的讀取),丟 `ReadTimeout`:讀取逾時只管每一塊之間,一次送一個位元組的伺服器可以一直拖下去;狀態列與標頭由呼叫端的 `public_http.overall_deadline()` 涵蓋)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | +| `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`request_body.py` 的 `form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯;`pretty_json_or_none()` 是同一套解析、不記 log 的版本,Response Inspector 的 body 與 JWT 各段(`jwt_decoder.shown_json()`)用它排版。`json_format/view_safe.py` 的 `dumps_for_view()` / `escape_for_view()`:工具顯示的 JSON 把文字框還不回原樣的字元(U+2029、U+FDD0、U+FDD1 會變換行,落單的 surrogate 會消失;U+2028 經 `toPlainText()`、U+0085 經 `splitlines()` 也會斷行)寫成 `\uXXXX`,JSON Format、Query/URL 轉 JSON、JWT、Response Inspector、cURL/HAR 產生的 JSON 與 Python 字串都經過它。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN` 與同一物件重複的 key(`load_json_verbatim()`,用 `json_process.unique_pairs`),Query 轉 JSON 遇到不是 UTF-8 的 percent-escape 報錯而不換成 U+FFFD,`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | @@ -362,7 +362,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 571 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 643 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -425,6 +425,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 垃圾回收只在 UI 執行緒跑:`start_editor()` 裝上 `gui_thread_gc.GuiThreadGarbageCollector`,關掉自動回收,改由 UI 執行緒上每秒一次的 QTimer 照直譯器自己的門檻回收(單元測試由 `test/test_utils/conftest.py` 做同樣的事)。自動回收會在任何配置超過門檻的執行緒跑,worker 上的一次回收曾把 UI 執行緒建立的 Qt 物件在 worker 上銷毀,計時器停不掉,之後 UI 執行緒把計時器事件送給已釋放的物件而崩潰。不要在 IDE 裡呼叫 `gc.enable()`。 +給使用者看的文字:伺服器或檔案來的字(遠端路徑、錯誤訊息、主機名、檔名)放進 `QMessageBox` / `QLabel` 前經 `pybreeze_ui/plain_text.as_text()`(`Qt.convertFromPlainText`),Qt 才不會把它當 markup、去載入裡面的 ``;`test_message_boxes_show_text.py` 檢查每個 `QMessageBox`。 + 鐵律:worker thread 一律不碰 UI。普通執行緒走 Queue + QTimer,`QThread` 走 Signal/Slot。分頁或視窗關閉時還在跑的 `QThread` 交給 `thread_keeper.let_run_out()`,不等它、也不讓它在執行中被銷毀。widget 留著的 thread(或其他物件)上接的 slot 不能抓住 widget 本身:接 bound method,或用 `thread_keeper.if_alive(weakref.ref(self), ...)`;lambda 抓 `self` 是經過 Qt 的循環參照,Python 的 GC 看不到,關掉的 widget 永遠不會釋放(`test_closed_panels_are_freed.py`)。 執行器的壽命:QTimer 接到執行器的 `pull_text()` / `_pull_text()` 這條連線**不會**讓執行器活著;reader 執行緒一結束,沒人持有的執行器就會被 GC,剩下的輸出和結束那一行都不會出現。所以執行器一律掛在它寫入的 `CodeWindow.runner` 上,跟視窗同壽;視窗又掛在主視窗的 `current_run_code_window`。 diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 5031c929..5393e050 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -9,3 +9,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **What**: batch rule 2 (`docs/updates/README.md`) says a month's file continues in `YYYY-MM-b.md` past about 800 lines; `2026-09.md` had reached 3156 lines and 218 entries without it. New entries now go to `2026-09-b.md`, listed in the Batches table. Recorded entries stay where they are (rule 5), and their index rows keep pointing at `2026-09.md`; its header now says where it continues. - **Files**: `docs/updates/2026-09-b.md` (new), `docs/updates/2026-09.md` (header), `docs/updates/README.md` (Batches, index) - **Open items**: none. + +## U-20260923-202 · 2026-09-24 · Bring the architecture map, README and CLAUDE.md back in line with the code · #docs + +- **What**: an audit of every backticked name, quoted count and stated behaviour in the documentation against the code (`architecture.md` and `PLUGIN_GUIDE.md` were accurate). + - `architecture_explore.md`: the package is 201 `.py` files and about 22,200 lines (said 189 and 16,300); 643 translation keys (said 571, three places); `start_editor()` at `main_ui.py:189` (said 100); 13 tool widgets (said 19 tool tabs); 18 `utils` subpackages (said 14, twice); 15 menu builders (said 14); `ssh/` 2,035 lines and the diagram editor 3,963 (said 1,740 and 3,760); two quoted file lengths refreshed; `add_image()` is `DiagramScene`'s, and `form_parts()` is in `request_body.py`. Added what the map left out: `plain_text.as_text()` (with the message-box check), `menu_utils.open_web_browser()`, `extend_jeditor_tab_menu/jupyter_lab_tab.py`, `mail_thunder_setting.py`. + - `README.md`: 643 keys; only batch and file runs pass the action file by path (running the tab in front passes its script on the command line, so the argv limit can apply there); Package Manager output goes to a run window, not the shell pane; a plugin gets one run action labelled with its suffixes, and `PLUGIN_GUIDE.md` links JEditor's guide for the full API; `pytest-cov` and `ruff` added to the development tools. + - `CLAUDE.md`: `apply_host_key_policy(client, parent)`; the tree lists `__main__.py`, `menu_utils` and `extend_jeditor_tab_menu`, and says docks are built in `tools`. +- **Files**: `architecture_explore.md`, `README.md`, `CLAUDE.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index fc272fa5..272288b6 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-202 | 2026-09-24 | Bring the architecture map, README and CLAUDE.md back in line with the code | #docs | [2026-09-b](2026-09-b.md) | | U-20260923-201 | 2026-09-24 | Continue the September update log in 2026-09-b.md | #docs | [2026-09-b](2026-09-b.md) | | U-20260923-200 | 2026-09-24 | Check that every tool tab opened and closed is freed | #test | [2026-09](2026-09.md) | | U-20260923-199 | 2026-09-24 | Free the Regex tab and the SFTP tree after a match or a transfer | #fix #tools #ssh | [2026-09](2026-09.md) | @@ -283,4 +284,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 1 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 2 | From 3d456cc59c808e8182a2f9b5b1df48a435f336f8 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:48:14 +0800 Subject: [PATCH 240/312] Run a script too long for a Windows command line from a file --- README.md | 2 +- architecture_explore.md | 4 +- docs/updates/2026-09-b.md | 9 ++ docs/updates/README.md | 3 +- .../python_task_process_manager.py | 54 ++++++++++-- test/test_utils/test_long_scripts.py | 85 +++++++++++++++++++ 6 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 test/test_utils/test_long_scripts.py diff --git a/README.md b/README.md index 75c8f983..e0bb164a 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Each module gets the same menu shape: **Run** (single script, batch directory, w - **Automation-aware syntax highlighting** — the `AT_*` / GUI / Web / Load keyword sets are registered for `.json`, and TestPioneer's schema for `.yml` and `.yaml`, on top of JEditor's language support - **Code editor** — built on [JEditor](https://github.com/Integration-Automation/JEDITOR): tabs, project tree, format checker, debugger, terminal, variable inspector and a git client pane -- **Script execution** — single or batch; a batch or file run passes each action file by path, so Windows' ~32 KB argv limit does not apply to it (running the tab in front passes its script on the command line) +- **Script execution** — single or batch; action files are passed by path, and a script from the tab in front that is too long for a Windows command line (~32 KB) goes through a temporary file - **Report generation** — HTML / JSON / XML after a run, with optional email delivery - **Integrated JupyterLab** — launches as a tab, installing JupyterLab into the project venv if it is missing - **Virtual environment awareness** — `venv/` and `.venv/` are detected and used automatically diff --git a/architecture_explore.md b/architecture_explore.md index a623769d..4fe1aef9 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -101,7 +101,7 @@ Template Method 定義的子行程生命週期: 三種啟動介面: -- `start_test_process(package, exec_str)` — 腳本內容直接走 `--execute_str`(Windows 上先 `json.dumps` 逃逸) +- `start_test_process(package, exec_str)` — 腳本內容直接走 `--execute_str`(Windows 上先 `json.dumps` 逃逸);Windows 上命令列超過 30,000 字元(上限 32,767)時改寫進暫存的 `pybreeze_run_*.json`、走 `--execute_file`(JSON 以全跳脫的 ASCII 寫回,套件用哪種編碼讀都一樣),執行結束或啟動失敗就刪掉 - `start_test_process_file(package, file_path)` — 走 `--execute_file`,避開 Windows ~32K 命令列上限 - `start_module_process(package, arguments, environment)` — 通用形式;**祕密(API key、token)走 environment 不走命令列**,工作管理員看不到 @@ -451,7 +451,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 120 個 `test_*.py`、2015 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2017 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 5393e050..16c23e3e 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -18,3 +18,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - `CLAUDE.md`: `apply_host_key_policy(client, parent)`; the tree lists `__main__.py`, `menu_utils` and `extend_jeditor_tab_menu`, and says docks are built in `tools`. - **Files**: `architecture_explore.md`, `README.md`, `CLAUDE.md` - **Open items**: none. + +## U-20260923-203 · 2026-09-24 · Run a script too long for a Windows command line from a file · #fix #executor + +- **What**: running the tab in front passes its script to the automation package as `--execute_str` on the command line. Windows refuses a command line over 32,767 characters, so an action script past that (about 600 steps) did not start at all: the run window said only "could not start". Batch and file runs already passed a path. +- **Fix**: `TaskProcessManager.start_test_process` measures the command line (`subprocess.list2cmdline`) and, on Windows past 30,000 characters, writes the script to a temporary `pybreeze_run_*.json` and passes it with `--execute_file`, the legacy flag every package takes. The JSON is written back with every character escaped: the released APITestka and MailThunder read that file in the locale's encoding (both have since fixed it in their sources), and an escape reads the same in any. The file is removed when the run ends or fails to start. Shorter scripts go on the command line as before. +- **Tests**: new `test_long_scripts.py` (2), through a stand-in package that reports the flag and what it read in ASCII: a 600-step script with Chinese text goes as a file, arrives whole, and the file is gone after the run (Windows only); a short script still goes as `--execute_str`. +- **Result / numbers**: the long run itself takes 0.4 s. 2017 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py`, `test/test_utils/test_long_scripts.py` (new), `README.md`, `architecture_explore.md` §4, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 272288b6..21633d43 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-203 | 2026-09-24 | Run a script too long for a Windows command line from a file | #fix #executor | [2026-09-b](2026-09-b.md) | | U-20260923-202 | 2026-09-24 | Bring the architecture map, README and CLAUDE.md back in line with the code | #docs | [2026-09-b](2026-09-b.md) | | U-20260923-201 | 2026-09-24 | Continue the September update log in 2026-09-b.md | #docs | [2026-09-b](2026-09-b.md) | | U-20260923-200 | 2026-09-24 | Check that every tool tab opened and closed is freed | #test | [2026-09](2026-09.md) | @@ -284,4 +285,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 2 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 3 | diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 9ec6fad2..4767b3cd 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -1,8 +1,11 @@ from __future__ import annotations +import contextlib import json +import os import subprocess import sys +import tempfile import threading from typing import Callable from pathlib import Path @@ -28,6 +31,11 @@ ) +# Windows refuses a command line over 32,767 characters; a script longer than +# this goes to the child as a file instead +_MAX_COMMAND_LINE = 30_000 + + def find_venv_path() -> Path: """Find virtual environment path, checking multiple common locations.""" if sys.platform in ["win32", "cygwin", "msys"]: @@ -71,6 +79,8 @@ def __init__( self._reader_grace = ReaderGrace() # Stop was asked for: a batch run that follows this one does not go on self.was_stopped = False + # The file a script too long for the command line was written to + self._script_file: Path | None = None self.task_done_trigger_function: Callable = task_done_trigger_function self.error_trigger_function: Callable = error_trigger_function @@ -95,19 +105,43 @@ def renew_path(self) -> bool: return True def start_test_process(self, package: str, exec_str: str): + """Run *package* on the script *exec_str*, passed on the command line. + + A script too long for a Windows command line goes as a file instead + (``--execute_file``): passed as it was, the run did not start at all. + """ if not self.renew_path(): return - if sys.platform in ["win32", "cygwin", "msys"]: - exec_str = json.dumps(exec_str) - args = [ - str(self.compiler_path), - "-m", - package, - "--execute_str", - exec_str - ] + argument = json.dumps(exec_str) if sys.platform in ["win32", "cygwin", "msys"] else exec_str + args = [str(self.compiler_path), "-m", package, "--execute_str", argument] + if sys.platform == "win32" and len(subprocess.list2cmdline(args)) > _MAX_COMMAND_LINE: + args[-2:] = ["--execute_file", str(self._write_script_file(exec_str))] self._spawn_and_pump(package, args) + def _write_script_file(self, script: str) -> Path: + """Write *script* to a file of its own for the child to read, and return its path. + + JSON is written back with every character escaped: some packages read + the file in the locale's encoding, and an escape reads the same in any. + The file goes when the run ends (``_remove_script_file``). + """ + try: + text = json.dumps(json.loads(script), ensure_ascii=True) + except (ValueError, RecursionError): + text = script # not JSON: the package reports it, as it would have + handle, name = tempfile.mkstemp(prefix="pybreeze_run_", suffix=".json") + with os.fdopen(handle, "w", encoding="utf-8") as file: + file.write(text) + self._script_file = Path(name) + return self._script_file + + def _remove_script_file(self) -> None: + """Remove the file a long script was written to, if there is one.""" + if self._script_file is not None: + with contextlib.suppress(OSError): + self._script_file.unlink() + self._script_file = None + def start_test_process_file(self, package: str, file_path: str): # Pass the action JSON as a path so we never hit the Windows ~32K # command-line cap when scripts are large. Caller owns the file. @@ -162,6 +196,7 @@ def _spawn_and_pump( # limit (a large script run as --execute_str): this raised out of # the menu and left a run window no one would ever see. pybreeze_logger.error("%s could not start: %r", package, error) + self._remove_script_file() self.main_window.append_output( f"[Error] {package} could not start: {error.strerror or error}\n", is_error=True, own_line=True) @@ -238,6 +273,7 @@ def exit_program(self): self.main_window.append_output( f"Task exit with code {self.process.returncode}\n", own_line=True) self.process = None + self._remove_script_file() if self.task_done_trigger_function is not None: try: self.task_done_trigger_function() diff --git a/test/test_utils/test_long_scripts.py b/test/test_utils/test_long_scripts.py new file mode 100644 index 00000000..91373cc1 --- /dev/null +++ b/test/test_utils/test_long_scripts.py @@ -0,0 +1,85 @@ +"""A script too long for a Windows command line still runs. + +Running the tab in front passes its script as ``--execute_str``; past Windows' +limit of 32,767 characters the child did not start at all ("could not start"). +""" +from __future__ import annotations + +import json +import os +import sys +import time + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest # noqa: E402 + +_REPORTER = """ +import json, sys +flag, value = sys.argv[1], sys.argv[2] +if flag == "--execute_file": + with open(value, encoding="ascii") as handle: + value = handle.read() +actions = json.loads(value) +if isinstance(actions, str): + actions = json.loads(actions) # on Windows --execute_str carries the script JSON-encoded once more +print(json.dumps({"flag": flag, "count": len(actions), "last": actions[-1]})) +""" + + +@pytest.fixture(scope="module") +def app(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def reporter(tmp_path, monkeypatch): + """A package that says which flag it was given and what it read, in ASCII.""" + (tmp_path / "report_args.py").write_text(_REPORTER, encoding="utf-8") + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + return "report_args" + + +def _run(app, package: str, script: str): + from pybreeze.extend.process_executor.python_task_process_manager import TaskProcessManager + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + class Window: + python_compiler = sys.executable + + window = CodeWindow() + window.main_window = Window() + manager = TaskProcessManager(window) + manager.renew_path = lambda: True + manager.compiler_path = sys.executable + manager.start_test_process(package, script) + written = manager._script_file + deadline = time.monotonic() + 30 + while manager.still_run_program and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.02) + output = window.code_result.toPlainText() + report = json.loads(next(line for line in output.splitlines() if line.startswith("{"))) + return report, written, manager + + +@pytest.mark.skipif(sys.platform != "win32", reason="the command-line limit is Windows'") +def test_a_long_script_goes_as_a_file_and_the_file_goes_after(app, reporter): + actions = [["AC_type_keyboard", {"text": "中文 " + "x" * 50}] for _ in range(600)] + script = json.dumps(actions, ensure_ascii=False) + assert len(script) > 32_767 + + report, written, manager = _run(app, reporter, script) + + assert report == {"flag": "--execute_file", "count": 600, "last": actions[-1]} + assert written is not None and not written.exists() + assert manager._script_file is None + + +def test_a_short_script_still_goes_on_the_command_line(app, reporter): + report, written, _manager = _run(app, reporter, '[["AC_type_keyboard", {"text": "hi"}]]') + + assert report == {"flag": "--execute_str", "count": 1, "last": ["AC_type_keyboard", {"text": "hi"}]} + assert written is None From a258380979d8e175eebee3307df87693668a66c5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:50:58 +0800 Subject: [PATCH 241/312] Note the JupyterLab front-end advisories in the dependency question --- progress.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/progress.md b/progress.md index bffed28e..789d773f 100644 --- a/progress.md +++ b/progress.md @@ -9,7 +9,7 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? - **#47** [BLOCKED] Closing the IDE closes JEditor's docked file editors (`editor_main/main_ui.py`, `_close_tool_tabs_and_docks`), and the published `je_editor` dock (`FullEditorWidget.closeEvent`) writes its buffer back on close whether edited or not, in UTF-8 with platform line endings, so exiting rewrites an LF file as CRLF. Fixed in JEditor (JEDITOR U-20260923-07: writes only when edited, in the file's own encoding and line ending); waits for the `je_editor` release that carries it, then bump the pin and add a PyBreeze test. -- **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). Should the dependencies require `jupyter_server>=2.21.0`, and should the tab check the installed version and offer to upgrade? +- **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). jupyterlab itself is 4.5.6 there, below the fixes for a sanitizer-allowed command-linker button that runs commands on one click (CVE-2026-42557, 4.5.7), an SVG opened in the image viewer keeping a same-origin script context (CVE-2026-73415) and an imported `overrides.json` (CVE-2026-73417), both fixed in 4.5.10 / 4.6.2; with no token, a script running in the page can drive the server. Should the dependencies require `jupyter_server>=2.21.0` and `jupyterlab>=4.5.10`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? - **#89** [DECIDE] The prthinker settings offer the Gemini, Cohere and Mistral backends but have no field for their keys: prthinker reads `PRTHINKER_GEMINI_API_KEY` / `_COHERE_` / `_MISTRAL_` from the environment the IDE was started in (as `test_prthinker_contract.py` notes), so choosing one of them in the dialog alone always fails for want of a key (`dialog/prthinker_setting_dialog.py`, `extend/prthinker_extend/prthinker_setting.py`). Should they get password fields like the OpenAI and Anthropic keys, or the dialog say where the key comes from? Related: a saved backend, platform or RAG value the dialog does not list is shown as the first item and replaced on the next Save, on purpose (`test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice`); a newer prthinker's value is lost that way. Keep it, or list the unknown value? From a3177fdf9200f3a452015a9c6210ee97ef44b424 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:55:35 +0800 Subject: [PATCH 242/312] Keep every recorded call, cookie and entry when importing a HAR file --- README.md | 2 +- architecture_explore.md | 4 ++-- docs/updates/2026-09-b.md | 12 ++++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/har_import/har_codegen.py | 24 +++++++++++++++----- pybreeze/utils/har_import/har_parser.py | 28 +++++++++++++++++++----- test/test_utils/test_har_import.py | 28 ++++++++++++++++++++++++ 7 files changed, 86 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e0bb164a..37aba101 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Targets: Python `requests`, a ready-to-run **pytest** test, **APITestka** (Pytho ![HAR import](images/tool_har_import.png) -Select what you want — or take everything listed — and generate one script using the same targets as the cURL importer. Repeated endpoints get numbered test names so no test silently replaces another; HTTP/2 pseudo-headers are dropped, and a `Cookie` header duplicating the recorded cookie list is removed so each value is sent once. A single selected request produces exactly what the cURL importer would. HAR is JSON, so this needs nothing beyond the standard library, and nothing is ever replayed for you. +Select what you want — or take everything listed — and generate one script using the same targets as the cURL importer. Repeated endpoints get numbered test names so no test silently replaces another; HTTP/2 pseudo-headers are dropped, and a `Cookie` header duplicating the recorded cookie list is removed so each value is sent once (cookies sharing a name, which a dictionary cannot hold, go as the header instead). An entry that cannot become a request, such as one with a malformed URL or method, is skipped and the rest load. A single selected request produces exactly what the cURL importer would. HAR is JSON, so this needs nothing beyond the standard library, and nothing is ever replayed for you. ### Response Inspector — paste a response, read everything in it diff --git a/architecture_explore.md b/architecture_explore.md index 4fe1aef9..e583e463 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -348,7 +348,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`overall_deadline(seconds)`:這個執行緒在區塊內的請求總共最多這麼久,釘住的連線等回應時把 socket 登記上去,時間到就 shutdown,丟 `ReadTimeout`(讀取逾時每來一個位元組就重算,慢慢送標頭的伺服器原本可以一直拖);AI 審查、Skill、CoT 每一步都包在 `overall_deadline(DEFAULT_MAX_READ_SECONDS)` 裡。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),時間到由 `_Watchdog` 關掉連線(urllib3 的 `HTTPResponse.shutdown()` 能中斷別的執行緒上正在等的讀取),丟 `ReadTimeout`:讀取逾時只管每一塊之間,一次送一個位元組的伺服器可以一直拖下去;狀態列與標頭由呼叫端的 `public_http.overall_deadline()` 涵蓋)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | | `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`request_body.py` 的 `form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | -| `har_import/` | `har_parser.py`(320) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | +| `har_import/` | `har_parser.py`(354) HAR → `CurlRequest`(重用 curl 那套 codegen;建不出請求的 entry 跳過,同名 cookie 改走 `Cookie` header);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯;`pretty_json_or_none()` 是同一套解析、不記 log 的版本,Response Inspector 的 body 與 JWT 各段(`jwt_decoder.shown_json()`)用它排版。`json_format/view_safe.py` 的 `dumps_for_view()` / `escape_for_view()`:工具顯示的 JSON 把文字框還不回原樣的字元(U+2029、U+FDD0、U+FDD1 會變換行,落單的 surrogate 會消失;U+2028 經 `toPlainText()`、U+0085 經 `splitlines()` 也會斷行)寫成 `\uXXXX`,JSON Format、Query/URL 轉 JSON、JWT、Response Inspector、cURL/HAR 產生的 JSON 與 Python 字串都經過它。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN` 與同一物件重複的 key(`load_json_verbatim()`,用 `json_process.unique_pairs`),Query 轉 JSON 遇到不是 UTF-8 的 percent-escape 報錯而不換成 U+FFFD,`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | @@ -451,7 +451,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2017 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2023 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 16c23e3e..58a9e022 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -27,3 +27,15 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: the long run itself takes 0.4 s. 2017 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py`, `test/test_utils/test_long_scripts.py` (new), `README.md`, `architecture_explore.md` §4, §18 - **Open items**: none. + +## U-20260923-204 · 2026-09-24 · Keep every recorded call, cookie and entry when importing a HAR file · #fix #har + +- **What**: three ways the HAR importer lost part of a recording. + - Test names: repeats were numbered `_2`, `_3` without looking at the other requests' own names, so GET `/a`, `/a`, `/a/2` became `test_get_a`, `test_get_a_2`, `test_get_a_2` and the second call to `/a` was never tested, the very thing the numbering was there to stop. + - Cookies: a browser sends two cookies of one name when their paths differ. They went into a dictionary, which kept the last, and the `Cookie` header that still had both was removed, so `sid=1` was gone from the generated request. + - Methods: one entry with a method that is not a token (`""`) refused the whole file, while an entry with a malformed URL is skipped. +- **Fix**: `unique_test_names` skips a number another request's own name has. `_apply_cookies` keeps the structured list only when its names are unique; otherwise the cookies go as the `Cookie` header, the recorded one or one built in the recorded order. `parse_har` skips an entry whose request cannot be built and logs why; a file left with none raises the first entry's reason, so a single bad method still reads "not an HTTP method". +- **Tests**: `test_har_import.py` +6: three orders of `/a`, `/a/2` give distinct names; two `sid` cookies with and without the header; a bad-method entry beside a good one. +- **Result / numbers**: 2023 tests in 121 files. The full suite before this change: 2002 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/utils/har_import/har_codegen.py`, `pybreeze/utils/har_import/har_parser.py`, `test/test_utils/test_har_import.py`, `README.md`, `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 21633d43..7d56c39c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-204 | 2026-09-24 | Keep every recorded call, cookie and entry when importing a HAR file | #fix #har | [2026-09-b](2026-09-b.md) | | U-20260923-203 | 2026-09-24 | Run a script too long for a Windows command line from a file | #fix #executor | [2026-09-b](2026-09-b.md) | | U-20260923-202 | 2026-09-24 | Bring the architecture map, README and CLAUDE.md back in line with the code | #docs | [2026-09-b](2026-09-b.md) | | U-20260923-201 | 2026-09-24 | Continue the September update log in 2026-09-b.md | #docs | [2026-09-b](2026-09-b.md) | @@ -285,4 +286,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 3 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 4 | diff --git a/pybreeze/utils/har_import/har_codegen.py b/pybreeze/utils/har_import/har_codegen.py index 48aa90d3..ff9e2d54 100644 --- a/pybreeze/utils/har_import/har_codegen.py +++ b/pybreeze/utils/har_import/har_codegen.py @@ -38,14 +38,26 @@ def unique_test_names(requests: list[CurlRequest]) -> list[str]: function twice, and the second would silently replace the first. :param requests: the requests to name, in order - :return: a name per request, suffixed ``_2``, ``_3`` … on a repeat + :return: a name per request, suffixed ``_2``, ``_3`` … on a repeat; a + suffix is skipped when another request's own name already has it + (``/a``, ``/a``, ``/a/2`` give ``test_get_a``, ``test_get_a_3``, ``test_get_a_2``) """ + bases = [test_function_name(request) for request in requests] + used = set(bases) names: list[str] = [] - seen: dict[str, int] = {} - for request in requests: - base = test_function_name(request) - seen[base] = seen.get(base, 0) + 1 - names.append(base if seen[base] == 1 else f"{base}_{seen[base]}") + taken: set[str] = set() + next_suffix: dict[str, int] = {} + for base in bases: + if base not in taken: + taken.add(base) + names.append(base) + continue + suffix = next_suffix.get(base, 2) + while f"{base}_{suffix}" in used or f"{base}_{suffix}" in taken: + suffix += 1 + next_suffix[base] = suffix + 1 + taken.add(f"{base}_{suffix}") + names.append(f"{base}_{suffix}") return names diff --git a/pybreeze/utils/har_import/har_parser.py b/pybreeze/utils/har_import/har_parser.py index 96e7789a..8ed3e21f 100644 --- a/pybreeze/utils/har_import/har_parser.py +++ b/pybreeze/utils/har_import/har_parser.py @@ -119,12 +119,20 @@ def _apply_cookies(request: CurlRequest, raw_request: dict) -> None: A HAR records cookies both as a structured list and inside the ``Cookie`` header. Keeping both would send every cookie twice, so the structured list wins — it is the one the generated code can edit. + + A browser sends two cookies of one name when their paths differ; a + dictionary keeps only the last, so then the cookies go as the ``Cookie`` + header instead, in the order recorded. """ - for name, value in _header_pairs(raw_request.get("cookies")): - request.cookies[name] = value - if not request.cookies: + pairs = _header_pairs(raw_request.get("cookies")) + if not pairs: return stored = stored_header_name(request.headers, _COOKIE_HEADER) + if len({name for name, _value in pairs}) < len(pairs): + if stored is None: + request.headers["Cookie"] = "; ".join(f"{name}={value}" for name, value in pairs) + return + request.cookies.update(pairs) if stored is not None: del request.headers[stored] @@ -257,9 +265,11 @@ def parse_har(text: str) -> list[HarEntry]: :param text: the contents of a ``.har`` file :return: the recorded requests, in the order they were captured :raises HarParseException: when the text is not a HAR export, or records no - request with a URL + request with a URL and a method (an entry without is skipped; when + none is left, the first entry's reason is the error) """ entries: list[HarEntry] = [] + first_error: HarParseException | None = None for raw_entry in _load_entries(text): raw_request = raw_entry.get("request") if not isinstance(raw_request, dict) or not raw_request.get("url"): @@ -269,13 +279,21 @@ def parse_har(text: str) -> list[HarEntry]: # tab. Not logged either -- a recorded URL may carry a token. pybreeze_logger.info("HAR entry with a malformed URL skipped") continue + try: + request = _entry_request(raw_request) + except HarParseException as error: + pybreeze_logger.info("HAR entry skipped: %s", error) + first_error = first_error or error + continue status, media_type = _response_details(raw_entry.get("response")) entries.append(HarEntry( - request=_entry_request(raw_request), + request=request, status=status, response_media_type=media_type, started=str(raw_entry.get("startedDateTime", "")), )) + if not entries and first_error is not None: + raise first_error if not entries: pybreeze_logger.error(no_entries_in_har_error) raise HarParseException(no_entries_in_har_error) diff --git a/test/test_utils/test_har_import.py b/test/test_utils/test_har_import.py index 39450e09..6ed9be30 100644 --- a/test/test_utils/test_har_import.py +++ b/test/test_utils/test_har_import.py @@ -141,6 +141,20 @@ def test_cookie_header_is_dropped_when_cookies_were_recorded(self): assert entry.request.cookies == {"sid": "abc"} assert "Cookie" not in entry.request.headers + def test_cookies_sharing_a_name_all_go_as_the_header(self): + # Two paths, two sid cookies: a dict would keep only the last + entry = parse_har(_har(_entry( + headers=[{"name": "cookie", "value": "sid=1; sid=2"}], + cookies=[{"name": "sid", "value": "1"}, {"name": "sid", "value": "2"}])))[0] + assert entry.request.cookies == {} + assert entry.request.headers == {"cookie": "sid=1; sid=2"} + + def test_cookies_sharing_a_name_without_a_header_make_one(self): + entry = parse_har(_har(_entry( + cookies=[{"name": "sid", "value": "1"}, {"name": "sid", "value": "2"}])))[0] + assert entry.request.cookies == {} + assert entry.request.headers == {"Cookie": "sid=1; sid=2"} + def test_cookie_header_is_kept_when_no_cookies_were_recorded(self): entry = parse_har(_har(_entry(headers=[{"name": "Cookie", "value": "sid=abc"}])))[0] assert entry.request.headers["Cookie"] == "sid=abc" @@ -253,6 +267,15 @@ def test_repeated_endpoint_is_numbered(self): _entry(url="https://x/api/items"), _entry(url="https://x/api/items")))] assert unique_test_names(requests) == ["test_get_api_items", "test_get_api_items_2"] + @pytest.mark.parametrize("paths", [("a", "a", "a/2"), ("a/2", "a", "a"), ("a", "a/2", "a", "a", "a/3")]) + def test_a_number_never_takes_another_requests_own_name(self, paths): + requests = [e.request for e in parse_har(_har(*[_entry(url=f"https://x/{p}") for p in paths]))] + + names = unique_test_names(requests) + + assert len(set(names)) == len(names) + assert "test_get_a_2" in names + class TestGenerateHarScript: def _requests(self, *urls: str): @@ -336,6 +359,11 @@ def test_a_method_that_is_not_a_token_is_refused(self): with pytest.raises(HarParseException, match="not an HTTP method"): parse_har(_har(_entry(method="GET():\n __import__('os').system('calc')\ndef t"))) + def test_an_entry_with_a_bad_method_is_skipped_and_the_rest_load(self): + entries = parse_har(_har(_entry(url="https://x/a", method=""), _entry(url="https://x/b"))) + + assert [entry.request.url for entry in entries] == ["https://x/b"] + def test_a_line_break_in_a_url_stays_inside_the_comment(self): import ast From 17c3a333fa86af3e84215bfc97c1b808b5854f91 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:57:46 +0800 Subject: [PATCH 243/312] Move exact_text beside plain_text in pybreeze_ui --- CLAUDE.md | 1 + architecture_explore.md | 7 ++++--- docs/updates/2026-09-b.md | 7 +++++++ docs/updates/README.md | 3 ++- pybreeze/pybreeze_ui/{tools_gui => }/exact_text.py | 0 pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py | 2 +- pybreeze/pybreeze_ui/tools_gui/diff_gui.py | 2 +- pybreeze/pybreeze_ui/tools_gui/hash_gui.py | 2 +- pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py | 2 +- pybreeze/pybreeze_ui/tools_gui/json_format_gui.py | 2 +- pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py | 2 +- pybreeze/pybreeze_ui/tools_gui/output_actions.py | 2 +- pybreeze/pybreeze_ui/tools_gui/query_json_gui.py | 2 +- pybreeze/pybreeze_ui/tools_gui/regex_gui.py | 2 +- pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py | 2 +- pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py | 2 +- test/test_utils/test_exact_text.py | 2 +- test/test_utils/test_view_safe.py | 2 +- 18 files changed, 27 insertions(+), 17 deletions(-) rename pybreeze/pybreeze_ui/{tools_gui => }/exact_text.py (100%) diff --git a/CLAUDE.md b/CLAUDE.md index dad1efe7..91e95c25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ pybreeze/ │ ├── thread_keeper.py # let_run_out: a worker QThread outlives its closed widget; if_alive: weak slots │ ├── gui_thread_gc.py # Garbage collected on a GUI-thread timer, never on a worker │ ├── plain_text.py # as_text: server/file text shown in message boxes as text, not markup +│ ├── exact_text.py # exact_text: a text box read as typed (toPlainText changes U+00A0, U+2028) │ ├── closing.py # may_close / AskingDock: tabs and docks with unsaved work are asked first │ ├── dialog/ # prthinker settings dialog │ └── syntax/ # Automation keyword highlighting definitions diff --git a/architecture_explore.md b/architecture_explore.md index e583e463..93fa3777 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -210,7 +210,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) --- -## 6. 工具分頁 `pybreeze_ui/tools_gui/`(13 個工具 widget + 3 個共用機制) +## 6. 工具分頁 `pybreeze_ui/tools_gui/`(13 個工具 widget + 2 個共用機制) 每個工具都是 `QWidget`,UI 極薄,真正邏輯全在 `pybreeze/utils/` 對應的純函式套件裡(所以測得動、也測了)。 @@ -230,10 +230,9 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `HeaderAnalyzerGUI` | `utils/header_tools/` | HTTP header 安全稽核(HSTS、CSP、CORS、Set-Cookie、banner…)| | `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT;`curl -i` 印出的多段回應(`100 Continue`、proxy 的 `Connection established`、`-L` 的轉址)取最後一段;只有一行又沒有狀態列就當 body | -### 三個橫向共用機制 +### 兩個橫向共用機制 - **`tool_tabs.open_tool_tab()`** — 工具之間互相「轉交」:Response Inspector 把狀態碼丟給 HTTP Status、headers 丟給 Header Analyzer、JWT 丟給 JWT Decoder、JSON body 丟給 JSON Format;curl 匯入把 URL 丟給 URL Builder。開新分頁並自動聚焦。 -- **`exact_text.exact_text()`** — 讀工具輸入一律走它(`document().toRawText()`,區塊分隔換回 `\n`):`toPlainText()` 是顯示用的,會把不斷行空白(U+00A0)變成空白、U+2028 變成換行,Hash 算的是別的字串、Diff 看不出差別 - **`output_actions.OutputActions`** — 統一的「複製 / 在編輯器開啟 / 存檔」三顆按鈕,綁在工具的唯讀輸出 `QTextEdit` 上,輸出也經 `exact_text()` 讀(不讓 U+00A0、U+2028 被改掉);副檔名與檔名可傳 callable 動態決定。存檔經 `replace_text()` 整檔替換,失敗(唯讀資料夾、被鎖住的檔案、磁碟滿)時原檔不動、會跳警告,說出檔名與原因。Qt 的文字元件留不住貼上的 CR,換行一律讀成 LF --- @@ -427,6 +426,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 給使用者看的文字:伺服器或檔案來的字(遠端路徑、錯誤訊息、主機名、檔名)放進 `QMessageBox` / `QLabel` 前經 `pybreeze_ui/plain_text.as_text()`(`Qt.convertFromPlainText`),Qt 才不會把它當 markup、去載入裡面的 ``;`test_message_boxes_show_text.py` 檢查每個 `QMessageBox`。 +讀使用者輸入的文字一律走 `pybreeze_ui/exact_text.exact_text()`(`document().toRawText()`,區塊分隔換回 `\n`):`toPlainText()` 是顯示用的,會把不斷行空白(U+00A0)變成空白、U+2028 變成換行,Hash 算的是別的字串、Diff 看不出差別。 + 鐵律:worker thread 一律不碰 UI。普通執行緒走 Queue + QTimer,`QThread` 走 Signal/Slot。分頁或視窗關閉時還在跑的 `QThread` 交給 `thread_keeper.let_run_out()`,不等它、也不讓它在執行中被銷毀。widget 留著的 thread(或其他物件)上接的 slot 不能抓住 widget 本身:接 bound method,或用 `thread_keeper.if_alive(weakref.ref(self), ...)`;lambda 抓 `self` 是經過 Qt 的循環參照,Python 的 GC 看不到,關掉的 widget 永遠不會釋放(`test_closed_panels_are_freed.py`)。 執行器的壽命:QTimer 接到執行器的 `pull_text()` / `_pull_text()` 這條連線**不會**讓執行器活著;reader 執行緒一結束,沒人持有的執行器就會被 GC,剩下的輸出和結束那一行都不會出現。所以執行器一律掛在它寫入的 `CodeWindow.runner` 上,跟視窗同壽;視窗又掛在主視窗的 `current_run_code_window`。 diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 58a9e022..2334069b 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -39,3 +39,10 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2023 tests in 121 files. The full suite before this change: 2002 passed, 15 skipped, startup tests exit 0. `ruff check` clean. - **Files**: `pybreeze/utils/har_import/har_codegen.py`, `pybreeze/utils/har_import/har_parser.py`, `test/test_utils/test_har_import.py`, `README.md`, `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-205 · 2026-09-24 · Move exact_text beside plain_text in pybreeze_ui · #refactor + +- **What**: a refactor, no behaviour change. `exact_text()` (a text box read as typed, through `toRawText()`) was in `tools_gui/`, but the prompt editor, the AI panels and the diagram editor's Mermaid import read their text boxes too and are about to use it. It moves to `pybreeze_ui/exact_text.py`, beside `plain_text.py`, the other helper shared across the UI. +- **Tests**: `test_exact_text.py`, `test_view_safe.py` and `test_closed_panels_are_freed.py` pass (40). `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/exact_text.py` (moved from `tools_gui/`), eleven `tools_gui` modules' imports, `test/test_utils/test_exact_text.py`, `test/test_utils/test_view_safe.py`, `CLAUDE.md` (tree), `architecture_explore.md` §6, §16 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 7d56c39c..17ed1873 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-205 | 2026-09-24 | Move exact_text beside plain_text in pybreeze_ui | #refactor | [2026-09-b](2026-09-b.md) | | U-20260923-204 | 2026-09-24 | Keep every recorded call, cookie and entry when importing a HAR file | #fix #har | [2026-09-b](2026-09-b.md) | | U-20260923-203 | 2026-09-24 | Run a script too long for a Windows command line from a file | #fix #executor | [2026-09-b](2026-09-b.md) | | U-20260923-202 | 2026-09-24 | Bring the architecture map, README and CLAUDE.md back in line with the code | #docs | [2026-09-b](2026-09-b.md) | @@ -286,4 +287,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 4 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 5 | diff --git a/pybreeze/pybreeze_ui/tools_gui/exact_text.py b/pybreeze/pybreeze_ui/exact_text.py similarity index 100% rename from pybreeze/pybreeze_ui/tools_gui/exact_text.py rename to pybreeze/pybreeze_ui/exact_text.py diff --git a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py index f9e487bb..b83d2e58 100644 --- a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py @@ -14,7 +14,7 @@ ) from je_editor import language_wrapper -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.pybreeze_ui.tools_gui.tool_tabs import open_tool_tab diff --git a/pybreeze/pybreeze_ui/tools_gui/diff_gui.py b/pybreeze/pybreeze_ui/tools_gui/diff_gui.py index 9e6f4c1b..6a74a9b0 100644 --- a/pybreeze/pybreeze_ui/tools_gui/diff_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/diff_gui.py @@ -7,7 +7,7 @@ ) from je_editor import language_wrapper -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.utils.diff_tools.text_diff import Comparison, DiffSummary, compare_texts diff --git a/pybreeze/pybreeze_ui/tools_gui/hash_gui.py b/pybreeze/pybreeze_ui/tools_gui/hash_gui.py index 1c1b8954..cdc98ee1 100644 --- a/pybreeze/pybreeze_ui/tools_gui/hash_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/hash_gui.py @@ -4,7 +4,7 @@ from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget from je_editor import language_wrapper -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.hash_tools.hash_text import hash_all diff --git a/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py b/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py index b4efd9ac..362d3963 100644 --- a/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py @@ -12,7 +12,7 @@ from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget from je_editor import language_wrapper -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.pybreeze_ui.tools_gui.tool_tabs import open_tool_tab diff --git a/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py b/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py index 539b47a0..e4833aea 100644 --- a/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py @@ -6,7 +6,7 @@ ) from je_editor import language_wrapper -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import ITEJsonException from pybreeze.utils.json_format.json_process import minify_json, reformat_json diff --git a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py index c56d8143..55ee8905 100644 --- a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py @@ -9,7 +9,7 @@ from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget from je_editor import language_wrapper -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import JwtDecodeException from pybreeze.utils.jwt_tools.jwt_decoder import ( diff --git a/pybreeze/pybreeze_ui/tools_gui/output_actions.py b/pybreeze/pybreeze_ui/tools_gui/output_actions.py index f88c2e4f..4606cd51 100644 --- a/pybreeze/pybreeze_ui/tools_gui/output_actions.py +++ b/pybreeze/pybreeze_ui/tools_gui/output_actions.py @@ -17,7 +17,7 @@ ) from je_editor import language_wrapper -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.utils.file_process.replace_file import replace_text from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.pybreeze_ui.plain_text import as_text diff --git a/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py b/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py index 71ad3021..659535c9 100644 --- a/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py @@ -6,7 +6,7 @@ ) from je_editor import language_wrapper -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import QueryConvertException from pybreeze.utils.logging.logger import pybreeze_logger diff --git a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py index ca7d3234..081d9e9e 100644 --- a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py @@ -9,7 +9,7 @@ from je_editor import language_wrapper from pybreeze.pybreeze_ui.thread_keeper import let_run_out -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import RegexTesterException from pybreeze.utils.logging.logger import pybreeze_logger diff --git a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py index 93943df0..994fc235 100644 --- a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py @@ -11,7 +11,7 @@ ) from je_editor import language_wrapper -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI from pybreeze.pybreeze_ui.tools_gui.json_format_gui import JsonFormatGUI diff --git a/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py b/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py index 4fe1aaf7..772a90ea 100644 --- a/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py @@ -6,7 +6,7 @@ ) from je_editor import language_wrapper -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.pybreeze_ui.exact_text import exact_text from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.utils.exception.exceptions import UrlConvertException from pybreeze.utils.logging.logger import pybreeze_logger diff --git a/test/test_utils/test_exact_text.py b/test/test_utils/test_exact_text.py index 14117eab..980d1ad9 100644 --- a/test/test_utils/test_exact_text.py +++ b/test/test_utils/test_exact_text.py @@ -11,7 +11,7 @@ from PySide6.QtWidgets import QApplication, QPlainTextEdit, QTextEdit from pybreeze.extend_multi_language.update_language_dict import update_language_dict -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text +from pybreeze.pybreeze_ui.exact_text import exact_text # A non-breaking space, and a line separator inside a line _ENTERED = "price: 100\nnext
line" diff --git a/test/test_utils/test_view_safe.py b/test/test_utils/test_view_safe.py index 69322298..b7bd7257 100644 --- a/test/test_utils/test_view_safe.py +++ b/test/test_utils/test_view_safe.py @@ -10,7 +10,7 @@ from PySide6.QtWidgets import QApplication, QPlainTextEdit # noqa: E402 — the platform must be set first -from pybreeze.pybreeze_ui.tools_gui.exact_text import exact_text # noqa: E402 +from pybreeze.pybreeze_ui.exact_text import exact_text # noqa: E402 from pybreeze.utils.json_format.view_safe import dumps_for_view, escape_for_view # noqa: E402 # What a Qt text view does not give back: U+2029, U+FDD0 and U+FDD1 come back From d0f6adb6108da8a19c386ddecb11341f8633ed52 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 07:58:59 +0800 Subject: [PATCH 244/312] Save a prompt file with the characters it was opened with --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../connect_gui/url/ai_code_review_gui.py | 3 ++- .../diagram_editor/diagram_editor_widget.py | 3 ++- .../code_review/cot_code_review_gui.py | 3 ++- .../prompt_edit_gui/prompt_editor_widget.py | 3 ++- .../extend_ai_gui/skills/skills_send_gui.py | 3 ++- test/test_utils/test_prompt_store.py | 13 +++++++++++++ 9 files changed, 35 insertions(+), 7 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 93fa3777..a0498bdf 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2023 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2024 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 2334069b..e34a3d71 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -46,3 +46,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Tests**: `test_exact_text.py`, `test_view_safe.py` and `test_closed_panels_are_freed.py` pass (40). `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/exact_text.py` (moved from `tools_gui/`), eleven `tools_gui` modules' imports, `test/test_utils/test_exact_text.py`, `test/test_utils/test_view_safe.py`, `CLAUDE.md` (tree), `architecture_explore.md` §6, §16 - **Open items**: none. + +## U-20260923-206 · 2026-09-24 · Save a prompt file with the characters it was opened with · #fix #ai + +- **What**: the prompt editor saved `toPlainText()`, which is for display: a no-break space came back as a space and U+2028 as a newline. Opening a prompt file and saving it, even after a one-character edit, changed characters the user never touched, and `replace_text` wrote that over their file. The AI panels (CoT review, Skills, AI code review) and the Mermaid import read their text boxes the same way, so what was sent or converted was not what was pasted. +- **Fix**: the five read through `exact_text()` (`document().toRawText()`, block breaks back to `\n`), as the tool tabs already did. U+2029, U+FDD0 and U+FDD1 in a file still come back as line breaks: no Qt text box keeps them apart from one. +- **Tests**: `test_prompt_store.py` +1: a prompt with a no-break space and U+2028, opened and saved, is on disk byte for byte as before. +- **Result / numbers**: 2024 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `extend_ai_gui/skills/skills_send_gui.py`, `extend_ai_gui/code_review/cot_code_review_gui.py`, `connect_gui/url/ai_code_review_gui.py`, `diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_prompt_store.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 17ed1873..073658c3 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-206 | 2026-09-24 | Save a prompt file with the characters it was opened with | #fix #ai | [2026-09-b](2026-09-b.md) | | U-20260923-205 | 2026-09-24 | Move exact_text beside plain_text in pybreeze_ui | #refactor | [2026-09-b](2026-09-b.md) | | U-20260923-204 | 2026-09-24 | Keep every recorded call, cookie and entry when importing a HAR file | #fix #har | [2026-09-b](2026-09-b.md) | | U-20260923-203 | 2026-09-24 | Run a script too long for a Windows command line from a file | #fix #executor | [2026-09-b](2026-09-b.md) | @@ -287,4 +288,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 5 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 6 | 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 9365bed7..6a355fb3 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 @@ -24,6 +24,7 @@ ) from pybreeze.utils.network.public_http import overall_deadline, public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url +from pybreeze.pybreeze_ui.exact_text import exact_text # What the "seen this URL before" file keeps. An API URL can carry a token in @@ -241,7 +242,7 @@ def send_request(self): return url = self.url_input.text().strip() method = self.method_box.currentText() - code_content = self.code_input.toPlainText().strip() + code_content = exact_text(self.code_input).strip() if not url: self.response_panel.setPlainText( diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 177b6f06..9b2a7fe0 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -34,6 +34,7 @@ from pybreeze.utils.file_process.read_capped import read_text_capped from pybreeze.utils.file_process.replace_file import replace_text, replace_written from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.exact_text import exact_text def _lang(key: str, fallback: str = "") -> str: @@ -129,7 +130,7 @@ def __init__(self, parent=None): layout.addLayout(btn_row) def get_text(self) -> str: - return self._editor.toPlainText() + return exact_text(self._editor) class DiagramEditorWidget(QWidget): 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 43168680..cc34d9fd 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 @@ -7,6 +7,7 @@ from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread from pybreeze.pybreeze_ui.thread_keeper import let_run_out +from pybreeze.pybreeze_ui.exact_text import exact_text class CoTCodeReviewGUI(QWidget): @@ -87,7 +88,7 @@ def start_sending(self): # 啟動傳送 Thread self.send_button.setEnabled(False) - self.thread = SenderThread(files=self.files, code=self.code_paste_area.toPlainText(), url=url) + self.thread = SenderThread(files=self.files, code=exact_text(self.code_paste_area), url=url) self.thread.update_response.connect(self.handle_response) self.thread.finished.connect(self._enable_send) self.thread.start() diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py index 22743334..8adba1e2 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py @@ -27,6 +27,7 @@ ) from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.pybreeze_ui.plain_text import as_text +from pybreeze.pybreeze_ui.exact_text import exact_text @dataclass(frozen=True) @@ -264,7 +265,7 @@ def save_file(self) -> None: return if not save_prompt_text( - self, self.current_file, self.middle_editor.toPlainText(), + self, self.current_file, exact_text(self.middle_editor), word.get(self._labels.error_title)): return self.watcher.addPath(self.current_file) 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 5c6bfb31..222febab 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 @@ -24,6 +24,7 @@ from pybreeze.utils.network.public_http import overall_deadline, public_session from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url from pybreeze.pybreeze_ui.plain_text import as_text +from pybreeze.pybreeze_ui.exact_text import exact_text # Where a skill template wants the code; the user puts it there before sending @@ -191,7 +192,7 @@ def send_prompt(self): return api_url = self.api_url_input.text().strip() - prompt_text = self.prompt_input.toPlainText().strip() + prompt_text = exact_text(self.prompt_input).strip() if not api_url or not prompt_text: self.response_output.setPlainText(language_wrapper.language_word_dict.get("skills_missing_input")) diff --git a/test/test_utils/test_prompt_store.py b/test/test_utils/test_prompt_store.py index 21b39225..4d73ab92 100644 --- a/test/test_utils/test_prompt_store.py +++ b/test/test_utils/test_prompt_store.py @@ -518,3 +518,16 @@ def locked(_path): assert "Permission denied" in editor.middle_editor.placeholderText() editor.deleteLater() + def test_saving_writes_back_the_characters_it_did_not_touch(self, prompts, monkeypatch): + # Saved from toPlainText(), a no-break space became a space and U+2028 a newline + original = "Review\xa0this:
{code_diff}\n" + write(prompts, "linter.md", original) + editor = _cot_editor(monkeypatch) + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + + editor.save_file() + + assert (prompts / "linter.md").read_text(encoding="utf-8") == original + editor.close() + editor.deleteLater() + From 9c0d92736db9af79a83cda462831cbdeed95cc79 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:01:57 +0800 Subject: [PATCH 245/312] Round a decimal epoch toward the past and refuse an offset past 59 minutes --- architecture_explore.md | 4 +- docs/updates/2026-09-b.md | 11 ++++ docs/updates/README.md | 3 +- .../timestamp_tools/timestamp_converter.py | 53 +++++++++++++++---- test/test_utils/test_timestamp_converter.py | 34 ++++++++++++ 5 files changed, 92 insertions(+), 13 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index a0498bdf..701545c6 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -219,7 +219,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `CurlImportGUI` | `utils/curl_import/` | 貼上 curl 指令 → 產生 requests / pytest / APITestka(py & json) / LoadDensity 腳本 | | `HarImportGUI` | `utils/har_import/` | 開 `.har` → 列出錄到的請求(可只看 API-like)→ 批次產生腳本 | | `JwtDecoderGUI` | `utils/jwt_tools/` | 解 JWT header/payload(不驗簽),時間戳轉可讀 UTC;貼上的文字先去掉空白,不是單純的 token 就取出裡面第一個(`Bearer `、引號、換行都可以),base64url 嚴格解碼 | -| `TimestampGUI` | `utils/timestamp_tools/` | epoch(依大小自動判秒/毫秒/微秒/奈秒,整數用 `int()` 精確換算)↔ ISO-8601(`_ISO_RE` 自己解析,3.10 到 3.14 讀法一致:`Z`/`z`、`±HH`、`±HHMM`、任意位數小數、basic 格式;八位數而且是合法日期就當 `YYYYMMDD`)。epoch 換算用 `utc_from_epoch_seconds()`(epoch + `timedelta`;`datetime.fromtimestamp` 在 Windows 上拒絕 1970 年前幾小時以外的值),JWT 的時間戳 claim 也用它 | +| `TimestampGUI` | `utils/timestamp_tools/` | epoch(依大小自動判秒/毫秒/微秒/奈秒;整數用 `int()`、小數用 `Decimal` 精確換算,一律往過去截到微秒)↔ ISO-8601(`_ISO_RE` 自己解析,3.10 到 3.14 讀法一致:`Z`/`z`、`±HH`、`±HHMM`、任意位數小數、basic 格式;八位數而且是合法日期就當 `YYYYMMDD`)。epoch 換算用 `utc_from_epoch_seconds()`(epoch + `timedelta`;`datetime.fromtimestamp` 在 Windows 上拒絕 1970 年前幾小時以外的值),JWT 的時間戳 claim 也用它 | | `HashGUI` | `utils/hash_tools/` | 多演算法摘要 | | `QueryJsonGUI` | `utils/query_tools/` | query string ↔ JSON 雙向 | | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2024 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2037 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index e34a3d71..4b0e9aa0 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -55,3 +55,14 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2024 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `extend_ai_gui/skills/skills_send_gui.py`, `extend_ai_gui/code_review/cot_code_review_gui.py`, `connect_gui/url/ai_code_review_gui.py`, `diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_prompt_store.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-207 · 2026-09-24 · Round a decimal epoch toward the past and refuse an offset past 59 minutes · #fix #timestamp + +- **What**: two ways the Timestamp Converter answered with an instant it was not given. + - A decimal epoch went through `float` and `timedelta(seconds=...)`, which rounds to the nearest microsecond: `0.0000009` came out a microsecond after the epoch, and `1700000000.9999999` a microsecond before the next second rounded up, while the result's fields say rounded toward the past and a whole number and an ISO fraction are cut. + - A zone offset's minutes were not checked: `2024-01-01T00:00+05:99` was taken as +06:39. +- **Fix**: a decimal epoch is read as a `Decimal` and cut to the microsecond (`_decimal_microseconds`, 60 digits, the widest exponent range, so `-1e-999999999` is a microsecond before 1970, not 0). One that is infinite, NaN or has 30 or more digits before the point is refused before any arithmetic (`1e999999999` overflowed the decimal context). `_zone` refuses 60 minutes or more. +- **Tests**: `test_timestamp_converter.py` +13: six decimals cut to the microsecond, six inputs no date can be refused, `+05:99` refused and `+05:59` taken. Passes on 3.11 and 3.10. +- **Result / numbers**: 2037 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/timestamp_tools/timestamp_converter.py`, `test/test_utils/test_timestamp_converter.py`, `architecture_explore.md` §6, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 073658c3..5245ef75 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-207 | 2026-09-24 | Round a decimal epoch toward the past and refuse an offset past 59 minutes | #fix #timestamp | [2026-09-b](2026-09-b.md) | | U-20260923-206 | 2026-09-24 | Save a prompt file with the characters it was opened with | #fix #ai | [2026-09-b](2026-09-b.md) | | U-20260923-205 | 2026-09-24 | Move exact_text beside plain_text in pybreeze_ui | #refactor | [2026-09-b](2026-09-b.md) | | U-20260923-204 | 2026-09-24 | Keep every recorded call, cookie and entry when importing a HAR file | #fix #har | [2026-09-b](2026-09-b.md) | @@ -288,4 +289,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 6 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 7 | diff --git a/pybreeze/utils/timestamp_tools/timestamp_converter.py b/pybreeze/utils/timestamp_tools/timestamp_converter.py index b5f45f73..e9693d75 100644 --- a/pybreeze/utils/timestamp_tools/timestamp_converter.py +++ b/pybreeze/utils/timestamp_tools/timestamp_converter.py @@ -9,9 +9,11 @@ """ from __future__ import annotations +import math import re from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from decimal import MAX_EMAX, MIN_EMIN, Decimal, InvalidOperation, localcontext from pybreeze.utils.exception.exception_tags import ( empty_timestamp_error, @@ -34,6 +36,15 @@ _MS_PER_SECOND = 1000 # The Unix epoch, in UTC _EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) +# Past this many digits before the point, an epoch is outside the years +# datetime can hold in any unit; a longer decimal is refused before any +# arithmetic, which would overflow (1e999999999) or build a huge integer +_MAX_EPOCH_DIGITS = 30 +# Digits a decimal epoch is worked out to: enough for the largest one taken, +# in microseconds, with its fraction +_DECIMAL_PRECISION = 60 +# Minutes in an hour, the most a zone offset's minutes may be +_MINUTES_PER_HOUR = 60 @dataclass(frozen=True) @@ -76,17 +87,36 @@ def utc_from_epoch_seconds(seconds: float) -> datetime: return _EPOCH + timedelta(seconds=seconds) -def _from_epoch(value: int | float) -> datetime: +def _decimal_microseconds(value: Decimal) -> int: + """Whole microseconds in the decimal epoch *value*, rounded toward the past. + + :raises OverflowError: infinite or NaN, or too long to be a date + """ + if not value.is_finite(): + raise OverflowError("not a finite epoch") + if value and value.adjusted() >= _MAX_EPOCH_DIGITS: + raise OverflowError("epoch out of range") + with localcontext() as context: + context.prec = _DECIMAL_PRECISION + # A tiny fraction (1e-999999999) is not rounded away to zero + context.Emax, context.Emin = MAX_EMAX, MIN_EMIN + return math.floor(value * 10 ** 6 / _PER_SECOND[detect_epoch_unit(value)]) + + +def _from_epoch(value: int | Decimal) -> datetime: """Build a UTC datetime from an epoch number, auto-detecting its unit. - A whole number is divided exactly (to the microsecond, rounding toward the - past): as a float, a nanosecond epoch has lost its last digits already. + The number is divided exactly, to the microsecond, rounding toward the + past: as a float, a nanosecond epoch has lost its last digits already, and + ``timedelta`` rounds a float's fraction to the nearest microsecond, so + ``0.0000009`` came out a microsecond after the epoch. """ - per_second = _PER_SECOND[detect_epoch_unit(value)] try: if isinstance(value, int): - return _EPOCH + timedelta(microseconds=value * 10 ** 6 // per_second) - return utc_from_epoch_seconds(value / per_second) + microseconds = value * 10 ** 6 // _PER_SECOND[detect_epoch_unit(value)] + else: + microseconds = _decimal_microseconds(value) + return _EPOCH + timedelta(microseconds=microseconds) except (OverflowError, ValueError) as error: pybreeze_logger.error(unrecognized_timestamp_error) raise TimestampParseException(unrecognized_timestamp_error) from error @@ -110,8 +140,11 @@ def _zone(text: str | None) -> timezone: return timezone.utc sign = -1 if text[0] == "-" else 1 digits = text[1:].replace(":", "") - offset = timedelta(hours=int(digits[:2]), minutes=int(digits[2:4] or 0)) - return timezone(sign * offset) + minutes = int(digits[2:4] or 0) + if minutes >= _MINUTES_PER_HOUR: + # +05:99 used to be taken as +06:39 + raise ValueError(f"zone offset minutes out of range: {text}") + return timezone(sign * timedelta(hours=int(digits[:2]), minutes=minutes)) def _iso_match(text: str) -> datetime | None: @@ -172,10 +205,10 @@ def _parse(text: str) -> datetime: date = _eight_digit_date(stripped) if date is not None: return date - for number_type in (int, float): + for number_type in (int, Decimal): try: number = number_type(stripped) - except ValueError: + except (ValueError, InvalidOperation): continue return _from_epoch(number) # Not a plain number; fall through to ISO parsing. diff --git a/test/test_utils/test_timestamp_converter.py b/test/test_utils/test_timestamp_converter.py index 51516d65..58322870 100644 --- a/test/test_utils/test_timestamp_converter.py +++ b/test/test_utils/test_timestamp_converter.py @@ -163,3 +163,37 @@ def test_a_field_out_of_range_is_refused(self): with pytest.raises(TimestampParseException): convert_timestamp("2024-13-01T00:00:00Z") + + def test_an_offset_past_59_minutes_is_refused(self): + # +05:99 was taken as +06:39 + from pybreeze.utils.exception.exceptions import TimestampParseException + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + with pytest.raises(TimestampParseException): + convert_timestamp("2024-01-01T00:00+05:99") + assert convert_timestamp("2024-01-01T00:00+05:59").iso_utc == "2023-12-31T18:01:00+00:00" + + +class TestADecimalEpochRoundsTowardThePast: + """As a whole number does, and as the result's fields say: a float was rounded to the nearest.""" + + @pytest.mark.parametrize(("text", "iso"), [ + ("0.0000009", "1970-01-01T00:00:00+00:00"), + ("-0.0000001", "1969-12-31T23:59:59.999999+00:00"), + ("1700000000.9999999", "2023-11-14T22:13:20.999999+00:00"), + ("1700000000123.4567", "2023-11-14T22:13:20.123456+00:00"), + ("-1e-999999999", "1969-12-31T23:59:59.999999+00:00"), + ("0e999999999", "1970-01-01T00:00:00+00:00"), + ]) + def test_it_is_cut_to_the_microsecond(self, text, iso): + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + assert convert_timestamp(text).iso_utc == iso + + @pytest.mark.parametrize("text", ["1e999999999", "Infinity", "-inf", "nan", "sNaN", "9" * 40]) + def test_one_no_date_can_be_is_refused(self, text): + from pybreeze.utils.exception.exceptions import TimestampParseException + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + with pytest.raises(TimestampParseException): + convert_timestamp(text) From fbfa216c61551675628de6fce3be29bb2d11c4fd Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:05:27 +0800 Subject: [PATCH 246/312] Keep the prthinker extra arguments after a # --- architecture_explore.md | 4 ++-- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/extend/prthinker_extend/prthinker_setting.py | 4 ++++ test/test_utils/test_prthinker_setting.py | 8 ++++++++ 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 701545c6..c3735bd6 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -373,7 +373,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 - `environment_for()` 把設定轉成 `PRTHINKER_*` 環境變數交給子行程,**命令列只留「這次要審什麼」** — API key 不會出現在工作管理員或執行紀錄 - `SECRET_SETTINGS` 四個欄位在 `loggable()` 中一律縮成 `(set)` / 空字串 - 模型名稱依後端交給不同變數(`MODEL_ENVIRONMENT`):`remote` / `local` 是 `PRTHINKER_MODEL_NAME`,其他後端是各自的 `PRTHINKER__MODEL`。prthinker 只有 local 讀 `PRTHINKER_MODEL_NAME`(remote 由伺服器決定模型,只拿來標示) -- `extra_arguments()` 經 `split_arguments()` 以命令列規則斷詞(引號內空白不拆);反斜線是路徑分隔字元的平台(Windows)上反斜線不當跳脫字元,`C:\reviews` 才不會變成 `C:reviews`。解析失敗當作沒有而不是讓整次審查失敗 +- `extra_arguments()` 經 `split_arguments()` 以命令列規則斷詞(引號內空白不拆,`#` 不當註解);反斜線是路徑分隔字元的平台(Windows)上反斜線不當跳脫字元,`C:\reviews` 才不會變成 `C:reviews`。解析失敗當作沒有而不是讓整次審查失敗 - `install_target()` 回傳 `[runner]`,因為 prthinker 不在 PyPI 上;資料夾要有 `pyproject.toml` 且專案名稱是 prthinker,否則不給目標、也不記住 - 規則檢索(`rag`,`RAG_MODES = ("off", "remote")`)**永遠明講**,而且兩個變數都送:`off` 給 `PRTHINKER_RAG_ENABLED=false` + `PRTHINKER_REMOTE_RAG=false`,`remote` 兩個都 `true`(走伺服器的 `/rag`),認不得的值當 `off`。子行程繼承 IDE 的環境,少送一個就會被使用者 shell 裡的設定決定。prthinker 的預設是本機 FAISS 檢索,但那份索引(`codes/`)只在它的原始碼庫、被排除在套件外,從這裡裝的 prthinker 一跑就 `ModuleNotFoundError: codes` @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2037 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2039 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 4b0e9aa0..dce0e88a 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -66,3 +66,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2037 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/utils/timestamp_tools/timestamp_converter.py`, `test/test_utils/test_timestamp_converter.py`, `architecture_explore.md` §6, §18 - **Open items**: none. + +## U-20260923-208 · 2026-09-24 · Keep the prthinker extra arguments after a # · #fix #prthinker + +- **What**: `split_arguments` built its own `shlex.shlex`, which reads `#` as the start of a comment (`shlex.split` turns that off; a hand-built lexer does not). Everything from the first `#` was dropped without a word: `--language C# --verbose` gave `--language C`, `--rules-url https://x/y#sec --max 3` lost the fragment and `--max 3`, and `--focus #security --depth 2` kept only `--focus`. The settings dialog checks with the same function, so it said nothing either. +- **Fix**: `lexer.commenters = ""`. +- **Tests**: `test_prthinker_setting.py` +2: `C#`, a URL with a fragment and `#security` all stay whole, with and without backslash escapes. +- **Result / numbers**: 2039 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §14, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 5245ef75..667f27b2 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-208 | 2026-09-24 | Keep the prthinker extra arguments after a # | #fix #prthinker | [2026-09-b](2026-09-b.md) | | U-20260923-207 | 2026-09-24 | Round a decimal epoch toward the past and refuse an offset past 59 minutes | #fix #timestamp | [2026-09-b](2026-09-b.md) | | U-20260923-206 | 2026-09-24 | Save a prompt file with the characters it was opened with | #fix #ai | [2026-09-b](2026-09-b.md) | | U-20260923-205 | 2026-09-24 | Move exact_text beside plain_text in pybreeze_ui | #refactor | [2026-09-b](2026-09-b.md) | @@ -289,4 +290,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 7 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 8 | diff --git a/pybreeze/extend/prthinker_extend/prthinker_setting.py b/pybreeze/extend/prthinker_extend/prthinker_setting.py index b0ea333a..ef613ca6 100644 --- a/pybreeze/extend/prthinker_extend/prthinker_setting.py +++ b/pybreeze/extend/prthinker_extend/prthinker_setting.py @@ -289,6 +289,10 @@ def split_arguments(text: str, *, backslash_escapes: bool) -> List[str]: """ lexer = shlex.shlex(text, posix=True) lexer.whitespace_split = True + # 不把 # 當註解:C#、網址的 #section 都是參數的一部分 + # "#" starts no comment (shlex.split clears it too): "C#" or a URL's + # "#section" cut off every argument after it + lexer.commenters = "" if not backslash_escapes: lexer.escape = "" return list(lexer) diff --git a/test/test_utils/test_prthinker_setting.py b/test/test_utils/test_prthinker_setting.py index 3b062a99..53e84761 100644 --- a/test/test_utils/test_prthinker_setting.py +++ b/test/test_utils/test_prthinker_setting.py @@ -232,6 +232,14 @@ def test_a_windows_path_keeps_its_backslashes(self): backslash_escapes=False, ) == ["--output-dir", r"C:\reviews\out", "--marker", r"C:\with space\x"] + @pytest.mark.parametrize("backslash_escapes", [False, True]) + def test_a_hash_is_part_of_an_argument_not_a_comment(self, backslash_escapes): + # Everything from the first "#" on was dropped without a word + assert split_arguments( + "--language C# --rules-url https://x/y#sec --focus #security --depth 2", + backslash_escapes=backslash_escapes, + ) == ["--language", "C#", "--rules-url", "https://x/y#sec", "--focus", "#security", "--depth", "2"] + def test_where_backslash_escapes_it_still_does(self): assert split_arguments(r"--marker a\ b", backslash_escapes=True) == ["--marker", "a b"] From 094efa924ac070155184c44065c5cb7999fb1dd4 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:08:05 +0800 Subject: [PATCH 247/312] Skip a diagram image entry that is not an object instead of failing Open --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../pybreeze_ui/diagram_editor/diagram_scene.py | 4 +++- test/test_utils/test_diagram_serialization.py | 15 +++++++++++++++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index c3735bd6..9bb26123 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2039 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2043 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index dce0e88a..b6cafa22 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -75,3 +75,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2039 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §14, §18 - **Open items**: none. + +## U-20260923-209 · 2026-09-24 · Skip a diagram image entry that is not an object instead of failing Open · #fix #diagram + +- **What**: a `.diagram.json` whose `images` list held something other than an object (`"x"`, `5`) made `DiagramImage.from_dict` call `.get` on it. The `AttributeError` was not among what `_load_images` skips, `load_from_dict` put the old canvas back and raised it again, and `_open_diagram` does not catch it either, so it escaped the slot and Open said nothing. A node or connection entry of that kind was already skipped. +- **Fix**: `_load_images` also skips on `AttributeError`, as `_load_connections` does. +- **Tests**: `test_diagram_serialization.py` +4: `"x"`, `5`, `null` and a list as a node, connection and image entry are skipped, and the good node still loads. +- **Result / numbers**: 2043 tests in 121 files. The full suite before this change: 2022 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `test/test_utils/test_diagram_serialization.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 667f27b2..559b4ab8 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-209 | 2026-09-24 | Skip a diagram image entry that is not an object instead of failing Open | #fix #diagram | [2026-09-b](2026-09-b.md) | | U-20260923-208 | 2026-09-24 | Keep the prthinker extra arguments after a # | #fix #prthinker | [2026-09-b](2026-09-b.md) | | U-20260923-207 | 2026-09-24 | Round a decimal epoch toward the past and refuse an offset past 59 minutes | #fix #timestamp | [2026-09-b](2026-09-b.md) | | U-20260923-206 | 2026-09-24 | Save a prompt file with the characters it was opened with | #fix #ai | [2026-09-b](2026-09-b.md) | @@ -290,4 +291,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 8 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 9 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 1ab3fa42..8068e7c4 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -798,7 +798,9 @@ def _load_images(self, image_dicts: list) -> None: for img_d in image_dicts: try: img = DiagramImage.from_dict(img_d) - except (KeyError, ValueError, TypeError) as err: + # AttributeError: an entry that is not an object ("x"), which + # escaped Open without a message + except (AttributeError, KeyError, ValueError, TypeError) as err: pybreeze_logger.debug("Skipping malformed diagram image %r: %s", img_d, err) continue self.addItem(img) diff --git a/test/test_utils/test_diagram_serialization.py b/test/test_utils/test_diagram_serialization.py index c8fafde7..31413fb8 100644 --- a/test/test_utils/test_diagram_serialization.py +++ b/test/test_utils/test_diagram_serialization.py @@ -88,6 +88,21 @@ def test_valid_items_load_despite_malformed_entries(self, qt_app): # Only the valid self-connection survives. assert len(scene.get_all_connections()) == 1 + @pytest.mark.parametrize("entry", ["x", 5, None, ["x", 0]]) + def test_an_entry_that_is_not_an_object_is_skipped(self, qt_app, entry): + # An image entry "x" raised AttributeError out of Open, with no message + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({ + "nodes": [{"id": 0, "x": 0, "y": 0, "text": "Good"}, entry], + "connections": [entry], + "images": [entry], + }) + + assert [n.text() for n in scene.get_all_nodes()] == ["Good"] + assert scene.get_all_connections() == [] + @pytest.mark.parametrize("source", [5, None, ["a.png"], {"x": 1}]) def test_an_image_source_that_is_not_text_is_dropped(self, qt_app, source): from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene From 1d1d6e02f3ac5e317260ffe6e432df7f805d6a36 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:08:06 +0800 Subject: [PATCH 248/312] Keep a quoted Mermaid edge label that holds a bar --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../pybreeze_ui/diagram_editor/diagram_mermaid_parser.py | 3 ++- test/test_utils/test_mermaid_parser.py | 7 +++++++ 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 9bb26123..2d8eb962 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2043 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2045 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index b6cafa22..3c5793ad 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -84,3 +84,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2043 tests in 121 files. The full suite before this change: 2022 passed, 15 skipped, startup tests exit 0. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `test/test_utils/test_diagram_serialization.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-210 · 2026-09-24 · Keep a quoted Mermaid edge label that holds a bar · #fix #diagram + +- **What**: Mermaid takes a quoted edge label between bars, `A-->|"a|b"|B`. The line was split with the quotes kept together, but `_parse_arrow` read the label with `\|([^|]*)\|`, which stopped at the bar inside the quotes: the edge was labelled `"a`. +- **Fix**: the label pattern takes a quoted label whole before an unquoted one (`\|\s*("[^"]*"|[^|]*)\s*\|`). +- **Tests**: `test_mermaid_parser.py` +2: `|"a|b"|` and `| "a|b" |` give the label `a|b` between two nodes. +- **Result / numbers**: 2045 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py`, `test/test_utils/test_mermaid_parser.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 559b4ab8..573a2ee2 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-210 | 2026-09-24 | Keep a quoted Mermaid edge label that holds a bar | #fix #diagram | [2026-09-b](2026-09-b.md) | | U-20260923-209 | 2026-09-24 | Skip a diagram image entry that is not an object instead of failing Open | #fix #diagram | [2026-09-b](2026-09-b.md) | | U-20260923-208 | 2026-09-24 | Keep the prthinker extra arguments after a # | #fix #prthinker | [2026-09-b](2026-09-b.md) | | U-20260923-207 | 2026-09-24 | Round a decimal epoch toward the past and refuse an offset past 59 minutes | #fix #timestamp | [2026-09-b](2026-09-b.md) | @@ -291,4 +292,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 9 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 10 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py index 285499b0..28e9b821 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py @@ -253,7 +253,8 @@ def _parse_node_group(raw: str, nodes: dict[str, _NodeInfo]) -> list[str]: def _parse_arrow(token: str) -> tuple[str, ConnectionStyle, float]: """Return ``(label, style, line_width)`` from an arrow token.""" label = "" - lm = re.search(r"\|([^|]*)\|", token) + # A quoted label is taken whole: '|"a|b"|' stopped at the "|" inside it + lm = re.search(r'\|\s*("[^"]*"|[^|]*)\s*\|', token) if lm: label = _unquote(lm.group(1).strip()) diff --git a/test/test_utils/test_mermaid_parser.py b/test/test_utils/test_mermaid_parser.py index 7f33a080..0e68bbdf 100644 --- a/test/test_utils/test_mermaid_parser.py +++ b/test/test_utils/test_mermaid_parser.py @@ -91,6 +91,13 @@ def test_pipe_label(self): r = parse_mermaid("graph TD\nA-->|yes|B") assert r["connections"][0]["label"] == "yes" + @pytest.mark.parametrize("line", ['A-->|"a|b"|B', 'A-->| "a|b" |B']) + def test_a_quoted_pipe_label_keeps_its_bar(self, line): + # The label stopped at the "|" inside the quotes: '"a' + r = parse_mermaid(f"graph TD\n{line}") + assert [c["label"] for c in r["connections"]] == ["a|b"] + assert len(r["nodes"]) == 2 + def test_inline_label(self): r = parse_mermaid("graph TD\nA -- maybe --> B") assert r["connections"][0]["label"] == "maybe" From d274d8425b0b316de0a2fe0b63aa0165c5076a13 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:08:06 +0800 Subject: [PATCH 249/312] Keep an empty host empty when the URL Builder rebuilds a URL --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/url_tools/url_convert.py | 5 +++++ test/test_utils/test_url_convert.py | 5 +++++ 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 2d8eb962..f3e9f3a9 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2045 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2049 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 3c5793ad..2edf3861 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -93,3 +93,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2045 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py`, `test/test_utils/test_mermaid_parser.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-211 · 2026-09-24 · Keep an empty host empty when the URL Builder rebuilds a URL · #fix #url + +- **What**: `http:////x` has an empty host and the path `//x`. `build_url` handed those to `urlunsplit`, which writes no empty authority, so the result was `http://x`: the path's first segment had become the host, and URL to JSON and back pointed somewhere else. +- **Fix**: with no authority and a path starting `//`, `build_url` writes the empty authority itself (`scheme:` + `//` + path, then the query and fragment as `urlunsplit` writes them). +- **Tests**: `test_url_convert.py` +4: `http:////x`, one with a query and fragment, `////x` and `file:////srv/share` rebuild as they were. Passes on 3.11 and 3.10. +- **Result / numbers**: 2049 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/url_tools/url_convert.py`, `test/test_utils/test_url_convert.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 573a2ee2..b24b9490 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-211 | 2026-09-24 | Keep an empty host empty when the URL Builder rebuilds a URL | #fix #url | [2026-09-b](2026-09-b.md) | | U-20260923-210 | 2026-09-24 | Keep a quoted Mermaid edge label that holds a bar | #fix #diagram | [2026-09-b](2026-09-b.md) | | U-20260923-209 | 2026-09-24 | Skip a diagram image entry that is not an object instead of failing Open | #fix #diagram | [2026-09-b](2026-09-b.md) | | U-20260923-208 | 2026-09-24 | Keep the prthinker extra arguments after a # | #fix #prthinker | [2026-09-b](2026-09-b.md) | @@ -292,4 +293,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 10 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 11 | diff --git a/pybreeze/utils/url_tools/url_convert.py b/pybreeze/utils/url_tools/url_convert.py index 6133f0e8..11431a7d 100644 --- a/pybreeze/utils/url_tools/url_convert.py +++ b/pybreeze/utils/url_tools/url_convert.py @@ -173,6 +173,11 @@ def build_url(components: dict) -> str: path = _part(components, "path") query = _build_query(components.get("query")) fragment = _part(components, "fragment") + if not netloc and path.startswith("//"): + # urlunsplit writes no empty authority, so the path's first segment + # became the host: http:////x came back as http://x + head = f"{scheme}:" if scheme else "" + return f"{head}//{path}{urlunsplit(('', '', '', query, fragment))}" return urlunsplit((scheme, netloc, path, query, fragment)) diff --git a/test/test_utils/test_url_convert.py b/test/test_utils/test_url_convert.py index 33292a52..b70c791c 100644 --- a/test/test_utils/test_url_convert.py +++ b/test/test_utils/test_url_convert.py @@ -85,6 +85,11 @@ class TestRoundTrip: def test_parse_then_build_is_stable(self, url): assert build_url(parse_url(url)) == url + @pytest.mark.parametrize("url", ["http:////x", "http:////x/y?a=1#f", "////x", "file:////srv/share"]) + def test_a_path_starting_with_two_slashes_does_not_become_the_host(self, url): + # http:////x came back as http://x: the path's "x" was now the host + assert build_url(parse_url(url)) == url + class TestJsonToUrl: def test_builds_from_json(self): From 0557239b1c4e4211bae4c86cf48000cb97e47f72 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:14:57 +0800 Subject: [PATCH 250/312] Read an HTTP/2 status pseudo-header only when it is ASCII digits --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/response_inspector/response_analyzer.py | 6 ++++-- test/test_utils/test_response_analyzer.py | 8 ++++++++ 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index f3e9f3a9..ff7b820f 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2049 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2052 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 2edf3861..eed0d4c0 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -102,3 +102,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2049 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/utils/url_tools/url_convert.py`, `test/test_utils/test_url_convert.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-212 · 2026-09-24 · Read an HTTP/2 status pseudo-header only when it is ASCII digits · #fix #inspector + +- **What**: the Response Inspector took a `:status:` pseudo-header's value when `str.isdigit()` held, which it does for `²`; `int("²")` then raised `ValueError`, and `response_inspector_gui` does not catch it, so pasting `:status: ²` raised out of the tab. +- **Fix**: the value must also be ASCII (`isascii()` and `isdigit()`), as the URL Builder's port already is. +- **Tests**: `test_response_analyzer.py` +3: `²`, Arabic-Indic digits and `4 0 4` give no status, and the headers after it are still read. +- **Result / numbers**: 2052 tests in 121 files. The full suite before this change: 2034 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/utils/response_inspector/response_analyzer.py`, `test/test_utils/test_response_analyzer.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index b24b9490..510b65e3 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-212 | 2026-09-24 | Read an HTTP/2 status pseudo-header only when it is ASCII digits | #fix #inspector | [2026-09-b](2026-09-b.md) | | U-20260923-211 | 2026-09-24 | Keep an empty host empty when the URL Builder rebuilds a URL | #fix #url | [2026-09-b](2026-09-b.md) | | U-20260923-210 | 2026-09-24 | Keep a quoted Mermaid edge label that holds a bar | #fix #diagram | [2026-09-b](2026-09-b.md) | | U-20260923-209 | 2026-09-24 | Skip a diagram image entry that is not an object instead of failing Open | #fix #diagram | [2026-09-b](2026-09-b.md) | @@ -293,4 +294,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 11 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 12 | diff --git a/pybreeze/utils/response_inspector/response_analyzer.py b/pybreeze/utils/response_inspector/response_analyzer.py index 63f1fa55..5f3c16ff 100644 --- a/pybreeze/utils/response_inspector/response_analyzer.py +++ b/pybreeze/utils/response_inspector/response_analyzer.py @@ -112,8 +112,10 @@ def _parse_one_response(lines: list[str]) -> tuple[_Status | None, dict[str, str _continue_value(headers, last_name, line.strip()) elif (pseudo := _PSEUDO_HEADER_RE.match(line)) is not None: # It ended the headers, and the rest was read as the body - if pseudo.group(1) == "status" and status is None and pseudo.group(2).strip().isdigit(): - status = (int(pseudo.group(2).strip()), "") + code = pseudo.group(2).strip() + # ASCII digits only: "²".isdigit() holds, and int() then raised out of the tab + if pseudo.group(1) == "status" and status is None and code.isascii() and code.isdigit(): + status = (int(code), "") elif (match := HEADER_LINE_RE.match(line)) is not None: last_name = _same_name(headers, match.group(1)) add_repeated_value(headers, last_name, match.group(2).strip()) diff --git a/test/test_utils/test_response_analyzer.py b/test/test_utils/test_response_analyzer.py index 8e6ebbb5..88ab9541 100644 --- a/test/test_utils/test_response_analyzer.py +++ b/test/test_utils/test_response_analyzer.py @@ -184,6 +184,14 @@ def test_an_http2_status_pseudo_header_gives_the_status_and_keeps_the_headers(se assert analysis.headers == {"content-type": "application/json"} assert analysis.is_json_body + @pytest.mark.parametrize("code", ["²", "٤٠٤", "4 0 4"]) + def test_a_status_that_is_not_ascii_digits_is_no_status(self, code): + # "²".isdigit() holds, and int() raised ValueError out of the tab + analysis = analyze_response(f":status: {code}\ncontent-type: text/plain\n\nbody") + + assert analysis.status is None + assert analysis.headers == {"content-type": "text/plain"} + class TestCurlShowsEveryResponse: """curl -i prints interim and earlier responses before the final one.""" From eeca6f9c5536e9b09e69900655f481d4dce7e3ef Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:14:58 +0800 Subject: [PATCH 251/312] Show one changed line in a long repetitive text as one line --- architecture_explore.md | 4 +- docs/updates/2026-09-b.md | 9 ++++ docs/updates/README.md | 3 +- pybreeze/utils/diff_tools/text_diff.py | 52 ++++++++++++++++++++++- test/test_utils/test_text_diff.py | 58 ++++++++++++++++++++++---- 5 files changed, 114 insertions(+), 12 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index ff7b820f..6c9b6da7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -225,7 +225,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | | `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組。pattern 在另一個行程裡跑(`find_matches_bounded()`:從原始碼執行時是 `python -I -S -c` 跑一段只用標準函式庫的固定腳本,工作用 JSON 從 stdin 進、結果從 stdout 出,5 秒後 kill;打包版沒有直譯器可用,仍是 multiprocessing spawn),分頁用 `RegexMatchThread` 等它:`re` 開始比對後就停不下來,災難性回溯只有整個行程能停。spawn 會重新匯入啟動 IDE 的腳本,README 那種沒有 `__main__` 防護的腳本會每跑一次就再開一個 IDE。還在跑的 worker 記在 `_RUNNING`,分頁關閉時 `stop_running_workers()` 結束它(IDE 以 `os._exit` 結束,子行程不會跟著走);執行中不能存檔,列到 `MAX_MATCHES` 上限時會註明可能還有更多 | | `HttpStatusGUI` | `utils/http_reference/` | 狀態碼參考,可依碼前綴或描述搜尋 | -| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計,`compare_texts()` 只比對一次、兩者共用同一個 `SequenceMatcher`(diff 照 `difflib.unified_diff` 的格式從它的 grouped opcodes 寫出),在 `DiffThread` 上算、不佔 UI 執行緒(4 萬行要四秒多),比對中按鈕停用、關閉時交給 `let_run_out()`。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | +| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計,`compare_texts()` 只比對一次、兩者共用同一個 `SequenceMatcher`(`_TrimmedMatcher`:先把相同的開頭結尾放一邊,剩下不到 2,000 行時關掉 autojunk,長而重複的文字改一行就是一行;diff 照 `difflib.unified_diff` 的格式從它的 grouped opcodes 寫出),在 `DiffThread` 上算、不佔 UI 執行緒(4 萬行要四秒多),比對中按鈕停用、關閉時交給 `let_run_out()`。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | | `JsonFormatGUI` | `utils/json_format/` | 美化 / 壓縮 / 驗證 | | `HeaderAnalyzerGUI` | `utils/header_tools/` | HTTP header 安全稽核(HSTS、CSP、CORS、Set-Cookie、banner…)| | `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT;`curl -i` 印出的多段回應(`100 Continue`、proxy 的 `Connection established`、`-L` 的轉址)取最後一段;只有一行又沒有狀態列就當 body | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2052 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2054 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index eed0d4c0..05d8755d 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -111,3 +111,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2052 tests in 121 files. The full suite before this change: 2034 passed, 15 skipped, startup tests exit 0. `ruff check` clean. - **Files**: `pybreeze/utils/response_inspector/response_analyzer.py`, `test/test_utils/test_response_analyzer.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-213 · 2026-09-24 · Show one changed line in a long repetitive text as one line · #fix #diff + +- **What**: `difflib.SequenceMatcher` treats a line making up more than 1% of a sequence of 200 or more as junk that cannot anchor a match. In a JSON or code response full of `}`, one changed line came out as hundreds removed and added: 300 lines of `{`, ` a`, `}` with one `a` changed gave 299 and 299, and 300 lines of `x` with the first changed gave 300 and 300. Turning the heuristic off is not the answer on its own: without it, 20,000 alike lines took 62 s to match. +- **Fix**: `_TrimmedMatcher` sets the unchanged head and tail aside and matches only what is left, with the heuristic off below 2,000 lines left (`_AUTOJUNK_FROM`); its `get_opcodes` gives the head and tail back as equal blocks, so the grouped opcodes, the unified diff and the counts come from it as before. The examples above are now 1 and 1, and the 20,000 lines take 8 ms. +- **Tests**: `test_text_diff.py`: the property that the diff is exactly `difflib.unified_diff`'s is replaced, since where two alignments are as short the trimmed match may pick the other: now the diff applied to the left text gives the right text line for line, the counts are its `+` and `-` lines, and it is never longer than `difflib`'s (300 examples). +2: the two examples above. +- **Result / numbers**: 2054 tests in 121 files. `ruff check` clean, complexity within 15. +- **Files**: `pybreeze/utils/diff_tools/text_diff.py`, `test/test_utils/test_text_diff.py`, `architecture_explore.md` §6, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 510b65e3..717524c6 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-213 | 2026-09-24 | Show one changed line in a long repetitive text as one line | #fix #diff | [2026-09-b](2026-09-b.md) | | U-20260923-212 | 2026-09-24 | Read an HTTP/2 status pseudo-header only when it is ASCII digits | #fix #inspector | [2026-09-b](2026-09-b.md) | | U-20260923-211 | 2026-09-24 | Keep an empty host empty when the URL Builder rebuilds a URL | #fix #url | [2026-09-b](2026-09-b.md) | | U-20260923-210 | 2026-09-24 | Keep a quoted Mermaid edge label that holds a bar | #fix #diagram | [2026-09-b](2026-09-b.md) | @@ -294,4 +295,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 12 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 13 | diff --git a/pybreeze/utils/diff_tools/text_diff.py b/pybreeze/utils/diff_tools/text_diff.py index 16e909bb..2552631e 100644 --- a/pybreeze/utils/diff_tools/text_diff.py +++ b/pybreeze/utils/diff_tools/text_diff.py @@ -18,6 +18,8 @@ _HEADER_LINES = 2 # diff's own note under a last line that has no newline _NO_NEWLINE_MARK = "\\ No newline at end of file" +# Lines left to match past which SequenceMatcher's junk heuristic stays on +_AUTOJUNK_FROM = 2000 @dataclass(frozen=True) @@ -77,6 +79,52 @@ class Comparison: diff: str +class _TrimmedMatcher(difflib.SequenceMatcher): + """A line matcher that sets the unchanged head and tail aside before matching. + + ``SequenceMatcher`` treats a line making up more than 1% of 200 or more as + junk that cannot anchor a match, so in a long text full of ``}`` one + changed line came out as hundreds removed and added. Trimmed, what is left + to match is usually short: under ``_AUTOJUNK_FROM`` lines the heuristic is + off, and it stays on above that, where matching without it grows with the + square of the lines (20,000 alike took a minute). + """ + + def __init__(self, left_lines: list[str], right_lines: list[str]) -> None: + head = 0 + limit = min(len(left_lines), len(right_lines)) + while head < limit and left_lines[head] == right_lines[head]: + head += 1 + tail = 0 + while (tail < limit - head + and left_lines[len(left_lines) - 1 - tail] == right_lines[len(right_lines) - 1 - tail]): + tail += 1 + self._head, self._tail = head, tail + self._sizes = (len(left_lines), len(right_lines)) + middle_left = left_lines[head:len(left_lines) - tail] + middle_right = right_lines[head:len(right_lines) - tail] + super().__init__(None, middle_left, middle_right, + autojunk=max(len(middle_left), len(middle_right)) >= _AUTOJUNK_FROM) + + def get_opcodes(self) -> list[tuple[str, int, int, int, int]]: + """The opcodes over the whole two texts, the head and tail as equal blocks.""" + head, tail = self._head, self._tail + left_size, right_size = self._sizes + codes = [("equal", 0, head, 0, head)] if head else [] + codes += [(tag, i1 + head, i2 + head, j1 + head, j2 + head) + for tag, i1, i2, j1, j2 in super().get_opcodes() + if (tag, i1, i2, j1, j2) != ("equal", 0, 0, 0, 0)] + if tail: + codes.append(("equal", left_size - tail, left_size, right_size - tail, right_size)) + merged: list[tuple[str, int, int, int, int]] = [] + for code in codes: + if merged and code[0] == merged[-1][0] == "equal": + code = ("equal", merged[-1][1], code[2], merged[-1][3], code[4]) + merged.pop() + merged.append(code) + return merged or [("equal", 0, 0, 0, 0)] + + def _unified_range(start: int, stop: int) -> str: """A hunk header's ``start,length``, as ``diff -u`` and ``difflib.unified_diff`` write it.""" length = stop - start @@ -86,7 +134,7 @@ def _unified_range(start: int, stop: int) -> str: def _unified_lines( - matcher: difflib.SequenceMatcher, left_lines: list[str], right_lines: list[str], + matcher: _TrimmedMatcher, left_lines: list[str], right_lines: list[str], labels: tuple[str, str]) -> list[str]: """The unified diff lines of *matcher*'s match, as ``difflib.unified_diff`` writes them.""" lines: list[str] = [] @@ -121,7 +169,7 @@ def compare_texts( :return: the counts and the diff """ left_lines, right_lines = _line_lists(left, right) - matcher = difflib.SequenceMatcher(None, left_lines, right_lines) + matcher = _TrimmedMatcher(left_lines, right_lines) added = 0 removed = 0 for tag, left_start, left_end, right_start, right_end in matcher.get_opcodes(): diff --git a/test/test_utils/test_text_diff.py b/test/test_utils/test_text_diff.py index f4e09d0e..e17bb2fa 100644 --- a/test/test_utils/test_text_diff.py +++ b/test/test_utils/test_text_diff.py @@ -1,6 +1,7 @@ """Tests for the text diff utility.""" from __future__ import annotations +import pytest from hypothesis import given, settings from hypothesis import strategies as st @@ -138,18 +139,61 @@ def test_a_removed_line_starting_with_dashes_gets_its_ending_named(): _LINES = st.lists(st.sampled_from(["a", "b", "c", "", "d e"]), max_size=25) +def _applied(diff: str, left: list[str]) -> list[str]: + """*left* with the unified *diff* applied, each context and removed line checked.""" + import re + + out: list[str] = [] + position = 0 + for line in diff.splitlines()[2:]: + if line.startswith("@@"): + start, length = re.match(r"@@ -(\d+)(?:,(\d+))? ", line).groups() + begin = int(start) - 1 if length != "0" else int(start) + out += left[position:begin] + position = begin + elif line.startswith("\\ No newline"): + continue + elif line.startswith("+"): + out.append(line[1:]) + else: + assert left[position] == line[1:] + if line.startswith(" "): + out.append(line[1:]) + position += 1 + return out + left[position:] + + @settings(max_examples=300, deadline=None) @given(left=_LINES, right=_LINES) -def test_the_diff_is_the_one_difflib_writes(left, right): - # One line matching now serves the counts and the diff; the diff must stay - # exactly difflib.unified_diff's +def test_the_diff_turns_the_left_into_the_right_and_is_no_longer_than_difflibs(left, right): + # Where two alignments are as short, the unchanged head and tail set aside + # first may pick the other one; the diff must still be right, and never + # longer than difflib.unified_diff's import difflib - from pybreeze.utils.diff_tools.text_diff import _line_lists, _shown, compare_texts + from pybreeze.utils.diff_tools.text_diff import _line_lists, compare_texts left_text, right_text = "\n".join(left), "\n".join(right) + comparison = compare_texts(left_text, right_text) + body = comparison.diff.splitlines()[2:] left_lines, right_lines = _line_lists(left_text, right_text) - written = list(difflib.unified_diff(left_lines, right_lines, "expected", "actual", lineterm="", n=3)) - expected = "\n".join(written[:2] + [_shown(line) for line in written[2:]]) + written = list(difflib.unified_diff(left_lines, right_lines, lineterm="", n=3))[2:] + + assert _applied(comparison.diff, left_text.splitlines()) == right_text.splitlines() + assert comparison.summary.added == sum(line.startswith("+") for line in body) + assert comparison.summary.removed == sum(line.startswith("-") for line in body) + changed = comparison.summary.added + comparison.summary.removed + assert changed <= sum(line[:1] in ("+", "-") for line in written) + + +@pytest.mark.parametrize(("left", "right"), [ + ("\n".join(["{", " a", "}"] * 100), "\n".join(["{", " a", "}"] * 100).replace(" a", " b", 1)), + ("x\n" * 300, "y\n" + "x\n" * 299), +]) +def test_one_changed_line_in_a_long_repetitive_text_is_one_line(left, right): + # A line making up over 1% of 200 or more was junk to difflib: 299 removed and added + from pybreeze.utils.diff_tools.text_diff import compare_texts + + summary = compare_texts(left, right).summary - assert compare_texts(left_text, right_text).diff == expected + assert (summary.added, summary.removed) == (1, 1) From 47fb192b7ecb69fda7491f0e4cb746b349f85fef Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:16:04 +0800 Subject: [PATCH 252/312] Share one refuse_constant between the JSON tools --- docs/updates/2026-09-b.md | 7 +++++++ docs/updates/README.md | 3 ++- pybreeze/utils/json_format/json_process.py | 9 ++++++--- pybreeze/utils/query_tools/query_convert.py | 9 ++------- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 05d8755d..4516bcb6 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -120,3 +120,10 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2054 tests in 121 files. `ruff check` clean, complexity within 15. - **Files**: `pybreeze/utils/diff_tools/text_diff.py`, `test/test_utils/test_text_diff.py`, `architecture_explore.md` §6, §18 - **Open items**: none. + +## U-20260923-214 · 2026-09-24 · Share one refuse_constant between the JSON tools · #refactor + +- **What**: a refactor, no behaviour change. `json_process` and `query_convert` each had the same `_refuse_constant` (the `parse_constant` hook that refuses `NaN` and `Infinity`), and the cURL body check is about to need it too. It is now `json_process.refuse_constant`, imported by `query_convert`. +- **Tests**: the JSON, query, URL and fuzz tests pass (515). `ruff check` clean. +- **Files**: `pybreeze/utils/json_format/json_process.py`, `pybreeze/utils/query_tools/query_convert.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 717524c6..66087625 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-214 | 2026-09-24 | Share one refuse_constant between the JSON tools | #refactor | [2026-09-b](2026-09-b.md) | | U-20260923-213 | 2026-09-24 | Show one changed line in a long repetitive text as one line | #fix #diff | [2026-09-b](2026-09-b.md) | | U-20260923-212 | 2026-09-24 | Read an HTTP/2 status pseudo-header only when it is ASCII digits | #fix #inspector | [2026-09-b](2026-09-b.md) | | U-20260923-211 | 2026-09-24 | Keep an empty host empty when the URL Builder rebuilds a URL | #fix #url | [2026-09-b](2026-09-b.md) | @@ -295,4 +296,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 13 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 14 | diff --git a/pybreeze/utils/json_format/json_process.py b/pybreeze/utils/json_format/json_process.py index 0699a4dd..35543459 100644 --- a/pybreeze/utils/json_format/json_process.py +++ b/pybreeze/utils/json_format/json_process.py @@ -55,8 +55,11 @@ def restore(self, serialised: str) -> str: return escape_for_view(restored) -def _refuse_constant(name: str) -> None: - """``NaN`` and ``Infinity`` are Python's extensions, not JSON.""" +def refuse_constant(name: str) -> None: + """``NaN`` and ``Infinity`` are Python's extensions, not JSON (``parse_constant``). + + :raises ValueError: always + """ raise ValueError(f"{name} is not JSON") @@ -84,7 +87,7 @@ def _parse(json_string: str, numbers: _Numbers) -> object: """ return loads( json_string, parse_float=numbers.hold, parse_int=numbers.hold, - parse_constant=_refuse_constant, object_pairs_hook=unique_pairs) + parse_constant=refuse_constant, object_pairs_hook=unique_pairs) def _load(json_string: str, numbers: _Numbers) -> object: diff --git a/pybreeze/utils/query_tools/query_convert.py b/pybreeze/utils/query_tools/query_convert.py index e5cb1d5d..f71a02c4 100644 --- a/pybreeze/utils/query_tools/query_convert.py +++ b/pybreeze/utils/query_tools/query_convert.py @@ -22,7 +22,7 @@ unencodable_text_error, ) from pybreeze.utils.exception.exceptions import QueryConvertException -from pybreeze.utils.json_format.json_process import DuplicateKeyError, unique_pairs +from pybreeze.utils.json_format.json_process import DuplicateKeyError, refuse_constant, unique_pairs from pybreeze.utils.json_format.view_safe import dumps_for_view from pybreeze.utils.logging.logger import pybreeze_logger @@ -148,11 +148,6 @@ def encode_pairs(pairs: list[tuple[str, str]]) -> str: raise QueryConvertException(unencodable_text_error) from error -def _refuse_constant(name: str) -> None: - """``NaN`` and ``Infinity`` are Python's extensions, not JSON.""" - raise ValueError(f"{name} is not JSON") - - def load_json_verbatim(json_text: str) -> object: """Parse *json_text* with every number kept as the text it was written as. @@ -166,5 +161,5 @@ def load_json_verbatim(json_text: str) -> object: :raises RecursionError: when it is nested deeper than the parser goes """ return json.loads( - json_text, parse_float=str, parse_int=str, parse_constant=_refuse_constant, + json_text, parse_float=str, parse_int=str, parse_constant=refuse_constant, object_pairs_hook=unique_pairs) From 36751ec6c3845bcd7ef174a5ad8f49259176d811 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:18:18 +0800 Subject: [PATCH 253/312] Send a JSON body raw when the object would not send it back as it was --- architecture_explore.md | 4 +- docs/updates/2026-09-b.md | 13 ++++++ docs/updates/README.md | 3 +- pybreeze/utils/curl_import/request_body.py | 47 ++++++++++++++++++++-- test/test_utils/test_script_templates.py | 22 ++++++++++ 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 6c9b6da7..edcad5a7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -346,7 +346,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`overall_deadline(seconds)`:這個執行緒在區塊內的請求總共最多這麼久,釘住的連線等回應時把 socket 登記上去,時間到就 shutdown,丟 `ReadTimeout`(讀取逾時每來一個位元組就重算,慢慢送標頭的伺服器原本可以一直拖);AI 審查、Skill、CoT 每一步都包在 `overall_deadline(DEFAULT_MAX_READ_SECONDS)` 裡。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),時間到由 `_Watchdog` 關掉連線(urllib3 的 `HTTPResponse.shutdown()` 能中斷別的執行緒上正在等的讀取),丟 `ReadTimeout`:讀取逾時只管每一塊之間,一次送一個位元組的伺服器可以一直拖下去;狀態列與標頭由呼叫端的 `public_http.overall_deadline()` 涵蓋)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`request_body.py` 的 `form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | +| `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`request_body.py` 的 `form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別(`body_kind()`:JSON 物件要能原樣送回才走 `json=`,重複的 key、float 裝不下的數字、`NaN`、巢狀超過 100 層都照原字串送);`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(354) HAR → `CurlRequest`(重用 curl 那套 codegen;建不出請求的 entry 跳過,同名 cookie 改走 `Cookie` header);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯;`pretty_json_or_none()` 是同一套解析、不記 log 的版本,Response Inspector 的 body 與 JWT 各段(`jwt_decoder.shown_json()`)用它排版。`json_format/view_safe.py` 的 `dumps_for_view()` / `escape_for_view()`:工具顯示的 JSON 把文字框還不回原樣的字元(U+2029、U+FDD0、U+FDD1 會變換行,落單的 surrogate 會消失;U+2028 經 `toPlainText()`、U+0085 經 `splitlines()` 也會斷行)寫成 `\uXXXX`,JSON Format、Query/URL 轉 JSON、JWT、Response Inspector、cURL/HAR 產生的 JSON 與 Python 字串都經過它。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN` 與同一物件重複的 key(`load_json_verbatim()`,用 `json_process.unique_pairs`),Query 轉 JSON 遇到不是 UTF-8 的 percent-escape 報錯而不換成 U+FFFD,`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2054 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2061 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 4516bcb6..76399114 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -127,3 +127,16 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Tests**: the JSON, query, URL and fuzz tests pass (515). `ruff check` clean. - **Files**: `pybreeze/utils/json_format/json_process.py`, `pybreeze/utils/query_tools/query_convert.py` - **Open items**: none. + +## U-20260923-215 · 2026-09-24 · Send a JSON body raw when the object would not send it back as it was · #fix #curl + +- **What**: `body_kind` turned a body sent with `Content-Type: application/json` into an object with a plain `json.loads`, and the generated scripts send that object (`json=`). What they sent was not what curl sends: + - a repeated key kept only its last value (`{"a":1,"a":2}` became `{"a": 2}`); + - a number a float cannot hold was rounded (`0.10000000000000000001` became `0.1`, `1e400` became `inf`), and `NaN` was taken; + - JSON nested deeper than the parser goes raised `RecursionError`, which the cURL tab (it catches `CurlParseException`), the HAR generator and the script templates let escape; + - nested past about 600 levels, writing the object as a Python literal recursed past the limit in three generators, and past 200 Python refuses the script anyway. +- **Fix**: the body is parsed with `unique_pairs`, `refuse_constant` and `_exact_float` (a float whose `repr` is not the same number as the text is refused), and taken as JSON only when it nests 100 levels or fewer (`_MAX_LITERAL_DEPTH`, counted without recursing); otherwise it is sent raw, as curl sends it. `0.1`, `1E3` and a long integer still go as JSON. +- **Tests**: `test_script_templates.py` +7: a repeated key, a long float, `1e400`, `NaN`, 5,000 levels and 102 levels go raw, every template target generates, and the `requests` script compiles; `0.1`, `1E3` and a 20-digit integer still go as JSON. +- **Result / numbers**: 2061 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/curl_import/request_body.py`, `test/test_utils/test_script_templates.py`, `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 66087625..937fc2c8 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-215 | 2026-09-24 | Send a JSON body raw when the object would not send it back as it was | #fix #curl | [2026-09-b](2026-09-b.md) | | U-20260923-214 | 2026-09-24 | Share one refuse_constant between the JSON tools | #refactor | [2026-09-b](2026-09-b.md) | | U-20260923-213 | 2026-09-24 | Show one changed line in a long repetitive text as one line | #fix #diff | [2026-09-b](2026-09-b.md) | | U-20260923-212 | 2026-09-24 | Read an HTTP/2 status pseudo-header only when it is ASCII digits | #fix #inspector | [2026-09-b](2026-09-b.md) | @@ -296,4 +297,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 14 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 15 | diff --git a/pybreeze/utils/curl_import/request_body.py b/pybreeze/utils/curl_import/request_body.py index ae5d6df4..0402b6da 100644 --- a/pybreeze/utils/curl_import/request_body.py +++ b/pybreeze/utils/curl_import/request_body.py @@ -7,13 +7,20 @@ from __future__ import annotations import json +import math +from decimal import Decimal from pybreeze.utils.curl_import.curl_parser import CurlRequest, add_repeated_value +from pybreeze.utils.json_format.json_process import refuse_constant, unique_pairs # Header that carries the body's media type _CONTENT_TYPE_HEADER = "content-type" # Media type that marks a JSON request body _JSON_MEDIA_TYPE = "application/json" +# Deepest nesting a JSON body is written into a script as a Python literal. +# Writing a deeper one recursed past Python's limit (600 levels), and Python +# refuses a script nesting brackets more than 200 deep anyway +_MAX_LITERAL_DEPTH = 100 # How a body should be sent: ("json", parsed_object) or ("data", raw_string) BodyKind = tuple[str, object] @@ -23,7 +30,10 @@ def body_kind(request: CurlRequest) -> BodyKind | None: """Return how *request*'s body should be sent, or ``None`` when there is none. The body is treated as JSON only when the ``Content-Type`` says so **and** the - body actually parses as JSON; otherwise it is sent as a raw string. + body parses as JSON that the object sends back unchanged; otherwise it is + sent as the raw string curl sends. A repeated key (the object keeps the + last), a number a float cannot hold (``0.10000000000000000001``), + ``NaN`` or JSON nested deeper than the parser goes are sent raw. :param request: the parsed curl request :return: ``("json", obj)``, ``("data", raw)``, or ``None`` when there is no body @@ -33,12 +43,43 @@ def body_kind(request: CurlRequest) -> BodyKind | None: content_type = (request.header_value(_CONTENT_TYPE_HEADER) or "").lower() if _JSON_MEDIA_TYPE in content_type: try: - return "json", json.loads(request.body) - except (ValueError, TypeError): + parsed = json.loads( + request.body, parse_float=_exact_float, parse_constant=refuse_constant, + object_pairs_hook=unique_pairs) + # RecursionError: nesting deeper than the parser goes, which escaped + # the cURL tab, the HAR generator and the script templates + except (ValueError, TypeError, RecursionError): return "data", request.body + if _depth(parsed) <= _MAX_LITERAL_DEPTH: + return "json", parsed return "data", request.body +def _depth(value: object) -> int: + """How deep *value*'s lists and dicts nest, counted without recursing.""" + deepest = 0 + pending: list[tuple[object, int]] = [(value, 0)] + while pending: + item, level = pending.pop() + deepest = max(deepest, level) + if isinstance(item, dict): + pending.extend((child, level + 1) for child in item.values()) + elif isinstance(item, list): + pending.extend((child, level + 1) for child in item) + return deepest + + +def _exact_float(text: str) -> float: + """*text* as a float, when the float is written back as the same number. + + :raises ValueError: when it is not (too many digits, or out of range) + """ + value = float(text) + if not math.isfinite(value) or Decimal(repr(value)) != Decimal(text): + raise ValueError(f"{text} does not survive as a float") + return value + + # Multipart form split into plain fields and file uploads (field -> filename); # a field given more than once maps to the list of its values FormParts = tuple[dict[str, str | list[str]], dict[str, str | list[str]]] diff --git a/test/test_utils/test_script_templates.py b/test/test_utils/test_script_templates.py index ef004982..a740e2a1 100644 --- a/test/test_utils/test_script_templates.py +++ b/test/test_utils/test_script_templates.py @@ -38,6 +38,28 @@ def test_json_content_type_but_invalid_body_is_data(self): request = parse_curl("curl -H 'Content-Type: application/json' -d 'not json' https://x") assert body_kind(request) == ("data", "not json") + @pytest.mark.parametrize("body", [ + '{"a": 1, "a": 2}', # the object kept only the last + '{"p": 0.10000000000000000001}', # the float wrote back 0.1 + '{"x": 1e400}', # inf + '[NaN]', # not JSON + "[" * 5000 + "]" * 5000, # RecursionError out of the tab + "[" * 102 + "]" * 102, # too deep to write as a literal + ], ids=["repeated-key", "long-float", "inf", "nan", "past-the-parser", "past-a-literal"]) + def test_a_body_the_object_would_not_send_back_as_it_was_goes_raw(self, body): + request = parse_curl(f"curl -H 'Content-Type: application/json' -d '{body}' https://x") + + assert body_kind(request) == ("data", body) + for target, _label in TEMPLATE_TARGETS: + generate_template(target, request) + compile(to_requests_code(request), "generated", "exec") + + def test_numbers_a_float_holds_still_go_as_json(self): + request = parse_curl( + "curl -H 'Content-Type: application/json' -d '{\"p\": 0.1, \"q\": 1E3, \"n\": 12345678901234567890}' https://x") + + assert body_kind(request) == ("json", {"p": 0.1, "q": 1000.0, "n": 12345678901234567890}) + class TestFormParts: def test_plain_field(self): From 0a3f42f936c7b898f6c04d3e8edea54b8b15d55c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:19:51 +0800 Subject: [PATCH 254/312] Send curl's data pieces in command-line order when one is a file --- architecture_explore.md | 4 +- docs/updates/2026-09-b.md | 9 +++++ docs/updates/README.md | 3 +- pybreeze/utils/curl_import/curl_parser.py | 4 ++ pybreeze/utils/curl_import/request_codegen.py | 40 ++++++++++++++++--- test/test_utils/test_curl_import.py | 17 ++++++++ 6 files changed, 69 insertions(+), 8 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index edcad5a7..c423c800 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -346,7 +346,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`overall_deadline(seconds)`:這個執行緒在區塊內的請求總共最多這麼久,釘住的連線等回應時把 socket 登記上去,時間到就 shutdown,丟 `ReadTimeout`(讀取逾時每來一個位元組就重算,慢慢送標頭的伺服器原本可以一直拖);AI 審查、Skill、CoT 每一步都包在 `overall_deadline(DEFAULT_MAX_READ_SECONDS)` 裡。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),時間到由 `_Watchdog` 關掉連線(urllib3 的 `HTTPResponse.shutdown()` 能中斷別的執行緒上正在等的讀取),丟 `ReadTimeout`:讀取逾時只管每一塊之間,一次送一個位元組的伺服器可以一直拖下去;狀態列與標頭由呼叫端的 `public_http.overall_deadline()` 涵蓋)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`request_body.py` 的 `form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別(`body_kind()`:JSON 物件要能原樣送回才走 `json=`,重複的 key、float 裝不下的數字、`NaN`、巢狀超過 100 層都照原字串送);`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | +| `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`request_body.py` 的 `form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣),和其他 `-d` 片段照命令列的順序接起來(`data_file_positions`);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別(`body_kind()`:JSON 物件要能原樣送回才走 `json=`,重複的 key、float 裝不下的數字、`NaN`、巢狀超過 100 層都照原字串送);`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(354) HAR → `CurlRequest`(重用 curl 那套 codegen;建不出請求的 entry 跳過,同名 cookie 改走 `Cookie` header);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯;`pretty_json_or_none()` 是同一套解析、不記 log 的版本,Response Inspector 的 body 與 JWT 各段(`jwt_decoder.shown_json()`)用它排版。`json_format/view_safe.py` 的 `dumps_for_view()` / `escape_for_view()`:工具顯示的 JSON 把文字框還不回原樣的字元(U+2029、U+FDD0、U+FDD1 會變換行,落單的 surrogate 會消失;U+2028 經 `toPlainText()`、U+0085 經 `splitlines()` 也會斷行)寫成 `\uXXXX`,JSON Format、Query/URL 轉 JSON、JWT、Response Inspector、cURL/HAR 產生的 JSON 與 Python 字串都經過它。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN` 與同一物件重複的 key(`load_json_verbatim()`,用 `json_process.unique_pairs`),Query 轉 JSON 遇到不是 UTF-8 的 percent-escape 報錯而不換成 U+FFFD,`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2061 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2064 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 76399114..09b7eb09 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -140,3 +140,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2061 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/utils/curl_import/request_body.py`, `test/test_utils/test_script_templates.py`, `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-216 · 2026-09-24 · Send curl's data pieces in command-line order when one is a file · #fix #curl + +- **What**: the parser kept `-d` pieces in two lists, inline text in `data_parts` and `@file` names in `data_file_refs`, and `data_from_file_expr` wrote every inline piece first. curl joins the pieces in the order given, so `curl -d @a.txt -d b=1` sends the file then `b=1`, while the generated script sent `b=1` then the file. +- **Fix**: the parser also records, for each file, how many inline pieces came before it (`CurlRequest.data_file_positions`); `data_from_file_expr` walks the pieces in that order (`_data_pieces`), joining neighbouring inline pieces into one string as before. A request built without positions puts its files after the inline pieces, as it did. +- **Tests**: `test_curl_import.py` +3: the generated expression, evaluated against real files, gives `A&b=1`, `b=1&A&c=2&Z\r\n` (a `--data-binary` file kept byte for byte, a `-d` file without CR/LF) and `x=1&y=2&A`. +- **Result / numbers**: 2064 tests in 121 files. `ruff check` clean, complexity within 15. +- **Files**: `pybreeze/utils/curl_import/curl_parser.py`, `pybreeze/utils/curl_import/request_codegen.py`, `test/test_utils/test_curl_import.py`, `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 937fc2c8..41acd718 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-216 | 2026-09-24 | Send curl's data pieces in command-line order when one is a file | #fix #curl | [2026-09-b](2026-09-b.md) | | U-20260923-215 | 2026-09-24 | Send a JSON body raw when the object would not send it back as it was | #fix #curl | [2026-09-b](2026-09-b.md) | | U-20260923-214 | 2026-09-24 | Share one refuse_constant between the JSON tools | #refactor | [2026-09-b](2026-09-b.md) | | U-20260923-213 | 2026-09-24 | Show one changed line in a long repetitive text as one line | #fix #diff | [2026-09-b](2026-09-b.md) | @@ -297,4 +298,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 15 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 16 | diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index dbc34b06..2314d302 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -79,6 +79,8 @@ class CurlRequest: :param form_strings: multipart ``name=value`` fields taken literally, from ``--form-string`` or a recorded text field; ``@`` means nothing there :param data_file_refs: filenames whose content forms the body (``-d @file``) + :param data_file_positions: for each of them, how many ``data_parts`` came + before it on the command line, so the body keeps curl's order :param binary_data_files: those of them given with ``--data-binary``, sent byte for byte; curl drops carriage returns and newlines from the others :param cookie_files: files ``-b`` names, which curl reads cookies from @@ -100,6 +102,7 @@ class CurlRequest: form_fields: list[str] = field(default_factory=list) form_strings: list[str] = field(default_factory=list) data_file_refs: list[str] = field(default_factory=list) + data_file_positions: list[int] = field(default_factory=list) binary_data_files: set[str] = field(default_factory=set) cookie_files: list[str] = field(default_factory=list) timeout: str | None = None @@ -381,6 +384,7 @@ def _apply_data_or_file(request: CurlRequest, value: str) -> None: """Record a ``-d`` value as an ``@file`` reference or an inline body part.""" if value.startswith("@"): request.data_file_refs.append(value[1:]) + request.data_file_positions.append(len(request.data_parts)) else: request.data_parts.append(value) diff --git a/pybreeze/utils/curl_import/request_codegen.py b/pybreeze/utils/curl_import/request_codegen.py index c6624ad2..14b4d1de 100644 --- a/pybreeze/utils/curl_import/request_codegen.py +++ b/pybreeze/utils/curl_import/request_codegen.py @@ -72,19 +72,49 @@ def data_from_file_expr(request: CurlRequest) -> str: returns and newlines. The file was read as UTF-8 text, so a binary one raised ``UnicodeDecodeError`` and a ``-d`` file kept its line breaks. + The pieces keep their order on the command line: ``-d @a.txt -d b=1`` is + the file, then ``b=1`` (all inline pieces used to go first). + :param request: the parsed curl request (must have ``data_file_refs``) :return: a Python expression, e.g. ``open("body.bin", "rb").read()`` """ pieces: list[str] = [] - if request.data_parts: - pieces.append(f"{python_string(request.body)}.encode()") - for name in request.data_file_refs: - read = f'open({python_string(name)}, "rb").read()' - pieces.append(read if name in request.binary_data_files + inline: list[str] = [] + for kind, text in _data_pieces(request): + if kind == "text": + inline.append(text) + continue + if inline: + pieces.append(f"{python_string('&'.join(inline))}.encode()") + inline = [] + read = f'open({python_string(text)}, "rb").read()' + pieces.append(read if text in request.binary_data_files else f'{read}.replace(b"\\r", b"").replace(b"\\n", b"")') + if inline: + pieces.append(f"{python_string('&'.join(inline))}.encode()") return ' + b"&" + '.join(pieces) +def _data_pieces(request: CurlRequest) -> list[tuple[str, str]]: + """The body's pieces in command-line order: ``("text", part)`` or ``("file", name)``. + + A file with no recorded position (a request built by hand) goes after + every inline part. + """ + positions = request.data_file_positions + [len(request.data_parts)] * ( + len(request.data_file_refs) - len(request.data_file_positions)) + files = sorted(zip(positions, range(len(positions)), request.data_file_refs)) + pieces: list[tuple[str, str]] = [] + next_file = 0 + for index, part in enumerate([*request.data_parts, None]): + while next_file < len(files) and files[next_file][0] <= index: + pieces.append(("file", files[next_file][2])) + next_file += 1 + if part is not None: + pieces.append(("text", part)) + return pieces + + def cookie_file_notes(request: CurlRequest) -> list[str]: """Comment lines saying which cookie files ``-b`` named, which the script does not read. diff --git a/test/test_utils/test_curl_import.py b/test/test_utils/test_curl_import.py index ebaef018..681c61e6 100644 --- a/test/test_utils/test_curl_import.py +++ b/test/test_utils/test_curl_import.py @@ -469,6 +469,23 @@ def test_inline_data_still_works(self): assert request.data_file_refs == [] assert request.body == "a=1" + @pytest.mark.parametrize(("command", "sent"), [ + ("curl -d @a.txt -d b=1 https://x", b"A&b=1"), + ("curl -d b=1 -d @a.txt -d c=2 --data-binary @z.bin https://x", b"b=1&A&c=2&Z\r\n"), + ("curl -d x=1 -d y=2 -d @a.txt https://x", b"x=1&y=2&A"), + ]) + def test_the_pieces_are_sent_in_command_line_order(self, tmp_path, monkeypatch, command, sent): + # Every inline piece went first: -d @a.txt -d b=1 sent "b=1&A" + from pybreeze.utils.curl_import.request_codegen import data_from_file_expr + + (tmp_path / "a.txt").write_bytes(b"A\r\n") + (tmp_path / "z.bin").write_bytes(b"Z\r\n") + monkeypatch.chdir(tmp_path) + + expression = data_from_file_expr(parse_curl(command)) + + assert eval(expression, {"__builtins__": {"open": open}}) == sent # noqa: S307 — the generator's own output, in a test + class TestParseCurlDataUrlencode: def test_encodes_value_part(self): From 50f1500b6fd03841a6b2c7fc874cf84e62a690b9 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:24:40 +0800 Subject: [PATCH 255/312] Say when an SSH key's passphrase is missing or wrong --- README.md | 2 +- architecture_explore.md | 10 ++-- docs/updates/2026-09-b.md | 9 ++++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 2 + .../extend_traditional_chinese.py | 2 + .../connect_gui/ssh/sftp_session.py | 6 +-- .../connect_gui/ssh/ssh_command_widget.py | 7 +-- .../connect_gui/ssh/ssh_key_loader.py | 22 ++++++++ test/test_utils/test_ssh_security.py | 54 +++++++++++++++++++ 10 files changed, 101 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 37aba101..18fa93ca 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 643 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 645 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index c423c800..1ee2e867 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 643 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 645 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 643 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 645 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -302,7 +302,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_file_viewer_widget.py` (691) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | -| `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | +| `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密)還是不支援的私鑰 | ### `url/ai_code_review_gui.py` @@ -361,7 +361,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 643 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 645 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2064 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2070 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 09b7eb09..ad1f2b71 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -149,3 +149,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2064 tests in 121 files. `ruff check` clean, complexity within 15. - **Files**: `pybreeze/utils/curl_import/curl_parser.py`, `pybreeze/utils/curl_import/request_codegen.py`, `test/test_utils/test_curl_import.py`, `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-217 · 2026-09-24 · Say when an SSH key's passphrase is missing or wrong · #fix #ssh + +- **What**: `load_private_key` tries each key class and gives `None` when none loads, and both the shell tab and the SFTP tree then said "Unsupported or invalid private key." That was also what an encrypted key with a wrong or empty passphrase gave (paramiko raises `PasswordRequiredException` or "checkints do not match" / "Bad password" there), so the user was sent looking for a key problem that was a typing one. +- **Fix**: `unloadable_key_reason(key_path, password)` asks each key class again with no passphrase; one that asks for a passphrase means the file is encrypted, so the reason is the passphrase: missing when none was given, wrong otherwise. Anything else stays unsupported. It is looked at only after a key failed to load, and reads no message text. Two new language keys, `ssh_key_error_passphrase_needed` and `ssh_key_error_passphrase_wrong` (645 keys each). +- **Tests**: `test_ssh_security.py` +6: encrypted RSA and Ed25519 keys in OpenSSH format and RSA and ECDSA keys in PEM load with the right passphrase and are reported as wrong / needed passphrase otherwise; a file that is not a key, and a missing file, are unsupported; each reason has a message in both languages. +- **Result / numbers**: 2070 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py`, `sftp_session.py`, `ssh_command_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py`, `test/test_utils/test_ssh_security.py`, `README.md`, `architecture_explore.md` §2, §3, §9, §13, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 41acd718..19b4efa0 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-217 | 2026-09-24 | Say when an SSH key's passphrase is missing or wrong | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260923-216 | 2026-09-24 | Send curl's data pieces in command-line order when one is a file | #fix #curl | [2026-09-b](2026-09-b.md) | | U-20260923-215 | 2026-09-24 | Send a JSON body raw when the object would not send it back as it was | #fix #curl | [2026-09-b](2026-09-b.md) | | U-20260923-214 | 2026-09-24 | Share one refuse_constant between the JSON tools | #refactor | [2026-09-b](2026-09-b.md) | @@ -298,4 +299,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 16 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 17 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index c8b9271e..aae2e058 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -168,6 +168,8 @@ "ssh_command_widget_dialog_title_key_error": "Key error", "ssh_command_widget_dialog_message_key_file_not_exist": "Key file does not exist.", "ssh_command_widget_error_message_unsupported_private_key": "Unsupported or invalid private key.", + "ssh_key_error_passphrase_needed": "The private key is protected by a passphrase: enter it in the password field.", + "ssh_key_error_passphrase_wrong": "The passphrase for the private key is wrong.", "ssh_command_widget_error_message_key_auth_failed": "Key auth failed", "ssh_command_widget_status_label_connected": "Connected", "ssh_command_widget_status_label_disconnected": "Disconnected", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 7f9027f8..c315ebfe 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -168,6 +168,8 @@ "ssh_command_widget_dialog_title_key_error": "金鑰錯誤", "ssh_command_widget_dialog_message_key_file_not_exist": "金鑰檔案不存在。", "ssh_command_widget_error_message_unsupported_private_key": "不支援或無效的私鑰。", + "ssh_key_error_passphrase_needed": "這把私鑰有密語保護:請在密碼欄輸入密語。", + "ssh_key_error_passphrase_wrong": "私鑰的密語不對。", "ssh_command_widget_error_message_key_auth_failed": "金鑰驗證失敗", "ssh_command_widget_status_label_connected": "已連線", "ssh_command_widget_status_label_disconnected": "已斷線", diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py b/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py index ae24409c..bfff80f2 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py @@ -25,7 +25,7 @@ from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import SHA1_ALGORITHMS from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import apply_host_key_policy -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key, unloadable_key_reason from pybreeze.utils.logging.logger import pybreeze_logger @@ -159,9 +159,7 @@ def _log_in(self, ssh: paramiko.SSHClient, host: str, port: int, username: str, return pkey = load_private_key(key_path, password, context="SFTP") if pkey is None: - raise ValueError( - self.word_dict.get("ssh_command_widget_error_message_unsupported_private_key") - ) + raise ValueError(self.word_dict.get(unloadable_key_reason(key_path, password))) ssh.connect(hostname=host, port=port, username=username, pkey=pkey, timeout=10, disabled_algorithms=SHA1_ALGORITHMS) 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 ff14e957..0e7560d3 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -20,7 +20,7 @@ from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import ( apply_host_key_policy, host_key_asker ) -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key, unloadable_key_reason from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget from pybreeze.pybreeze_ui.thread_keeper import if_alive, let_run_out from pybreeze.utils.logging.logger import pybreeze_logger @@ -303,10 +303,7 @@ def _connect_with_key(self, client: paramiko.SSHClient, host: str, port: int, us try: pkey = load_private_key(key_path, password, context="SSH") if pkey is None: - raise ValueError( - self.word_dict.get( - "ssh_command_widget_error_message_unsupported_private_key" - )) + raise ValueError(self.word_dict.get(unloadable_key_reason(key_path, password))) client.connect(hostname=host, port=port, username=user, pkey=pkey, timeout=10, disabled_algorithms=SHA1_ALGORITHMS) except CONNECT_ERRORS as e: diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py index 96648f11..e7ded7f7 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py @@ -16,6 +16,11 @@ paramiko.ECDSAKey, ) +# Word-dict keys for why a key file could not be loaded +UNSUPPORTED_KEY = "ssh_command_widget_error_message_unsupported_private_key" +PASSPHRASE_NEEDED = "ssh_key_error_passphrase_needed" +PASSPHRASE_WRONG = "ssh_key_error_passphrase_wrong" + def load_private_key(key_path: str, password: str, *, context: str = "SSH") -> paramiko.PKey | None: """Try each supported key type against *key_path*; return the first that parses. @@ -30,3 +35,20 @@ def load_private_key(key_path: str, password: str, *, context: str = "SSH") -> p except (paramiko.SSHException, ValueError, OSError) as error: pybreeze_logger.debug("%s key type %s rejected: %s", context, key_cls.__name__, error) return None + + +def unloadable_key_reason(key_path: str, password: str) -> str: + """The word-dict key saying why :func:`load_private_key` gave ``None`` for *key_path*. + + A key file that is encrypted is one some key class asks a passphrase for + when given none; then the passphrase was missing or wrong, not the key + unsupported, which is all the message used to say. + """ + for key_cls in _KEY_CLASSES: + try: + key_cls.from_private_key_file(key_path, None) + except paramiko.PasswordRequiredException: + return PASSPHRASE_WRONG if password else PASSPHRASE_NEEDED + except (paramiko.SSHException, ValueError, OSError) as error: + pybreeze_logger.debug("Key type %s rejected: %s", key_cls.__name__, error) + return UNSUPPORTED_KEY diff --git a/test/test_utils/test_ssh_security.py b/test/test_utils/test_ssh_security.py index e666dce7..f5482bac 100644 --- a/test/test_utils/test_ssh_security.py +++ b/test/test_utils/test_ssh_security.py @@ -4,6 +4,7 @@ import hashlib import paramiko +import pytest from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import SHA1_ALGORITHMS from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import _fingerprint_sha256 @@ -51,6 +52,59 @@ def test_encrypted_key_requires_correct_passphrase(self, tmp_path): assert isinstance(load_private_key(str(path), "secret"), paramiko.RSAKey) +def _encrypted_key_file(tmp_path, kind: str): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa + + key, key_format = { + "rsa-openssh": (rsa.generate_private_key(65537, 2048), serialization.PrivateFormat.OpenSSH), + "ed25519-openssh": (ed25519.Ed25519PrivateKey.generate(), serialization.PrivateFormat.OpenSSH), + "rsa-pem": (rsa.generate_private_key(65537, 2048), serialization.PrivateFormat.TraditionalOpenSSL), + "ecdsa-pem": (ec.generate_private_key(ec.SECP256R1()), serialization.PrivateFormat.TraditionalOpenSSL), + }[kind] + path = tmp_path / kind + path.write_bytes(key.private_bytes( + serialization.Encoding.PEM, key_format, serialization.BestAvailableEncryption(b"right"))) + return str(path) + + +class TestWhyAKeyDidNotLoad: + """A wrong or missing passphrase was reported as an unsupported key.""" + + @pytest.mark.parametrize("kind", ["rsa-openssh", "ed25519-openssh", "rsa-pem", "ecdsa-pem"]) + def test_the_passphrase_is_named(self, tmp_path, kind): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import ( + PASSPHRASE_NEEDED, PASSPHRASE_WRONG, unloadable_key_reason + ) + + path = _encrypted_key_file(tmp_path, kind) + + assert load_private_key(path, "right") is not None + assert load_private_key(path, "wrong") is None + assert unloadable_key_reason(path, "wrong") == PASSPHRASE_WRONG + assert load_private_key(path, "") is None + assert unloadable_key_reason(path, "") == PASSPHRASE_NEEDED + + def test_a_file_that_is_no_key_is_unsupported(self, tmp_path): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import UNSUPPORTED_KEY, unloadable_key_reason + + path = tmp_path / "not_a_key" + path.write_text("hello", encoding="utf-8") + + assert unloadable_key_reason(str(path), "anything") == UNSUPPORTED_KEY + assert unloadable_key_reason(str(tmp_path / "missing"), "") == UNSUPPORTED_KEY + + def test_each_reason_has_a_message(self): + from pybreeze.extend_multi_language.extend_english import pybreeze_english_word_dict as english_word_dict + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict as traditional_chinese_word_dict, + ) + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_key_loader + + for key in (ssh_key_loader.UNSUPPORTED_KEY, ssh_key_loader.PASSPHRASE_NEEDED, ssh_key_loader.PASSPHRASE_WRONG): + assert english_word_dict.get(key) and traditional_chinese_word_dict.get(key) + + class TestSha1Algorithms: def test_a_transport_given_them_offers_no_sha1(self): import socket From 4ee0ccf4357d92199271238c41c46353a6d9bb4a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:27:00 +0800 Subject: [PATCH 256/312] Skip a bad known_hosts line instead of failing the SSH connect --- architecture_explore.md | 4 +- docs/updates/2026-09-b.md | 9 +++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_host_key_policy.py | 73 +++++++++++++------ test/test_utils/test_ssh_host_key_policy.py | 27 +++++++ 5 files changed, 90 insertions(+), 26 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 1ee2e867..8733cfdb 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -300,7 +300,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | | `ssh_file_viewer_widget.py` (691) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | -| `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時讀檔案的現況再加上這把 key(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | +| `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密)還是不支援的私鑰 | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2070 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2072 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index ad1f2b71..6bc5e19f 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -158,3 +158,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2070 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py`, `sftp_session.py`, `ssh_command_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py`, `test/test_utils/test_ssh_security.py`, `README.md`, `architecture_explore.md` §2, §3, §9, §13, §18 - **Open items**: none. + +## U-20260923-218 · 2026-09-24 · Skip a bad known_hosts line instead of failing the SSH connect · #fix #ssh + +- **What**: paramiko's `HostKeys.load` skips a line that raises `SSHException`, but a line whose key is not base64 raises `InvalidHostKey`, which is no `SSHException`, and loading stops there. One such line in `~/.ssh/known_hosts` or `~/.pybreeze/ssh_known_hosts` (a truncated paste, a hand edit) made `apply_host_key_policy` raise: out of the shell tab's Connect slot, and in the SFTP tree before `SshConnectThread.run`'s `try`, so the connect ended with no message. The same line in the middle of a connect made the host key question raise. And accepting a host rewrote the file from what paramiko could read, so a bad line or an `ssh-dss` key (paramiko 4 dropped DSA) vanished from it. +- **Fix**: `load_known_hosts(target, path)` reads the file line by line as `HostKeys.load` does, skipping a line that cannot be read with a warning (its line number, not its content), as OpenSSH does; `apply_host_key_policy` loads both files through it into the client's keys, and `_read_known_hosts` uses it too. `_store` appends one line for the accepted key to the file as it is, still replaced in one step, instead of writing back what paramiko parsed. +- **Tests**: `test_ssh_host_key_policy.py` +2: a bad line before a good one leaves the good host known and the Connect set up; accepting a host keeps a bad line and an `ssh-dss` line where they were. +- **Result / numbers**: 2072 tests in 121 files. The full suite before the passphrase change: 2049 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py`, `test/test_utils/test_ssh_host_key_policy.py`, `architecture_explore.md` §9, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 19b4efa0..b8844840 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-218 | 2026-09-24 | Skip a bad known_hosts line instead of failing the SSH connect | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260923-217 | 2026-09-24 | Say when an SSH key's passphrase is missing or wrong | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260923-216 | 2026-09-24 | Send curl's data pieces in command-line order when one is a file | #fix #curl | [2026-09-b](2026-09-b.md) | | U-20260923-215 | 2026-09-24 | Send a JSON body raw when the object would not send it back as it was | #fix #curl | [2026-09-b](2026-09-b.md) | @@ -299,4 +300,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 17 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 18 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py index 9312e59d..d1b6a1e7 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py @@ -18,12 +18,13 @@ import paramiko import shiboken6 +from paramiko.hostkeys import HostKeyEntry, InvalidHostKey from je_editor import language_wrapper from PySide6.QtCore import QObject, Qt, QThread, Signal, Slot from PySide6.QtWidgets import QMessageBox from pybreeze.utils.app_dirs import pybreeze_data_dir -from pybreeze.utils.file_process.replace_file import replace_written +from pybreeze.utils.file_process.replace_file import replace_text from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.pybreeze_ui.plain_text import as_text @@ -57,15 +58,43 @@ def _declined_just_now(hostname: str, fingerprint: str) -> bool: return declined_at is not None and time.monotonic() - declined_at < _DECLINE_REMEMBERED_SECONDS +def _file_lines(path: Path) -> list[str]: + """The lines of *path*; none when it is missing or cannot be read.""" + if not path.is_file(): + return [] + try: + return path.read_bytes().decode("utf-8", errors="replace").splitlines() + except OSError as err: + pybreeze_logger.warning("Failed to read known hosts %s: %s", path.name, err) + return [] + + +def load_known_hosts(target: paramiko.HostKeys, path: Path) -> None: + """Add every host key in the known hosts file *path* to *target*, skipping bad lines. + + ``HostKeys.load`` stops at a line whose key is not base64 with + ``InvalidHostKey``, which is no ``SSHException``: it escaped the shell + tab's Connect and ended the SFTP connect without a word. OpenSSH skips + such a line, and so does this. + """ + for number, line in enumerate(_file_lines(path), 1): + line = line.strip() + if not line or line.startswith("#"): + continue + try: + entry = HostKeyEntry.from_line(line, number) + except (InvalidHostKey, paramiko.SSHException, ValueError) as err: + pybreeze_logger.warning("Skipping line %d of %s: %s", number, path.name, type(err).__name__) + continue + if entry is not None: + for name in entry.hostnames: + target.add(name, entry.key.get_name(), entry.key) + + def _read_known_hosts() -> paramiko.HostKeys: """The known hosts file as it is now; empty when it is missing or unreadable.""" known = paramiko.HostKeys() - path = _known_hosts_path() - if path.is_file(): - try: - known.load(str(path)) - except OSError as err: - pybreeze_logger.warning("Failed to load PyBreeze known_hosts: %s", err) + load_known_hosts(known, _known_hosts_path()) return known @@ -75,18 +104,19 @@ def _is_trusted_on_disk(hostname: str, key: paramiko.PKey) -> bool: return entry is not None and entry.get(key.get_name()) == key -def _store(hostname: str, key_type: str, key: paramiko.PKey) -> None: - """Add the key to the file as it is now. +def _store(hostname: str, key: paramiko.PKey) -> None: + """Add a line for the key to the file as it is now. Not ``client.save_host_keys``: that writes the client's own copy, read at - its Connect, over hosts another tab accepted since. + its Connect, over hosts another tab accepted since. Nor ``HostKeys.save`` + of what paramiko read: a line it cannot read (a bad one, an ``ssh-dss`` + key paramiko 4 no longer takes) was dropped from the file. The file is + replaced in one step: a failure part-way lost every host trusted so far. """ - known = _read_known_hosts() - known.add(hostname, key_type, key) + path = _known_hosts_path() + line = HostKeyEntry([hostname], key).to_line() try: - # Replaced in one step: HostKeys.save opens the file for writing, which - # empties it, and a failure part-way lost every host trusted so far - replace_written(_known_hosts_path(), lambda target: known.save(str(target))) + replace_text(path, "\n".join([*_file_lines(path), line.rstrip("\n")]) + "\n") except OSError as err: pybreeze_logger.warning( "Failed to persist SSH host key for %s: %s", hostname, err @@ -153,7 +183,7 @@ def missing_host_key( f"Host key for {hostname} rejected by user." ) client.get_host_keys().add(hostname, key_type, key) - _store(hostname, key_type, key) + _store(hostname, key) pybreeze_logger.info( "SSH host key for %s accepted and stored (%s)", hostname, fingerprint ) @@ -225,11 +255,8 @@ def host_key_asker() -> HostKeyAsker: def apply_host_key_policy(client: paramiko.SSHClient, parent: QWidget | None) -> None: """Load known hosts and attach the interactive TOFU policy to *client*.""" - client.load_system_host_keys() - known_hosts = _known_hosts_path() - if known_hosts.is_file(): - try: - client.load_host_keys(str(known_hosts)) - except OSError as err: - pybreeze_logger.warning("Failed to load PyBreeze known_hosts: %s", err) + # Both files are read leniently into the client's own keys: a bad line in + # either stopped paramiko's loaders with an error the Connect let escape + load_known_hosts(client.get_host_keys(), Path.home() / ".ssh" / "known_hosts") + load_known_hosts(client.get_host_keys(), _known_hosts_path()) client.set_missing_host_key_policy(InteractiveHostKeyPolicy(parent)) diff --git a/test/test_utils/test_ssh_host_key_policy.py b/test/test_utils/test_ssh_host_key_policy.py index 862ad7e6..c9052ab2 100644 --- a/test/test_utils/test_ssh_host_key_policy.py +++ b/test/test_utils/test_ssh_host_key_policy.py @@ -167,3 +167,30 @@ def refuse(*_args): assert (tmp_path / "ssh_known_hosts").read_text(encoding="utf-8") == before assert sorted(path.name for path in tmp_path.iterdir()) == ["ssh_known_hosts"] + + +# A line whose key is not base64: HostKeys.load raised InvalidHostKey, no SSHException +_BAD_LINE = "badhost ssh-ed25519 abc" +# A line paramiko 4 no longer reads (it dropped DSA), which is still someone's host +_DSA_LINE = "old.example ssh-dss AAAAB3NzaC1kc3MAAACBAP==" + + +def test_a_bad_line_skips_that_line_and_the_rest_still_load(asked, keys, tmp_path): + good = paramiko.hostkeys.HostKeyEntry(["good.example"], keys[0]).to_line().strip() + (tmp_path / "ssh_known_hosts").write_text(f"{_BAD_LINE}\n{good}\n", encoding="utf-8") + client = paramiko.SSHClient() + + policy_mod.apply_host_key_policy(client, None) + + assert client.get_host_keys().lookup("good.example")["ssh-rsa"] == keys[0] + assert client.get_host_keys().lookup("badhost") is None + + +def test_accepting_a_host_keeps_the_lines_paramiko_could_not_read(asked, keys, tmp_path): + (tmp_path / "ssh_known_hosts").write_text(f"{_BAD_LINE}\n{_DSA_LINE}\n", encoding="utf-8") + + _meet("new.example", keys[0]) + + lines = (tmp_path / "ssh_known_hosts").read_text(encoding="utf-8").splitlines() + assert lines[:2] == [_BAD_LINE, _DSA_LINE] + assert policy_mod._read_known_hosts().lookup("new.example")["ssh-rsa"] == keys[0] From 1901c1b122094f7f49616336795c85cbcec5b8a6 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:30:09 +0800 Subject: [PATCH 257/312] Keep a carriage return split from what follows it in the run window and the SSH terminal --- architecture_explore.md | 8 ++++---- docs/updates/2026-09-b.md | 11 ++++++++++ docs/updates/README.md | 3 ++- progress.md | 1 + .../extend/process_executor/queue_pump.py | 6 ++---- .../connect_gui/ssh/ssh_command_widget.py | 4 ++-- .../show_code_window/code_window.py | 20 ++++++++++++++----- pybreeze/utils/terminal_text.py | 15 ++++++++++++++ test/test_utils/test_code_window.py | 19 ++++++++++++++++++ test/test_utils/test_ssh_terminal_output.py | 11 ++++++++++ 10 files changed, 82 insertions(+), 16 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 8733cfdb..aa77275f 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -158,7 +158,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 - `output_queue()` — 每條管線的 queue 最多 `MAX_QUEUED_MESSAGES`(10,000)則;滿了 reader 就等(每 0.2 秒看一次 `keep_reading`),子行程寫管線也跟著等,跑得跟視窗顯示一樣快,像終端機;以前不設上限,印個不停的腳本會一直吃記憶體,按 Stop 後再一口氣全倒進視窗 - `ReaderGrace` / `any_alive()` / `OUTPUT_STILL_HELD_NOTE` — 子行程結束後 reader 還能讀多久(`READER_GRACE_SECONDS = 2.0`,從結束後第一個 tick 起算)。管線要等最後一個握著它的行程結束才會 EOF,子行程開的行程沒轉向輸出時會一直握著;以前兩個執行器在 UI 執行緒上各 join 2 秒,IDE 卡 4 秒還是丟掉之後的輸出。現在由 pump 逐 tick 詢問,時間到就結束執行並在視窗註明 -- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。終端機控制碼(CSI 顏色與游標移動、OSC 等控制字串、`ESC ( B` 這類 nF 與其他兩位元組 escape)先拿掉、backspace 套用到前一個字元、tab、換行、`\r` 以外的控制字元丟掉(與 SSH terminal 共用 `utils/terminal_text.strip_terminal_controls()`;讀取切斷在 escape 中間時,reader 把尾巴留給下一段,`queue_pump` 的 `split_incomplete_escape()`),`\r\n` 是換行,單獨的 `\r` 像終端機一樣回到行首、由後面的文字取代這一行(`_insert_rewinding()`;結尾的 `\r` 等下一段來才回捲,跑完的進度條不會被清掉);換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 +- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。終端機控制碼(CSI 顏色與游標移動、OSC 等控制字串、`ESC ( B` 這類 nF 與其他兩位元組 escape)先拿掉、backspace 套用到前一個字元、tab、換行、`\r` 以外的控制字元丟掉(與 SSH terminal 共用 `utils/terminal_text.strip_terminal_controls()`;讀取切斷在 escape 中間時,reader 把尾巴留給下一段,`queue_pump` 的 `split_incomplete_escape()`),`\r\n` 是換行,單獨的 `\r` 像終端機一樣回到行首、由後面的文字取代這一行(`_insert_rewinding()`;結尾的 `\r` 記在 `_rewind_pending`,等下一段來才套用:接著是 `\n` 就是換行,否則回捲,跑完的進度條不會被清掉);換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 --- @@ -298,7 +298,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | -| `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元與 escape 被讀取切斷時留到下一次接上,escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | +| `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | | `ssh_file_viewer_widget.py` (691) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | @@ -339,7 +339,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | 套件 | 內容 | |---|---| | `app_dirs.py` | `pybreeze_data_dir()` → `~/.pybreeze`,所有持久化資料的單一位置,建立時為 `0700`(`DATA_DIR_MODE`);`pybreeze_data_path()` 只給路徑、不建立 | -| `terminal_text.py` | 終端輸出的 escape 與控制字元:`strip_terminal_controls()`(CSI、OSC/DCS 等控制字串、nF、兩位元組 escape 剝除,backspace 套用,其餘 C0 丟掉)、`split_incomplete_escape()`(讀取切斷在 escape 中間時把尾巴留給下一次)。SSH terminal 與執行視窗共用 | +| `terminal_text.py` | 終端輸出的 escape 與控制字元:`strip_terminal_controls()`(CSI、OSC/DCS 等控制字串、nF、兩位元組 escape 剝除,backspace 套用,其餘 C0 丟掉)、`split_incomplete_escape()`(讀取切斷在 escape 中間時把尾巴留給下一次)、`split_unfinished_end()`(再加上它前面或最後的 `\r`)。SSH terminal 與執行視窗共用 | | `subprocess_util.py` | `utf8_subprocess_env()`(釘 `PYTHONIOENCODING`,解 Windows cp950 亂碼)、`no_window_creationflags()`(`CREATE_NO_WINDOW`,避免 GUI 程式彈出黑窗) | | `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2072 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2088 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 6bc5e19f..4d3a4655 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -167,3 +167,14 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2072 tests in 121 files. The full suite before the passphrase change: 2049 passed, 15 skipped, startup tests exit 0. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py`, `test/test_utils/test_ssh_host_key_policy.py`, `architecture_explore.md` §9, §18 - **Open items**: none. + +## U-20260923-219 · 2026-09-24 · Keep a carriage return split from what follows it in the run window and the SSH terminal · #fix #output + +- **What**: output arrives in reads that end wherever a buffer did. + - Run window: a read ending on `\r\x1b[` (a progress bar's rewind-and-clear, `\r\x1b[K`) sent the `\r` with the cut escape held back, and once the escape was stripped the piece ended on a lone `\r`, which the window drops as waiting for what comes next. The rewind never happened: `50%\r\x1b[K60%\n` read in two showed `50%60%`. Holding the `\r` with the escape was not enough on its own: the piece `\r\x1b[K` still ends on a bare `\r` once stripped. + - SSH terminal: `TerminalDecoder` did not hold a trailing `\r` at all, and the view takes a lone `\r` as a paragraph break, so a `\r\n` split between two 4 KB reads made a blank line in long output. +- **Fix**: `terminal_text.split_unfinished_end()` holds the unfinished escape and a `\r` just before it or at the very end; `queue_pump.read_stream_into_queue` and `TerminalDecoder.feed` both use it. `CodeWindow` remembers a piece that ended on a lone `\r` (`_rewind_pending`) and applies it to the next: a line break before `\n`, a rewind before anything else, so a finished bar still stays shown. +- **Tests**: `test_code_window.py` +15: `50%\r\x1b[K60%\n` read with every buffer size from 1 to 15 shows `60%`; `test_ssh_terminal_output.py` +1: `line1\r` then `\nline2\r\n` is two lines, no blank one. +- **Result / numbers**: 2088 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/terminal_text.py`, `pybreeze/extend/process_executor/queue_pump.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_code_window.py`, `test/test_utils/test_ssh_terminal_output.py`, `architecture_explore.md` §4, §9, §12, §18 +- **Open items**: a backspace at the start of a read cannot take back a character shown by the previous read (a spinner writing `|`, `\b/`, `\b-` as separate reads shows `|/-`): progress #105. diff --git a/docs/updates/README.md b/docs/updates/README.md index b8844840..aee7e20d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-219 | 2026-09-24 | Keep a carriage return split from what follows it in the run window and the SSH terminal | #fix #output | [2026-09-b](2026-09-b.md) | | U-20260923-218 | 2026-09-24 | Skip a bad known_hosts line instead of failing the SSH connect | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260923-217 | 2026-09-24 | Say when an SSH key's passphrase is missing or wrong | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260923-216 | 2026-09-24 | Send curl's data pieces in command-line order when one is a file | #fix #curl | [2026-09-b](2026-09-b.md) | @@ -300,4 +301,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 18 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 19 | diff --git a/progress.md b/progress.md index 789d773f..929d9d00 100644 --- a/progress.md +++ b/progress.md @@ -16,3 +16,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. - **#102** [BLOCKED] A report mail can wait forever: je_mail_thunder's `SMTPWrapper(host, port)` passes no timeout to `SMTP_SSL`, so a mail server that accepts the connection and then stalls holds the report-mail thread (`extend/mail_thunder_extend/mail_thunder_setting.py`, `send_report`) with no end, and the run window never says whether the report went. Needs a `timeout` argument in MailThunder (`je_mail_thunder/smtp/smtp_wrapper.py:20`); then pass 30 s here. - **#103** [BLOCKED] PyBreeze's automation keywords are never highlighted: `syntax_extend_package` registers them for `.json`, `.yml` and `.yaml` (`pybreeze_ui/syntax/syntax_extend.py`), but JEditor has built-in rules for those suffixes, so `CodeEditor.reset_highlighter` gets a `GenericHighlighter` from `highlighter_for`, and only `PythonHighlighter` reads the plugin registry (`je_editor/pyside_ui/code/syntax/generic_syntax.py:124`, `code_edit_plaintext.py:423-425` in `D:\Codes\JEDITOR` at d372c35). Needs JEditor's generic highlighter to add a registered language's words for the suffix; the `architecture.md` §6 contract changes with it. +- **#105** A backspace at the start of a read cannot take back a character an earlier read already showed: `strip_terminal_controls` applies a backspace only inside one piece and drops the rest (`utils/terminal_text.py:28`), so a spinner writing `|`, `\b/`, `\b-` in separate writes shows `|/-` in the run window (`show_code_window/code_window.py`, `append_output`) and the SSH terminal (`connect_gui/ssh/ssh_command_widget.py`, `_insert_output`). Needs the views to delete what a leading backspace takes back from the current line. diff --git a/pybreeze/extend/process_executor/queue_pump.py b/pybreeze/extend/process_executor/queue_pump.py index 4d08d94b..3ad4b3b9 100644 --- a/pybreeze/extend/process_executor/queue_pump.py +++ b/pybreeze/extend/process_executor/queue_pump.py @@ -17,7 +17,7 @@ from typing import IO from pybreeze.utils.logging.logger import pybreeze_logger -from pybreeze.utils.terminal_text import split_incomplete_escape +from pybreeze.utils.terminal_text import split_unfinished_end # Drain up to this many messages per timer tick. Draining one line per ~100 ms # tick caps throughput at ~10 lines/s and makes chatty scripts crawl; batching @@ -105,9 +105,7 @@ def read_stream_into_queue( line = decoder.decode(line) # An escape sequence cut off at the end waits for the rest: stripped # in two pieces, "\x1b[3" + "2m" showed "[32m" in the window - line, held = split_incomplete_escape(held + line) - if not held and line.endswith("\r"): - line, held = line[:-1], "\r" + line, held = split_unfinished_end(held + line) if line and not _put(target_queue, line, keep_reading): return rest = held + decoder.decode(b"", final=True) 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 0e7560d3..d77c388a 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -24,7 +24,7 @@ from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget from pybreeze.pybreeze_ui.thread_keeper import if_alive, let_run_out from pybreeze.utils.logging.logger import pybreeze_logger -from pybreeze.utils.terminal_text import split_incomplete_escape, strip_terminal_controls +from pybreeze.utils.terminal_text import split_unfinished_end, strip_terminal_controls # What closing a channel or a client can raise on a connection already broken CLOSE_ERRORS = (OSError, EOFError, paramiko.SSHException) @@ -50,7 +50,7 @@ def reset(self) -> None: def feed(self, data: bytes) -> str: """Return the text *data* completes, escape sequences removed.""" - text, self._pending = split_incomplete_escape(self._pending + self._decoder.decode(data)) + text, self._pending = split_unfinished_end(self._pending + self._decoder.decode(data)) return strip_terminal_controls(text) diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index 80ee6df9..1354a272 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -22,22 +22,25 @@ MAX_OUTPUT_BLOCKS = 10000 -def _insert_rewinding(cursor: QTextCursor, text: str, text_format: QTextCharFormat) -> None: +def _insert_rewinding(cursor: QTextCursor, text: str, text_format: QTextCharFormat) -> bool: """Insert *text* at *cursor*, a lone ``\\r`` going back to the start of the line. As a terminal does: a progress bar that rewinds with ``\\r`` redraws its line instead of adding one per step. ``\\r\\n`` and ``\\n`` are line breaks. + + :return: whether *text* ended on a ``\\r`` still to be applied: it waits + for what comes next, since rewound now, a finished progress bar's last + line would be erased with nothing to replace it """ pieces = text.replace("\r\n", "\n").split("\r") cursor.insertText(pieces[0], text_format) for index, piece in enumerate(pieces[1:], start=1): if not piece and index == len(pieces) - 1: - # A trailing \r waits for what comes next: rewound now, a finished - # progress bar's last line would be erased with nothing to replace it - break + return True cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock, QTextCursor.MoveMode.KeepAnchor) cursor.removeSelectedText() cursor.insertText(piece, text_format) + return False class CodeWindow(QWidget): @@ -57,6 +60,10 @@ def __init__(self): # end, and the rest of the output and the exit line never arrive. self.runner: TaskProcessManager | FileRunnerProcess | None = None self._closed_while_running = False + # The last output ended on a lone \r, which the next applies: a line + # break before "\n", a rewind before anything else. Dropped instead, + # "\r" + "\x1b[K60%" in two pieces left the old bar: "50%60%" + self._rewind_pending = False self.grid_layout = QGridLayout() self.code_result = QPlainTextEdit() self.code_result.setLineWrapMode(self.code_result.LineWrapMode.NoWrap) @@ -138,6 +145,9 @@ def append_output(self, text: str, is_error: bool = False, *, own_line: bool = F text_format = QTextCharFormat() color_key = "error_output_color" if is_error else "normal_output_color" text_format.setForeground(actually_color_dict.get(color_key)) - _insert_rewinding(cursor, strip_terminal_controls(text), text_format) + text = strip_terminal_controls(text) + if self._rewind_pending: + text = "\r" + text + self._rewind_pending = _insert_rewinding(cursor, text, text_format) if follow_output: scroll_bar.setValue(scroll_bar.maximum()) diff --git a/pybreeze/utils/terminal_text.py b/pybreeze/utils/terminal_text.py index e9a3269c..89850f0c 100644 --- a/pybreeze/utils/terminal_text.py +++ b/pybreeze/utils/terminal_text.py @@ -59,3 +59,18 @@ def split_incomplete_escape(text: str) -> tuple[str, str]: if tail is not None and len(text) - tail.start() <= _MAX_PENDING_ESCAPE: return text[:tail.start()], text[tail.start():] return text, "" + + +def split_unfinished_end(text: str) -> tuple[str, str]: + """*text* as what can be shown now and what must wait for the next read. + + That is an unfinished escape sequence (:func:`split_incomplete_escape`) + and a carriage return just before it or at the very end: the ``\\n`` that + makes it a line ending, or the text a lone one rewinds over, is still to + come. Shown alone, a split ``\\r\\n`` made a blank line, and a ``\\r`` + before a cut ``\\x1b[K`` was dropped, so a progress bar was not rewound. + """ + shown, held = split_incomplete_escape(text) + if shown.endswith("\r"): + return shown[:-1], "\r" + held + return shown, held diff --git a/test/test_utils/test_code_window.py b/test/test_utils/test_code_window.py index 9e232325..c51a8a44 100644 --- a/test/test_utils/test_code_window.py +++ b/test/test_utils/test_code_window.py @@ -109,6 +109,25 @@ def test_a_lone_carriage_return_redraws_the_line(self, qt_app): assert window.code_result.toPlainText() == "one\ndone\n" + @pytest.mark.parametrize("buffer_size", range(1, 16)) + def test_a_rewind_and_clear_cut_anywhere_still_rewinds(self, qt_app, buffer_size): + # A read ending on "\r\x1b[" sent the "\r" on its own, the window + # dropped it as the end of a piece, and "50%" stayed: "50%60%" + import io + from queue import Queue + + from pybreeze.extend.process_executor.queue_pump import read_stream_into_queue + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + pieces: Queue = Queue() + read_stream_into_queue(io.BytesIO(b"50%\r\x1b[K60%\n"), pieces, buffer_size=buffer_size, + encoding="utf-8", keep_reading=lambda: True) + window = CodeWindow() + while not pieces.empty(): + window.append_output(pieces.get()) + + assert window.code_result.toPlainText() == "60%\n" + def test_a_bar_that_ends_on_a_carriage_return_stays_shown(self, qt_app): from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow diff --git a/test/test_utils/test_ssh_terminal_output.py b/test/test_utils/test_ssh_terminal_output.py index a4ccbc07..5a7fb40d 100644 --- a/test/test_utils/test_ssh_terminal_output.py +++ b/test/test_utils/test_ssh_terminal_output.py @@ -84,6 +84,17 @@ def test_reads_continue_the_line_they_belong_to(self, app): assert widget.terminal.toPlainText() == "abcd\nef" widget.close() + def test_a_line_ending_cut_between_reads_is_one_line_break(self, app): + # "\r" at the end of one read and "\n" at the start of the next were + # two paragraph breaks: a blank line in long output + widget = self._widget() + + for chunk in (b"line1\r", b"\nline2\r\n"): + widget._on_data(chunk) + + assert widget.terminal.toPlainText() == "line1\nline2\n" + widget.close() + def test_a_notice_starts_on_a_line_of_its_own(self, app): widget = self._widget() widget._on_data(b"$ ") From bdd09d4db890d1a107f54afa0b6dd9f16dd687b2 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:31:35 +0800 Subject: [PATCH 258/312] Let a backspace take back what an earlier read showed --- architecture_explore.md | 4 ++-- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../connect_gui/ssh/ssh_command_widget.py | 17 ++++++++++++++--- .../pybreeze_ui/show_code_window/code_window.py | 8 +++++++- pybreeze/utils/terminal_text.py | 12 ++++++++++++ test/test_utils/test_code_window.py | 11 +++++++++++ test/test_utils/test_ssh_terminal_output.py | 10 ++++++++++ 9 files changed, 67 insertions(+), 8 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index aa77275f..98fda49e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -339,7 +339,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | 套件 | 內容 | |---|---| | `app_dirs.py` | `pybreeze_data_dir()` → `~/.pybreeze`,所有持久化資料的單一位置,建立時為 `0700`(`DATA_DIR_MODE`);`pybreeze_data_path()` 只給路徑、不建立 | -| `terminal_text.py` | 終端輸出的 escape 與控制字元:`strip_terminal_controls()`(CSI、OSC/DCS 等控制字串、nF、兩位元組 escape 剝除,backspace 套用,其餘 C0 丟掉)、`split_incomplete_escape()`(讀取切斷在 escape 中間時把尾巴留給下一次)、`split_unfinished_end()`(再加上它前面或最後的 `\r`)。SSH terminal 與執行視窗共用 | +| `terminal_text.py` | 終端輸出的 escape 與控制字元:`strip_terminal_controls()`(CSI、OSC/DCS 等控制字串、nF、兩位元組 escape 剝除,backspace 套用,其餘 C0 丟掉)、`split_incomplete_escape()`(讀取切斷在 escape 中間時把尾巴留給下一次)、`split_unfinished_end()`(再加上它前面或最後的 `\r`)、`take_leading_backspaces()`(一段開頭的 backspace 留給畫面,擦掉前一段已經顯示的字,不越過行首)。SSH terminal 與執行視窗共用 | | `subprocess_util.py` | `utf8_subprocess_env()`(釘 `PYTHONIOENCODING`,解 Windows cp950 亂碼)、`no_window_creationflags()`(`CREATE_NO_WINDOW`,避免 GUI 程式彈出黑窗) | | `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2088 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2090 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 4d3a4655..144fbc6a 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -178,3 +178,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2088 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/utils/terminal_text.py`, `pybreeze/extend/process_executor/queue_pump.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_code_window.py`, `test/test_utils/test_ssh_terminal_output.py`, `architecture_explore.md` §4, §9, §12, §18 - **Open items**: a backspace at the start of a read cannot take back a character shown by the previous read (a spinner writing `|`, `\b/`, `\b-` as separate reads shows `|/-`): progress #105. + +## U-20260923-220 · 2026-09-24 · Let a backspace take back what an earlier read showed · #fix #output #done + +- **What**: progress #105. `strip_terminal_controls` applies a backspace to the character before it in the same piece and drops the rest, so one at the start of a read, meant for a character the previous read already put on screen, did nothing: a spinner writing `|`, `\b/`, `\b-` in separate writes showed `|/-` in the run window and the SSH terminal. +- **Fix**: `terminal_text.take_leading_backspaces()` splits off the backspaces a piece starts with. `CodeWindow.append_output` and `SSHCommandWidget._insert_output` delete that many characters from the end of the current line, never past its start, as a terminal's backspace does; `TerminalDecoder.feed` passes them through to the view. After a pending `\r` the line is being rewound anyway, and they are dropped. +- **Tests**: `test_code_window.py` +1 and `test_ssh_terminal_output.py` +1: the spinner's frames, then four backspaces and `done`, leave `line` and `done`; in the SSH terminal a bold escape between frames does not get in the way. +- **Result / numbers**: 2090 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/terminal_text.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_code_window.py`, `test/test_utils/test_ssh_terminal_output.py`, `progress.md` (#105 removed), `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index aee7e20d..c78c38eb 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-220 | 2026-09-24 | Let a backspace take back what an earlier read showed | #fix #output #done | [2026-09-b](2026-09-b.md) | | U-20260923-219 | 2026-09-24 | Keep a carriage return split from what follows it in the run window and the SSH terminal | #fix #output | [2026-09-b](2026-09-b.md) | | U-20260923-218 | 2026-09-24 | Skip a bad known_hosts line instead of failing the SSH connect | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260923-217 | 2026-09-24 | Say when an SSH key's passphrase is missing or wrong | #fix #ssh | [2026-09-b](2026-09-b.md) | @@ -301,4 +302,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 19 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 20 | diff --git a/progress.md b/progress.md index 929d9d00..789d773f 100644 --- a/progress.md +++ b/progress.md @@ -16,4 +16,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. - **#102** [BLOCKED] A report mail can wait forever: je_mail_thunder's `SMTPWrapper(host, port)` passes no timeout to `SMTP_SSL`, so a mail server that accepts the connection and then stalls holds the report-mail thread (`extend/mail_thunder_extend/mail_thunder_setting.py`, `send_report`) with no end, and the run window never says whether the report went. Needs a `timeout` argument in MailThunder (`je_mail_thunder/smtp/smtp_wrapper.py:20`); then pass 30 s here. - **#103** [BLOCKED] PyBreeze's automation keywords are never highlighted: `syntax_extend_package` registers them for `.json`, `.yml` and `.yaml` (`pybreeze_ui/syntax/syntax_extend.py`), but JEditor has built-in rules for those suffixes, so `CodeEditor.reset_highlighter` gets a `GenericHighlighter` from `highlighter_for`, and only `PythonHighlighter` reads the plugin registry (`je_editor/pyside_ui/code/syntax/generic_syntax.py:124`, `code_edit_plaintext.py:423-425` in `D:\Codes\JEDITOR` at d372c35). Needs JEditor's generic highlighter to add a registered language's words for the suffix; the `architecture.md` §6 contract changes with it. -- **#105** A backspace at the start of a read cannot take back a character an earlier read already showed: `strip_terminal_controls` applies a backspace only inside one piece and drops the rest (`utils/terminal_text.py:28`), so a spinner writing `|`, `\b/`, `\b-` in separate writes shows `|/-` in the run window (`show_code_window/code_window.py`, `append_output`) and the SSH terminal (`connect_gui/ssh/ssh_command_widget.py`, `_insert_output`). Needs the views to delete what a leading backspace takes back from the current line. 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 d77c388a..8f214304 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -24,7 +24,7 @@ from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget from pybreeze.pybreeze_ui.thread_keeper import if_alive, let_run_out from pybreeze.utils.logging.logger import pybreeze_logger -from pybreeze.utils.terminal_text import split_unfinished_end, strip_terminal_controls +from pybreeze.utils.terminal_text import split_unfinished_end, strip_terminal_controls, take_leading_backspaces # What closing a channel or a client can raise on a connection already broken CLOSE_ERRORS = (OSError, EOFError, paramiko.SSHException) @@ -49,9 +49,14 @@ def reset(self) -> None: self._pending = "" def feed(self, data: bytes) -> str: - """Return the text *data* completes, escape sequences removed.""" + """Return the text *data* completes, escape sequences removed. + + Backspaces it starts with are kept, for the view to take back what an + earlier read showed; any others are applied here. + """ text, self._pending = split_unfinished_end(self._pending + self._decoder.decode(data)) - return strip_terminal_controls(text) + backspaces, text = take_leading_backspaces(text) + return "\x08" * backspaces + strip_terminal_controls(text) # Bound the terminal scrollback so an endless stream (``tail -f``, ``yes``) @@ -235,6 +240,12 @@ def _insert_output(self, text: str) -> None: following = scroll_bar.value() == scroll_bar.maximum() end = QTextCursor(self.terminal.document()) end.movePosition(QTextCursor.MoveOperation.End) + backspaces, text = take_leading_backspaces(text) + if backspaces: + # They take back what an earlier read showed, never past the line's start + end.movePosition(QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.KeepAnchor, + min(backspaces, end.positionInBlock())) + end.removeSelectedText() end.insertText(text) if following: scroll_bar.setValue(scroll_bar.maximum()) diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index 1354a272..4e3a5011 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -7,7 +7,7 @@ from PySide6.QtGui import QGuiApplication, QTextCharFormat, QTextCursor from PySide6.QtWidgets import QWidget, QGridLayout, QPlainTextEdit, QScrollArea -from pybreeze.utils.terminal_text import strip_terminal_controls +from pybreeze.utils.terminal_text import strip_terminal_controls, take_leading_backspaces if TYPE_CHECKING: from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess @@ -145,9 +145,15 @@ def append_output(self, text: str, is_error: bool = False, *, own_line: bool = F text_format = QTextCharFormat() color_key = "error_output_color" if is_error else "normal_output_color" text_format.setForeground(actually_color_dict.get(color_key)) + backspaces, text = take_leading_backspaces(text) text = strip_terminal_controls(text) if self._rewind_pending: text = "\r" + text + elif backspaces: + # They take back what an earlier piece showed, never past the line's start + cursor.movePosition(QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.KeepAnchor, + min(backspaces, cursor.positionInBlock())) + cursor.removeSelectedText() self._rewind_pending = _insert_rewinding(cursor, text, text_format) if follow_output: scroll_bar.setValue(scroll_bar.maximum()) diff --git a/pybreeze/utils/terminal_text.py b/pybreeze/utils/terminal_text.py index 89850f0c..50c63338 100644 --- a/pybreeze/utils/terminal_text.py +++ b/pybreeze/utils/terminal_text.py @@ -47,6 +47,18 @@ def strip_terminal_controls(text: str) -> str: return _CONTROL_CHARACTER.sub('', text) +def take_leading_backspaces(text: str) -> tuple[int, str]: + """How many backspaces *text* starts with, and the rest of it. + + Those take back characters an earlier read already showed, which only the + view can do: :func:`strip_terminal_controls` applies a backspace inside + one piece and dropped these, so a spinner writing ``|``, ``\\b/``, ``\\b-`` + in separate writes showed ``|/-``. + """ + rest = text.lstrip("\x08") + return len(text) - len(rest), rest + + def split_incomplete_escape(text: str) -> tuple[str, str]: """*text* as what can be shown now and the unfinished escape sequence at its end. diff --git a/test/test_utils/test_code_window.py b/test/test_utils/test_code_window.py index c51a8a44..17ab55f5 100644 --- a/test/test_utils/test_code_window.py +++ b/test/test_utils/test_code_window.py @@ -128,6 +128,17 @@ def test_a_rewind_and_clear_cut_anywhere_still_rewinds(self, qt_app, buffer_size assert window.code_result.toPlainText() == "60%\n" + def test_a_backspace_takes_back_what_an_earlier_piece_showed(self, qt_app): + # A spinner writing each frame separately showed "|/-done" + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + for piece in ["line\n", "|", "\b/", "\b-", "\b\b\b\bdone\n"]: + window.append_output(piece) + + # Never past the start of the line: "line" stays + assert window.code_result.toPlainText() == "line\ndone\n" + def test_a_bar_that_ends_on_a_carriage_return_stays_shown(self, qt_app): from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow diff --git a/test/test_utils/test_ssh_terminal_output.py b/test/test_utils/test_ssh_terminal_output.py index 5a7fb40d..758ad1a2 100644 --- a/test/test_utils/test_ssh_terminal_output.py +++ b/test/test_utils/test_ssh_terminal_output.py @@ -84,6 +84,16 @@ def test_reads_continue_the_line_they_belong_to(self, app): assert widget.terminal.toPlainText() == "abcd\nef" widget.close() + def test_a_backspace_takes_back_what_an_earlier_read_showed(self, app): + # A spinner's frames in separate reads showed "|/-done" + widget = self._widget() + + for chunk in (b"line\r\n", b"|", b"\x08/", b"\x08\x1b[1m-", b"\x08\x08\x08done\r\n"): + widget._on_data(chunk) + + assert widget.terminal.toPlainText() == "line\ndone\n" + widget.close() + def test_a_line_ending_cut_between_reads_is_one_line_break(self, app): # "\r" at the end of one read and "\n" at the start of the next were # two paragraph breaks: a blank line in long output From 226d9dae0dea87182a33d9924b42210c02454f4c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:37:31 +0800 Subject: [PATCH 259/312] Keep the HAR tab's output the script of the file and target shown --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 12 ++++++ docs/updates/README.md | 3 +- .../pybreeze_ui/tools_gui/har_import_gui.py | 27 +++++++++++-- test/test_utils/test_har_import_gui.py | 38 +++++++++++++++++++ 5 files changed, 77 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 98fda49e..227115e8 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2090 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2094 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 144fbc6a..fb5d3689 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -187,3 +187,15 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2090 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/utils/terminal_text.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_code_window.py`, `test/test_utils/test_ssh_terminal_output.py`, `progress.md` (#105 removed), `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-221 · 2026-09-24 · Keep the HAR tab's output the script of the file and target shown · #fix #har + +- **What**: three ways the HAR tab's output (what Save, Copy and Open in editor take) belonged to something no longer shown. + - Loading another file reset the list and the summary but kept the previous file's script, and Save wrote it. + - A file that could not be read showed the error but left the previous file's requests listed, and Generate made their script under the error. + - Choosing another target did not generate again, while Save names the file after the target: generated for `requests` and switched to the APITestka action list, Save offered `actions.json` and wrote Python into it. The cURL tab already generates again on a target change. +- **Fix**: a successful load clears the output; `_report_error` also clears the listed requests; the target selector's `currentIndexChanged` generates again from the requests the output was made from (`_generated_from`), and does nothing when there is no output yet. +- **Tests**: `test_har_import_gui.py` +4, one per case above and one that a target chosen before generating generates nothing. +- **Result / numbers**: 2094 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `test/test_utils/test_har_import_gui.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c78c38eb..c7b2a5f3 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-221 | 2026-09-24 | Keep the HAR tab's output the script of the file and target shown | #fix #har | [2026-09-b](2026-09-b.md) | | U-20260923-220 | 2026-09-24 | Let a backspace take back what an earlier read showed | #fix #output #done | [2026-09-b](2026-09-b.md) | | U-20260923-219 | 2026-09-24 | Keep a carriage return split from what follows it in the run window and the SSH terminal | #fix #output | [2026-09-b](2026-09-b.md) | | U-20260923-218 | 2026-09-24 | Skip a bad known_hosts line instead of failing the SSH connect | #fix #ssh | [2026-09-b](2026-09-b.md) | @@ -302,4 +303,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 20 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 21 | diff --git a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py index 13560e0f..991bbb57 100644 --- a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py @@ -45,6 +45,9 @@ def __init__(self, main_window=None) -> None: self._entries: list[HarEntry] = [] self._shown: list[HarEntry] = [] self._generated_code: str | None = None + # The requests the output was generated from, to generate again for + # another target + self._generated_from: list[HarEntry] = [] word = language_wrapper.language_word_dict self.open_button = QPushButton(word.get("har_import_open_button")) @@ -68,6 +71,9 @@ def __init__(self, main_window=None) -> None: self.target_select = QComboBox() for target_key, label_key in TEMPLATE_TARGETS: self.target_select.addItem(word.get(label_key), target_key) + # Save names the file after the target: the output has to follow it, + # or Python was saved as actions.json + self.target_select.currentIndexChanged.connect(self._regenerate) self.generate_selected_button = QPushButton(word.get("har_import_generate_selected")) self.generate_selected_button.clicked.connect(self.generate_selected) @@ -137,19 +143,28 @@ def load_text(self, text: str) -> bool: entries = parse_har(text) except HarParseException as error: pybreeze_logger.info("har_import_gui.py parse failed: %r", error) - self._entries = [] - self._refresh_entry_list() self._report_error( language_wrapper.language_word_dict.get("har_import_error").format( error=str(error))) return False self._entries = entries self._refresh_entry_list() + # The previous file's script is not this one's: Save wrote it + self._generated_code = None + self._generated_from = [] + self.output_edit.clear() return True def _report_error(self, message: str) -> None: - """Show *message* as the summary and clear any generated output.""" + """Show *message* as the summary, with nothing listed and no output. + + The previous file's requests went too: listed under the error, Generate + still made their script. + """ + self._entries = [] + self._refresh_entry_list() self._generated_code = None + self._generated_from = [] self.summary_label.setText(message) self.output_edit.setPlainText(message) @@ -178,9 +193,15 @@ def selected_entries(self) -> list[HarEntry]: rows = sorted(index.row() for index in self.entry_list.selectedIndexes()) return [self._shown[row] for row in rows if 0 <= row < len(self._shown)] + def _regenerate(self) -> None: + """Generate the output again for the target now chosen, if there is output.""" + if self._generated_code is not None: + self._generate(self._generated_from, "har_import_empty_hint") + def _generate(self, entries: list[HarEntry], empty_hint_key: str) -> None: """Generate a script for *entries*, or show the hint when there are none.""" word = language_wrapper.language_word_dict + self._generated_from = list(entries) if not entries: self._generated_code = None self.output_edit.setPlainText(word.get(empty_hint_key)) diff --git a/test/test_utils/test_har_import_gui.py b/test/test_utils/test_har_import_gui.py index 29f0af53..3b095cbc 100644 --- a/test/test_utils/test_har_import_gui.py +++ b/test/test_utils/test_har_import_gui.py @@ -219,3 +219,41 @@ def test_a_target_that_cannot_carry_an_upload_says_so(widget): widget.generate_all() assert "choose a Python target" in widget.output_edit.toPlainText() + + +class TestWhatTheOutputBelongsTo: + """Save writes the output: it must be the script of the file and target now shown.""" + + def test_loading_another_file_clears_the_previous_script(self, loaded): + loaded.generate_all() + other = _HAR.replace("api.example.com", "second.example") + + assert loaded.load_text(other) + + assert loaded.output_edit.toPlainText() == "" + assert not loaded.actions._has_output() + + def test_a_file_that_cannot_be_read_unlists_the_previous_one(self, loaded, tmp_path): + with patch( + "pybreeze.pybreeze_ui.tools_gui.har_import_gui.QFileDialog.getOpenFileName", + return_value=(str(tmp_path / "gone.har"), ""), + ): + loaded.open_file() + + assert loaded.entry_list.count() == 0 + loaded.generate_all() + assert "/v1/items" not in loaded.output_edit.toPlainText() + assert not loaded.actions._has_output() + + def test_choosing_another_target_generates_again(self, loaded): + loaded.generate_all() + + loaded.target_select.setCurrentIndex(loaded.target_select.findData("apitestka_action")) + + assert len(json.loads(loaded.output_edit.toPlainText())) == 2 + assert loaded.actions.suggested_filename() == "actions.json" + + def test_choosing_a_target_before_generating_generates_nothing(self, loaded): + loaded.target_select.setCurrentIndex(loaded.target_select.findData("pytest")) + + assert loaded.output_edit.toPlainText() == "" From c03b3f55a7e21a3cd51c8206bad23d62045538b0 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:38:39 +0800 Subject: [PATCH 260/312] Show a plugin's suffixes in the Run with box as text --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../menu/plugin_menu/build_run_with_menu.py | 5 +++-- .../test_message_boxes_show_text.py | 20 ++++++++++++++++++- 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 227115e8..0124f02d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2094 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2095 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index fb5d3689..787b1268 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -199,3 +199,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2094 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `test/test_utils/test_har_import_gui.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-222 · 2026-09-24 · Show a plugin's suffixes in the Run with box as text, and check every box by what it is assigned to · #fix #qt #plugin + +- **What**: running a file through a plugin whose suffixes do not match it shows a message box with the plugin's suffixes and the file's own, set with `msg.setText(...)` as they were; Qt reads markup in them (`AutoText`), so a suffix written as `` was loaded. `test_message_boxes_show_text.py` did not see it: it took an instance box's `setText` only when the receiver's name had "box" in it, and this one is `msg`. +- **Fix**: the text goes through `as_text()`. The guard now also checks the setters of every name a `QMessageBox(...)` is assigned to in the module (`_box_names`). +- **Tests**: `test_message_boxes_show_text.py` +1: `msg = QMessageBox(parent)` then `msg.setText(reason)` is checked. Run against the file before the fix, the guard flags its line 136. +- **Result / numbers**: 2095 tests in 121 files. The full suite before the HAR tab change: 2075 passed, 15 skipped, startup tests exit 0; 442 passed on Python 3.10. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `test/test_utils/test_message_boxes_show_text.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c7b2a5f3..7fb504e7 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-222 | 2026-09-24 | Show a plugin's suffixes in the Run with box as text, and check every box by what it is assigned to | #fix #qt #plugin | [2026-09-b](2026-09-b.md) | | U-20260923-221 | 2026-09-24 | Keep the HAR tab's output the script of the file and target shown | #fix #har | [2026-09-b](2026-09-b.md) | | U-20260923-220 | 2026-09-24 | Let a backspace take back what an earlier read showed | #fix #output #done | [2026-09-b](2026-09-b.md) | | U-20260923-219 | 2026-09-24 | Keep a carriage return split from what follows it in the run window and the SSH terminal | #fix #output | [2026-09-b](2026-09-b.md) | @@ -303,4 +304,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 21 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 22 | diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index fad72650..b65d347e 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -132,12 +132,13 @@ def run_current_file_with(main_window: PyBreezeMainWindow, run_config: dict) -> msg = QMessageBox(main_window) msg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) msg.setWindowTitle(language_wrapper.language_word_dict.get("run_with_menu_label")) - msg.setText( + # The suffixes are the plugin's and the file's: text, not markup + msg.setText(as_text( language_wrapper.language_word_dict.get("run_with_suffix_mismatch").format( suffix=suffix, expected=", ".join(supported), ) - ) + )) msg.exec() return diff --git a/test/test_utils/test_message_boxes_show_text.py b/test/test_utils/test_message_boxes_show_text.py index 27506748..36b1b42a 100644 --- a/test/test_utils/test_message_boxes_show_text.py +++ b/test/test_utils/test_message_boxes_show_text.py @@ -27,18 +27,36 @@ def _is_plain(node: ast.AST) -> bool: return isinstance(func, ast.Attribute) and func.attr == "get" +def _box_names(tree: ast.AST) -> set[str]: + """What a ``QMessageBox(...)`` is assigned to: ``msg`` was not seen as a box.""" + names = set() + for node in ast.walk(tree): + if (isinstance(node, ast.Assign) and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) and node.value.func.id == "QMessageBox"): + names.update(ast.unparse(target) for target in node.targets) + return names + + def _box_texts(tree: ast.AST): + boxes = _box_names(tree) for node in ast.walk(tree): if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): continue func = node.func + receiver = ast.unparse(func.value) if (func.attr in _STATIC_BOXES and isinstance(func.value, ast.Name) and func.value.id == "QMessageBox" and len(node.args) >= 3): yield node.args[2] - elif func.attr in _BOX_TEXT_SETTERS and "box" in ast.unparse(func.value).lower() and node.args: + elif func.attr in _BOX_TEXT_SETTERS and node.args and (receiver in boxes or "box" in receiver.lower()): yield node.args[0] +def test_a_box_held_by_any_name_is_checked(): + tree = ast.parse("msg = QMessageBox(parent)\nmsg.setText(reason)\n") + + assert [ast.unparse(text) for text in _box_texts(tree)] == ["reason"] + + def test_every_message_box_text_is_plain_or_goes_through_as_text(): unsafe = [] for path in Path(pybreeze.__file__).parent.rglob("*.py"): From aeeb6f01efbc57661f7efe2a687626efa99c7cb6 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:40:17 +0800 Subject: [PATCH 261/312] Percent-encode what a text box would change when the URL Builder builds a URL --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/url_tools/url_convert.py | 23 +++++++++++++++++++++-- test/test_utils/test_url_convert.py | 14 ++++++++++++++ 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 0124f02d..b4f30ea3 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2095 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2101 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 787b1268..71fbd5a3 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -208,3 +208,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2095 tests in 121 files. The full suite before the HAR tab change: 2075 passed, 15 skipped, startup tests exit 0; 442 passed on Python 3.10. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `test/test_utils/test_message_boxes_show_text.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-223 · 2026-09-24 · Percent-encode what a text box would change when the URL Builder builds a URL · #fix #url + +- **What**: JSON to URL encoded the query but wrote the path, fragment, host and user name as given. A fragment `\r` (or U+2029, U+FDD0, a `\r\n`) gave `http://h/p#\r`, which the output box showed, and Copy and Save wrote, as `#\n`: another URL than the one built. A space or a control character does not belong in a URL as it is anyway. +- **Fix**: `build_url` percent-encodes, as UTF-8, every control character (C0, DEL, C1), space, U+2028, U+2029, U+FDD0, U+FDD1 and lone surrogate wherever it is in the URL (`_encode_unwritable`). Anything else is written as given: `%20` already there stays, and `中文` in a fragment is not encoded. +- **Tests**: `test_url_convert.py` +6: `\r`, U+2029 and U+FDD0 in a fragment and a space and newline in a path are encoded; `中文` and an existing `%20` are left alone. Passes on 3.11 and 3.10. +- **Result / numbers**: 2101 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/url_tools/url_convert.py`, `test/test_utils/test_url_convert.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 7fb504e7..52aa09a2 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-223 | 2026-09-24 | Percent-encode what a text box would change when the URL Builder builds a URL | #fix #url | [2026-09-b](2026-09-b.md) | | U-20260923-222 | 2026-09-24 | Show a plugin's suffixes in the Run with box as text, and check every box by what it is assigned to | #fix #qt #plugin | [2026-09-b](2026-09-b.md) | | U-20260923-221 | 2026-09-24 | Keep the HAR tab's output the script of the file and target shown | #fix #har | [2026-09-b](2026-09-b.md) | | U-20260923-220 | 2026-09-24 | Let a backspace take back what an earlier read showed | #fix #output #done | [2026-09-b](2026-09-b.md) | @@ -304,4 +305,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 22 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 23 | diff --git a/pybreeze/utils/url_tools/url_convert.py b/pybreeze/utils/url_tools/url_convert.py index 11431a7d..a84218cf 100644 --- a/pybreeze/utils/url_tools/url_convert.py +++ b/pybreeze/utils/url_tools/url_convert.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import re from urllib.parse import SplitResult, urlsplit, urlunsplit from pybreeze.utils.exception.exception_tags import ( @@ -161,6 +162,8 @@ def build_url(components: dict) -> str: """Rebuild a URL from a components dict (the inverse of :func:`parse_url`). Missing parts default to empty, so a partial object still yields a URL. + Controls, spaces and the separators a text box turns into line breaks are + percent-encoded wherever they are; anything else is written as given. :param components: URL parts as produced by :func:`parse_url` :return: the assembled URL @@ -177,8 +180,24 @@ def build_url(components: dict) -> str: # urlunsplit writes no empty authority, so the path's first segment # became the host: http:////x came back as http://x head = f"{scheme}:" if scheme else "" - return f"{head}//{path}{urlunsplit(('', '', '', query, fragment))}" - return urlunsplit((scheme, netloc, path, query, fragment)) + return _encode_unwritable(f"{head}//{path}{urlunsplit(('', '', '', query, fragment))}") + return _encode_unwritable(urlunsplit((scheme, netloc, path, query, fragment))) + + +# Characters no URL holds as they are: controls, spaces, line and paragraph +# separators, the noncharacters Qt reads as line breaks, and lone surrogates +_UNWRITABLE = re.compile("[\x00-\x20\x7f-\x9f

﷐﷑\ud800-\udfff]") + + +def _encode_unwritable(url: str) -> str: + """*url* with :data:`_UNWRITABLE` characters percent-encoded (UTF-8), the rest as it is. + + The query is encoded already; a path, fragment or user name was written as + given, so ``#\\r`` came out as a URL with a carriage return in it, which + the output box then showed, copied and saved as ``#\\n``. + """ + return _UNWRITABLE.sub( + lambda match: "".join(f"%{byte:02X}" for byte in match.group().encode("utf-8", "surrogatepass")), url) def _part(components: dict, name: str) -> str: diff --git a/test/test_utils/test_url_convert.py b/test/test_utils/test_url_convert.py index b70c791c..93f82091 100644 --- a/test/test_utils/test_url_convert.py +++ b/test/test_utils/test_url_convert.py @@ -85,6 +85,20 @@ class TestRoundTrip: def test_parse_then_build_is_stable(self, url): assert build_url(parse_url(url)) == url + @pytest.mark.parametrize(("part", "value", "written"), [ + ("fragment", "\r", "http://h/p#%0D"), + ("fragment", "a
b", "http://h/p#a%E2%80%A9b"), + ("fragment", "﷐", "http://h/p#%EF%B7%90"), + ("path", "/a b\n", "http://h/a%20b%0A"), + ("fragment", "中文", "http://h/p#中文"), + ("fragment", "x%20y", "http://h/p#x%20y"), + ]) + def test_what_a_text_box_would_change_is_percent_encoded(self, part, value, written): + # The output box gave "#\r" back as "#\n": Copy and Save wrote another URL + components = {"scheme": "http", "host": "h", "path": "/p", part: value} + + assert build_url(components) == written + @pytest.mark.parametrize("url", ["http:////x", "http:////x/y?a=1#f", "////x", "file:////srv/share"]) def test_a_path_starting_with_two_slashes_does_not_become_the_host(self, url): # http:////x came back as http://x: the path's "x" was now the host From 85ec8360a2646ebf877afd055c5619000b2bdc15 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:41:47 +0800 Subject: [PATCH 262/312] Hand -b cookies to the header analyzer as the Cookie header curl sends --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 10 ++++++++++ docs/updates/README.md | 3 ++- .../pybreeze_ui/tools_gui/curl_import_gui.py | 19 +++++++++++++----- test/test_utils/test_curl_import_gui.py | 20 +++++++++++++++++++ 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index b4f30ea3..63e7cf6d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2101 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2103 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 71fbd5a3..5929ac7d 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -217,3 +217,13 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2101 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/utils/url_tools/url_convert.py`, `test/test_utils/test_url_convert.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-224 · 2026-09-24 · Hand -b cookies to the header analyzer as the Cookie header curl sends · #fix #curl + +- **What**: the cURL tab's Open headers in analyzer handed over `request.headers` only. Cookies given with `-b` are kept apart in `request.cookies`, so for `curl https://x -b 'c=3; d=4'` the analyzer never saw the `Cookie` header curl sends, and with no other header the button stayed disabled. +- **Checked first**: with both `-H 'Cookie: a=1'` and `-b c=3`, curl 8.7.1 sends only `Cookie: a=1` (it adds the `-b` string only when no custom `Cookie` header is given; seen against a local server), and the generated `requests` script does the same (`requests` writes its own `Cookie` only when none is given). So that case was right, and stays as it is. +- **Fix**: `open_headers_in_analyzer` adds `Cookie: name=value; ...` from `-b` when no `Cookie` header is given; the button is enabled when there are headers or cookies. +- **Tests**: `test_curl_import_gui.py` +2: `-b 'c=3; d=4'` alone enables the button and hands over `Cookie: c=3; d=4`; with `-H 'Cookie: a=1' -b 'c=3'` only `a=1` is handed over. +- **Result / numbers**: 2103 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py`, `test/test_utils/test_curl_import_gui.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 52aa09a2..ae648963 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-224 | 2026-09-24 | Hand -b cookies to the header analyzer as the Cookie header curl sends | #fix #curl | [2026-09-b](2026-09-b.md) | | U-20260923-223 | 2026-09-24 | Percent-encode what a text box would change when the URL Builder builds a URL | #fix #url | [2026-09-b](2026-09-b.md) | | U-20260923-222 | 2026-09-24 | Show a plugin's suffixes in the Run with box as text, and check every box by what it is assigned to | #fix #qt #plugin | [2026-09-b](2026-09-b.md) | | U-20260923-221 | 2026-09-24 | Keep the HAR tab's output the script of the file and target shown | #fix #har | [2026-09-b](2026-09-b.md) | @@ -305,4 +306,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 23 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 24 | diff --git a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py index b83d2e58..b8aa6f2f 100644 --- a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py @@ -22,6 +22,7 @@ from pybreeze.utils.curl_import.curl_parser import CurlRequest, parse_curl from pybreeze.utils.curl_import.script_templates import TEMPLATE_TARGETS, generate_template from pybreeze.utils.exception.exceptions import CurlParseException +from pybreeze.utils.header_tools.header_merge import stored_header_name from pybreeze.utils.logging.logger import pybreeze_logger # The single target that generates JSON rather than Python @@ -130,7 +131,7 @@ def convert(self) -> None: self._generated_code = code self._request = request self.open_url_button.setEnabled(bool(request.url)) - self.open_headers_button.setEnabled(bool(request.headers)) + self.open_headers_button.setEnabled(bool(request.headers or request.cookies)) self.output_edit.setPlainText(code) def open_url_in_builder(self) -> QWidget | None: @@ -147,11 +148,19 @@ def open_url_in_builder(self) -> QWidget | None: "extend_tools_menu_url_builder_tab_label") def open_headers_in_analyzer(self) -> QWidget | None: - """Open the command's headers in the header analyzer, already analysed.""" - if self._request is None or not self._request.headers: + """Open the command's headers in the header analyzer, already analysed. + + Cookies from ``-b`` go as the ``Cookie`` header they are sent as; curl + (and ``requests``) sends them only when no ``-H 'Cookie: ...'`` is given. + """ + if self._request is None: + return None + headers = dict(self._request.headers) + if self._request.cookies and stored_header_name(headers, "Cookie") is None: + headers["Cookie"] = "; ".join(f"{name}={value}" for name, value in self._request.cookies.items()) + if not headers: return None - block = "\n".join( - f"{name}: {value}" for name, value in self._request.headers.items()) + block = "\n".join(f"{name}: {value}" for name, value in headers.items()) return open_tool_tab( self._main_window, HeaderAnalyzerGUI(main_window=self._main_window, initial_headers=block), diff --git a/test/test_utils/test_curl_import_gui.py b/test/test_utils/test_curl_import_gui.py index 1e9c63ca..94638470 100644 --- a/test/test_utils/test_curl_import_gui.py +++ b/test/test_utils/test_curl_import_gui.py @@ -273,6 +273,26 @@ def test_open_headers_opens_prefilled_analyzer(self, widget_with_window): assert "Accept: application/json" in output assert "X-Trace: 1" in output + def test_cookies_from_b_go_as_the_cookie_header_they_are_sent_as(self, widget_with_window): + # They were left out: the analyzer never saw the Cookie header curl sends + gui, window = widget_with_window + gui.input_edit.setPlainText("curl https://x -b 'c=3; d=4'") + gui.convert() + + assert gui.open_headers_button.isEnabled() + gui.open_headers_in_analyzer() + assert "Cookie: c=3; d=4" in window.tab_widget.added[0][0].output_edit.toPlainText() + + def test_a_cookie_header_given_is_what_is_sent_and_shown(self, widget_with_window): + # curl (and requests) sends -b's cookies only when no Cookie header is given + gui, window = widget_with_window + gui.input_edit.setPlainText("curl https://x -H 'Cookie: a=1' -b 'c=3'") + gui.convert() + gui.open_headers_in_analyzer() + + output = window.tab_widget.added[0][0].output_edit.toPlainText() + assert "Cookie: a=1" in output and "c=3" not in output + def test_open_headers_before_convert_is_noop(self, widget_with_window): gui, window = widget_with_window assert gui.open_headers_in_analyzer() is None From ec789c39476bab3f92c9baadb9d4518f958e6c32 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:46:42 +0800 Subject: [PATCH 263/312] Draw the canvas where it is when the diagram is exported --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 ++++ docs/updates/README.md | 3 +- .../diagram_editor/diagram_editor_widget.py | 11 ++-- test/test_utils/test_diagram_editor_widget.py | 51 +++++++++++++++++++ 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 63e7cf6d..c9032df1 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2103 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2105 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 5929ac7d..3b6b5e24 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -227,3 +227,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2103 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py`, `test/test_utils/test_curl_import_gui.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-225 · 2026-09-24 · Draw the canvas where it is when the diagram is exported · #fix #diagram + +- **What**: PNG and SVG export scaled and moved the painter to the content's rect, then called `scene.render(painter, QRectF(), rect)`. A null target is the paint device's whole rect, taken in the already moved coordinates, so the scene was mapped a second time: a 200x100 red node at (500, 300) came out as a 63x63 red corner of a 578x378 PNG, and in the SVG it sat under a translate that put it outside the view box. A diagram drawn near the origin looked roughly right, which is why it went unnoticed. +- **Fix**: the painter is left as it is, and `render` gets the target explicitly: the image's rect for PNG (the 2x scale is in the image size), the view box for SVG. +- **Tests**: `test_diagram_editor_widget.py` +2, both failing before the fix: the PNG of a red node away from the origin is red at its middle, a quarter and three quarters across, and white in the margin; the SVG, rendered back, is red at its middle. +- **Result / numbers**: 2105 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_diagram_editor_widget.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index ae648963..6580af36 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-225 | 2026-09-24 | Draw the canvas where it is when the diagram is exported | #fix #diagram | [2026-09-b](2026-09-b.md) | | U-20260923-224 | 2026-09-24 | Hand -b cookies to the header analyzer as the Cookie header curl sends | #fix #curl | [2026-09-b](2026-09-b.md) | | U-20260923-223 | 2026-09-24 | Percent-encode what a text box would change when the URL Builder builds a URL | #fix #url | [2026-09-b](2026-09-b.md) | | U-20260923-222 | 2026-09-24 | Show a plugin's suffixes in the Run with box as text, and check every box by what it is assigned to | #fix #qt #plugin | [2026-09-b](2026-09-b.md) | @@ -306,4 +307,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 24 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 25 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 9b2a7fe0..22e5a4a3 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -558,10 +558,10 @@ def _export_png(self) -> None: image.fill(Qt.GlobalColor.white) painter = QPainter(image) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - painter.scale(scale, scale) - painter.translate(-rect.topLeft()) self._scene.clearSelection() - self._scene.render(painter, QRectF(), rect) + # render() maps the scene's rect onto the target itself: a painter + # scaled and moved as well put the drawing off to one side, shrunk + self._scene.render(painter, QRectF(0, 0, image.width(), image.height()), rect) painter.end() # Written beside the file and moved into place: a save that failed # part-way used to leave the previous export cut short @@ -595,8 +595,9 @@ def _write_svg(self, target: Path, rect: QRectF) -> None: if not painter.isActive(): raise OSError("could not open SVG for writing") painter.setRenderHint(QPainter.RenderHint.Antialiasing) - painter.translate(-rect.topLeft()) - self._scene.render(painter, QRectF(), rect) + # Onto the view box as it is: with the painter moved as well, the + # drawing sat outside it + self._scene.render(painter, QRectF(0, 0, rect.width(), rect.height()), rect) if not painter.end(): raise OSError("could not finish writing the SVG") diff --git a/test/test_utils/test_diagram_editor_widget.py b/test/test_utils/test_diagram_editor_widget.py index 07b1ef8a..1dd190d5 100644 --- a/test/test_utils/test_diagram_editor_widget.py +++ b/test/test_utils/test_diagram_editor_widget.py @@ -139,6 +139,57 @@ def test_an_svg_export_writes_an_svg(self, editor, tmp_path, monkeypatch): assert " 200 and QColor(image.pixel(x, y)).green() < 60 + for x, y in ((image.width() // 2, image.height() // 2), + (image.width() // 4, image.height() // 2), + (image.width() * 3 // 4, image.height() // 2))] + assert red == [True, True, True] + assert QColor(image.pixel(5, 5)).name() == "#ffffff" + + def test_an_svg_export_views_the_canvas_where_it_is(self, editor, tmp_path, monkeypatch): + # Rendered the same way, the node sat outside the SVG's view box + from PySide6.QtCore import QRectF + from PySide6.QtGui import QColor, QImage, QPainter + from PySide6.QtSvg import QSvgRenderer + + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + + target = tmp_path / "diagram.svg" + monkeypatch.setattr(diagram_editor_widget.QFileDialog, "getSaveFileName", + staticmethod(lambda *args, **kwargs: (str(target), ""))) + editor._scene.load_from_dict({"nodes": [{ + "id": 0, "x": 500, "y": 300, "w": 200, "h": 100, "text": "", + "shape": "RECTANGLE", "fill_color": "#ff0000", "border_color": "#ff0000"}]}) + + editor._export_svg() + + renderer = QSvgRenderer(str(target)) + image = QImage(renderer.defaultSize(), QImage.Format.Format_ARGB32) + image.fill(0xFFFFFFFF) + painter = QPainter(image) + renderer.render(painter, QRectF(0, 0, image.width(), image.height())) + painter.end() + middle = QColor(image.pixel(image.width() // 2, image.height() // 2)) + assert middle.red() > 200 and middle.green() < 60 + @pytest.mark.parametrize("kind", ["png", "svg"]) def test_an_export_that_fails_leaves_the_previous_one(self, editor, tmp_path, monkeypatch, kind): # It was written in place: a failure part-way left the last export cut short From 2334bd629265ee1b0a17f2ba127601df18a74b92 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:47:50 +0800 Subject: [PATCH 264/312] Scale a diagram image from the image as loaded, so resizing does not blur it --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../diagram_editor/diagram_items.py | 9 ++++++--- test/test_utils/test_diagram_images.py | 19 +++++++++++++++++++ 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index c9032df1..b26145ab 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2105 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2106 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 3b6b5e24..0aafa833 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -236,3 +236,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2105 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_diagram_editor_widget.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-226 · 2026-09-24 · Scale a diagram image from the image as loaded, so resizing does not blur it · #fix #diagram + +- **What**: `DiagramImage.set_size` (a drag on a resize handle) scaled `self._pix_item.pixmap()`, the copy already scaled for the current size, not the image as loaded. Dragged down and back up, a 400x400 image was 50x50 scaled back to 400x400: blurred on the canvas and in exports for good, while a save and reopen, or any undo, showed it sharp again. +- **Fix**: the item keeps the pixmap it was given (`_original`, shared with the scene's cache, no copy) and scales every size from it. +- **Tests**: `test_diagram_images.py` +1: a 400x400 checkerboard resized to 50 and back is pixel for pixel what it was; the same steps done the old way are not. +- **Result / numbers**: 2106 tests in 121 files. The full suite before the export change: 2088 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_images.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 6580af36..fef7cc54 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-226 | 2026-09-24 | Scale a diagram image from the image as loaded, so resizing does not blur it | #fix #diagram | [2026-09-b](2026-09-b.md) | | U-20260923-225 | 2026-09-24 | Draw the canvas where it is when the diagram is exported | #fix #diagram | [2026-09-b](2026-09-b.md) | | U-20260923-224 | 2026-09-24 | Hand -b cookies to the header analyzer as the Cookie header curl sends | #fix #curl | [2026-09-b](2026-09-b.md) | | U-20260923-223 | 2026-09-24 | Percent-encode what a text box would change when the URL Builder builds a URL | #fix #url | [2026-09-b](2026-09-b.md) | @@ -307,4 +308,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 25 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 26 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py index 87da198f..2f6dce3b 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_items.py @@ -813,6 +813,9 @@ def __init__( self._pix_item = QGraphicsPixmapItem(self) self._pix_item.setTransformationMode(Qt.TransformationMode.SmoothTransformation) self._pix_item.setCacheMode(QGraphicsItem.CacheMode.DeviceCoordinateCache) + # The image as loaded: every size is scaled from it. Scaled from the + # shown copy, a resize down and back up left it blurred for good + self._original: QPixmap | None = None if pixmap and not pixmap.isNull(): self._apply_pixmap(pixmap) @@ -826,6 +829,7 @@ def __init__( # --- pixmap --- def _apply_pixmap(self, pixmap: QPixmap) -> None: + self._original = pixmap scaled = pixmap.scaled( int(self.img_w), int(self.img_h), Qt.AspectRatioMode.KeepAspectRatio, @@ -859,9 +863,8 @@ def set_size(self, w: float, h: float) -> None: self.prepareGeometryChange() self.img_w, self.img_h = w, h self.setRect(0, 0, w, h) - pix = self._pix_item.pixmap() - if pix and not pix.isNull(): - self._apply_pixmap(QPixmap(pix)) + if self._original is not None: + self._apply_pixmap(self._original) self._center_label() self._update_handles() diff --git a/test/test_utils/test_diagram_images.py b/test/test_utils/test_diagram_images.py index bb7334dc..64eaa852 100644 --- a/test/test_utils/test_diagram_images.py +++ b/test/test_utils/test_diagram_images.py @@ -334,3 +334,22 @@ def test_an_image_resizes_by_its_corner_handle(app): assert (image.img_w, image.img_h) == (150, 130) assert scene.undo_stack.count() == 1 + + +def test_an_image_resized_down_and_back_is_as_sharp_as_before(app): + # Each resize scaled the copy on show: 400 -> 50 -> 400 left it blurred + from PySide6.QtGui import QPixmap + + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage + + checkers = QImage(400, 400, QImage.Format.Format_RGB32) + for y in range(400): + for x in range(400): + checkers.setPixelColor(x, y, QColor("black") if (x // 20 + y // 20) % 2 else QColor("white")) + item = DiagramImage(0, 0, 400, 400, pixmap=QPixmap.fromImage(checkers)) + before = item._pix_item.pixmap().toImage() + + item.set_size(50, 50) + item.set_size(400, 400) + + assert item._pix_item.pixmap().toImage() == before From 9d2f5f14f12697784fef54ff235646336734f472 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:50:26 +0800 Subject: [PATCH 265/312] Leave a redirect's body and Location unread in the AI panels and the image download --- CLAUDE.md | 2 +- architecture_explore.md | 6 +-- docs/updates/2026-09-b.md | 12 ++++++ docs/updates/README.md | 3 +- .../diagram_editor/diagram_net_utils.py | 7 +++- pybreeze/utils/network/public_http.py | 23 ++++++++++- test/test_utils/test_public_http.py | 38 +++++++++++++++++++ 7 files changed, 83 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 91e95c25..47ed005e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ ruff check pybreeze/ # before committing non-trivia 1. `http://` / `https://` only — block `file://`, `ftp://`, `data:`, `gopher://` 2. Resolve the hostname and reject private / loopback / link-local / reserved IPs 3. Enforce timeouts (15 s downloads, 30 s API calls) and response size caps (20 MB binary). A read timeout restarts with every byte, so a request that may run long also gets an overall bound: `public_http.overall_deadline()` around the request (image downloads: 120 s) and `read_capped_text`'s `max_seconds` -4. `allow_redirects=False`, or re-validate every redirect target +4. `allow_redirects=False`, or re-validate every redirect target. `public_session()` follows no redirect and leaves a 3xx unread whatever `allow_redirects` says (`requests` otherwise reads its whole body and parses its `Location` to prepare `Response.next`) 5. Connect only to the address checked: send through `public_session()` (requests) or `PublicHTTPHandler` / `PublicHTTPSHandler` (urllib) from `utils/network/public_http.py`, which check again as they connect and connect only to the addresses checked (each in turn), so a name that resolves differently after validation (DNS rebinding) gets nowhere private. `test_http_goes_through_public_connections.py` fails on a direct `requests.*` or `urlopen` call Reference implementations: `utils/network/url_validation.py` (`validate_url`), `utils/network/public_http.py` (`public_session`), `utils/network/http_client.py` (`read_capped_text`), `diagram_editor/diagram_net_utils.py` (`safe_download_image`). Never pass a user URL to `urlopen()` / `requests.*` unvalidated, and never set `verify=False`. diff --git a/architecture_explore.md b/architecture_explore.md index b26145ab..6cf6d14f 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -248,7 +248,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `diagram_property_panel.py` (453) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單;每記一步 undo(場景的 `recorded`)就重新整理(在畫布上拖把手改大小時選取沒變);寬、高各自只改自己那一邊;數字欄位不追鍵盤、輸入完才套用 | | `diagram_view.py` (195) | `QGraphicsView`:滾輪與按鈕縮放都經 `_step_zoom()`(有上下界,界外時仍可往界內走;橫向滾輪不縮放),`fit()` 把「符合視窗」夾在上下界內、中鍵平移、`drawBackground` 畫格線 | | `diagram_commands.py` (48) | `DiagramSnapshotCommand(QUndoCommand)` — 快照式 undo,存變更前後完整場景狀態;有 `merge_key` 的連續步驟(同一個項目的同一個屬性:大小、字級、線寬)合併成一步(`id()` / `mergeWith`) | -| `diagram_net_utils.py` (112) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗、`_OPENER` 用 `PublicHTTPHandler` / `PublicHTTPSHandler`(連線當下再檢查一次並只連到那個位址)、20 MB 大小上限、每次等資料 15 秒 timeout,整個下載另有 `overall_deadline(DOWNLOAD_DEADLINE_SECONDS)` 120 秒上限(慢慢送位元組的伺服器原本可以一直卡住下載執行緒) | +| `diagram_net_utils.py` (112) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗(驗過就關掉轉址回應,urllib 不會把轉址的內容整個讀完)、`_OPENER` 用 `PublicHTTPHandler` / `PublicHTTPSHandler`(連線當下再檢查一次並只連到那個位址)、20 MB 大小上限、每次等資料 15 秒 timeout,整個下載另有 `overall_deadline(DOWNLOAD_DEADLINE_SECONDS)` 120 秒上限(慢慢送位元組的伺服器原本可以一直卡住下載執行緒) | `diagram_net_utils` 是 CLAUDE.md 指定的網路安全參考實作,其他 HTTP 呼叫端則統一走 `utils/network/url_validation.py` 驗證、經 `utils/network/public_http.py` 的 `public_session()` 送出。 @@ -344,7 +344,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | | `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | -| `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`overall_deadline(seconds)`:這個執行緒在區塊內的請求總共最多這麼久,釘住的連線等回應時把 socket 登記上去,時間到就 shutdown,丟 `ReadTimeout`(讀取逾時每來一個位元組就重算,慢慢送標頭的伺服器原本可以一直拖);AI 審查、Skill、CoT 每一步都包在 `overall_deadline(DEFAULT_MAX_READ_SECONDS)` 裡。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | +| `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`_NoRedirectSession`:不跟也不準備轉址,3xx 原封不讀地回來;`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`overall_deadline(seconds)`:這個執行緒在區塊內的請求總共最多這麼久,釘住的連線等回應時把 socket 登記上去,時間到就 shutdown,丟 `ReadTimeout`(讀取逾時每來一個位元組就重算,慢慢送標頭的伺服器原本可以一直拖);AI 審查、Skill、CoT 每一步都包在 `overall_deadline(DEFAULT_MAX_READ_SECONDS)` 裡。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),時間到由 `_Watchdog` 關掉連線(urllib3 的 `HTTPResponse.shutdown()` 能中斷別的執行緒上正在等的讀取),丟 `ReadTimeout`:讀取逾時只管每一塊之間,一次送一個位元組的伺服器可以一直拖下去;狀態列與標頭由呼叫端的 `public_http.overall_deadline()` 涵蓋)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | | `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`request_body.py` 的 `form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣),和其他 `-d` 片段照命令列的順序接起來(`data_file_positions`);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別(`body_kind()`:JSON 物件要能原樣送回才走 `json=`,重複的 key、float 裝不下的數字、`NaN`、巢狀超過 100 層都照原字串送);`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | | `har_import/` | `har_parser.py`(354) HAR → `CurlRequest`(重用 curl 那套 codegen;建不出請求的 entry 跳過,同名 cookie 改走 `Cookie` header);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2106 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2109 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 0aafa833..18614c3a 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -245,3 +245,15 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2106 tests in 121 files. The full suite before the export change: 2088 passed, 15 skipped, startup tests exit 0. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_images.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-227 · 2026-09-24 · Leave a redirect's body and Location unread in the AI panels and the image download · #fix #network #security + +- **What**: the AI panels (CoT review, Skills, AI code review) send with `allow_redirects=False`, but `requests`' `Session.send` still prepares the next request for `Response.next`, and doing so reads the redirect's whole body and parses its `Location`. + - A `Location: http://[bad/x` raised `ValueError`, which none of the three threads catch: the Skills panel stayed on "Generating" for good, the review panel showed only its first line, and the CoT chain stopped half-way without a word. + - A 302 with a 40 MB body was read whole before `read_capped_text` ever ran; only the overall deadline bounded it. + - The diagram's image download has the same gap in urllib: after `_ValidatingRedirectHandler` validates a hop, `http_error_302` reads the redirect's whole body before following it, past `MAX_DOWNLOAD_BYTES`. +- **Fix**: `public_session()` is a `_NoRedirectSession`, whose `resolve_redirects` yields nothing, so a 3xx comes back unread whatever `allow_redirects` says (nothing here follows one; Network rule 4). `_ValidatingRedirectHandler.redirect_request` closes the redirect response once the hop is validated, so the read after it gets nothing. +- **Tests**: `test_public_http.py` +3, against local servers: a 302 claiming 1 GB with `Location: http://[bad/x` or a good one comes back as a 302, `next` empty, body unread; the image opener follows a 302 claiming 1 GB to the image. Done the old way, the first raises `ValueError` and the second `IncompleteRead`. +- **Result / numbers**: 2109 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/network/public_http.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py`, `test/test_utils/test_public_http.py`, `CLAUDE.md` (Network rule 4), `architecture_explore.md` §7, §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index fef7cc54..ccc17272 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-227 | 2026-09-24 | Leave a redirect's body and Location unread in the AI panels and the image download | #fix #network #security | [2026-09-b](2026-09-b.md) | | U-20260923-226 | 2026-09-24 | Scale a diagram image from the image as loaded, so resizing does not blur it | #fix #diagram | [2026-09-b](2026-09-b.md) | | U-20260923-225 | 2026-09-24 | Draw the canvas where it is when the diagram is exported | #fix #diagram | [2026-09-b](2026-09-b.md) | | U-20260923-224 | 2026-09-24 | Hand -b cookies to the header analyzer as the Cookie header curl sends | #fix #curl | [2026-09-b](2026-09-b.md) | @@ -308,4 +309,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 26 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 27 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py index 6e836bf1..3488a07b 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py @@ -43,7 +43,12 @@ class _ValidatingRedirectHandler(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): _validate_url(newurl) - return super().redirect_request(req, fp, code, msg, headers, newurl) + new = super().redirect_request(req, fp, code, msg, headers, newurl) + if new is not None: + # Closed unread: http_error_302 reads the redirect's whole body + # before following it, past MAX_DOWNLOAD_BYTES + fp.close() + return new # The HTTP handlers connect only to the address they check as they connect, diff --git a/pybreeze/utils/network/public_http.py b/pybreeze/utils/network/public_http.py index afb456b1..44c7451c 100644 --- a/pybreeze/utils/network/public_http.py +++ b/pybreeze/utils/network/public_http.py @@ -185,9 +185,28 @@ def init_poolmanager(self, *args: Any, **kwargs: Any) -> None: } +class _NoRedirectSession(requests.Session): + """A session that never looks at a redirect: its 3xx response comes back as it is. + + With ``allow_redirects=False`` ``Session.send`` still prepares the next + request for ``Response.next``, and doing so read the redirect's whole body + (past every size cap) and parsed its ``Location``, which raised + ``ValueError`` out of the panels for ``http://[bad``. Nothing here follows + a redirect anyway (Network rule 4). + """ + + def resolve_redirects(self, *args: Any, **kwargs: Any) -> Iterator[Any]: + """No redirect is followed or prepared.""" + return iter(()) + + def public_session() -> requests.Session: - """A ``requests.Session`` for user-supplied URLs; still pass each URL through ``validate_url``.""" - session = requests.Session() + """A ``requests.Session`` for user-supplied URLs; still pass each URL through ``validate_url``. + + It follows no redirect, whatever ``allow_redirects`` says: a 3xx response + is returned unread. + """ + session = _NoRedirectSession() adapter = PublicAddressAdapter() session.mount("http://", adapter) session.mount("https://", adapter) diff --git a/test/test_utils/test_public_http.py b/test/test_utils/test_public_http.py index aac01ba4..d5afb912 100644 --- a/test/test_utils/test_public_http.py +++ b/test/test_utils/test_public_http.py @@ -357,3 +357,41 @@ def serve() -> None: stop.set() server.close() thread.join(5) + + +class TestARedirectIsNotLookedInto: + """Nothing follows a redirect through these; its body and Location stay unread.""" + + @pytest.mark.parametrize("location", ["http://[bad/x", "http://elsewhere.test/"]) + def test_the_session_hands_the_redirect_back_unread(self, dns, loopback_allowed, location): + # Session.send prepared Response.next even with allow_redirects=False: + # it read the whole body and parsed Location, raising ValueError for [bad + server = _Listener( + f"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 1000000000\r\n" + f"Connection: close\r\n\r\npartial".encode()) + try: + with _session() as session: + response = session.get(f"http://service.test:{server.port}/", timeout=(3, 3), + allow_redirects=False, stream=True) + + assert response.status_code == 302 + assert response.next is None + assert not response._content_consumed + response.close() + finally: + server.close() + + def test_the_image_opener_does_not_read_the_redirect_body(self, dns, loopback_allowed): + # http_error_302 read the redirect's whole body before following it + image = _Listener(b"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: 3\r\n" + b"Connection: close\r\n\r\nPNG") + redirect = _Listener( + f"HTTP/1.1 302 Found\r\nLocation: http://service.test:{image.port}/i.png\r\n" + f"Content-Length: 1000000000\r\nConnection: close\r\n\r\npartial".encode()) + try: + data = diagram_net_utils.safe_download_image(f"http://service.test:{redirect.port}/") + + assert data == b"PNG" + finally: + redirect.close() + image.close() From c1792fb8108eac756e9d6b28f9722a75e814c6e3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 08:59:23 +0800 Subject: [PATCH 266/312] Give pip the prthinker source folder as a path, not a name --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/extend/prthinker_extend/prthinker_setting.py | 5 ++++- test/test_utils/test_prthinker_setting.py | 9 +++++++++ 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 6cf6d14f..6d38cb3b 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2109 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2110 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 18614c3a..bac5eb4a 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -257,3 +257,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2109 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/utils/network/public_http.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py`, `test/test_utils/test_public_http.py`, `CLAUDE.md` (Network rule 4), `architecture_explore.md` §7, §12, §18 - **Open items**: none. + +## U-20260923-228 · 2026-09-24 · Give pip the prthinker source folder as a path, not a name · #fix #prthinker + +- **What**: `install_target` accepted a relative source folder typed in the settings (`prthinker`, checked against the working directory) and gave pip `prthinker[runner]`. With no path separator in it, pip reads that as a requirement for the package on PyPI, not as the folder: the Install action fetched `prthinker` from PyPI (or failed), and a project published under that name would have been installed in the user's source's place. An absolute path is read as a folder (`file:///...`). +- **Fix**: the folder goes to pip as `path.absolute()`. +- **Tests**: `test_prthinker_setting.py` +1: with the working directory holding a `prthinker` source folder, `install_target("prthinker")` gives that folder's absolute path with `[runner]`. +- **Result / numbers**: 2110 tests in 121 files. The full suite before this change: 2094 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index ccc17272..31fc971b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-228 | 2026-09-24 | Give pip the prthinker source folder as a path, not a name | #fix #prthinker | [2026-09-b](2026-09-b.md) | | U-20260923-227 | 2026-09-24 | Leave a redirect's body and Location unread in the AI panels and the image download | #fix #network #security | [2026-09-b](2026-09-b.md) | | U-20260923-226 | 2026-09-24 | Scale a diagram image from the image as loaded, so resizing does not blur it | #fix #diagram | [2026-09-b](2026-09-b.md) | | U-20260923-225 | 2026-09-24 | Draw the canvas where it is when the diagram is exported | #fix #diagram | [2026-09-b](2026-09-b.md) | @@ -309,4 +310,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 27 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 28 | diff --git a/pybreeze/extend/prthinker_extend/prthinker_setting.py b/pybreeze/extend/prthinker_extend/prthinker_setting.py index ef613ca6..67269a04 100644 --- a/pybreeze/extend/prthinker_extend/prthinker_setting.py +++ b/pybreeze/extend/prthinker_extend/prthinker_setting.py @@ -351,7 +351,10 @@ def install_target(source_path: str) -> str: path = Path(source_path.strip()) if source_path.strip() else None if path is None or not _is_prthinker_source(path): return "" - return f"{path}[{INSTALL_EXTRAS}]" + # 絕對路徑:pip 把沒有路徑分隔字元的 prthinker[runner] 當成 PyPI 上的套件名 + # Absolute: pip takes "prthinker[runner]", with no separator in it, for a + # package name on PyPI and installs that instead of the folder + return f"{path.absolute()}[{INSTALL_EXTRAS}]" def _is_prthinker_source(folder: Path) -> bool: diff --git a/test/test_utils/test_prthinker_setting.py b/test/test_utils/test_prthinker_setting.py index 53e84761..80f1058a 100644 --- a/test/test_utils/test_prthinker_setting.py +++ b/test/test_utils/test_prthinker_setting.py @@ -279,6 +279,15 @@ def test_any_other_folder_is_no_target(self, tmp_path): def test_nothing_chosen_is_no_target(self): assert install_target("") == "" + def test_a_relative_folder_is_given_to_pip_as_a_path(self, tmp_path, monkeypatch): + # "prthinker[runner]" has no separator, and pip took it for the PyPI package + folder = tmp_path / "prthinker" + folder.mkdir() + self._source(folder) + monkeypatch.chdir(tmp_path) + + assert install_target("prthinker") == f"{tmp_path / 'prthinker'}[{INSTALL_EXTRAS}]" + def test_a_path_that_is_not_a_folder_is_no_target(self, tmp_path): a_file = tmp_path / "pyproject.toml" a_file.write_text("", encoding="utf-8") From 2d4b5d1af077c5638ba3792f80d18618211d10fd Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 09:02:01 +0800 Subject: [PATCH 267/312] Run with the IDE's own interpreter when no venv is found --- README.md | 2 +- architecture_explore.md | 4 +-- docs/updates/2026-09-b.md | 9 +++++ docs/updates/README.md | 3 +- .../python_task_process_manager.py | 21 ++++++++++-- test/test_utils/test_build_task_process.py | 33 +++++++++++++++++-- test/test_utils/test_run_output.py | 1 + test/test_utils/test_task_manager_venv.py | 6 +++- 8 files changed, 69 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 18fa93ca..0ddebb38 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Each module gets the same menu shape: **Run** (single script, batch directory, w - **Script execution** — single or batch; action files are passed by path, and a script from the tab in front that is too long for a Windows command line (~32 KB) goes through a temporary file - **Report generation** — HTML / JSON / XML after a run, with optional email delivery - **Integrated JupyterLab** — launches as a tab, installing JupyterLab into the project venv if it is missing -- **Virtual environment awareness** — `venv/` and `.venv/` are detected and used automatically +- **Virtual environment awareness** — `venv/` and `.venv/` are detected and used automatically; without one, scripts run on the interpreter the IDE runs on --- diff --git a/architecture_explore.md b/architecture_explore.md index 6d38cb3b..5d66a354 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -92,7 +92,7 @@ Template Method 定義的子行程生命週期: | 階段 | 做的事 | |---|---| -| 解譯器解析 | `renew_path()` → 執行視窗帶著 IDE 選定的直譯器(`python_compiler`,由 `build_task_process()` 從主視窗抄過來)就用它;沒選才用 `find_venv_path()` 找 `venv/`、`.venv/`,交給 `check_and_choose_venv()`;找不到時**不拋例外**,直接把錯誤寫進執行視窗並回傳 `False` | +| 解譯器解析 | `renew_path()` → 執行視窗帶著 IDE 選定的直譯器(`python_compiler`,由 `build_task_process()` 從主視窗抄過來)就用它;沒選才用 `default_interpreter()`:工作目錄有 `venv/`、`.venv/` 就交給 `check_and_choose_venv()`,沒有就用 IDE 自己的直譯器(`sys.executable`,和 JupyterLab 分頁一樣;PATH 上的 `python3` 在 Windows 常是 Store 的空殼,exit 9009 什麼也不說),只有打包版才去 PATH 找;找不到時**不拋例外**,直接把錯誤寫進執行視窗並回傳 `False` | | 啟動 | `subprocess.Popen(args, shell=False, stdin=DEVNULL, creationflags=CREATE_NO_WINDOW, env=PYTHONIOENCODING=...)`;`stdin` 不接 IDE 的主控台(腳本 `input()` 立刻拿到 EOF);`Popen` 丟 `OSError`(直譯器不見、命令列超過 Windows 上限)時寫進執行視窗並顯示,不從選單拋出 | | 讀取 | 兩條 daemon Thread 各自經 `queue_pump.read_stream_into_queue()` 對 stdout / stderr `read1()`(有多少讀多少,不等換行:沒換行的狀態列與用 `\r` 重畫的進度條才會即時出現;沒有 `read1` 的文字串流才逐行讀),原樣塞進 `Queue`(保留縮排、行尾與空行);**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | | 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則,交給 `CodeWindow.append_output()` | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2110 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2112 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index bac5eb4a..eea08f68 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -266,3 +266,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2110 tests in 121 files. The full suite before this change: 2094 passed, 15 skipped, startup tests exit 0. `ruff check` clean. - **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-229 · 2026-09-24 · Run with the IDE's own interpreter when no venv is found, not whatever python3 is on PATH · #fix #executor + +- **What**: with no interpreter chosen and no `venv`/`.venv` in the working folder, every automation run, prthinker review, TestPioneer run and pip install went to JEditor's last resort, `shutil.which("python3")` then `python`. On Windows that is often the Microsoft Store stub (`WindowsApps\\python3.EXE`, on this machine too), which exits 9009 and prints nothing: the run window said only "Task exit with code 9009", and an install gave no reason. A real interpreter found there seldom has the automation packages, which are the IDE's own dependencies, and pip installed into it rather than the one the IDE runs on. The JupyterLab tab already falls back to the IDE's own interpreter (`choose_python`). +- **Fix**: `default_interpreter()`: a `venv`/`.venv` in the working folder, else `sys.executable`; only a packaged build (`sys.frozen`, where `sys.executable` is the app) still searches PATH through `check_and_choose_venv`. `renew_path` uses it. +- **Tests**: `test_build_task_process.py` +2: with no venv the IDE's own interpreter runs it; a packaged build still looks on PATH. The three tests of "no Python found" now set up a packaged build, the only case left where it can happen, and the working-folder test gives a real folder. +- **Result / numbers**: 2112 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py`, `test/test_utils/test_build_task_process.py`, `test_task_manager_venv.py`, `test_run_output.py`, `README.md`, `architecture_explore.md` §4, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 31fc971b..b348ba8c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-229 | 2026-09-24 | Run with the IDE's own interpreter when no venv is found, not whatever python3 is on PATH | #fix #executor | [2026-09-b](2026-09-b.md) | | U-20260923-228 | 2026-09-24 | Give pip the prthinker source folder as a path, not a name | #fix #prthinker | [2026-09-b](2026-09-b.md) | | U-20260923-227 | 2026-09-24 | Leave a redirect's body and Location unread in the AI panels and the image download | #fix #network #security | [2026-09-b](2026-09-b.md) | | U-20260923-226 | 2026-09-24 | Scale a diagram image from the image as loaded, so resizing does not blur it | #fix #diagram | [2026-09-b](2026-09-b.md) | @@ -310,4 +311,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 28 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 29 | diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 4767b3cd..07e61a21 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -55,6 +55,24 @@ def find_venv_path() -> Path: return candidates[0] +def default_interpreter() -> str: + """The interpreter a run uses when none was chosen in the IDE. + + A ``venv`` or ``.venv`` in the working folder, else the IDE's own, as the + JupyterLab tab does (``choose_python``); only a packaged build, whose + ``sys.executable`` is the app, looks on PATH. PATH came before the IDE's + own: on Windows that is often the Microsoft Store's ``python3`` stub, which + exits 9009 printing nothing, and a real one there seldom has the + automation packages the IDE was installed with. + + :raises JEditorExecException: when a packaged build finds no Python + """ + venv_path = find_venv_path() + if venv_path.is_dir() or getattr(sys, "frozen", False): + return check_and_choose_venv(venv_path) + return sys.executable + + class TaskProcessManager: def __init__( self, @@ -91,9 +109,8 @@ def renew_path(self) -> bool: Python can be found, surfacing the error in the run window instead of crashing the menu callback.""" if self.main_window.python_compiler is None: - venv_path = find_venv_path() try: - self.compiler_path = check_and_choose_venv(venv_path) + self.compiler_path = default_interpreter() except JEditorExecException as error: pybreeze_logger.error("No Python interpreter found for run: %r", error) self.main_window.append_output( diff --git a/test/test_utils/test_build_task_process.py b/test/test_utils/test_build_task_process.py index 6368d4b7..7a3237ce 100644 --- a/test/test_utils/test_build_task_process.py +++ b/test/test_utils/test_build_task_process.py @@ -42,12 +42,12 @@ def test_the_interpreter_chosen_in_the_ide_is_used(self, qt_app): assert process.renew_path() is True assert process.compiler_path == "C:/envs/py312/python.exe" - def test_without_a_choice_the_working_directory_is_searched(self, qt_app, monkeypatch): + def test_without_a_choice_the_working_directory_is_searched(self, qt_app, monkeypatch, tmp_path): from pybreeze.extend.process_executor import python_task_process_manager as manager_module from pybreeze.extend.process_executor.process_executor_utils import build_task_process searched: list = [] - monkeypatch.setattr(manager_module, "find_venv_path", lambda: "venv-dir") + monkeypatch.setattr(manager_module, "find_venv_path", lambda: tmp_path) monkeypatch.setattr( manager_module, "check_and_choose_venv", lambda path: searched.append(path) or "found/python") @@ -55,9 +55,36 @@ def test_without_a_choice_the_working_directory_is_searched(self, qt_app, monkey process = build_task_process(MainWindow()) assert process.renew_path() is True - assert searched == ["venv-dir"] + assert searched == [tmp_path] assert process.compiler_path == "found/python" + def test_without_a_venv_the_ide_s_own_interpreter_runs_it(self, qt_app, monkeypatch, tmp_path): + # PATH came first: on Windows often the Store's python3 stub, exit 9009 and nothing said + import sys + + from pybreeze.extend.process_executor import python_task_process_manager as manager_module + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + monkeypatch.setattr(manager_module, "find_venv_path", lambda: tmp_path / "no-venv") + monkeypatch.setattr(manager_module, "check_and_choose_venv", lambda path: "from/PATH/python3") + + process = build_task_process(MainWindow()) + + assert process.renew_path() is True + assert process.compiler_path == sys.executable + + def test_a_packaged_ide_still_looks_on_path(self, qt_app, monkeypatch, tmp_path): + # Its sys.executable is the app, not a Python + import sys + + from pybreeze.extend.process_executor import python_task_process_manager as manager_module + + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(manager_module, "find_venv_path", lambda: tmp_path / "no-venv") + monkeypatch.setattr(manager_module, "check_and_choose_venv", lambda path: "from/PATH/python3") + + assert manager_module.default_interpreter() == "from/PATH/python3" + def test_the_run_window_is_kept_and_the_old_result_cleared(self, qt_app): from pybreeze.extend.process_executor.process_executor_utils import build_task_process diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py index a434f0a2..14d2c52e 100644 --- a/test/test_utils/test_run_output.py +++ b/test/test_utils/test_run_output.py @@ -242,6 +242,7 @@ def test_no_interpreter_is_reported_not_raised(self, qt_app, monkeypatch): def no_python(_path): raise JEditorExecException("no python interpreter found") + monkeypatch.setattr(sys, "frozen", True, raising=False) # only a packaged build can find none monkeypatch.setattr(manager_module, "check_and_choose_venv", no_python) main_window = MainWindow() diff --git a/test/test_utils/test_task_manager_venv.py b/test/test_utils/test_task_manager_venv.py index ff343bf3..98cf5b44 100644 --- a/test/test_utils/test_task_manager_venv.py +++ b/test/test_utils/test_task_manager_venv.py @@ -1,6 +1,8 @@ from __future__ import annotations import os +import sys +from pathlib import Path os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -36,7 +38,9 @@ def test_returns_false_without_crashing(self, qt_app, monkeypatch): manager = TaskProcessManager.__new__(TaskProcessManager) manager.main_window = window - monkeypatch.setattr(mod, "find_venv_path", lambda: "ignored") + # Only a packaged build, with no venv, can find no Python at all + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(mod, "find_venv_path", lambda: Path("ignored")) monkeypatch.setattr(mod, "check_and_choose_venv", _raise_no_python) # Must report (not raise) so the run menu callback does not crash. From 86f67d156dcf9bdd99a743d268bab29073c34f77 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 09:02:09 +0800 Subject: [PATCH 268/312] Fix a doubled backslash in the update log --- docs/updates/2026-09-b.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index eea08f68..d0ebc8d5 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -269,7 +269,7 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and ## U-20260923-229 · 2026-09-24 · Run with the IDE's own interpreter when no venv is found, not whatever python3 is on PATH · #fix #executor -- **What**: with no interpreter chosen and no `venv`/`.venv` in the working folder, every automation run, prthinker review, TestPioneer run and pip install went to JEditor's last resort, `shutil.which("python3")` then `python`. On Windows that is often the Microsoft Store stub (`WindowsApps\\python3.EXE`, on this machine too), which exits 9009 and prints nothing: the run window said only "Task exit with code 9009", and an install gave no reason. A real interpreter found there seldom has the automation packages, which are the IDE's own dependencies, and pip installed into it rather than the one the IDE runs on. The JupyterLab tab already falls back to the IDE's own interpreter (`choose_python`). +- **What**: with no interpreter chosen and no `venv`/`.venv` in the working folder, every automation run, prthinker review, TestPioneer run and pip install went to JEditor's last resort, `shutil.which("python3")` then `python`. On Windows that is often the Microsoft Store stub (`WindowsApps\python3.EXE`, on this machine too), which exits 9009 and prints nothing: the run window said only "Task exit with code 9009", and an install gave no reason. A real interpreter found there seldom has the automation packages, which are the IDE's own dependencies, and pip installed into it rather than the one the IDE runs on. The JupyterLab tab already falls back to the IDE's own interpreter (`choose_python`). - **Fix**: `default_interpreter()`: a `venv`/`.venv` in the working folder, else `sys.executable`; only a packaged build (`sys.frozen`, where `sys.executable` is the app) still searches PATH through `check_and_choose_venv`. `renew_path` uses it. - **Tests**: `test_build_task_process.py` +2: with no venv the IDE's own interpreter runs it; a packaged build still looks on PATH. The three tests of "no Python found" now set up a packaged build, the only case left where it can happen, and the working-folder test gives a real folder. - **Result / numbers**: 2112 tests in 121 files. `ruff check` clean. From af15fb4fa48aea253ff341def4934367d483126b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 09:02:48 +0800 Subject: [PATCH 269/312] Ask whether to require the TestPioneer that reads YAML as UTF-8 --- progress.md | 1 + 1 file changed, 1 insertion(+) diff --git a/progress.md b/progress.md index 789d773f..e156a699 100644 --- a/progress.md +++ b/progress.md @@ -16,3 +16,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#92** [DECIDE] The embedded JupyterLab has no token (`--ServerApp.token=`): the loopback bind keeps browsers and other machines out, but not other accounts on a shared machine (RDS, a lab Linux box), which get a kernel as the IDE user. Generate a token per launch and load the view with it? The Security › JupyterLab rule would change with it. - **#102** [BLOCKED] A report mail can wait forever: je_mail_thunder's `SMTPWrapper(host, port)` passes no timeout to `SMTP_SSL`, so a mail server that accepts the connection and then stalls holds the report-mail thread (`extend/mail_thunder_extend/mail_thunder_setting.py`, `send_report`) with no end, and the run window never says whether the report went. Needs a `timeout` argument in MailThunder (`je_mail_thunder/smtp/smtp_wrapper.py:20`); then pass 30 s here. - **#103** [BLOCKED] PyBreeze's automation keywords are never highlighted: `syntax_extend_package` registers them for `.json`, `.yml` and `.yaml` (`pybreeze_ui/syntax/syntax_extend.py`), but JEditor has built-in rules for those suffixes, so `CodeEditor.reset_highlighter` gets a `GenericHighlighter` from `highlighter_for`, and only `PythonHighlighter` reads the plugin registry (`je_editor/pyside_ui/code/syntax/generic_syntax.py:124`, `code_edit_plaintext.py:423-425` in `D:\Codes\JEDITOR` at d372c35). Needs JEditor's generic highlighter to add a registered language's words for the suffix; the `architecture.md` §6 contract changes with it. +- **#106** [DECIDE] `test_pioneer` is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), and before 0.1.34 it read a YAML file in the locale's encoding: on a Traditional Chinese Windows (cp950) TestPioneer "Run YAML" of a file with a non-ASCII character, which the editor saves as UTF-8, exits 1 with `UnicodeDecodeError` (`extend/process_executor/test_pioneer/test_pioneer_process_manager.py`). This repo's `.venv` has 0.1.30; 0.1.34 reads it as UTF-8. Should the dependencies require `test_pioneer>=0.1.34`? From 8960f959744ec79d799f8eee8706d1cf099111fb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 09:12:52 +0800 Subject: [PATCH 270/312] Keep difflib's junk heuristic on after trimming, so a repetitive diff stays quick --- architecture_explore.md | 4 ++-- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/diff_tools/text_diff.py | 14 +++++++------- test/test_utils/test_text_diff.py | 15 +++++++++++++++ 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 5d66a354..4e8e86ab 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -225,7 +225,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | | `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組。pattern 在另一個行程裡跑(`find_matches_bounded()`:從原始碼執行時是 `python -I -S -c` 跑一段只用標準函式庫的固定腳本,工作用 JSON 從 stdin 進、結果從 stdout 出,5 秒後 kill;打包版沒有直譯器可用,仍是 multiprocessing spawn),分頁用 `RegexMatchThread` 等它:`re` 開始比對後就停不下來,災難性回溯只有整個行程能停。spawn 會重新匯入啟動 IDE 的腳本,README 那種沒有 `__main__` 防護的腳本會每跑一次就再開一個 IDE。還在跑的 worker 記在 `_RUNNING`,分頁關閉時 `stop_running_workers()` 結束它(IDE 以 `os._exit` 結束,子行程不會跟著走);執行中不能存檔,列到 `MAX_MATCHES` 上限時會註明可能還有更多 | | `HttpStatusGUI` | `utils/http_reference/` | 狀態碼參考,可依碼前綴或描述搜尋 | -| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計,`compare_texts()` 只比對一次、兩者共用同一個 `SequenceMatcher`(`_TrimmedMatcher`:先把相同的開頭結尾放一邊,剩下不到 2,000 行時關掉 autojunk,長而重複的文字改一行就是一行;diff 照 `difflib.unified_diff` 的格式從它的 grouped opcodes 寫出),在 `DiffThread` 上算、不佔 UI 執行緒(4 萬行要四秒多),比對中按鈕停用、關閉時交給 `let_run_out()`。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | +| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計,`compare_texts()` 只比對一次、兩者共用同一個 `SequenceMatcher`(`_TrimmedMatcher`:先把相同的開頭結尾放一邊再比對中間,長而重複的文字改一行就是一行;autojunk 照 difflib 的預設,關掉的話重複的文字比對時間隨行數平方成長;diff 照 `difflib.unified_diff` 的格式從它的 grouped opcodes 寫出),在 `DiffThread` 上算、不佔 UI 執行緒(4 萬行要四秒多),比對中按鈕停用、關閉時交給 `let_run_out()`。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | | `JsonFormatGUI` | `utils/json_format/` | 美化 / 壓縮 / 驗證 | | `HeaderAnalyzerGUI` | `utils/header_tools/` | HTTP header 安全稽核(HSTS、CSP、CORS、Set-Cookie、banner…)| | `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT;`curl -i` 印出的多段回應(`100 Continue`、proxy 的 `Connection established`、`-L` 的轉址)取最後一段;只有一行又沒有狀態列就當 body | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2112 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2113 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index d0ebc8d5..7c8d97c3 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -275,3 +275,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2112 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py`, `test/test_utils/test_build_task_process.py`, `test_task_manager_venv.py`, `test_run_output.py`, `README.md`, `architecture_explore.md` §4, §18 - **Open items**: none. + +## U-20260923-230 · 2026-09-24 · Keep difflib's junk heuristic on for what is left after trimming, so a repetitive diff stays quick · #fix #diff #perf + +- **What**: U-20260923-213 set the unchanged head and tail aside and turned `SequenceMatcher`'s junk heuristic off when fewer than 2,000 lines were left to match. Without it, matching repetitive text grows with the square of its lines: 998 pairs of `}` and a changed line (1,996 lines) took 55.6 s, where `difflib` as it was takes 1 ms, and 2,002 lines took 5 ms: a cliff at the threshold. Found reviewing this round's own changes. +- **Fix**: the heuristic stays as `difflib` sets it (on from 200 lines). The trimming alone is what fixed the one-line change in a long repetitive text, and it still does. `_AUTOJUNK_FROM` is gone. The docstring now says that `ratio()` and `get_matching_blocks()` describe only what is left to match (nothing calls them). +- **Tests**: `test_text_diff.py` +1: those 1,996 lines compare in under 5 s. The property that the diff turns the left into the right and is never longer than `difflib`'s (300 examples) and the two one-changed-line cases still pass. +- **Result / numbers**: the worst case under 200 lines (198 alike) takes 0.04 s. 2113 tests in 121 files. The full suite before this change: 2097 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/utils/diff_tools/text_diff.py`, `test/test_utils/test_text_diff.py`, `architecture_explore.md` §6, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index b348ba8c..d0a87a7d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-230 | 2026-09-24 | Keep difflib's junk heuristic on for what is left after trimming, so a repetitive diff stays quick | #fix #diff #perf | [2026-09-b](2026-09-b.md) | | U-20260923-229 | 2026-09-24 | Run with the IDE's own interpreter when no venv is found, not whatever python3 is on PATH | #fix #executor | [2026-09-b](2026-09-b.md) | | U-20260923-228 | 2026-09-24 | Give pip the prthinker source folder as a path, not a name | #fix #prthinker | [2026-09-b](2026-09-b.md) | | U-20260923-227 | 2026-09-24 | Leave a redirect's body and Location unread in the AI panels and the image download | #fix #network #security | [2026-09-b](2026-09-b.md) | @@ -311,4 +312,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 29 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 30 | diff --git a/pybreeze/utils/diff_tools/text_diff.py b/pybreeze/utils/diff_tools/text_diff.py index 2552631e..97023988 100644 --- a/pybreeze/utils/diff_tools/text_diff.py +++ b/pybreeze/utils/diff_tools/text_diff.py @@ -18,8 +18,6 @@ _HEADER_LINES = 2 # diff's own note under a last line that has no newline _NO_NEWLINE_MARK = "\\ No newline at end of file" -# Lines left to match past which SequenceMatcher's junk heuristic stays on -_AUTOJUNK_FROM = 2000 @dataclass(frozen=True) @@ -85,9 +83,12 @@ class _TrimmedMatcher(difflib.SequenceMatcher): ``SequenceMatcher`` treats a line making up more than 1% of 200 or more as junk that cannot anchor a match, so in a long text full of ``}`` one changed line came out as hundreds removed and added. Trimmed, what is left - to match is usually short: under ``_AUTOJUNK_FROM`` lines the heuristic is - off, and it stays on above that, where matching without it grows with the - square of the lines (20,000 alike took a minute). + to match is usually short, under the 200 lines the heuristic starts at. + It is not turned off for what is left: without it, matching repetitive + text grows with the square of its lines (1,996 lines took 55 s). + + Only the opcodes cover both texts whole; ``ratio()`` and + ``get_matching_blocks()`` describe what is left to match. """ def __init__(self, left_lines: list[str], right_lines: list[str]) -> None: @@ -103,8 +104,7 @@ def __init__(self, left_lines: list[str], right_lines: list[str]) -> None: self._sizes = (len(left_lines), len(right_lines)) middle_left = left_lines[head:len(left_lines) - tail] middle_right = right_lines[head:len(right_lines) - tail] - super().__init__(None, middle_left, middle_right, - autojunk=max(len(middle_left), len(middle_right)) >= _AUTOJUNK_FROM) + super().__init__(None, middle_left, middle_right) def get_opcodes(self) -> list[tuple[str, int, int, int, int]]: """The opcodes over the whole two texts, the head and tail as equal blocks.""" diff --git a/test/test_utils/test_text_diff.py b/test/test_utils/test_text_diff.py index e17bb2fa..390101a1 100644 --- a/test/test_utils/test_text_diff.py +++ b/test/test_utils/test_text_diff.py @@ -197,3 +197,18 @@ def test_one_changed_line_in_a_long_repetitive_text_is_one_line(left, right): summary = compare_texts(left, right).summary assert (summary.added, summary.removed) == (1, 1) + + +def test_repetitive_text_just_under_2000_lines_is_quick(): + # The junk heuristic was turned off below 2,000 lines left to match: 55 s here + import time + + from pybreeze.utils.diff_tools.text_diff import compare_texts + + left = "".join(f"}}\na{i}\n" for i in range(998)) + right = "".join(f"}}\nb{i}\n" for i in range(998)) + started = time.monotonic() + + compare_texts(left, right) + + assert time.monotonic() - started < 5 From eb61bd0feb1002536388ebe505216df8ae70e4c1 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 09:13:29 +0800 Subject: [PATCH 271/312] Generate the HAR script again when going back from a target that could not carry it --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../pybreeze_ui/tools_gui/har_import_gui.py | 8 ++++++-- test/test_utils/test_har_import_gui.py | 19 +++++++++++++++++++ 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 4e8e86ab..f3b73f41 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2113 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2114 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 7c8d97c3..585ac11c 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -284,3 +284,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: the worst case under 200 lines (198 alike) takes 0.04 s. 2113 tests in 121 files. The full suite before this change: 2097 passed, 15 skipped, startup tests exit 0. `ruff check` clean. - **Files**: `pybreeze/utils/diff_tools/text_diff.py`, `test/test_utils/test_text_diff.py`, `architecture_explore.md` §6, §18 - **Open items**: none. + +## U-20260923-231 · 2026-09-24 · Generate the HAR script again when going back from a target that could not carry it · #fix #har + +- **What**: U-20260923-221 made a target change generate the HAR script again, but only while there was output (`_generated_code`). A target that cannot carry the requests (the APITestka action list and a recorded file upload) leaves none, so going back to one that can did nothing: the selector said `requests`, the output still showed the APITestka error, and Save stayed off. Found reviewing this round's own changes. +- **Fix**: `_regenerate` goes by what was generated from (`_generated_from`), which a failed target keeps. +- **Tests**: `test_har_import_gui.py` +1: an upload generated for `requests`, switched to the action list (no output), and back gives the same `requests` script, with Save on again. +- **Result / numbers**: 2114 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `test/test_utils/test_har_import_gui.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index d0a87a7d..81b77c7b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-231 | 2026-09-24 | Generate the HAR script again when going back from a target that could not carry it | #fix #har | [2026-09-b](2026-09-b.md) | | U-20260923-230 | 2026-09-24 | Keep difflib's junk heuristic on for what is left after trimming, so a repetitive diff stays quick | #fix #diff #perf | [2026-09-b](2026-09-b.md) | | U-20260923-229 | 2026-09-24 | Run with the IDE's own interpreter when no venv is found, not whatever python3 is on PATH | #fix #executor | [2026-09-b](2026-09-b.md) | | U-20260923-228 | 2026-09-24 | Give pip the prthinker source folder as a path, not a name | #fix #prthinker | [2026-09-b](2026-09-b.md) | @@ -312,4 +313,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 30 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 31 | diff --git a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py index 991bbb57..fe30c2c6 100644 --- a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py @@ -194,8 +194,12 @@ def selected_entries(self) -> list[HarEntry]: return [self._shown[row] for row in rows if 0 <= row < len(self._shown)] def _regenerate(self) -> None: - """Generate the output again for the target now chosen, if there is output.""" - if self._generated_code is not None: + """Generate the output again for the target now chosen, if something was generated. + + Also after a target that could not carry the requests: going back to + one that can left its error in the output. + """ + if self._generated_from: self._generate(self._generated_from, "har_import_empty_hint") def _generate(self, entries: list[HarEntry], empty_hint_key: str) -> None: diff --git a/test/test_utils/test_har_import_gui.py b/test/test_utils/test_har_import_gui.py index 3b095cbc..2ad9d2c2 100644 --- a/test/test_utils/test_har_import_gui.py +++ b/test/test_utils/test_har_import_gui.py @@ -257,3 +257,22 @@ def test_choosing_a_target_before_generating_generates_nothing(self, loaded): loaded.target_select.setCurrentIndex(loaded.target_select.findData("pytest")) assert loaded.output_edit.toPlainText() == "" + + def test_going_back_from_a_target_that_failed_generates_again(self, widget): + # A recorded upload: the APITestka action list cannot carry it + upload = json.dumps({"log": {"entries": [{ + "request": {"method": "POST", "url": "https://api.example.com/up", "headers": [], + "postData": {"mimeType": "multipart/form-data", + "params": [{"name": "f", "fileName": "a.txt"}]}}, + "response": {"status": 200, "content": {"mimeType": "application/json"}}}]}}) + widget.load_text(upload) + widget.api_only_check.setChecked(False) + widget.generate_all() + requests_script = widget.output_edit.toPlainText() + + widget.target_select.setCurrentIndex(widget.target_select.findData("apitestka_action")) + assert not widget.actions._has_output() + widget.target_select.setCurrentIndex(widget.target_select.findData("requests")) + + assert widget.output_edit.toPlainText() == requests_script + assert widget.actions._has_output() From 62b604b7bbf2dfa6544dbdfa1ed3c67af6044150 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 09:14:50 +0800 Subject: [PATCH 272/312] Call an encrypted SSH key of a type paramiko cannot load unsupported --- architecture_explore.md | 4 +-- docs/updates/2026-09-b.md | 9 +++++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_key_loader.py | 35 +++++++++++++++++-- test/test_utils/test_ssh_security.py | 24 +++++++++++++ 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index f3b73f41..7a3ed930 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -302,7 +302,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_file_viewer_widget.py` (691) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | -| `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密)還是不支援的私鑰 | +| `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密,而且給的密語解不開它;用 `cryptography` 試解)還是不支援的私鑰(例如加密的 DSA) | ### `url/ai_code_review_gui.py` @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2114 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2115 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 585ac11c..7ce49b18 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -293,3 +293,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2114 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `test/test_utils/test_har_import_gui.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-232 · 2026-09-24 · Call an encrypted SSH key of a type paramiko cannot load unsupported, not its passphrase wrong · #fix #ssh + +- **What**: U-20260923-217 took a key file that asks for a passphrase as proof that a failed load was the passphrase's fault. An encrypted key in OpenSSH format asks before paramiko looks at its type, so one paramiko 4 cannot load (DSA; FIDO `sk-` keys likely too) was reported as having a wrong passphrase even with the right one, where it used to say "unsupported key". Found reviewing this round's own changes. +- **Fix**: with a passphrase given, `unloadable_key_reason` asks `cryptography` whether it decrypts the file (`load_ssh_private_key`, then `load_pem_private_key`; `UnsupportedAlgorithm` counts as decrypted): if it does, the key is unsupported; if not, the passphrase is wrong. `cryptography` is paramiko's own dependency, so it is there wherever the SSH tabs are. Its DSA deprecation warning is silenced for this check. +- **Tests**: `test_ssh_security.py` +1: an encrypted DSA key in OpenSSH format does not load, is unsupported with the right passphrase and has a wrong passphrase with another. The four RSA, Ed25519 and ECDSA cases still report a missing and a wrong passphrase. +- **Result / numbers**: 2115 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py`, `test/test_utils/test_ssh_security.py`, `architecture_explore.md` §9, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 81b77c7b..9d46492e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-232 | 2026-09-24 | Call an encrypted SSH key of a type paramiko cannot load unsupported, not its passphrase wrong | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260923-231 | 2026-09-24 | Generate the HAR script again when going back from a target that could not carry it | #fix #har | [2026-09-b](2026-09-b.md) | | U-20260923-230 | 2026-09-24 | Keep difflib's junk heuristic on for what is left after trimming, so a repetitive diff stays quick | #fix #diff #perf | [2026-09-b](2026-09-b.md) | | U-20260923-229 | 2026-09-24 | Run with the IDE's own interpreter when no venv is found, not whatever python3 is on PATH | #fix #executor | [2026-09-b](2026-09-b.md) | @@ -313,4 +314,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 31 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 32 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py index e7ded7f7..8c2069be 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py @@ -6,7 +6,13 @@ """ from __future__ import annotations +import warnings +from pathlib import Path + import paramiko +from cryptography.exceptions import UnsupportedAlgorithm +from cryptography.hazmat.primitives import serialization +from cryptography.utils import CryptographyDeprecationWarning from pybreeze.utils.logging.logger import pybreeze_logger @@ -42,13 +48,38 @@ def unloadable_key_reason(key_path: str, password: str) -> str: A key file that is encrypted is one some key class asks a passphrase for when given none; then the passphrase was missing or wrong, not the key - unsupported, which is all the message used to say. + unsupported, which is all the message used to say. A passphrase given is + wrong only when it does not decrypt the file: an encrypted key of a type + paramiko cannot load (DSA, a FIDO key) asks for one too, and with the + right one it is still unsupported. """ for key_cls in _KEY_CLASSES: try: key_cls.from_private_key_file(key_path, None) except paramiko.PasswordRequiredException: - return PASSPHRASE_WRONG if password else PASSPHRASE_NEEDED + if not password: + return PASSPHRASE_NEEDED + return UNSUPPORTED_KEY if _decrypts(key_path, password) else PASSPHRASE_WRONG except (paramiko.SSHException, ValueError, OSError) as error: pybreeze_logger.debug("Key type %s rejected: %s", key_cls.__name__, error) return UNSUPPORTED_KEY + + +def _decrypts(key_path: str, password: str) -> bool: + """Whether *password* decrypts the private key file at *key_path*, whatever its key type.""" + try: + data = Path(key_path).read_bytes() + except OSError: + return False + with warnings.catch_warnings(): + # A DSA key is deprecated in cryptography, which is what is being found out + warnings.simplefilter("ignore", CryptographyDeprecationWarning) + for loader in (serialization.load_ssh_private_key, serialization.load_pem_private_key): + try: + loader(data, password.encode("utf-8")) + return True + except UnsupportedAlgorithm: + return True # decrypted far enough to see a type it does not take + except (ValueError, TypeError) as error: + pybreeze_logger.debug("Key not decrypted by %s: %s", loader.__name__, type(error).__name__) + return False diff --git a/test/test_utils/test_ssh_security.py b/test/test_utils/test_ssh_security.py index f5482bac..44644b7c 100644 --- a/test/test_utils/test_ssh_security.py +++ b/test/test_utils/test_ssh_security.py @@ -85,6 +85,30 @@ def test_the_passphrase_is_named(self, tmp_path, kind): assert load_private_key(path, "") is None assert unloadable_key_reason(path, "") == PASSPHRASE_NEEDED + def test_an_encrypted_key_paramiko_cannot_load_is_unsupported_with_the_right_passphrase(self, tmp_path): + # A DSA key in OpenSSH format asks for a passphrase too: the right one + # was reported as wrong + import warnings + + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import dsa + + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import ( + PASSPHRASE_WRONG, UNSUPPORTED_KEY, unloadable_key_reason + ) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # cryptography deprecates DSA + data = dsa.generate_private_key(1024).private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, + serialization.BestAvailableEncryption(b"right")) + path = tmp_path / "id_dsa" + path.write_bytes(data) + + assert load_private_key(str(path), "right") is None + assert unloadable_key_reason(str(path), "right") == UNSUPPORTED_KEY + assert unloadable_key_reason(str(path), "wrong") == PASSPHRASE_WRONG + def test_a_file_that_is_no_key_is_unsupported(self, tmp_path): from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import UNSUPPORTED_KEY, unloadable_key_reason From 74fd1d3c37dfc859f7da5de194cfe723a721ae62 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 09:15:29 +0800 Subject: [PATCH 273/312] Round a long decimal epoch down inside the decimal arithmetic too --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/timestamp_tools/timestamp_converter.py | 4 +++- test/test_utils/test_timestamp_converter.py | 1 + 5 files changed, 16 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 7a3ed930..9627bf0a 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2115 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2116 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 7ce49b18..47fda9fa 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -302,3 +302,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2115 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py`, `test/test_utils/test_ssh_security.py`, `architecture_explore.md` §9, §18 - **Open items**: none. + +## U-20260923-233 · 2026-09-24 · Round a long decimal epoch down inside the decimal arithmetic too · #fix #timestamp + +- **What**: `_decimal_microseconds` (U-20260923-207) worked to 60 digits in the default rounding (half even) and then took the floor. A fraction longer than that rounded up first: `0.` followed by 70 nines gave `00:00:01`, not `00:00:00.999999`, against the "rounded toward the past" the result promises. The float path before it did the same, so it was not a regression; found reviewing this round's own changes. +- **Fix**: the decimal context rounds down (`ROUND_FLOOR`) as well: a result rounded down never passes the whole number below the true one, so the floor after it is exact. +- **Tests**: `test_timestamp_converter.py` +1: 70 nines give `.999999`. +- **Result / numbers**: 2116 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/timestamp_tools/timestamp_converter.py`, `test/test_utils/test_timestamp_converter.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9d46492e..35a825d4 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-233 | 2026-09-24 | Round a long decimal epoch down inside the decimal arithmetic too | #fix #timestamp | [2026-09-b](2026-09-b.md) | | U-20260923-232 | 2026-09-24 | Call an encrypted SSH key of a type paramiko cannot load unsupported, not its passphrase wrong | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260923-231 | 2026-09-24 | Generate the HAR script again when going back from a target that could not carry it | #fix #har | [2026-09-b](2026-09-b.md) | | U-20260923-230 | 2026-09-24 | Keep difflib's junk heuristic on for what is left after trimming, so a repetitive diff stays quick | #fix #diff #perf | [2026-09-b](2026-09-b.md) | @@ -314,4 +315,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 32 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 33 | diff --git a/pybreeze/utils/timestamp_tools/timestamp_converter.py b/pybreeze/utils/timestamp_tools/timestamp_converter.py index e9693d75..19450583 100644 --- a/pybreeze/utils/timestamp_tools/timestamp_converter.py +++ b/pybreeze/utils/timestamp_tools/timestamp_converter.py @@ -13,7 +13,7 @@ import re from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from decimal import MAX_EMAX, MIN_EMIN, Decimal, InvalidOperation, localcontext +from decimal import MAX_EMAX, MIN_EMIN, ROUND_FLOOR, Decimal, InvalidOperation, localcontext from pybreeze.utils.exception.exception_tags import ( empty_timestamp_error, @@ -98,6 +98,8 @@ def _decimal_microseconds(value: Decimal) -> int: raise OverflowError("epoch out of range") with localcontext() as context: context.prec = _DECIMAL_PRECISION + # Down, as the floor after it: rounded to nearest, 0.999... (70 nines) became 1 s + context.rounding = ROUND_FLOOR # A tiny fraction (1e-999999999) is not rounded away to zero context.Emax, context.Emin = MAX_EMAX, MIN_EMIN return math.floor(value * 10 ** 6 / _PER_SECOND[detect_epoch_unit(value)]) diff --git a/test/test_utils/test_timestamp_converter.py b/test/test_utils/test_timestamp_converter.py index 58322870..f9059885 100644 --- a/test/test_utils/test_timestamp_converter.py +++ b/test/test_utils/test_timestamp_converter.py @@ -184,6 +184,7 @@ class TestADecimalEpochRoundsTowardThePast: ("1700000000123.4567", "2023-11-14T22:13:20.123456+00:00"), ("-1e-999999999", "1969-12-31T23:59:59.999999+00:00"), ("0e999999999", "1970-01-01T00:00:00+00:00"), + ("0." + "9" * 70, "1970-01-01T00:00:00.999999+00:00"), # rounded up to 1 s at 60 digits ]) def test_it_is_cut_to_the_microsecond(self, text, iso): from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp From 536f48684abac2b203f7654e91f0639aa7240f1f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 09:16:15 +0800 Subject: [PATCH 274/312] Never write a known_hosts file over when it cannot be read, and keep its bytes --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 12 ++++++++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_host_key_policy.py | 15 +++++++--- test/test_utils/test_ssh_host_key_policy.py | 29 +++++++++++++++++++ 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 9627bf0a..7d18fcc1 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2116 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2118 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 47fda9fa..6b08dfd8 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -311,3 +311,15 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2116 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/utils/timestamp_tools/timestamp_converter.py`, `test/test_utils/test_timestamp_converter.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-234 · 2026-09-24 · Never write a known_hosts file over when it cannot be read, and keep its bytes · #fix #ssh + +- **What**: three things U-20260923-218 got wrong in `ssh_host_key_policy.py`, found reviewing this round's own changes. + - `_store` built the new file from `_file_lines`, which gives no lines for a file that exists but cannot be read (locked by another program, no permission). Accepting a host then replaced the whole file with that one line: every host trusted so far was gone. + - The lines were decoded with replacement, so a byte that is not UTF-8 (in a comment, say) was written back as U+FFFD for good. + - `apply_host_key_policy` loaded `~/.ssh/known_hosts` first and PyBreeze's file after it, so for one host and key type PyBreeze's key replaced the system one, where paramiko had checked the system file first. +- **Fix**: `_store` reads the file as bytes and appends the new line to them (still replaced in one step through `replace_written`); a read that fails is an `OSError` that stores nothing and is logged, as a failed write already was. The system file is loaded last, so it wins again. +- **Tests**: `test_ssh_host_key_policy.py` +2: a `\xe9` in a comment is still there after a host is accepted; with the file unreadable, accepting a host leaves it as it was. +- **Result / numbers**: 2118 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py`, `test/test_utils/test_ssh_host_key_policy.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 35a825d4..5dd44adc 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-234 | 2026-09-24 | Never write a known_hosts file over when it cannot be read, and keep its bytes | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260923-233 | 2026-09-24 | Round a long decimal epoch down inside the decimal arithmetic too | #fix #timestamp | [2026-09-b](2026-09-b.md) | | U-20260923-232 | 2026-09-24 | Call an encrypted SSH key of a type paramiko cannot load unsupported, not its passphrase wrong | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260923-231 | 2026-09-24 | Generate the HAR script again when going back from a target that could not carry it | #fix #har | [2026-09-b](2026-09-b.md) | @@ -315,4 +316,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 33 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 34 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py index d1b6a1e7..cc4d6aa8 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py @@ -24,7 +24,7 @@ from PySide6.QtWidgets import QMessageBox from pybreeze.utils.app_dirs import pybreeze_data_dir -from pybreeze.utils.file_process.replace_file import replace_text +from pybreeze.utils.file_process.replace_file import replace_written from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.pybreeze_ui.plain_text import as_text @@ -114,9 +114,15 @@ def _store(hostname: str, key: paramiko.PKey) -> None: replaced in one step: a failure part-way lost every host trusted so far. """ path = _known_hosts_path() - line = HostKeyEntry([hostname], key).to_line() + line = HostKeyEntry([hostname], key).to_line().encode("utf-8") try: - replace_text(path, "\n".join([*_file_lines(path), line.rstrip("\n")]) + "\n") + # As bytes: decoded and written back, a byte that is not UTF-8 (in a + # comment, say) became U+FFFD for good. And a file there that cannot + # be read is not written over with this one line + existing = path.read_bytes() if path.exists() else b"" + if existing and not existing.endswith(b"\n"): + existing += b"\n" + replace_written(path, lambda target: target.write_bytes(existing + line)) except OSError as err: pybreeze_logger.warning( "Failed to persist SSH host key for %s: %s", hostname, err @@ -257,6 +263,7 @@ def apply_host_key_policy(client: paramiko.SSHClient, parent: QWidget | None) -> """Load known hosts and attach the interactive TOFU policy to *client*.""" # Both files are read leniently into the client's own keys: a bad line in # either stopped paramiko's loaders with an error the Connect let escape - load_known_hosts(client.get_host_keys(), Path.home() / ".ssh" / "known_hosts") + # The system file last: for one host and key type it wins, as it did load_known_hosts(client.get_host_keys(), _known_hosts_path()) + load_known_hosts(client.get_host_keys(), Path.home() / ".ssh" / "known_hosts") client.set_missing_host_key_policy(InteractiveHostKeyPolicy(parent)) diff --git a/test/test_utils/test_ssh_host_key_policy.py b/test/test_utils/test_ssh_host_key_policy.py index c9052ab2..2960d2c6 100644 --- a/test/test_utils/test_ssh_host_key_policy.py +++ b/test/test_utils/test_ssh_host_key_policy.py @@ -194,3 +194,32 @@ def test_accepting_a_host_keeps_the_lines_paramiko_could_not_read(asked, keys, t lines = (tmp_path / "ssh_known_hosts").read_text(encoding="utf-8").splitlines() assert lines[:2] == [_BAD_LINE, _DSA_LINE] assert policy_mod._read_known_hosts().lookup("new.example")["ssh-rsa"] == keys[0] + + +def test_accepting_a_host_keeps_bytes_that_are_not_utf8(asked, keys, tmp_path): + # The file was decoded with replacement and written back: \xe9 became U+FFFD + (tmp_path / "ssh_known_hosts").write_bytes(b"# caf\xe9\n") + + _meet("new.example", keys[0]) + + assert (tmp_path / "ssh_known_hosts").read_bytes().startswith(b"# caf\xe9\n") + + +def test_a_file_that_cannot_be_read_is_not_written_over(asked, keys, tmp_path, monkeypatch): + # Read as empty, it was replaced by the one new line: every host trusted so far gone + from pathlib import Path + + known = tmp_path / "ssh_known_hosts" + known.write_bytes(b"trusted.example ssh-ed25519 AAAA\n") + real_read = Path.read_bytes + + def locked(self): + if self == known: + raise PermissionError(13, "locked") + return real_read(self) + + monkeypatch.setattr(Path, "read_bytes", locked) + _meet("new.example", keys[0]) + monkeypatch.setattr(Path, "read_bytes", real_read) + + assert known.read_bytes() == b"trusted.example ssh-ed25519 AAAA\n" From f1c460aa4e50a874a3caa8c35631662b2f8dd05d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 10:27:06 +0800 Subject: [PATCH 275/312] Skip a HAR entry holding half a character instead of failing the whole file --- architecture_explore.md | 4 ++-- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/utils/har_import/har_parser.py | 14 ++++++++++++++ test/test_utils/test_har_import.py | 11 +++++++++++ 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 7d18fcc1..3176cda4 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -347,7 +347,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`_NoRedirectSession`:不跟也不準備轉址,3xx 原封不讀地回來;`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`overall_deadline(seconds)`:這個執行緒在區塊內的請求總共最多這麼久,釘住的連線等回應時把 socket 登記上去,時間到就 shutdown,丟 `ReadTimeout`(讀取逾時每來一個位元組就重算,慢慢送標頭的伺服器原本可以一直拖);AI 審查、Skill、CoT 每一步都包在 `overall_deadline(DEFAULT_MAX_READ_SECONDS)` 裡。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),時間到由 `_Watchdog` 關掉連線(urllib3 的 `HTTPResponse.shutdown()` 能中斷別的執行緒上正在等的讀取),丟 `ReadTimeout`:讀取逾時只管每一塊之間,一次送一個位元組的伺服器可以一直拖下去;狀態列與標頭由呼叫端的 `public_http.overall_deadline()` 涵蓋)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | | `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`request_body.py` 的 `form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣),和其他 `-d` 片段照命令列的順序接起來(`data_file_positions`);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別(`body_kind()`:JSON 物件要能原樣送回才走 `json=`,重複的 key、float 裝不下的數字、`NaN`、巢狀超過 100 層都照原字串送);`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | -| `har_import/` | `har_parser.py`(354) HAR → `CurlRequest`(重用 curl 那套 codegen;建不出請求的 entry 跳過,同名 cookie 改走 `Cookie` header);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | +| `har_import/` | `har_parser.py`(354) HAR → `CurlRequest`(重用 curl 那套 codegen;建不出請求的 entry 跳過,含半個字元(JSON 的 `\ud800`)的也跳過,同名 cookie 改走 `Cookie` header);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | | `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯;`pretty_json_or_none()` 是同一套解析、不記 log 的版本,Response Inspector 的 body 與 JWT 各段(`jwt_decoder.shown_json()`)用它排版。`json_format/view_safe.py` 的 `dumps_for_view()` / `escape_for_view()`:工具顯示的 JSON 把文字框還不回原樣的字元(U+2029、U+FDD0、U+FDD1 會變換行,落單的 surrogate 會消失;U+2028 經 `toPlainText()`、U+0085 經 `splitlines()` 也會斷行)寫成 `\uXXXX`,JSON Format、Query/URL 轉 JSON、JWT、Response Inspector、cURL/HAR 產生的 JSON 與 Python 字串都經過它。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN` 與同一物件重複的 key(`load_json_verbatim()`,用 `json_process.unique_pairs`),Query 轉 JSON 遇到不是 UTF-8 的 percent-escape 報錯而不換成 U+FFFD,`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | | `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2118 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2121 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 6b08dfd8..63414e84 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -323,3 +323,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2118 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py`, `test/test_utils/test_ssh_host_key_policy.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260923-235 · 2026-09-24 · Skip a HAR entry holding half a character instead of failing the whole file · #fix #har + +- **What**: a HAR file is JSON, and JSON's `\ud800` escape gives a lone surrogate: half a character, which no request can carry. In a URL's query or a `queryString` value it raised `UnicodeEncodeError` from `parse_har` (the query check and the entry's summary encode it), and the HAR tab catches only `HarParseException`, so the whole file failed out of the slot; the LoadDensity target raised it again while generating. Found by throwing random text at every pure-logic entry point; the other JSON tools (URL, query, JSON format, JWT, response inspector, a cURL JSON body) already handle it. +- **Fix**: `parse_har` skips an entry whose recorded request holds a lone surrogate anywhere (`_is_unicode`: the request as JSON, encoded as UTF-8), as it skips one with a malformed URL or method, and logs it without the content. +- **Tests**: `test_har_import.py` +3: a lone surrogate in the URL's query, in a `queryString` value or in a header skips that entry, and the good one beside it loads. +- **Result / numbers**: 2121 tests in 121 files. The full suite before this change: 2103 passed, 15 skipped, startup tests exit 0, gates clean, 510 passed on Python 3.10. `ruff check` clean. +- **Files**: `pybreeze/utils/har_import/har_parser.py`, `test/test_utils/test_har_import.py`, `architecture_explore.md` §12, §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 5dd44adc..9c253ef2 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-235 | 2026-09-24 | Skip a HAR entry holding half a character instead of failing the whole file | #fix #har | [2026-09-b](2026-09-b.md) | | U-20260923-234 | 2026-09-24 | Never write a known_hosts file over when it cannot be read, and keep its bytes | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260923-233 | 2026-09-24 | Round a long decimal epoch down inside the decimal arithmetic too | #fix #timestamp | [2026-09-b](2026-09-b.md) | | U-20260923-232 | 2026-09-24 | Call an encrypted SSH key of a type paramiko cannot load unsupported, not its passphrase wrong | #fix #ssh | [2026-09-b](2026-09-b.md) | @@ -316,4 +317,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 34 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 35 | diff --git a/pybreeze/utils/har_import/har_parser.py b/pybreeze/utils/har_import/har_parser.py index 8ed3e21f..48673220 100644 --- a/pybreeze/utils/har_import/har_parser.py +++ b/pybreeze/utils/har_import/har_parser.py @@ -259,6 +259,15 @@ def _load_entries(text: str) -> list[dict]: return [entry for entry in entries if isinstance(entry, dict)] +def _is_unicode(raw_request: dict) -> bool: + """Whether every text in *raw_request* is whole Unicode (no lone surrogate).""" + try: + json.dumps(raw_request, ensure_ascii=False).encode("utf-8") + except UnicodeEncodeError: + return False + return True + + def parse_har(text: str) -> list[HarEntry]: """Parse a HAR export into one entry per recorded request. @@ -274,6 +283,11 @@ def parse_har(text: str) -> list[HarEntry]: raw_request = raw_entry.get("request") if not isinstance(raw_request, dict) or not raw_request.get("url"): continue + if not _is_unicode(raw_request): + # A JSON escape such as "\ud800" gives half a character, which no + # request can carry: encoding it raised out of the tab + pybreeze_logger.info("HAR entry with a lone surrogate skipped") + continue if not url_is_well_formed(str(raw_request["url"])): # Skipped, not shown: listing or generating it raised from the # tab. Not logged either -- a recorded URL may carry a token. diff --git a/test/test_utils/test_har_import.py b/test/test_utils/test_har_import.py index 6ed9be30..f5f89d24 100644 --- a/test/test_utils/test_har_import.py +++ b/test/test_utils/test_har_import.py @@ -359,6 +359,17 @@ def test_a_method_that_is_not_a_token_is_refused(self): with pytest.raises(HarParseException, match="not an HTTP method"): parse_har(_har(_entry(method="GET():\n __import__('os').system('calc')\ndef t"))) + @pytest.mark.parametrize("bad", [ + {"url": "https://x/a?q=\ud800"}, + {"url": "https://x/a", "query": [{"name": "q", "value": "\ud800"}]}, + {"url": "https://x/a", "headers": [{"name": "X", "value": "\udfff"}]}, + ]) + def test_an_entry_with_half_a_character_is_skipped_and_the_rest_load(self, bad): + # JSON's "\ud800" escape: encoding it raised UnicodeEncodeError out of the tab + entries = parse_har(_har(_entry(**bad), _entry(url="https://x/b"))) + + assert [entry.request.url for entry in entries] == ["https://x/b"] + def test_an_entry_with_a_bad_method_is_skipped_and_the_rest_load(self): entries = parse_har(_har(_entry(url="https://x/a", method=""), _entry(url="https://x/b"))) From a4f805de249aff4513cb6296321ec7196bafe7d3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 10:29:29 +0800 Subject: [PATCH 276/312] Apply backspaces in one pass, so a long rub-out does not hold the UI --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++ docs/updates/README.md | 3 ++- pybreeze/utils/terminal_text.py | 27 ++++++++++++++------ test/test_utils/test_ansi_escape.py | 38 +++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 10 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 3176cda4..9b574eb6 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2121 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2123 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 63414e84..7abe91a7 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -332,3 +332,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2121 tests in 121 files. The full suite before this change: 2103 passed, 15 skipped, startup tests exit 0, gates clean, 510 passed on Python 3.10. `ruff check` clean. - **Files**: `pybreeze/utils/har_import/har_parser.py`, `test/test_utils/test_har_import.py`, `architecture_explore.md` §12, §18 - **Open items**: none. + +## U-20260923-236 · 2026-09-24 · Apply backspaces in one pass, so a long rub-out does not hold the UI · #fix #output #perf + +- **What**: `strip_terminal_controls` applied backspaces by removing a character and the backspace after it with a regex, pass after pass, until nothing changed. Each pass takes back one pair where the characters and the backspaces meet, so a run of N characters and N backspaces took N passes over the text: 2,000 took 44 ms and 20,000 took 5.2 s. It runs on the UI thread for every piece of the run window and the SSH terminal. Found timing every pure tool at two sizes; the others grow about linearly at any size they meet. +- **Fix**: `_apply_backspaces` goes through the text once with a stack, with the same rule (a backspace takes back the character before it, never a line break, and one with nothing to take back stays for the control-character filter); text without a backspace skips it. +- **Tests**: `test_ansi_escape.py` +2: on 500 random texts of `a`, `b`, newlines and backspaces it gives what the pass-after-pass way gave; 10,000 characters and 10,000 backspaces take under a second. +- **Result / numbers**: the 20,000-character case now takes about 10 ms. 2123 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/terminal_text.py`, `test/test_utils/test_ansi_escape.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9c253ef2..d31a5ef8 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260923-236 | 2026-09-24 | Apply backspaces in one pass, so a long rub-out does not hold the UI | #fix #output #perf | [2026-09-b](2026-09-b.md) | | U-20260923-235 | 2026-09-24 | Skip a HAR entry holding half a character instead of failing the whole file | #fix #har | [2026-09-b](2026-09-b.md) | | U-20260923-234 | 2026-09-24 | Never write a known_hosts file over when it cannot be read, and keep its bytes | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260923-233 | 2026-09-24 | Round a long decimal epoch down inside the decimal arithmetic too | #fix #timestamp | [2026-09-b](2026-09-b.md) | @@ -317,4 +318,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 35 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 36 | diff --git a/pybreeze/utils/terminal_text.py b/pybreeze/utils/terminal_text.py index 50c63338..fa1c9259 100644 --- a/pybreeze/utils/terminal_text.py +++ b/pybreeze/utils/terminal_text.py @@ -22,10 +22,8 @@ # Control characters the view cannot show, once the sequences are gone: BEL # and the rest of C0 but tab, newline and carriage return, and DEL. -# Backspace is applied first (_BACKSPACED) +# Backspace is applied first (_apply_backspaces) _CONTROL_CHARACTER = re.compile('[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') -# A character and the backspace that takes it back (not across a line break) -_BACKSPACED = re.compile('[^\n\x08]\x08') # The end of a read that stops inside an escape sequence: a lone ESC, a CSI # still waiting for its final byte, a control string still waiting for its @@ -39,14 +37,27 @@ def strip_terminal_controls(text: str) -> str: """*text* without escape sequences, with backspaces applied and other controls dropped.""" text = ANSI_ESCAPE_PATTERN.sub('', text) - while True: - applied = _BACKSPACED.sub('', text) - if applied == text: - break - text = applied + if '\x08' in text: + text = _apply_backspaces(text) return _CONTROL_CHARACTER.sub('', text) +def _apply_backspaces(text: str) -> str: + """Let each backspace take back the character before it, never a line break. + + One pass: removing a character and its backspace a pair at a time, again + and again, took 5 s on the UI thread for 10,000 characters rubbed out. + A backspace with nothing to take back stays, for the caller to drop. + """ + kept: list[str] = [] + for character in text: + if character == '\x08' and kept and kept[-1] not in ('\n', '\x08'): + kept.pop() + else: + kept.append(character) + return ''.join(kept) + + def take_leading_backspaces(text: str) -> tuple[int, str]: """How many backspaces *text* starts with, and the rest of it. diff --git a/test/test_utils/test_ansi_escape.py b/test/test_utils/test_ansi_escape.py index 59a50734..13766853 100644 --- a/test/test_utils/test_ansi_escape.py +++ b/test/test_utils/test_ansi_escape.py @@ -64,3 +64,41 @@ def test_controls_are_applied_or_dropped(self, raw, expected): from pybreeze.utils.terminal_text import strip_terminal_controls assert strip_terminal_controls(raw) == expected + + +def _backspaced_pair_by_pair(text: str) -> str: + """How backspaces were applied before: a character and its backspace removed, pass after pass.""" + import re + + pair = re.compile("[^\n\x08]\x08") + while True: + applied = pair.sub("", text) + if applied == text: + return text + text = applied + + +def test_backspaces_are_applied_as_before(): + from hypothesis import given, settings + from hypothesis import strategies as st + + from pybreeze.utils.terminal_text import _apply_backspaces + + @settings(max_examples=500, deadline=None) + @given(st.text(alphabet="ab\n\x08", max_size=40)) + def same(text): + assert _apply_backspaces(text) == _backspaced_pair_by_pair(text) + + same() + + +def test_a_long_rub_out_is_quick(): + # Pair by pair, 10,000 characters and their 10,000 backspaces took 5 s on the UI thread + import time + + from pybreeze.utils.terminal_text import strip_terminal_controls + + started = time.perf_counter() + + assert strip_terminal_controls("a" * 10_000 + "\x08" * 10_000 + "done") == "done" + assert time.perf_counter() - started < 1 From 53b08a4ff24847e983c37161f26e29a9896bb5f0 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 10:29:36 +0800 Subject: [PATCH 277/312] Correct a measured time in the update log --- docs/updates/2026-09-b.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 7abe91a7..56861091 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -338,6 +338,6 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **What**: `strip_terminal_controls` applied backspaces by removing a character and the backspace after it with a regex, pass after pass, until nothing changed. Each pass takes back one pair where the characters and the backspaces meet, so a run of N characters and N backspaces took N passes over the text: 2,000 took 44 ms and 20,000 took 5.2 s. It runs on the UI thread for every piece of the run window and the SSH terminal. Found timing every pure tool at two sizes; the others grow about linearly at any size they meet. - **Fix**: `_apply_backspaces` goes through the text once with a stack, with the same rule (a backspace takes back the character before it, never a line break, and one with nothing to take back stays for the control-character filter); text without a backspace skips it. - **Tests**: `test_ansi_escape.py` +2: on 500 random texts of `a`, `b`, newlines and backspaces it gives what the pass-after-pass way gave; 10,000 characters and 10,000 backspaces take under a second. -- **Result / numbers**: the 20,000-character case now takes about 10 ms. 2123 tests in 121 files. `ruff check` clean. +- **Result / numbers**: the 20,000-character case now takes 2 ms. 2123 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/utils/terminal_text.py`, `test/test_utils/test_ansi_escape.py`, `architecture_explore.md` §18 - **Open items**: none. From db37d9e62bcc13bb606e0d86147774237752f6f1 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:01:51 +0800 Subject: [PATCH 278/312] Translate the SFTP tree's menu and headers, and mark its placeholder row by data --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_file_viewer_widget.py | 28 +++++--- test/test_utils/test_sftp_tree_actions.py | 69 +++++++++++++++++++ 5 files changed, 101 insertions(+), 10 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 9b574eb6..d7e4322c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2123 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2127 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 56861091..0d634158 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -341,3 +341,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: the 20,000-character case now takes 2 ms. 2123 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/utils/terminal_text.py`, `test/test_utils/test_ansi_escape.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260924-237 · 2026-09-24 · SFTP tree speaks the IDE language, and an entry named ... is not its placeholder · #fix #ssh #i18n + +- **What**: the SFTP tree's context menu and column headers were English literals while both language dictionaries held their `ssh_file_viewer_context_menu_action_*` and `ssh_file_viewer_tree_header_*` entries, unused. A folder's not-yet-listed row was recognised by its text `...`, so a server entry named `...` (a valid name) listed first in a folder was taken for it: expanding the folder again removed the entry and listed the folder again. Right-clicking the placeholder or the loading row acted on a path of `''` (Delete asked about `''`, Rename sent `("", "/name")`). Found listing the dictionary keys no code reads. +- **Fix**: the menu and the headers read the dictionaries; the placeholder carries `PLACEHOLDER_ROLE` item data and is recognised by it; a right-click on a row without a path acts on the folder it is in. +- **Tests**: `test_sftp_tree_actions.py` +4 (an entry named `...` is kept on a second expand; an unexpanded folder still has its placeholder; the menu and headers in Traditional Chinese; the placeholder's menu acts on its folder), three of which fail on the old code. +- **Result / numbers**: 2127 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_sftp_tree_actions.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index d31a5ef8..6508084c 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-237 | 2026-09-24 | SFTP tree speaks the IDE language, and an entry named ... is not its placeholder | #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | | U-20260923-236 | 2026-09-24 | Apply backspaces in one pass, so a long rub-out does not hold the UI | #fix #output #perf | [2026-09-b](2026-09-b.md) | | U-20260923-235 | 2026-09-24 | Skip a HAR entry holding half a character instead of failing the whole file | #fix #har | [2026-09-b](2026-09-b.md) | | U-20260923-234 | 2026-09-24 | Never write a known_hosts file over when it cannot be read, and keep its bytes | #fix #ssh | [2026-09-b](2026-09-b.md) | @@ -318,4 +319,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 36 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 37 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index c6d50448..4de6c7b4 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -50,6 +50,9 @@ def format_size(num_bytes: int) -> str: # Item data: the serial of the listing a directory item is waiting for LISTING_ROLE = Qt.ItemDataRole.UserRole +# Item data: set on the row a folder shows until it is first expanded. Told +# apart by its text, "...", a server entry named "..." was taken for it +PLACEHOLDER_ROLE = Qt.ItemDataRole.UserRole + 1 # What a Windows file name cannot hold @@ -118,7 +121,9 @@ def __init__(self, external_login_widget: LoginWidget = None, add_login_widget: # UI: Tree self.tree = QTreeWidget() - self.tree.setHeaderLabels(["Name", "Type", "Size", "Path"]) + self.tree.setHeaderLabels([ + self.word_dict.get(f"ssh_file_viewer_tree_header_{column}") + for column in ("name", "type", "size", "path")]) self.tree.itemExpanded.connect(self.on_item_expanded) self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.tree.customContextMenuRequested.connect(self.on_context_menu) @@ -285,6 +290,7 @@ def add_placeholder(self, item: QTreeWidgetItem): 加入占位子項以表示可延遲載入。 """ placeholder = QTreeWidgetItem(["...", "", "", ""]) + placeholder.setData(0, PLACEHOLDER_ROLE, True) item.addChild(placeholder) def is_placeholder_present(self, item: QTreeWidgetItem) -> bool: @@ -294,7 +300,7 @@ def is_placeholder_present(self, item: QTreeWidgetItem) -> bool: """ if item.childCount() == 0: return False - return item.child(0).text(0) == "..." + return item.child(0).data(0, PLACEHOLDER_ROLE) is True def on_item_expanded(self, item: QTreeWidgetItem): """ @@ -372,14 +378,20 @@ def on_context_menu(self, pos): 顯示右鍵選單以進行檔案操作。 """ item = self.tree.itemAt(pos) + if item is not None and not item.text(3): + # The "..." or loading row: no entry of its own, so the folder it is in + item = item.parent() menu = QMenu(self) - refresh_act = menu.addAction("Refresh") - create_act = menu.addAction("Create folder") - rename_act = menu.addAction("Rename") - delete_act = menu.addAction("Delete") - download_act = menu.addAction("Download") - upload_act = menu.addAction("Upload to this folder") + def action(name: str): + return menu.addAction(self.word_dict.get(f"ssh_file_viewer_context_menu_action_{name}")) + + refresh_act = action("refresh") + create_act = action("create_folder") + rename_act = action("rename") + delete_act = action("delete") + download_act = action("download") + upload_act = action("upload") action = menu.exec_(self.tree.viewport().mapToGlobal(pos)) if action is None: diff --git a/test/test_utils/test_sftp_tree_actions.py b/test/test_utils/test_sftp_tree_actions.py index 64ddfb83..3b4b931b 100644 --- a/test/test_utils/test_sftp_tree_actions.py +++ b/test/test_utils/test_sftp_tree_actions.py @@ -229,3 +229,72 @@ def test_only_the_last_safe_part(self, remote, suggested): assert local_file_name(remote) == suggested + + +class TestAnEntryNamedDots: + """The row a folder shows until expanded read "...", and was told apart by that text alone.""" + + def test_is_not_taken_for_the_placeholder(self, tree): + widget, client, root, _warnings = tree + client.listings["/"].append(_entry("odd", directory=True)) + client.listings["/odd"] = [_entry("...", directory=True), _entry("b.txt")] + widget.action_refresh(root) + _wait_for(lambda: _idle(widget)) + folder = _child(root, "odd") + widget.on_item_expanded(folder) + _wait_for(lambda: _idle(widget)) + + assert not widget.is_placeholder_present(folder) + widget.on_item_expanded(folder) # collapsed and expanded again + _wait_for(lambda: _idle(widget)) + + assert [folder.child(i).text(0) for i in range(folder.childCount())] == ["...", "b.txt"] + assert _child(folder, "...").text(3) == "/odd/..." + + def test_a_folder_not_yet_expanded_still_has_its_placeholder(self, tree): + _widget, _client, root, _warnings = tree + folder = _child(root, "src") + + assert tree[0].is_placeholder_present(folder) + + +class TestTheMenu: + def _open(self, widget, monkeypatch, item, choose: int | None = None) -> list[str]: + shown: list[str] = [] + + def run(menu, _pos): + shown.extend(action.text() for action in menu.actions()) + return None if choose is None else menu.actions()[choose] + + monkeypatch.setattr(tree_mod.QMenu, "exec_", run) + monkeypatch.setattr(widget.tree, "itemAt", lambda _pos: item) + widget.on_context_menu(widget.tree.rect().center()) + return shown + + def test_speaks_the_ide_language(self, app, monkeypatch): + # The menu and the column headers were English whatever the IDE spoke + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict as word, + ) + monkeypatch.setattr(tree_mod.language_wrapper, "language_word_dict", word) + widget = tree_mod.SSHFileTreeManager() + try: + shown = self._open(widget, monkeypatch, None) + headers = widget.tree.headerItem() + + assert shown == ["重新整理", "建立資料夾", "重新命名", "刪除", "下載", "上傳至此資料夾"] + assert [headers.text(i) for i in range(4)] == ["名稱", "類型", "大小", "路徑"] + finally: + widget.close() + widget.deleteLater() + + def test_on_the_placeholder_acts_on_its_folder(self, tree, monkeypatch): + # It has no path: Delete asked about '' and Rename sent ("", "/name") + widget, _client, root, _warnings = tree + folder = _child(root, "src") + refreshed: list = [] + monkeypatch.setattr(widget, "action_refresh", refreshed.append) + + self._open(widget, monkeypatch, folder.child(0), choose=0) + + assert refreshed == [folder] From 5588ed0aefbbc2e2642b887850b4f63746504042 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:05:56 +0800 Subject: [PATCH 279/312] Show the SSH connect line in the IDE language, and the status as Connected --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 2 +- .../extend_traditional_chinese.py | 2 +- .../connect_gui/ssh/ssh_command_widget.py | 8 +++-- test/test_utils/test_ssh_terminal_output.py | 34 +++++++++++++++++++ 7 files changed, 53 insertions(+), 7 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index d7e4322c..1e2aa43f 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2127 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2129 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 0d634158..68fc89ec 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -350,3 +350,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2127 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_sftp_tree_actions.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260924-238 · 2026-09-24 · The SSH shell's connect line in the IDE language, and its status Connected · #fix #ssh #i18n + +- **What**: once the shell was open its output read `Connected to host:22 as user`, the ` as ` in English whatever the IDE spoke (`已連線至 host:22 as user`), and an IPv6 address ran into its port (`::1:22`). The status label was set from the same key, reading `Connected to` / `已連線至` with nothing after it, while `ssh_command_widget_status_label_connected` went unused. Found listing the dictionary keys no code reads. +- **Fix**: `ssh_command_widget_log_message_connected` is one format string, `Connected to {host}:{port} as {user}` / `已以 {user} 身分連線至 {host}:{port}`; an address with a colon is shown in brackets; the status label uses `ssh_command_widget_status_label_connected`. +- **Tests**: `test_ssh_terminal_output.py` +2 (a host name and `::1`, in Traditional Chinese: the line and the status), which fail on the old code. +- **Result / numbers**: 2129 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ssh_terminal_output.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 6508084c..3695d518 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-238 | 2026-09-24 | The SSH shell's connect line in the IDE language, and its status Connected | #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-237 | 2026-09-24 | SFTP tree speaks the IDE language, and an entry named ... is not its placeholder | #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | | U-20260923-236 | 2026-09-24 | Apply backspaces in one pass, so a long rub-out does not hold the UI | #fix #output #perf | [2026-09-b](2026-09-b.md) | | U-20260923-235 | 2026-09-24 | Skip a HAR entry holding half a character instead of failing the whole file | #fix #har | [2026-09-b](2026-09-b.md) | @@ -319,4 +320,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 37 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 38 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index aae2e058..8d6c52bb 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -173,7 +173,7 @@ "ssh_command_widget_error_message_key_auth_failed": "Key auth failed", "ssh_command_widget_status_label_connected": "Connected", "ssh_command_widget_status_label_disconnected": "Disconnected", - "ssh_command_widget_log_message_connected": "Connected to", + "ssh_command_widget_log_message_connected": "Connected to {host}:{port} as {user}", "ssh_command_widget_log_message_error": "[Error] ", "ssh_command_widget_log_message_channel_closed": "[Channel closed]", "ssh_command_widget_error_message_reader_failed": "Reader error", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index c315ebfe..f29ea6e3 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -173,7 +173,7 @@ "ssh_command_widget_error_message_key_auth_failed": "金鑰驗證失敗", "ssh_command_widget_status_label_connected": "已連線", "ssh_command_widget_status_label_disconnected": "已斷線", - "ssh_command_widget_log_message_connected": "已連線至", + "ssh_command_widget_log_message_connected": "已以 {user} 身分連線至 {host}:{port}", "ssh_command_widget_log_message_error": "[錯誤]", "ssh_command_widget_log_message_channel_closed": "[通道已關閉]", "ssh_command_widget_error_message_reader_failed": "讀取錯誤", 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 8f214304..824deafb 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -349,9 +349,11 @@ def _start_shell(self, channel: paramiko.Channel, host: str, port: int, user: st self.reader_thread.closed.connect(self._on_closed) self.reader_thread.start() self.login_widget.status_label.setText( - self.word_dict.get("ssh_command_widget_log_message_connected")) - self.append_text(f"{self.word_dict.get('ssh_command_widget_log_message_connected')}" - f" {host}:{port} as {user}\n") + self.word_dict.get("ssh_command_widget_status_label_connected")) + # An IPv6 address in brackets, or its port reads as one more group + shown_host = f"[{host}]" if ":" in host else host + self.append_text(self.word_dict.get("ssh_command_widget_log_message_connected").format( + host=shown_host, port=port, user=user) + "\n") def _on_data(self, data: bytes): self._insert_output(self._decoder.feed(data)) diff --git a/test/test_utils/test_ssh_terminal_output.py b/test/test_utils/test_ssh_terminal_output.py index 758ad1a2..3f1a9eee 100644 --- a/test/test_utils/test_ssh_terminal_output.py +++ b/test/test_utils/test_ssh_terminal_output.py @@ -193,3 +193,37 @@ def test_a_long_command_arrives_whole_as_utf8(self, app): assert widget.command_input_edit.text() == "" widget.shell_channel = None widget.close() + + +class _IdleReader: + """A reader that is never started: the connect message is all that is looked at.""" + + def __init__(self, _channel) -> None: + self.data_received = self.closed = self + + def connect(self, _slot) -> None: + """No signal is ever sent.""" + + def start(self) -> None: + """Nothing to read.""" + + +class TestTheConnectMessage: + """It read "Connected to host:22 as user" with " as " in English, and the status "Connected to".""" + + @pytest.mark.parametrize(("host", "shown"), [("example.org", "example.org:22"), ("::1", "[::1]:22")]) + def test_names_the_session_in_the_ide_language(self, app, monkeypatch, host, shown): + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict as word, + ) + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_command_widget as shell_mod + monkeypatch.setattr(shell_mod, "SSHReaderThread", _IdleReader) + widget = SSHCommandWidget() + widget.word_dict = word + + widget._start_shell(object(), host, 22, "alice") + + assert widget.terminal.toPlainText().endswith(f"已以 alice 身分連線至 {shown}\n") + assert widget.login_widget.status_label.text() == "已連線" + widget.shell_channel = widget.reader_thread = None + widget.close() From 2c8abe6e2b1de16780c445dc8cd93f8b2c9165ee Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:08:00 +0800 Subject: [PATCH 280/312] Give the reason JupyterLab did not start in the IDE language --- README.md | 2 +- architecture_explore.md | 8 ++-- docs/updates/2026-09-b.md | 9 +++++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 3 +- .../extend_traditional_chinese.py | 1 + .../jupyter_lab_gui/jupyter_lab_thread.py | 9 ++--- test/test_utils/test_jupyter_ready.py | 38 +++++++++++++++++++ 8 files changed, 61 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0ddebb38..140ea891 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 645 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 646 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index 1e2aa43f..17eaf4eb 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 645 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 646 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 645 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 646 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -361,7 +361,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 645 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 646 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2129 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2131 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 68fc89ec..b5732fd7 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -359,3 +359,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2129 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ssh_terminal_output.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260924-239 · 2026-09-24 · Why JupyterLab did not start, in the IDE language · #fix #jupyter #i18n + +- **What**: the JupyterLab tab shows why the server did not start after its translated `jupyterlab_init_failed`, but the two reasons the launcher itself gives were English literals: `JupyterLab startup timeout (60s)` and `JupyterLab exited early (code 1): ...`. `jupyterlab_timeout` was in both dictionaries and never read. Found listing the dictionary keys no code reads. +- **Fix**: `_wait_until_ready` builds the timeout from `jupyterlab_timeout` (English now `JupyterLab did not start in time`) and the early exit from a new `jupyterlab_exited_early` format string (`{code}`, `{output}`) in both languages. +- **Tests**: `test_jupyter_ready.py` +2 (both reasons in Traditional Chinese; server output holding `{0}` is kept as written), which fail on the old code; its fixture now merges the dictionaries itself, as the module no longer ran alone otherwise. +- **Result / numbers**: 646 keys in each language. 2131 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_jupyter_ready.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 3695d518..842090fb 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-239 | 2026-09-24 | Why JupyterLab did not start, in the IDE language | #fix #jupyter #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-238 | 2026-09-24 | The SSH shell's connect line in the IDE language, and its status Connected | #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-237 | 2026-09-24 | SFTP tree speaks the IDE language, and an entry named ... is not its placeholder | #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | | U-20260923-236 | 2026-09-24 | Apply backspaces in one pass, so a long rub-out does not hold the UI | #fix #output #perf | [2026-09-b](2026-09-b.md) | @@ -320,4 +321,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 38 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 39 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 8d6c52bb..13958a14 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -369,7 +369,8 @@ "jupyterlab_init": "Initializing...", "jupyterlab_downloading": "Downloading...", "jupyterlab_loading": "Loading...", - "jupyterlab_timeout": "JupyterLab Timeout", + "jupyterlab_timeout": "JupyterLab did not start in time", + "jupyterlab_exited_early": "JupyterLab exited early (code {code}): {output}", "jupyterlab_init_failed": "JupyterLab init failed", # Plugin Menu "plugin_menu_label": "Plugins", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index f29ea6e3..b846f513 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -369,6 +369,7 @@ "jupyterlab_downloading": "下載中...", "jupyterlab_loading": "載入中...", "jupyterlab_timeout": "JupyterLab 啟動超時", + "jupyterlab_exited_early": "JupyterLab 提早結束(結束代碼 {code}):{output}", "jupyterlab_init_failed": "JupyterLab 啟動失敗", # Diagram Editor — 選單 "extend_tools_menu_diagram_editor_tab_action": "架構圖編輯器分頁", 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 432c6295..b2850e7d 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py @@ -197,19 +197,18 @@ def _wait_until_ready(self, port: int) -> None: process = self.process if process is None: raise RuntimeError("JupyterLab process was not started") + word = language_wrapper.language_word_dict start_time = time.time() while True: elapsed = time.time() - start_time if elapsed > self.startup_timeout: - raise TimeoutError( - f"JupyterLab startup timeout ({self.startup_timeout}s)") + raise TimeoutError(f"{word.get('jupyterlab_timeout')} ({self.startup_timeout}s)") # Fail fast if the server died (port conflict, bad install, ...) # instead of polling a dead port until the full timeout elapses. if process.poll() is not None: - raise RuntimeError( - f"JupyterLab exited early (code {process.returncode}): " - f"{self._output_tail()}") + raise RuntimeError(word.get("jupyterlab_exited_early").format( + code=process.returncode, output=self._output_tail())) self.status_update.emit( f"{language_wrapper.language_word_dict.get('jupyterlab_loading')} " diff --git a/test/test_utils/test_jupyter_ready.py b/test/test_utils/test_jupyter_ready.py index 8077a79d..e1dc663f 100644 --- a/test/test_utils/test_jupyter_ready.py +++ b/test/test_utils/test_jupyter_ready.py @@ -16,6 +16,8 @@ def qt_app(): app = QApplication([]) except Exception as exc: # pragma: no cover - no usable Qt platform pytest.skip(f"Cannot start QApplication: {exc}") + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + update_language_dict() return app @@ -72,6 +74,42 @@ def test_returns_when_port_open(self, qt_app, monkeypatch): assert thread._wait_until_ready(59999) is None +class TestTheReasonIsInTheIdeLanguage: + """A start that timed out or a server that exited read in English whatever the IDE spoke.""" + + @pytest.fixture() + def chinese(self, monkeypatch): + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict as word, + ) + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + monkeypatch.setattr(mod.language_wrapper, "language_word_dict", word) + + def test_a_timeout(self, qt_app, chinese, monkeypatch): + thread = _thread(qt_app) + thread.startup_timeout = 0 + thread.process = _AliveProcess() + monkeypatch.setattr(thread, "_port_open", lambda port: False) + + with pytest.raises(TimeoutError, match=r"^JupyterLab 啟動超時 \(0s\)$"): + thread._wait_until_ready(59999) + + def test_an_early_exit(self, qt_app, chinese): + import tempfile + + thread = _thread(qt_app) + thread.process = _DeadProcess() + with tempfile.TemporaryFile(mode="w+", encoding="utf-8") as output: + thread._output = output + output.write("port in use {0}\n") + + with pytest.raises(RuntimeError) as exc: + thread._wait_until_ready(59999) + + assert str(exc.value).startswith("JupyterLab 提早結束(結束代碼 1):") + assert "port in use {0}" in str(exc.value) + + class TestRunCleansUpOnFailure: def test_startup_failure_terminates_orphan_process(self, qt_app, monkeypatch): import pybreeze.pybreeze_ui.jupyter_lab_gui.jupyter_lab_thread as mod From 2c3ecc85b25e993a8568c9bf2482bd05886cc8e8 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:10:59 +0800 Subject: [PATCH 281/312] Add a Save As button to the diagram editor --- architecture_explore.md | 4 ++-- docs/updates/2026-09-b.md | 9 ++++++++ docs/updates/README.md | 3 ++- .../diagram_editor/diagram_editor_widget.py | 1 + test/test_utils/test_diagram_editor_widget.py | 22 +++++++++++++++++++ 5 files changed, 36 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 17eaf4eb..d4b5414e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -241,7 +241,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | 檔案 | 職責 | |---|---| -| `diagram_editor_widget.py` (675) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔經 `replace_text()` 先寫 `.saving` 再換上去,存檔失敗不會毀掉上一份 | +| `diagram_editor_widget.py` (678) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔經 `replace_text()` 先寫 `.saving` 再換上去,存檔失敗不會毀掉上一份 | | `diagram_scene.py` (888) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點與圖片之上/之下;`to_dict()` 給每個節點與圖片記下它在兩者之間由下往上的位置(`stack`),載入後 `_restore_stacking()` 照這個順序重新加回場景(同 z 值時後加的在上面),圖片也存 `z`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | | `diagram_items.py` (960) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`DiagramScene.add_image()`(`diagram_scene.py`)把圖放進 scene 的 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級;位置經 `_coordinate()`:不是有限數字就跳過這一筆,超過 `MAX_COORDINATE`(一百萬)就夾回來;連線建好所有東西之後才掛到兩端節點上 | | `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2131 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2132 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index b5732fd7..bbf6bc3b 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -368,3 +368,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 646 keys in each language. 2131 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_jupyter_ready.py`, `README.md`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-240 · 2026-09-24 · A Save As button in the diagram editor · #fix #diagram #ui + +- **What**: the diagram editor's file row had New, Open, Save and Import. Save As was reachable only through Ctrl+Shift+S, so a diagram saved once had no way from the toolbar to a new file, while `diagram_editor_action_save_as` (`Save As` / `另存新檔`) sat unused in both dictionaries. Found listing the dictionary keys no code reads. +- **Fix**: `_add_file_buttons` adds a Save As button after Save, calling `_save_as_diagram`. +- **Tests**: `test_diagram_editor_widget.py` +1 (with a current file, the button asks for a path and writes there, leaving the first path alone), which fails on the old code. +- **Result / numbers**: 2132 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_diagram_editor_widget.py`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 842090fb..7b006a99 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-240 | 2026-09-24 | A Save As button in the diagram editor | #fix #diagram #ui | [2026-09-b](2026-09-b.md) | | U-20260924-239 | 2026-09-24 | Why JupyterLab did not start, in the IDE language | #fix #jupyter #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-238 | 2026-09-24 | The SSH shell's connect line in the IDE language, and its status Connected | #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-237 | 2026-09-24 | SFTP tree speaks the IDE language, and an entry named ... is not its placeholder | #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | @@ -321,4 +322,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 39 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 40 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index 22e5a4a3..e776bd09 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -230,6 +230,7 @@ def _add_file_buttons(self, row2: QHBoxLayout) -> None: ("diagram_editor_action_new", self._new_diagram), ("diagram_editor_action_open", self._open_diagram), ("diagram_editor_action_save", self._save_diagram), + ("diagram_editor_action_save_as", self._save_as_diagram), ("diagram_editor_action_import", self._import_mermaid), ]: btn = _make_action_btn(_lang(lang_key)) diff --git a/test/test_utils/test_diagram_editor_widget.py b/test/test_utils/test_diagram_editor_widget.py index 1dd190d5..800846d4 100644 --- a/test/test_utils/test_diagram_editor_widget.py +++ b/test/test_utils/test_diagram_editor_widget.py @@ -65,6 +65,28 @@ def test_a_save_writes_the_diagram(self, editor, tmp_path): stored = json.loads(target.read_text(encoding="utf-8")) assert [node["text"] for node in stored["nodes"]] == ["A"] + def test_save_as_has_a_button(self, editor, tmp_path, monkeypatch): + # Only Ctrl+Shift+S reached it: once saved, a diagram had no way to a + # new file from the toolbar + from PySide6.QtWidgets import QPushButton + + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import _lang + + first, second = tmp_path / "first.diagram.json", tmp_path / "second.diagram.json" + editor._scene.load_from_dict(_A_DIAGRAM) + editor._current_path = first + monkeypatch.setattr( + diagram_editor_widget.QFileDialog, "getSaveFileName", + staticmethod(lambda *args, **kwargs: (str(second), ""))) + button = next(button for button in editor.findChildren(QPushButton) + if button.text() == _lang("diagram_editor_action_save_as")) + + button.click() + + assert second.is_file() and not first.exists() + assert editor._current_path == second + class TestOpening: def test_a_file_that_is_not_a_diagram_leaves_the_canvas_alone( From 50ea1013a46b230845d2a4f5280aa1192e9895ec Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:12:18 +0800 Subject: [PATCH 282/312] Remove translations no code reads --- README.md | 2 +- architecture_explore.md | 6 +++--- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/extend_multi_language/extend_english.py | 5 ----- .../extend_multi_language/extend_traditional_chinese.py | 5 ----- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 140ea891..c8c5417b 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 646 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 641 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index d4b5414e..484f2801 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 646 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 641 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 646 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 641 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -361,7 +361,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 646 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 641 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index bbf6bc3b..878f9abf 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -377,3 +377,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2132 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_diagram_editor_widget.py`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-241 · 2026-09-24 · Drop five translations nothing shows · #cleanup #i18n + +- **What**: five keys were in both dictionaries with no code reading them, directly or by a built name: `ssh_file_viewer_log_message_refreshing`, `ssh_file_viewer_dialog_label_input_text`, `cot_gui_error_read_file`, `skills_finished_signal` and `skills_error_signal` (the last two described signals, not text anyone sees). The remaining keys no PyBreeze code reads by their full name are read by a built name (`header_finding_*`, `header_analyzer_level_*`, the SFTP menu and headers) or by JEditor (`plugin_browser_*`, `tools_menu_re_edge_gpt_*`). +- **Fix**: removed from `extend_english.py` and `extend_traditional_chinese.py`. +- **Tests**: `test_language_parity.py` and `test_startup_language.py` pass unchanged. +- **Result / numbers**: 641 keys in each language. +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 7b006a99..b70a8559 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-241 | 2026-09-24 | Drop five translations nothing shows | #cleanup #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-240 | 2026-09-24 | A Save As button in the diagram editor | #fix #diagram #ui | [2026-09-b](2026-09-b.md) | | U-20260924-239 | 2026-09-24 | Why JupyterLab did not start, in the IDE language | #fix #jupyter #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-238 | 2026-09-24 | The SSH shell's connect line in the IDE language, and its status Connected | #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | @@ -322,4 +323,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 40 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 41 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 13958a14..c53152e5 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -194,7 +194,6 @@ "ssh_file_viewer_context_menu_action_delete": "Delete", "ssh_file_viewer_context_menu_action_download": "Download", "ssh_file_viewer_context_menu_action_upload": "Upload to this folder", - "ssh_file_viewer_log_message_refreshing": "Refreshing current item children (or root)", "ssh_file_viewer_dialog_title_no_selection": "No selection", "ssh_file_viewer_dialog_message_select_folder_to_create": "Select a folder to create inside.", "ssh_file_viewer_dialog_message_bad_name": "A name is one entry: it cannot contain '/', or be '.' or '..'.", @@ -240,7 +239,6 @@ "ssh_file_viewer_dialog_title_select_local_file": "Select local file to upload", "ssh_file_viewer_dialog_title_uploaded": "Uploaded", "ssh_file_viewer_dialog_message_uploaded_to": "Uploaded to", - "ssh_file_viewer_dialog_label_input_text": "Input text", "ssh_file_viewer_dialog_button_ok": "OK", "ssh_file_viewer_window_title_file_tree_manager": "SSH File TreeView Manager", "ssh_file_viewer_tree_header_name": "Name", @@ -347,12 +345,9 @@ "cot_gui_label_prompt_area": "Prompt Area", "cot_gui_label_response_area": "Response Area", "cot_gui_button_send": "Start Sending", - "cot_gui_error_read_file": "Unable to read file:", "cot_gui_error_no_url": "Please enter the API URL first!", "cot_gui_error_sending": "Error sending:", # Skills GUI - "skills_finished_signal": "Success or error message", - "skills_error_signal": "Exception occurred", "skills_error_status": "Error: {status_code}\n{text}", "skills_exception": "Exception occurred: {error}", "skills_api_url_label": "LLM API URL:", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index b846f513..ee3f2c8e 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -194,7 +194,6 @@ "ssh_file_viewer_context_menu_action_delete": "刪除", "ssh_file_viewer_context_menu_action_download": "下載", "ssh_file_viewer_context_menu_action_upload": "上傳至此資料夾", - "ssh_file_viewer_log_message_refreshing": "正在重新整理目前項目的子項(或根)", "ssh_file_viewer_dialog_title_no_selection": "未選取", "ssh_file_viewer_dialog_message_select_folder_to_create": "請選擇一個資料夾以建立子資料夾。", "ssh_file_viewer_dialog_message_bad_name": "名稱只能是單一項目:不能含有「/」,也不能是「.」或「..」。", @@ -239,7 +238,6 @@ "ssh_file_viewer_dialog_title_select_local_file": "選擇要上傳的本地檔案", "ssh_file_viewer_dialog_title_uploaded": "已上傳", "ssh_file_viewer_dialog_message_uploaded_to": "已上傳至", - "ssh_file_viewer_dialog_label_input_text": "輸入文字", "ssh_file_viewer_dialog_button_ok": "確定", "ssh_file_viewer_window_title_file_tree_manager": "SSH 檔案樹狀管理器", "ssh_file_viewer_tree_header_name": "名稱", @@ -346,12 +344,9 @@ "cot_gui_label_prompt_area": "傳送資料區域", "cot_gui_label_response_area": "回傳區域", "cot_gui_button_send": "開始傳送", - "cot_gui_error_read_file": "無法讀取檔案:", "cot_gui_error_no_url": "請先輸入 API URL!", "cot_gui_error_sending": "Error sending:", # Skills GUI - "skills_finished_signal": "成功或錯誤訊息", - "skills_error_signal": "發生例外", "skills_error_status": "錯誤: {status_code}\n{text}", "skills_exception": "發生例外: {error}", "skills_api_url_label": "LLM API URL:", From 8add463a31fa568a5ce455c7d63d73a44c043c2a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:17:06 +0800 Subject: [PATCH 283/312] Translate the CoT review panel's remaining English --- README.md | 2 +- architecture_explore.md | 8 ++++---- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../extend_multi_language/extend_english.py | 1 + .../extend_traditional_chinese.py | 7 ++++--- .../code_review/cot_code_review_gui.py | 3 ++- test/test_utils/test_ai_gui_lifecycle.py | 18 ++++++++++++++++++ 8 files changed, 41 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c8c5417b..c2f2ae19 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 641 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 642 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index 484f2801..d5dc62b5 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 641 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 642 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 641 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 642 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -361,7 +361,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 641 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 642 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2132 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2133 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 878f9abf..cf40fddd 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -386,3 +386,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 641 keys in each language. - **Files**: `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `README.md`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-242 · 2026-09-24 · The CoT review's last English words in Traditional Chinese · #fix #ai #i18n + +- **What**: in Traditional Chinese the CoT review panel still showed English: its window title (`Prompt Sender UI`), the start of each failed file's line in the answer view (`Error sending:`) and the title of the warning for an empty URL, a `"Warning"` literal in the code. The save dialog of every tool tab offered `Text (*.txt)`. Found scanning the Traditional Chinese dictionary for values with no Chinese in them. +- **Fix**: `cot_gui_window_title` (`Prompt 傳送介面`), `cot_gui_error_sending` (`傳送失敗:`) and `output_actions_filter_text` (`文字檔 (*.txt)`) translated; the warning takes its title from a new `cot_gui_warning_title` (`Warning` / `警告`). The Chinese values still without Chinese are names, URLs, examples and file filters. +- **Tests**: `test_ai_gui_lifecycle.py` +1 (the empty-URL warning in Traditional Chinese, title and text). +- **Result / numbers**: 642 keys in each language. 2133 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ai_gui_lifecycle.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index b70a8559..fe3cf428 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-242 | 2026-09-24 | The CoT review's last English words in Traditional Chinese | #fix #ai #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-241 | 2026-09-24 | Drop five translations nothing shows | #cleanup #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-240 | 2026-09-24 | A Save As button in the diagram editor | #fix #diagram #ui | [2026-09-b](2026-09-b.md) | | U-20260924-239 | 2026-09-24 | Why JupyterLab did not start, in the IDE language | #fix #jupyter #i18n | [2026-09-b](2026-09-b.md) | @@ -323,4 +324,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 41 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 42 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index c53152e5..8825afbb 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -345,6 +345,7 @@ "cot_gui_label_prompt_area": "Prompt Area", "cot_gui_label_response_area": "Response Area", "cot_gui_button_send": "Start Sending", + "cot_gui_warning_title": "Warning", "cot_gui_error_no_url": "Please enter the API URL first!", "cot_gui_error_sending": "Error sending:", # Skills GUI diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index ee3f2c8e..3f7d7417 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -337,15 +337,16 @@ "extend_tools_menu_skill_prompt_send_dock_action": "Skill Prompt 傳送停駐窗格", "extend_tools_menu_skill_prompt_send_dock_title": "Skill 提示詞傳送 GUI", # CoT code-review GUI - "cot_gui_window_title": "Prompt Sender UI", + "cot_gui_window_title": "Prompt 傳送介面", "cot_gui_label_api_url": "API URL:", "cot_gui_placeholder_api_url": "請輸入要傳送的 API URL,例如 http://127.0.0.1:5000/api", "cot_gui_placeholder_code_paste_area": "這裡會顯示要傳送的 Prompt 內容", "cot_gui_label_prompt_area": "傳送資料區域", "cot_gui_label_response_area": "回傳區域", "cot_gui_button_send": "開始傳送", + "cot_gui_warning_title": "警告", "cot_gui_error_no_url": "請先輸入 API URL!", - "cot_gui_error_sending": "Error sending:", + "cot_gui_error_sending": "傳送失敗:", # Skills GUI "skills_error_status": "錯誤: {status_code}\n{text}", "skills_exception": "發生例外: {error}", @@ -421,7 +422,7 @@ "output_actions_save_dialog_title": "儲存輸出", "output_actions_filter_python": "Python (*.py)", "output_actions_filter_json": "JSON (*.json)", - "output_actions_filter_text": "Text (*.txt)", + "output_actions_filter_text": "文字檔 (*.txt)", # JWT 解碼器 — 選單 "extend_tools_menu_jwt_decoder_tab_action": "JWT 解碼器分頁", "extend_tools_menu_jwt_decoder_tab_label": "JWT 解碼器", 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 cc34d9fd..5f7cbfc5 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 @@ -70,7 +70,8 @@ def start_sending(self): # 取得 URL url = self.url_input.text().strip() if not url: - QMessageBox.warning(self, "Warning", language_wrapper.language_word_dict.get("cot_gui_error_no_url")) + word = language_wrapper.language_word_dict + QMessageBox.warning(self, word.get("cot_gui_warning_title"), word.get("cot_gui_error_no_url")) return # The URL is checked by the worker, which reports a refusal as the # "error" answer: checked here too, its DNS lookup froze the IDE. diff --git a/test/test_utils/test_ai_gui_lifecycle.py b/test/test_utils/test_ai_gui_lifecycle.py index e5c5bdee..72242496 100644 --- a/test/test_utils/test_ai_gui_lifecycle.py +++ b/test/test_utils/test_ai_gui_lifecycle.py @@ -121,3 +121,21 @@ def test_no_thread_just_accepts(self): SkillsSendGUI.closeEvent(gui, event) event.accept.assert_called_once() + + +class TestCoTWithoutAUrl: + def test_the_warning_speaks_the_ide_language(self, qapp, monkeypatch): + # Its title was "Warning" whatever the IDE spoke + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict as word, + ) + from pybreeze.pybreeze_ui.extend_ai_gui.code_review import cot_code_review_gui as cot_mod + monkeypatch.setattr(cot_mod.language_wrapper, "language_word_dict", word) + shown: list = [] + monkeypatch.setattr(cot_mod.QMessageBox, "warning", lambda *args: shown.append(args[1:3])) + gui = CoTCodeReviewGUI() + + gui.start_sending() + + assert shown == [("警告", "請先輸入 API URL!")] + gui.deleteLater() From cece5e68ad1ca6aa0e10df595d04041ca09f5568 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:26:41 +0800 Subject: [PATCH 284/312] Write the multi-script menu entries in Chinese, each word once --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../extend_traditional_chinese.py | 16 ++++++++-------- test/test_utils/test_language_parity.py | 10 ++++++++++ 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index d5dc62b5..9e963f21 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2133 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2134 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index cf40fddd..46f37922 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -395,3 +395,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 642 keys in each language. 2133 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ai_gui_lifecycle.py`, `README.md`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-243 · 2026-09-24 · Multi-script menu entries read as Chinese, without a word twice · #fix #i18n + +- **What**: in Traditional Chinese the automation menus of Autocontrol, FileAutomation, LoadDensity and WebRunner offered `運行 Multi WebRunner 腳本` and `運行 Multi WebRunner 腳本 腳本並寄信`: an English word, and in the four send-mail entries `腳本` twice. APITestka's own entries already read `運行多個 APITestka 腳本`. Found scanning the Traditional Chinese dictionary for a word written twice. +- **Fix**: the eight entries read `運行多個 腳本` and `運行多個 腳本並寄信`. `運行` and `插件` stay: JEditor's own Run and Plugins menus use them, and PyBreeze's entries sit beside those in the same menu bar. +- **Tests**: `test_language_parity.py` +1 (no value in either language writes a word twice, before a space or directly before Chinese text), which fails on the old dictionary. +- **Result / numbers**: 2134 tests in 121 files. +- **Files**: `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index fe3cf428..50feee8d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-243 | 2026-09-24 | Multi-script menu entries read as Chinese, without a word twice | #fix #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-242 | 2026-09-24 | The CoT review's last English words in Traditional Chinese | #fix #ai #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-241 | 2026-09-24 | Drop five translations nothing shows | #cleanup #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-240 | 2026-09-24 | A Save As button in the diagram editor | #fix #diagram #ui | [2026-09-b](2026-09-b.md) | @@ -324,4 +325,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 42 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 43 | diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 3f7d7417..d4916b32 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -37,8 +37,8 @@ "autocontrol_menu_label": "Autocontrol", "autocontrol_run_script_label": "運行 Autocontrol 腳本", "autocontrol_run_script_with_send_label": "運行 Autocontrol 腳本並寄信", - "autocontrol_run_multi_script_label": "運行 Multi Autocontrol 腳本", - "autocontrol_run_multi_script_with_send_label": "運行 Multi Autocontrol 腳本 腳本並寄信", + "autocontrol_run_multi_script_label": "運行多個 Autocontrol 腳本", + "autocontrol_run_multi_script_with_send_label": "運行多個 Autocontrol 腳本並寄信", "autocontrol_doc_label": "開啟 Autocontrol 文件", "autocontrol_doc_tab_label": "Autocontrol 文件", "autocontrol_github_label": "開啟 Autocontrol GitHub", @@ -53,8 +53,8 @@ "file_automation_menu_label": "FileAutomation", "file_automation_run_script_label": "運行 FileAutomation 腳本", "file_automation_run_script_with_send_label": "運行 FileAutomation 腳本並寄信", - "file_automation_run_multi_script_label": "運行 Multi FileAutomation 腳本", - "file_automation_run_multi_script_with_send_label": "運行 Multi FileAutomation 腳本 腳本並寄信", + "file_automation_run_multi_script_label": "運行多個 FileAutomation 腳本", + "file_automation_run_multi_script_with_send_label": "運行多個 FileAutomation 腳本並寄信", "file_automation_doc_label": "開啟 FileAutomation 文件", "file_automation_doc_tab_label": "FileAutomation 文件", "file_automation_github_label": "開啟 FileAutomation GitHub", @@ -64,8 +64,8 @@ "load_density_menu_label": "LoadDensity", "load_density_run_script_label": "運行 LoadDensity 腳本", "load_density_run_script_with_send_label": "運行 LoadDensity 腳本並寄信", - "load_density_run_multi_script_label": "運行 Multi LoadDensity 腳本", - "load_density_run_multi_script_with_send_label": "運行 Multi LoadDensity 腳本 腳本並寄信", + "load_density_run_multi_script_label": "運行多個 LoadDensity 腳本", + "load_density_run_multi_script_with_send_label": "運行多個 LoadDensity 腳本並寄信", "load_density_doc_label": "開啟 LoadDensity 文件", "load_density_doc_tab_label": "LoadDensity 文件", "load_density_github_label": "開啟 LoadDensity GitHub", @@ -83,8 +83,8 @@ "web_runner_menu_label": "WebRunner", "web_runner_run_script_label": "運行 WebRunner 腳本", "web_runner_run_script_with_send_label": "運行 WebRunner 腳本並寄信", - "web_runner_run_multi_script_label": "運行 Multi WebRunner 腳本", - "web_runner_run_multi_script_with_send_label": "運行 Multi WebRunner 腳本 腳本並寄信", + "web_runner_run_multi_script_label": "運行多個 WebRunner 腳本", + "web_runner_run_multi_script_with_send_label": "運行多個 WebRunner 腳本並寄信", "web_runner_doc_label": "開啟 WebRunner 文件", "web_runner_doc_tab_label": "WebRunner 文件", "web_runner_github_label": "開啟 WebRunner GitHub", diff --git a/test/test_utils/test_language_parity.py b/test/test_utils/test_language_parity.py index 70685f08..43191802 100644 --- a/test/test_utils/test_language_parity.py +++ b/test/test_utils/test_language_parity.py @@ -58,6 +58,16 @@ def test_placeholders_match_across_languages(self): } assert not mismatched, f"Placeholder mismatches between languages: {mismatched}" + def test_no_word_is_written_twice(self): + # Four Chinese menu entries read "運行 Multi WebRunner 腳本 腳本並寄信" + doubled = { + key: value + for words in (EN, ZH) + for key, value in words.items() + if re.search(r"(? Date: Thu, 24 Sep 2026 11:44:20 +0800 Subject: [PATCH 285/312] Follow symbolic links in SFTP listings, so a link to a folder opens --- architecture_explore.md | 4 +- docs/updates/2026-09-b.md | 9 +++ docs/updates/README.md | 3 +- .../connect_gui/ssh/sftp_session.py | 26 +++++++- test/test_utils/test_sftp_listing.py | 59 +++++++++++++++++++ 5 files changed, 96 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 9e963f21..b47c6eb0 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (691) + `sftp_session.py` (427) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (703) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序;列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密,而且給的密語解不開它;用 `cryptography` 試解)還是不支援的私鑰(例如加密的 DSA) | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2134 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2138 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 46f37922..be0c406b 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -404,3 +404,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2134 tests in 121 files. - **Files**: `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260924-244 · 2026-09-24 · A link to a folder opens as a folder in the SFTP tree · #fix #ssh + +- **What**: an SFTP listing describes a symbolic link itself (OpenSSH's server reads each entry with `lstat`), and the tree took everything that was not a directory for a file. A link to a folder -- `/bin`, `/lib`, `/sbin` at the root of most current Linux systems, or a `current` release link -- showed as a file of a few bytes: it could not be opened, and Download fetched a folder. Found reading the tree for logic keyed on what a row shows. +- **Fix**: `SFTPClientWrapper.list_dir` looks each link up with `stat` (`_follow_link`, in the same hold of the session) and gives the entry its target's type and size; a link leading nowhere stays a link and shows as a file, as before. +- **Tests**: `test_sftp_listing.py` +4 (a link to a folder is a folder; a link to a file carries the file's size; a dangling link stays a link; only links are looked up). +- **Result / numbers**: 2138 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py`, `test/test_utils/test_sftp_listing.py`, `architecture_explore.md` (line counts re-measured; the tree's had not been updated with U-20260924-237) +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 50feee8d..c398d2ee 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-244 | 2026-09-24 | A link to a folder opens as a folder in the SFTP tree | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260924-243 | 2026-09-24 | Multi-script menu entries read as Chinese, without a word twice | #fix #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-242 | 2026-09-24 | The CoT review's last English words in Traditional Chinese | #fix #ai #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-241 | 2026-09-24 | Drop five translations nothing shows | #cleanup #i18n | [2026-09-b](2026-09-b.md) | @@ -325,4 +326,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 43 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 44 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py b/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py index bfff80f2..fbcef5b8 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py @@ -222,11 +222,16 @@ def _session(self, wait: float | None = None) -> Iterator[paramiko.SFTPClient]: def list_dir(self, path: str): """ - List directory entries with stat attributes. Worker thread. + List directory entries with stat attributes, a symbolic link with its + target's (``_follow_link``). Worker thread. 列出目錄項目(含屬性)。 """ with self._session() as sftp: - return sftp.listdir_attr(path) + entries = sftp.listdir_attr(path) + for entry in entries: + if entry.st_mode is not None and stat.S_ISLNK(entry.st_mode): + _follow_link(sftp, remote_join(path, entry.filename), entry) + return entries def mkdir(self, path: str): """ @@ -314,6 +319,23 @@ def upload(self, local_path: str, remote_path: str, replace: bool = False) -> bo return True +def _follow_link(sftp: paramiko.SFTPClient, path: str, entry: paramiko.SFTPAttributes) -> None: + """Give *entry*, a symbolic link at *path*, the type and size of what it points to. + + A listing describes a link itself (the server reads it with ``lstat``), so + a link to a folder -- ``/bin`` and ``/lib`` on most Linux systems -- showed + as a file: it could not be opened, and Download fetched a folder. A link + that leads nowhere is left as it is. + """ + try: + target = sftp.stat(path) + except OSError as error: + pybreeze_logger.debug("SFTP link %s leads nowhere: %r", path, error) + return + entry.st_mode = target.st_mode + entry.st_size = target.st_size + + def _remote_exists(sftp: paramiko.SFTPClient, path: str) -> bool: """Whether *path* exists on the server.""" try: diff --git a/test/test_utils/test_sftp_listing.py b/test/test_utils/test_sftp_listing.py index 7a14339b..ea04b53a 100644 --- a/test/test_utils/test_sftp_listing.py +++ b/test/test_utils/test_sftp_listing.py @@ -148,3 +148,62 @@ def test_closing_mid_listing_lets_it_run_out(self, app): assert is_kept(listing) client.gate("/").set() _wait_for(lambda: not is_kept(listing)) + + +class TestLinks: + """A listing describes a link itself: a link to a folder (``/bin`` on most Linux systems) showed as a file.""" + + class _Server: + def __init__(self) -> None: + link = stat.S_IFLNK | 0o777 + self.listing = [ + SimpleNamespace(filename="bin", st_mode=link, st_size=7), + SimpleNamespace(filename="latest.log", st_mode=link, st_size=9), + SimpleNamespace(filename="gone", st_mode=link, st_size=4), + SimpleNamespace(filename="etc", st_mode=stat.S_IFDIR | 0o755, st_size=4096), + ] + self.targets = { + "/bin": SimpleNamespace(st_mode=stat.S_IFDIR | 0o755, st_size=4096), + "/latest.log": SimpleNamespace(st_mode=stat.S_IFREG | 0o644, st_size=1234), + } + self.stats: list[str] = [] + + def listdir_attr(self, _path: str): + return self.listing + + def stat(self, path: str): + self.stats.append(path) + if path not in self.targets: + raise FileNotFoundError(path) + return self.targets[path] + + def _listed(self, server) -> dict: + from pybreeze.pybreeze_ui.connect_gui.ssh.sftp_session import SFTPClientWrapper + + wrapper = SFTPClientWrapper() + wrapper._ssh = SimpleNamespace(close=lambda: None) + wrapper._sftp = server + return {entry.filename: entry for entry in wrapper.list_dir("/")} + + def test_a_link_to_a_folder_is_a_folder(self, app): + entries = self._listed(self._Server()) + + assert stat.S_ISDIR(entries["bin"].st_mode) + + def test_a_link_to_a_file_has_the_files_size(self, app): + entries = self._listed(self._Server()) + + assert stat.S_ISREG(entries["latest.log"].st_mode) + assert entries["latest.log"].st_size == 1234 + + def test_a_link_leading_nowhere_stays_a_link(self, app): + entries = self._listed(self._Server()) + + assert stat.S_ISLNK(entries["gone"].st_mode) + + def test_only_links_are_looked_up(self, app): + server = self._Server() + + self._listed(server) + + assert sorted(server.stats) == ["/bin", "/gone", "/latest.log"] From 8adfc9cc6dc46b86faf647f122334c7f6b7ef6fb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:45:24 +0800 Subject: [PATCH 286/312] Remove an event filter the SFTP tree never installs --- README.md | 2 +- architecture_explore.md | 8 +++---- docs/updates/2026-09-b.md | 9 ++++++++ docs/updates/README.md | 3 ++- .../extend_multi_language/extend_english.py | 1 - .../extend_traditional_chinese.py | 1 - .../connect_gui/ssh/ssh_file_viewer_widget.py | 22 ++++--------------- 7 files changed, 20 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index c2f2ae19..c8c5417b 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 642 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 641 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index b47c6eb0..66d8e9f8 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 642 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 641 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 642 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 641 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (703) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序;列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (689) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序;列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密,而且給的密語解不開它;用 `cryptography` 試解)還是不支援的私鑰(例如加密的 DSA) | @@ -361,7 +361,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 642 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 641 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index be0c406b..8bae1601 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -413,3 +413,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2138 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py`, `test/test_utils/test_sftp_listing.py`, `architecture_explore.md` (line counts re-measured; the tree's had not been updated with U-20260924-237) - **Open items**: none. + +## U-20260924-245 · 2026-09-24 · Remove the SFTP tree's event filter nothing installs · #cleanup #ssh + +- **What**: `SSHFileTreeManager.eventFilter` pressed an OK button on Enter in an "improvised input dialog" that no longer exists: names are asked for with `QInputDialog.getText`, which handles Enter itself, and nothing installs the filter. It was the only reader of `ssh_file_viewer_dialog_button_ok`, and the only user of three imports. +- **Fix**: removed the method, the imports (`QEvent`, `QLineEdit`, `QPushButton`) and the key from both dictionaries; `get_text`'s docstring says what it does. +- **Tests**: the SFTP tree tests pass unchanged. No behaviour change. +- **Result / numbers**: 641 keys in each language. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index c398d2ee..9d2addb3 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-245 | 2026-09-24 | Remove the SFTP tree's event filter nothing installs | #cleanup #ssh | [2026-09-b](2026-09-b.md) | | U-20260924-244 | 2026-09-24 | A link to a folder opens as a folder in the SFTP tree | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260924-243 | 2026-09-24 | Multi-script menu entries read as Chinese, without a word twice | #fix #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-242 | 2026-09-24 | The CoT review's last English words in Traditional Chinese | #fix #ai #i18n | [2026-09-b](2026-09-b.md) | @@ -326,4 +327,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 44 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 45 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 8825afbb..2a4792b5 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -239,7 +239,6 @@ "ssh_file_viewer_dialog_title_select_local_file": "Select local file to upload", "ssh_file_viewer_dialog_title_uploaded": "Uploaded", "ssh_file_viewer_dialog_message_uploaded_to": "Uploaded to", - "ssh_file_viewer_dialog_button_ok": "OK", "ssh_file_viewer_window_title_file_tree_manager": "SSH File TreeView Manager", "ssh_file_viewer_tree_header_name": "Name", "ssh_file_viewer_tree_header_type": "Type", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index d4916b32..eed13b26 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -238,7 +238,6 @@ "ssh_file_viewer_dialog_title_select_local_file": "選擇要上傳的本地檔案", "ssh_file_viewer_dialog_title_uploaded": "已上傳", "ssh_file_viewer_dialog_message_uploaded_to": "已上傳至", - "ssh_file_viewer_dialog_button_ok": "確定", "ssh_file_viewer_window_title_file_tree_manager": "SSH 檔案樹狀管理器", "ssh_file_viewer_tree_header_name": "名稱", "ssh_file_viewer_tree_header_type": "類型", diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 4de6c7b4..c9a8730c 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -11,9 +11,9 @@ import stat from collections.abc import Callable -from PySide6.QtCore import Qt, QEvent, QThread, Signal +from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QLineEdit, QPushButton, QTreeWidget, QTreeWidgetItem, + QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QMenu, QFileDialog, QMessageBox, QSplitter, QInputDialog, QStyle ) from je_editor import language_wrapper @@ -682,22 +682,8 @@ def _transfer_failed(self, error_message: str) -> None: def get_text(self, title: str, label: str): """ - Simple input dialog using QMessageBox alternative. - 簡易文字輸入對話框(基於 QLineEdit)。 + Ask for one line of text; ``(text, ok)`` as ``QInputDialog.getText`` gives it. + 詢問一行文字。 """ text, ok = QInputDialog.getText(self, title, label) return text, ok - - def eventFilter(self, obj, event): - """ - Allow Enter to trigger OK in our improvised input dialog. - 允許在自製輸入框中使用 Enter 觸發確定。 - """ - if isinstance(obj, QLineEdit) and event.type() == QEvent.Type.KeyPress: - if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): - w = obj.window() - for b in w.findChildren(QPushButton): - if b.text().lower() in (self.word_dict.get("ssh_file_viewer_dialog_button_ok"),): - b.click() - return True - return super().eventFilter(obj, event) From 1b1d511aa0d6478715d8ed0ada3de1a9ba83dac7 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:46:20 +0800 Subject: [PATCH 287/312] Refactor: keep each SFTP tree item's kind in item data --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++ docs/updates/README.md | 3 ++- .../connect_gui/ssh/ssh_file_viewer_widget.py | 27 ++++++++++++++----- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 66d8e9f8..d7f1626e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (689) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序;列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (702) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密,而且給的密語解不開它;用 `cryptography` 試解)還是不支援的私鑰(例如加密的 DSA) | diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 8bae1601..120b1c33 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -422,3 +422,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 641 keys in each language. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `README.md`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-246 · 2026-09-24 · The SFTP tree tells folders from files by item data, not by its Type column · #refactor #ssh + +- **What**: seven places in the SFTP tree decided folder or file by comparing the Type column's text with `"dir"` / `"file"`, so that column could not be translated without breaking Refresh, Delete, Download, Upload and folder creation. +- **Change**: `make_item` stores the kind under `KIND_ROLE`; `is_folder()` and `is_file()` read it, and `folder_item()` and the menu actions use them. The column still shows `dir` / `file`. +- **Tests**: the SFTP tree, listing, upload and teardown tests (63) pass unchanged. No behaviour change. +- **Result / numbers**: `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 9d2addb3..dddf7444 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-246 | 2026-09-24 | The SFTP tree tells folders from files by item data, not by its Type column | #refactor #ssh | [2026-09-b](2026-09-b.md) | | U-20260924-245 | 2026-09-24 | Remove the SFTP tree's event filter nothing installs | #cleanup #ssh | [2026-09-b](2026-09-b.md) | | U-20260924-244 | 2026-09-24 | A link to a folder opens as a folder in the SFTP tree | #fix #ssh | [2026-09-b](2026-09-b.md) | | U-20260924-243 | 2026-09-24 | Multi-script menu entries read as Chinese, without a word twice | #fix #i18n | [2026-09-b](2026-09-b.md) | @@ -327,4 +328,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 45 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 46 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index c9a8730c..9aee6e50 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -53,6 +53,8 @@ def format_size(num_bytes: int) -> str: # Item data: set on the row a folder shows until it is first expanded. Told # apart by its text, "...", a server entry named "..." was taken for it PLACEHOLDER_ROLE = Qt.ItemDataRole.UserRole + 1 +# Item data: what an entry is, "dir" or "file"; the placeholder and loading rows have none +KIND_ROLE = Qt.ItemDataRole.UserRole + 2 # What a Windows file name cannot hold @@ -72,9 +74,19 @@ def local_file_name(remote_path: str) -> str: return name or "download" +def is_folder(item: QTreeWidgetItem) -> bool: + """Whether *item* is a remote folder.""" + return item.data(0, KIND_ROLE) == "dir" + + +def is_file(item: QTreeWidgetItem) -> bool: + """Whether *item* is a remote file (not a folder, a placeholder or a loading row).""" + return item.data(0, KIND_ROLE) == "file" + + def folder_item(item: QTreeWidgetItem | None) -> QTreeWidgetItem | None: """The tree item of the folder *item* is in, or *item* itself when it is a folder.""" - if item is None or item.text(1) == "dir": + if item is None or is_folder(item): return item return item.parent() @@ -274,6 +286,7 @@ def make_item(self, name: str, typ: str, size: int, full_path: str) -> QTreeWidg """ size_text = "" if typ == "dir" else format_size(size) item = QTreeWidgetItem([name, typ, size_text, full_path]) + item.setData(0, KIND_ROLE, typ) if typ == "dir": # Use QStyle enum for standard icons # 使用 QStyle 列舉取得標準資料夾圖示 @@ -424,7 +437,7 @@ def action_refresh(self, item: QTreeWidgetItem | None): return # Clear and reload target.takeChildren() - if target.text(1) == "dir": + if is_folder(target): self.add_placeholder(target) self.on_item_expanded(target) @@ -440,7 +453,7 @@ def action_create_folder(self, item: QTreeWidgetItem | None): self.word_dict.get("ssh_file_viewer_dialog_message_select_folder_to_create")) return base_path = item.text(3) - if item.text(1) != "dir": + if not is_folder(item): base_path = posixpath.dirname(base_path) name, ok = self.get_text( self.word_dict.get("ssh_file_viewer_dialog_title_create_folder"), @@ -525,7 +538,7 @@ def _renamed(self, item: QTreeWidgetItem, new_name: str, new_path: str) -> None: """Show *item* under its new name once the server has renamed it. UI thread.""" item.setText(0, new_name) item.setText(3, new_path) - if item.text(1) == "dir" and item.childCount() and not self.is_placeholder_present(item): + if is_folder(item) and item.childCount() and not self.is_placeholder_present(item): # Its loaded children still carried the old path, and a download, # delete or rename of one went there: list them again self.action_refresh(item) @@ -546,7 +559,7 @@ def action_delete(self, item: QTreeWidgetItem | None): ) if reply != QMessageBox.StandardButton.Yes: return - remove = self.client.remove_dir if item.text(1) == "dir" else self.client.remove_file + remove = self.client.remove_dir if is_folder(item) else self.client.remove_file self._in_background(lambda: remove(path), lambda: self._removed(item)) def _removed(self, item: QTreeWidgetItem) -> None: @@ -562,7 +575,7 @@ def action_download(self, item: QTreeWidgetItem | None): Download selected file to local. 將選定檔案下載至本地。 """ - if item is None or item.text(1) != "file": + if item is None or not is_file(item): QMessageBox.information( self, self.word_dict.get("ssh_file_viewer_dialog_title_invalid_selection"), @@ -588,7 +601,7 @@ def action_upload(self, item: QTreeWidgetItem | None): """ if item is None: return - target_dir = item.text(3) if item.text(1) == "dir" else posixpath.dirname(item.text(3)) + target_dir = item.text(3) if is_folder(item) else posixpath.dirname(item.text(3)) local_path, _ = QFileDialog.getOpenFileName( self, self.word_dict.get("ssh_file_viewer_dialog_title_select_local_file"), "") if not local_path: From 49921824b2b0b2ad78320e6a394d6d1c59f883eb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:47:08 +0800 Subject: [PATCH 288/312] Show the SFTP tree's Type column in the IDE language --- README.md | 2 +- architecture_explore.md | 8 ++++---- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/extend_multi_language/extend_english.py | 2 ++ .../extend_multi_language/extend_traditional_chinese.py | 2 ++ .../connect_gui/ssh/ssh_file_viewer_widget.py | 3 ++- test/test_utils/test_sftp_tree_actions.py | 4 ++++ 8 files changed, 26 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c8c5417b..b127cfc8 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 641 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 643 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index d7f1626e..cbf67cc2 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 641 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 643 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 641 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 643 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (702) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (703) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密,而且給的密語解不開它;用 `cryptography` 試解)還是不支援的私鑰(例如加密的 DSA) | @@ -361,7 +361,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 641 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 643 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 120b1c33..20e5a777 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -431,3 +431,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-247 · 2026-09-24 · The SFTP tree's Type column in the IDE language · #fix #ssh #i18n + +- **What**: the SFTP tree's Type column showed `dir` and `file`, internal words, in English whatever the IDE spoke. With the kind kept in item data (U-20260924-246) the column no longer drives anything. +- **Fix**: `make_item` shows `ssh_file_viewer_type_dir` / `ssh_file_viewer_type_file` (`Folder` / `File`, `資料夾` / `檔案`). +- **Tests**: `test_sftp_tree_actions.py`'s Traditional Chinese test also checks the Type column and that the translated rows are still a folder and a file. +- **Result / numbers**: 643 keys in each language. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_sftp_tree_actions.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index dddf7444..ad64729e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-247 | 2026-09-24 | The SFTP tree's Type column in the IDE language | #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-246 | 2026-09-24 | The SFTP tree tells folders from files by item data, not by its Type column | #refactor #ssh | [2026-09-b](2026-09-b.md) | | U-20260924-245 | 2026-09-24 | Remove the SFTP tree's event filter nothing installs | #cleanup #ssh | [2026-09-b](2026-09-b.md) | | U-20260924-244 | 2026-09-24 | A link to a folder opens as a folder in the SFTP tree | #fix #ssh | [2026-09-b](2026-09-b.md) | @@ -328,4 +329,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 46 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 47 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 2a4792b5..c07ea4d7 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -244,6 +244,8 @@ "ssh_file_viewer_tree_header_type": "Type", "ssh_file_viewer_tree_header_size": "Size", "ssh_file_viewer_tree_header_path": "Path", + "ssh_file_viewer_type_dir": "Folder", + "ssh_file_viewer_type_file": "File", "ssh_file_viewer_dialog_title_missing_input": "Missing input", "ssh_file_viewer_dialog_message_missing_input": "Host, user, and password are required.", "ssh_file_viewer_dialog_title_connection_failed": "Connection failed", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index eed13b26..59cd0427 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -243,6 +243,8 @@ "ssh_file_viewer_tree_header_type": "類型", "ssh_file_viewer_tree_header_size": "大小", "ssh_file_viewer_tree_header_path": "路徑", + "ssh_file_viewer_type_dir": "資料夾", + "ssh_file_viewer_type_file": "檔案", "ssh_file_viewer_dialog_title_missing_input": "缺少輸入", "ssh_file_viewer_dialog_message_missing_input": "必須輸入主機、使用者與密碼。", "ssh_file_viewer_dialog_title_connection_failed": "連線失敗", diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 9aee6e50..21c93452 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -285,7 +285,8 @@ def make_item(self, name: str, typ: str, size: int, full_path: str) -> QTreeWidg 建立帶有中繼資料的樹狀項目。 """ size_text = "" if typ == "dir" else format_size(size) - item = QTreeWidgetItem([name, typ, size_text, full_path]) + kind_text = self.word_dict.get(f"ssh_file_viewer_type_{typ}") + item = QTreeWidgetItem([name, kind_text, size_text, full_path]) item.setData(0, KIND_ROLE, typ) if typ == "dir": # Use QStyle enum for standard icons diff --git a/test/test_utils/test_sftp_tree_actions.py b/test/test_utils/test_sftp_tree_actions.py index 3b4b931b..159e97e5 100644 --- a/test/test_utils/test_sftp_tree_actions.py +++ b/test/test_utils/test_sftp_tree_actions.py @@ -284,6 +284,10 @@ def test_speaks_the_ide_language(self, app, monkeypatch): assert shown == ["重新整理", "建立資料夾", "重新命名", "刪除", "下載", "上傳至此資料夾"] assert [headers.text(i) for i in range(4)] == ["名稱", "類型", "大小", "路徑"] + # The Type column read "dir" / "file" + folder, file = widget.make_item("a", "dir", 0, "/a"), widget.make_item("b", "file", 5, "/b") + assert (folder.text(1), file.text(1)) == ("資料夾", "檔案") + assert tree_mod.is_folder(folder) and tree_mod.is_file(file) finally: widget.close() widget.deleteLater() From 6ebd28aedd67354491e8be10535f9bd6ef4fba55 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:48:11 +0800 Subject: [PATCH 289/312] Start an SFTP rename from the current name, and send nothing when it is unchanged --- architecture_explore.md | 4 +-- docs/updates/2026-09-b.md | 9 ++++++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_file_viewer_widget.py | 15 +++++----- test/test_utils/test_sftp_tree_actions.py | 30 +++++++++++++++++++ 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index cbf67cc2..a93feaf0 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (703) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (704) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密,而且給的密語解不開它;用 `cryptography` 試解)還是不支援的私鑰(例如加密的 DSA) | @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2138 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2141 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 20e5a777..a1c27c12 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -440,3 +440,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 643 keys in each language. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_sftp_tree_actions.py`, `README.md`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-248 · 2026-09-24 · SFTP Rename starts from the current name · #fix #ssh #ui + +- **What**: Rename in the SFTP tree opened an empty box, so changing one letter of a name meant typing all of it; and giving the same name sent a rename of the entry onto itself, which some servers answer with a failure. +- **Fix**: `get_text` takes the text to start from and passes it to `QInputDialog.getText`; Rename starts from the entry's name, and a name that comes back unchanged (spaces aside) sends nothing. +- **Tests**: `test_sftp_tree_actions.py` +3 (the box starts from the current name; the same name sends nothing; the dialog is given the starting text). +- **Result / numbers**: last full unit run 2119 passed, 15 skipped (as of `cece5e6`). 2141 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_sftp_tree_actions.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index ad64729e..229c3e86 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-248 | 2026-09-24 | SFTP Rename starts from the current name | #fix #ssh #ui | [2026-09-b](2026-09-b.md) | | U-20260924-247 | 2026-09-24 | The SFTP tree's Type column in the IDE language | #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-246 | 2026-09-24 | The SFTP tree tells folders from files by item data, not by its Type column | #refactor #ssh | [2026-09-b](2026-09-b.md) | | U-20260924-245 | 2026-09-24 | Remove the SFTP tree's event filter nothing installs | #cleanup #ssh | [2026-09-b](2026-09-b.md) | @@ -329,4 +330,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 47 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 48 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 21c93452..52153d90 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -13,7 +13,7 @@ from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem, + QWidget, QVBoxLayout, QLineEdit, QTreeWidget, QTreeWidgetItem, QMenu, QFileDialog, QMessageBox, QSplitter, QInputDialog, QStyle ) from je_editor import language_wrapper @@ -523,8 +523,9 @@ def action_rename(self, item: QTreeWidgetItem | None): old_path = item.text(3) new_name, ok = self.get_text( self.word_dict.get("ssh_file_viewer_dialog_title_rename"), - f"{self.word_dict.get('ssh_file_viewer_dialog_label_new_name_for_item')}: {item.text(0)}") - if not ok or not new_name.strip(): + f"{self.word_dict.get('ssh_file_viewer_dialog_label_new_name_for_item')}: {item.text(0)}", + item.text(0)) + if not ok or not new_name.strip() or new_name.strip() == item.text(0): return new_name = self._checked_name(new_name) if new_name is None: @@ -694,10 +695,10 @@ def _transfer_failed(self, error_message: str) -> None: as_text(f"{self.word_dict.get('ssh_file_viewer_dialog_message_operation_failed')}: " f"{error_message}")) - def get_text(self, title: str, label: str): + def get_text(self, title: str, label: str, text: str = ""): """ - Ask for one line of text; ``(text, ok)`` as ``QInputDialog.getText`` gives it. + Ask for one line of text, starting from *text*; ``(text, ok)`` as ``QInputDialog.getText`` gives it. 詢問一行文字。 """ - text, ok = QInputDialog.getText(self, title, label) - return text, ok + answer, ok = QInputDialog.getText(self, title, label, QLineEdit.EchoMode.Normal, text) + return answer, ok diff --git a/test/test_utils/test_sftp_tree_actions.py b/test/test_utils/test_sftp_tree_actions.py index 159e97e5..6be24f2d 100644 --- a/test/test_utils/test_sftp_tree_actions.py +++ b/test/test_utils/test_sftp_tree_actions.py @@ -302,3 +302,33 @@ def test_on_the_placeholder_acts_on_its_folder(self, tree, monkeypatch): self._open(widget, monkeypatch, folder.child(0), choose=0) assert refreshed == [folder] + + +class TestRenaming: + def test_starts_from_the_current_name(self, tree, monkeypatch): + # The box was empty: changing one letter meant typing the whole name + widget, _client, root, _warnings = tree + asked: list = [] + monkeypatch.setattr(widget, "get_text", lambda *args: asked.append(args) or ("", False)) + + widget.action_rename(_child(root, "notes.txt")) + + assert asked[0][2] == "notes.txt" + + def test_the_same_name_sends_nothing(self, tree, monkeypatch): + widget, client, root, _warnings = tree + _answer(widget, monkeypatch, " notes.txt ") + + widget.action_rename(_child(root, "notes.txt")) + + assert client.renamed == [] + assert not widget._calls + + def test_the_dialog_is_given_the_name(self, tree, monkeypatch): + widget, _client, _root, _warnings = tree + given: list = [] + monkeypatch.setattr(tree_mod.QInputDialog, "getText", + staticmethod(lambda *args: given.append(args) or ("x", True))) + + assert widget.get_text("t", "l", "start") == ("x", True) + assert given[0][-1] == "start" From 7a63ac7aca98ebcaa0c304e5249b4c2deccdf029 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:49:22 +0800 Subject: [PATCH 290/312] Say that SFTP deletes only an empty folder when a folder delete is refused --- README.md | 2 +- architecture_explore.md | 10 +++--- docs/updates/2026-09-b.md | 9 +++++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 2 ++ .../extend_traditional_chinese.py | 1 + .../connect_gui/ssh/ssh_file_viewer_widget.py | 14 +++++++- test/test_utils/test_sftp_tree_actions.py | 36 +++++++++++++++++++ 8 files changed, 69 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b127cfc8..29618d82 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 643 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 644 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index a93feaf0..550f1502 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 643 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 644 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 643 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 644 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (704) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (717) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密,而且給的密語解不開它;用 `cryptography` 試解)還是不支援的私鑰(例如加密的 DSA) | @@ -361,7 +361,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 643 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 644 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2141 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2143 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index a1c27c12..0e9ec31a 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -449,3 +449,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: last full unit run 2119 passed, 15 skipped (as of `cece5e6`). 2141 tests in 121 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_sftp_tree_actions.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260924-249 · 2026-09-24 · Say why an SFTP folder was not deleted · #fix #ssh #ui + +- **What**: SFTP removes only an empty folder. Deleting one with anything in it, OpenSSH answers with a bare `Failure`, and the tree said `Error: Failure`, leaving the user to guess. +- **Fix**: the tree removes a folder through `_remove_folder`, which, when the server refuses, raises the refusal again as `ssh_file_viewer_message_folder_not_removed`: the folder was not deleted, SFTP deletes only an empty folder, and what the server said. A refused file delete is shown as before. +- **Tests**: `test_sftp_tree_actions.py` +2 (a refused folder delete says why and leaves the tree as it was; a refused file delete shows the server's reason alone). +- **Result / numbers**: startup tests exit 0, gates and `ruff check` clean on `6ebd28a`. 644 keys in each language. 2143 tests in 121 files. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_sftp_tree_actions.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 229c3e86..5392cd2f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-249 | 2026-09-24 | Say why an SFTP folder was not deleted | #fix #ssh #ui | [2026-09-b](2026-09-b.md) | | U-20260924-248 | 2026-09-24 | SFTP Rename starts from the current name | #fix #ssh #ui | [2026-09-b](2026-09-b.md) | | U-20260924-247 | 2026-09-24 | The SFTP tree's Type column in the IDE language | #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-246 | 2026-09-24 | The SFTP tree tells folders from files by item data, not by its Type column | #refactor #ssh | [2026-09-b](2026-09-b.md) | @@ -330,4 +331,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 48 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 49 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index c07ea4d7..d1f71088 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -205,6 +205,8 @@ "ssh_file_viewer_dialog_label_new_name_for_item": "New name for", "ssh_file_viewer_dialog_title_confirm_delete": "Confirm delete", "ssh_file_viewer_dialog_message_confirm_delete": "Delete", + "ssh_file_viewer_message_folder_not_removed": + "The folder was not deleted. SFTP deletes only an empty folder: empty it first. The server said: {error}", "ssh_file_viewer_dialog_title_invalid_selection": "Invalid selection", "ssh_file_viewer_dialog_message_select_file_to_download": "Select a file to download.", "ssh_file_viewer_dialog_title_save_as": "Save as", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 59cd0427..83204a35 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -205,6 +205,7 @@ "ssh_file_viewer_dialog_label_new_name_for_item": "新名稱", "ssh_file_viewer_dialog_title_confirm_delete": "確認刪除", "ssh_file_viewer_dialog_message_confirm_delete": "是否刪除", + "ssh_file_viewer_message_folder_not_removed": "資料夾沒有刪除。SFTP 只能刪除空的資料夾,請先清空。伺服器回覆:{error}", "ssh_file_viewer_dialog_title_invalid_selection": "選取無效", "ssh_file_viewer_dialog_message_select_file_to_download": "請選擇要下載的檔案。", "ssh_file_viewer_dialog_title_save_as": "另存新檔", diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 52153d90..746c4a0f 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -561,9 +561,21 @@ def action_delete(self, item: QTreeWidgetItem | None): ) if reply != QMessageBox.StandardButton.Yes: return - remove = self.client.remove_dir if is_folder(item) else self.client.remove_file + remove = self._remove_folder if is_folder(item) else self.client.remove_file self._in_background(lambda: remove(path), lambda: self._removed(item)) + def _remove_folder(self, path: str) -> None: + """Remove the folder at *path*, saying why when it cannot be. Worker thread. + + SFTP removes only an empty folder, and OpenSSH refuses any other with + a bare "Failure", which was all the message said. + """ + try: + self.client.remove_dir(path) + except OSError as error: + raise OSError(self.word_dict.get( + "ssh_file_viewer_message_folder_not_removed").format(error=error)) from error + def _removed(self, item: QTreeWidgetItem) -> None: """Take *item* out of the tree once the server has removed it. UI thread.""" parent = item.parent() diff --git a/test/test_utils/test_sftp_tree_actions.py b/test/test_utils/test_sftp_tree_actions.py index 6be24f2d..8f9d0af9 100644 --- a/test/test_utils/test_sftp_tree_actions.py +++ b/test/test_utils/test_sftp_tree_actions.py @@ -332,3 +332,39 @@ def test_the_dialog_is_given_the_name(self, tree, monkeypatch): assert widget.get_text("t", "l", "start") == ("x", True) assert given[0][-1] == "start" + + +class TestDeletingAFolder: + def test_a_refusal_says_only_an_empty_folder_goes(self, app, tree, monkeypatch): + # OpenSSH refuses a folder with something in it with a bare "Failure" + widget, client, root, _warnings = tree + shown: list = [] + monkeypatch.setattr(QMessageBox, "critical", lambda *args: shown.append(args[2])) + monkeypatch.setattr(QMessageBox, "question", lambda *_args: QMessageBox.StandardButton.Yes) + + def refuse(_path: str) -> None: + raise OSError("Failure") + + client.remove_dir = refuse + widget.action_delete(_child(root, "src")) + _wait_for(lambda: _idle(widget)) + + word = tree_mod.language_wrapper.language_word_dict + expected = word.get("ssh_file_viewer_message_folder_not_removed").format(error="Failure") + assert len(shown) == 1 and expected in shown[0] + assert _child(root, "src").text(3) == "/src" + + def test_a_file_refusal_is_shown_as_it_is(self, app, tree, monkeypatch): + widget, client, root, _warnings = tree + shown: list = [] + monkeypatch.setattr(QMessageBox, "critical", lambda *args: shown.append(args[2])) + monkeypatch.setattr(QMessageBox, "question", lambda *_args: QMessageBox.StandardButton.Yes) + + def refuse(_path: str) -> None: + raise OSError("Permission denied") + + client.remove_file = refuse + widget.action_delete(_child(root, "notes.txt")) + _wait_for(lambda: _idle(widget)) + + assert len(shown) == 1 and "empty" not in shown[0] and "Permission denied" in shown[0] From 0f5b0ddb4d68842c6f2627979f0491e7df1c0a45 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:49:30 +0800 Subject: [PATCH 291/312] Correct a line count in the architecture map --- architecture_explore.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/architecture_explore.md b/architecture_explore.md index 550f1502..87f846ad 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -299,7 +299,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (717) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (716) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密,而且給的密語解不開它;用 `cryptography` 試解)還是不支援的私鑰(例如加密的 DSA) | From 85c0e45145b747cb4989a014f0d9d4e0b5a4c6f5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:50:49 +0800 Subject: [PATCH 292/312] Let Enter connect in the SSH login form, and browse for the key file --- README.md | 4 +- architecture_explore.md | 10 +-- docs/updates/2026-09-b.md | 9 +++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 2 + .../extend_traditional_chinese.py | 2 + .../connect_gui/ssh/ssh_login_widget.py | 20 ++++- test/test_utils/test_ssh_login_form.py | 73 +++++++++++++++++++ 8 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 test/test_utils/test_ssh_login_form.py diff --git a/README.md b/README.md index 29618d82..e75dd797 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ A WYSIWYG `QGraphicsScene` editor: rectangle, rounded, ellipse and diamond nodes ![SSH client](images/ssh_client.png) -Password or private-key authentication, an interactive shell with ANSI handling and keepalive, and a lazy-loading SFTP tree with create-folder / rename / delete / upload / download. Every SFTP request runs in the background, so a stalled link never freezes the IDE. An upload asks before it replaces a file on the server. Both directions write to a temporary file first, so a dropped link leaves the old copy whole. Unknown host keys are **not** auto-accepted: the SHA256 fingerprint is shown for confirmation on first connection (trust on first use) and persisted to `~/.pybreeze/ssh_known_hosts`. +Password or private-key authentication (the key file picked with Browse, starting in `~/.ssh`), an interactive shell with ANSI handling and keepalive, and a lazy-loading SFTP tree with create-folder / rename / delete / upload / download. Every SFTP request runs in the background, so a stalled link never freezes the IDE. An upload asks before it replaces a file on the server. Both directions write to a temporary file first, so a dropped link leaves the old copy whole. Unknown host keys are **not** auto-accepted: the SHA256 fingerprint is shown for confirmation on first connection (trust on first use) and persisted to `~/.pybreeze/ssh_known_hosts`. ### And also @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 644 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 646 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index 87f846ad..3b97133e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 644 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 646 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 644 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 646 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -297,7 +297,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | 檔案 | 職責 | |---|---| | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | -| `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | +| `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`;任一欄按 Enter 就按下連線;金鑰欄旁的「瀏覽...」從 `~/.ssh` 開檔案對話框,選了檔就勾起金鑰驗證) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | | `ssh_file_viewer_widget.py` (716) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | @@ -361,7 +361,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 644 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 646 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 121 個 `test_*.py`、2143 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 122 個 `test_*.py`、2150 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 0e9ec31a..63be34a3 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -458,3 +458,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: startup tests exit 0, gates and `ruff check` clean on `6ebd28a`. 644 keys in each language. 2143 tests in 121 files. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_sftp_tree_actions.py`, `README.md`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-250 · 2026-09-24 · SSH login: Enter connects, and the key file can be browsed for · #feature #ssh #ui + +- **What**: the SSH tab's login form needed the mouse for Connect, and the private key's path had to be typed in full. +- **Change**: Enter in the host, user, password or key field clicks Connect. A Browse button beside the key field opens a file dialog in `~/.ssh` (the home folder when there is none); a chosen file fills the field and ticks key authentication, and cancelling changes nothing. +- **Tests**: new `test_ssh_login_form.py` (7: Enter in each of the four fields; a chosen file fills the path and ticks the box; the dialog starts in `~/.ssh`; cancelling leaves what was typed). +- **Result / numbers**: 646 keys in each language. 2150 tests in 122 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ssh_login_form.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 5392cd2f..0c2f7357 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-250 | 2026-09-24 | SSH login: Enter connects, and the key file can be browsed for | #feature #ssh #ui | [2026-09-b](2026-09-b.md) | | U-20260924-249 | 2026-09-24 | Say why an SFTP folder was not deleted | #fix #ssh #ui | [2026-09-b](2026-09-b.md) | | U-20260924-248 | 2026-09-24 | SFTP Rename starts from the current name | #fix #ssh #ui | [2026-09-b](2026-09-b.md) | | U-20260924-247 | 2026-09-24 | The SFTP tree's Type column in the IDE language | #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | @@ -331,4 +332,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 49 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 50 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index d1f71088..c26ab502 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -265,6 +265,8 @@ "ssh_login_widget_button_use_key_auth": "Use key auth", "ssh_login_widget_button_connect": "Connect", "ssh_login_widget_button_disconnect": "Disconnect", + "ssh_login_widget_button_browse_key": "Browse...", + "ssh_login_widget_dialog_title_choose_key": "Choose a private key", "ssh_login_widget_status_disconnected": "Disconnected", # AI Code Review GUI "ai_code_review_gui_window_title": "AI Code-Review Client", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 83204a35..86f6d661 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -263,6 +263,8 @@ "ssh_login_widget_button_use_key_auth": "使用金鑰驗證", "ssh_login_widget_button_connect": "連線", "ssh_login_widget_button_disconnect": "斷線", + "ssh_login_widget_button_browse_key": "瀏覽...", + "ssh_login_widget_dialog_title_choose_key": "選擇私密金鑰", "ssh_login_widget_status_disconnected": "已斷線", # AI Code Review GUI "ai_code_review_gui_window_title": "AI 程式碼審查客戶端", diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py index 071238a3..fde352eb 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py @@ -1,8 +1,10 @@ from __future__ import annotations +from pathlib import Path + from PySide6.QtWidgets import ( QWidget, QHBoxLayout, QVBoxLayout, QLabel, - QLineEdit, QSpinBox, QCheckBox, QPushButton + QLineEdit, QSpinBox, QCheckBox, QPushButton, QFileDialog ) from je_editor import language_wrapper @@ -20,6 +22,7 @@ def __init__(self, parent=None): self.pass_edit = QLineEdit() self.key_edit = QLineEdit() self.use_key_check = QCheckBox(language_wrapper.language_word_dict.get("ssh_login_widget_button_use_key_auth")) + self.browse_key_btn = QPushButton(language_wrapper.language_word_dict.get("ssh_login_widget_button_browse_key")) self.connect_btn = QPushButton(language_wrapper.language_word_dict.get("ssh_login_widget_button_connect")) self.disconnect_btn = QPushButton(language_wrapper.language_word_dict.get("ssh_login_widget_button_disconnect")) self.status_label = QLabel(language_wrapper.language_word_dict.get("ssh_login_widget_status_disconnected")) @@ -39,6 +42,10 @@ def _setup_ui(self): self.pass_edit.setEchoMode(QLineEdit.EchoMode.Password) self.key_edit.setPlaceholderText( language_wrapper.language_word_dict.get("ssh_login_widget_placeholder_private_key")) + self.browse_key_btn.clicked.connect(self.choose_key_file) + # Enter in any field connects, as in a login form + for edit in (self.host_edit, self.user_edit, self.pass_edit, self.key_edit): + edit.returnPressed.connect(self.connect_btn.click) # 佈局設計 top = QHBoxLayout() @@ -53,6 +60,7 @@ def _setup_ui(self): auth.addWidget(self.use_key_check) auth.addWidget(QLabel(language_wrapper.language_word_dict.get("ssh_login_widget_label_key"))) auth.addWidget(self.key_edit) + auth.addWidget(self.browse_key_btn) auth.addWidget(QLabel(language_wrapper.language_word_dict.get("ssh_login_widget_label_password"))) auth.addWidget(self.pass_edit) @@ -67,3 +75,13 @@ def _setup_ui(self): root.addLayout(conn) self.setLayout(root) + + def choose_key_file(self) -> None: + """Pick the private key file in a dialog, starting in ``~/.ssh``; it also ticks key authentication.""" + start = Path.home() / ".ssh" + path, _ = QFileDialog.getOpenFileName( + self, language_wrapper.language_word_dict.get("ssh_login_widget_dialog_title_choose_key"), + str(start if start.is_dir() else Path.home())) + if path: + self.key_edit.setText(path) + self.use_key_check.setChecked(True) diff --git a/test/test_utils/test_ssh_login_form.py b/test/test_utils/test_ssh_login_form.py new file mode 100644 index 00000000..8fb03273 --- /dev/null +++ b/test/test_utils/test_ssh_login_form.py @@ -0,0 +1,73 @@ +"""The SSH login form: Enter connects, and the key file can be picked instead of typed.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_login_widget as login_mod + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def form(app): + widget = login_mod.LoginWidget() + yield widget + widget.deleteLater() + + +class TestEnter: + @pytest.mark.parametrize("field", ["host_edit", "user_edit", "pass_edit", "key_edit"]) + def test_in_any_field_connects(self, form, field): + # Connecting needed the mouse + clicks: list = [] + form.connect_btn.clicked.connect(lambda: clicks.append(True)) + + getattr(form, field).returnPressed.emit() + + assert clicks == [True] + + +class TestChoosingTheKey: + def test_a_chosen_file_fills_the_path_and_ticks_key_authentication(self, form, monkeypatch, tmp_path): + key = tmp_path / "id_ed25519" + asked: list = [] + monkeypatch.setattr(login_mod.QFileDialog, "getOpenFileName", + staticmethod(lambda *args: asked.append(args) or (str(key), ""))) + + form.browse_key_btn.click() + + assert form.key_edit.text() == str(key) + assert form.use_key_check.isChecked() + assert asked[0][1] == login_mod.language_wrapper.language_word_dict.get( + "ssh_login_widget_dialog_title_choose_key") + + def test_starts_in_the_ssh_folder(self, form, monkeypatch, tmp_path): + (tmp_path / ".ssh").mkdir() + monkeypatch.setattr(login_mod.Path, "home", staticmethod(lambda: tmp_path)) + asked: list = [] + monkeypatch.setattr(login_mod.QFileDialog, "getOpenFileName", + staticmethod(lambda *args: asked.append(args) or ("", ""))) + + form.choose_key_file() + + assert asked[0][2] == str(tmp_path / ".ssh") + + def test_cancelling_changes_nothing(self, form, monkeypatch): + form.key_edit.setText("typed") + monkeypatch.setattr(login_mod.QFileDialog, "getOpenFileName", staticmethod(lambda *args: ("", ""))) + + form.choose_key_file() + + assert form.key_edit.text() == "typed" + assert not form.use_key_check.isChecked() From 924c895b75704bca6867dbd2341efb030dfd2eac Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:52:42 +0800 Subject: [PATCH 293/312] Run the regex pattern on Enter --- README.md | 2 +- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/pybreeze_ui/tools_gui/regex_gui.py | 1 + test/test_utils/test_regex_gui.py | 12 ++++++++++++ 6 files changed, 26 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e75dd797..f2b64481 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Each is a tab or a dock, each has the same copy / open-in-editor / save-to-file ![JWT decoder, regex tester, HTTP status reference, JSON format](images/tools_montage_a.png) - **JWT Decoder** — header and payload as pretty JSON, with `exp` / `iat` / `nbf` / `auth_time` as readable UTC. Inspection only: the signature is never verified and the token is never trusted. -- **Regex Tester** — `IGNORECASE` / `MULTILINE` / `DOTALL` / `VERBOSE`, every match with offsets, numbered groups and named groups. An invalid pattern reports a friendly error instead of crashing. +- **Regex Tester** — `IGNORECASE` / `MULTILINE` / `DOTALL` / `VERBOSE`, every match with offsets, numbered groups and named groups. Enter in the pattern runs it. An invalid pattern reports a friendly error instead of crashing. - **HTTP Status Reference** — search the full status table (sourced from the standard library, so it stays current) by code prefix or keyword. - **JSON Format** — pretty-print or minify, with a clear validation error when the input is not JSON. diff --git a/architecture_explore.md b/architecture_explore.md index 3b97133e..666eb51f 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 122 個 `test_*.py`、2150 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 122 個 `test_*.py`、2151 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 63be34a3..b92cc54c 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -467,3 +467,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 646 keys in each language. 2150 tests in 122 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ssh_login_form.py`, `README.md`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-251 · 2026-09-24 · Enter in the regex pattern runs it · #feature #tools #ui + +- **What**: the Regex Tester ran a pattern only from its Test button; Enter in the one-line pattern field did nothing, while the Timestamp tab's field converts on Enter. +- **Change**: `pattern_edit.returnPressed` runs `test()`, which already ignores a second start while a match is running. +- **Tests**: `test_regex_gui.py` +1 (Enter in the field starts a match and its result is shown), which fails on the old code. +- **Result / numbers**: 2151 tests in 122 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/regex_gui.py`, `test/test_utils/test_regex_gui.py`, `README.md`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 0c2f7357..55c80a08 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-251 | 2026-09-24 | Enter in the regex pattern runs it | #feature #tools #ui | [2026-09-b](2026-09-b.md) | | U-20260924-250 | 2026-09-24 | SSH login: Enter connects, and the key file can be browsed for | #feature #ssh #ui | [2026-09-b](2026-09-b.md) | | U-20260924-249 | 2026-09-24 | Say why an SFTP folder was not deleted | #fix #ssh #ui | [2026-09-b](2026-09-b.md) | | U-20260924-248 | 2026-09-24 | SFTP Rename starts from the current name | #fix #ssh #ui | [2026-09-b](2026-09-b.md) | @@ -332,4 +333,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 50 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 51 | diff --git a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py index 081d9e9e..65e495c8 100644 --- a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py @@ -76,6 +76,7 @@ def __init__(self, main_window=None) -> None: self.pattern_label = QLabel(word.get("regex_pattern_label")) self.pattern_edit = QLineEdit() self.pattern_edit.setPlaceholderText(word.get("regex_pattern_placeholder")) + self.pattern_edit.returnPressed.connect(self.test) self.flag_checkboxes: dict[str, QCheckBox] = {} flags_row = QHBoxLayout() diff --git a/test/test_utils/test_regex_gui.py b/test/test_utils/test_regex_gui.py index ad8fd1e5..e49aaf7d 100644 --- a/test/test_utils/test_regex_gui.py +++ b/test/test_utils/test_regex_gui.py @@ -44,6 +44,18 @@ def test_finds_matches(self, widget): assert "'1'" in output assert "'22'" in output + def test_enter_in_the_pattern_runs_it(self, widget): + # Only the button ran it + widget.pattern_edit.setText(r"\d+") + widget.text_edit.setPlainText("a1 b22") + + widget.pattern_edit.returnPressed.emit() + + assert widget._match_thread is not None, "Enter started nothing" + assert widget._match_thread.wait(30_000), "the pattern did not come back" + QApplication.processEvents() + assert "'22'" in widget.output_edit.toPlainText() + def test_no_match_message(self, widget): widget.pattern_edit.setText(r"z") widget.text_edit.setPlainText("abc") From 3ce0a91db7c392dc719cbe542e058db2a88f78f8 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:55:57 +0800 Subject: [PATCH 294/312] Show a revealed file selected in Explorer and Finder, and a failed start as a warning --- README.md | 2 +- architecture_explore.md | 4 +- docs/updates/2026-09-b.md | 9 +++++ docs/updates/README.md | 3 +- .../editor_main/file_tree_context_menu.py | 34 ++++++++++------ .../test_utils/test_file_tree_context_menu.py | 39 +++++++++++++++++++ 6 files changed, 76 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f2b64481..56c92ad7 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ Password or private-key authentication (the key file picked with Browse, startin ### And also -- **File Tree Context Menu** — right-click to create, rename, delete, copy absolute or relative paths, or reveal the item in your platform file manager. Renaming or deleting a file open in an editor tab keeps the tab in sync. +- **File Tree Context Menu** — right-click to create, rename, delete, copy absolute or relative paths, or reveal the item in your platform file manager (a file shown selected in Explorer and Finder). Renaming or deleting a file open in an editor tab keeps the tab in sync. - **Package Manager** — install automation modules and build tools from the menu, output in a run window. - **Integrated Documentation** — each module's docs and GitHub page open as in-IDE browser tabs. diff --git a/architecture_explore.md b/architecture_explore.md index 666eb51f..287d1618 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -73,7 +73,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE - `add_menu_to_menubar()` — 建構全部選單(見 §5) - `syntax_extend_package()` — 註冊 `.json` / `.yml` / `.yaml` 自動化關鍵字高亮 - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁(`_add_extend_tabs()`:每一個分頁各自建,建不起來的只記 log,不會讓整個 IDE 起不來) - - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器(`rename_self_tab()` 會清掉「未儲存」標記,有未存的編輯就用 `_on_text_changed()` 放回去,否則改名後五秒內關分頁會直接丟掉編輯);Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。新增與改名的名稱不能帶磁碟代號、根目錄、`..` 或 `:`,也不能解析到資料夾外(`_inside()`;改名只能是單一名稱)。刪除資料夾用 `remove_folder()`(唯讀檔清掉唯讀屬性再刪,git 的物件檔就是唯讀),符號連結與 junction 只刪連結本身。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開 + - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器(`rename_self_tab()` 會清掉「未儲存」標記,有未存的編輯就用 `_on_text_changed()` 放回去,否則改名後五秒內關分頁會直接丟掉編輯);Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。新增與改名的名稱不能帶磁碟代號、根目錄、`..` 或 `:`,也不能解析到資料夾外(`_inside()`;改名只能是單一名稱)。刪除資料夾用 `remove_folder()`(唯讀檔清掉唯讀屬性再刪,git 的物件檔就是唯讀),符號連結與 junction 只刪連結本身。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開。「在檔案總管中顯示」由 `reveal_command()` 組指令:Windows 用 Explorer 的 `/select,`、macOS 用 `open -R` 把檔案選起來,其他平台 `xdg-open` 只能開資料夾;啟動失敗(例如沒有 `xdg-open`)經 `_perform_file_op()` 跳警告 - `close_tab()` 覆寫 JEditor 的:分頁有 `may_close()` 就先問(提示詞編輯器、架構圖編輯器有未存的變更時會問);關掉的工具分頁 `deleteLater()`(JEditor 的 `removeTab` 不刪 widget,關過的工具分頁會留到 IDE 結束),JEditor 自己的編輯器分頁不動,關閉 IDE 時也先問過每個分頁與 dock,有一個說不就取消關閉 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) 3. `apply_stylesheet()` 套 qt_material 主題(預設 `dark_amber.xml`) @@ -452,7 +452,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 122 個 `test_*.py`、2151 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 122 個 `test_*.py`、2158 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index b92cc54c..6aed5600 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -476,3 +476,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2151 tests in 122 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/tools_gui/regex_gui.py`, `test/test_utils/test_regex_gui.py`, `README.md`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260924-252 · 2026-09-24 · Reveal in file explorer shows a file selected, and says when it cannot · #fix #editor #ui + +- **What**: the project tree's "Reveal in file explorer" opened a file's folder, not the file: in a folder of hundreds the user still had to find it. And the file manager was started with nothing around it, so a missing `xdg-open` (a Linux desktop without it) or a failed start raised out of the menu's slot as a traceback, with nothing shown. +- **Fix**: `reveal_command()` builds the command: Explorer (from `SystemRoot`) with `/select, `, Finder with `open -R `, and `xdg-open` on the folder elsewhere, as `xdg-open` cannot select; a folder is opened as before. The action runs it through `_perform_file_op`, so an `OSError` is shown in the tree's error dialog. `os.startfile` is no longer used. +- **Tests**: `test_file_tree_context_menu.py` +7 (a file is selected on Windows and macOS; a folder is opened on all three; elsewhere a file's folder opens; Explorer comes from `SystemRoot`; a missing file manager is shown, not raised). The command line for a path with spaces was checked: `explorer.exe /select, "C:\...\c d.txt"`. +- **Result / numbers**: 2158 tests in 122 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_file_tree_context_menu.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 55c80a08..a3b081d1 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-252 | 2026-09-24 | Reveal in file explorer shows a file selected, and says when it cannot | #fix #editor #ui | [2026-09-b](2026-09-b.md) | | U-20260924-251 | 2026-09-24 | Enter in the regex pattern runs it | #feature #tools #ui | [2026-09-b](2026-09-b.md) | | U-20260924-250 | 2026-09-24 | SSH login: Enter connects, and the key file can be browsed for | #feature #ssh #ui | [2026-09-b](2026-09-b.md) | | U-20260924-249 | 2026-09-24 | Say why an SFTP folder was not deleted | #fix #ssh #ui | [2026-09-b](2026-09-b.md) | @@ -333,4 +334,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 51 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 52 | diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index 602875f8..963d1210 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -138,7 +138,7 @@ def _show_context_menu(pos, tree_view: QTreeView, main_window) -> None: elif action == copy_rel_path_act: _action_copy_path(tree_view, path, relative=True) elif action == reveal_act: - _action_reveal_in_explorer(path) + _action_reveal_in_explorer(tree_view, path) # --------------- actions --------------- @@ -403,18 +403,30 @@ def _action_copy_path(tree_view: QTreeView, path: Path | None, relative: bool = clipboard.setText(text) -def _action_reveal_in_explorer(path: Path | None) -> None: +def reveal_command(path: Path, platform: str = sys.platform) -> list[str]: + """The command that shows *path* in the platform's file manager. + + A file is shown selected in its folder where the file manager can do that + (Explorer's ``/select,``, Finder's ``open -R``); it used to open the folder + alone, leaving the user to find the file in it. ``xdg-open`` can only open + a folder, so elsewhere a file's folder is opened. + """ + is_folder = path.is_dir() + if platform == "win32": + explorer = str(Path(os.environ.get("SystemRoot", "C:/Windows")) / "explorer.exe") + return [explorer, str(path)] if is_folder else [explorer, "/select,", str(path)] + if platform == "darwin": + return ["open", str(path)] if is_folder else ["open", "-R", str(path)] + return ["xdg-open", str(path if is_folder else path.parent)] + + +def _action_reveal_in_explorer(tree_view: QTreeView, path: Path | None) -> None: if path is None: return - target = path if path.is_dir() else path.parent - # "Reveal in file explorer" — platform file-manager invocation on a path the - # user already selected in our tree. shell=False, fixed argv[0]. nosec B603/B606/B607. - if sys.platform == "win32": - os.startfile(str(target)) # nosec B606 # nosemgrep # noqa: S606 - elif sys.platform == "darwin": - subprocess.Popen(["open", str(target)]) # nosec B603 B607 # nosemgrep # noqa: S603,S607 - else: - subprocess.Popen(["xdg-open", str(target)]) # nosec B603 B607 # nosemgrep # noqa: S603,S607 + command = reveal_command(path) + # A file manager started on a path the user picked in the tree. shell=False, + # fixed argv[0]; a missing xdg-open is shown, not raised out of the slot. + _perform_file_op(tree_view, lambda: subprocess.Popen(command)) # nosec B603 B607 # nosemgrep # noqa: S603 def _inside(tree_view: QTreeView, parent: Path, name: str, *, single: bool = False) -> Path | None: diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py index 87ba2440..5de4c68a 100644 --- a/test/test_utils/test_file_tree_context_menu.py +++ b/test/test_utils/test_file_tree_context_menu.py @@ -523,3 +523,42 @@ def test_a_rename_keeps_the_unsaved_mark_of_an_edited_tab(tree, tmp_path, monkey assert (tmp_path / "b.py").is_file() assert editor.renamed and editor._is_modified + + +class TestRevealing: + """A file was never shown selected: its folder opened, and the user had to find it.""" + + @pytest.mark.parametrize(("platform", "flags"), [("win32", ["/select,"]), ("darwin", ["-R"])]) + def test_a_file_is_shown_selected(self, tmp_path, platform, flags): + target = tmp_path / "a.py" + target.write_text("", encoding="utf-8") + + command = ctx.reveal_command(target, platform) + + assert command[1:] == [*flags, str(target)] + + @pytest.mark.parametrize("platform", ["win32", "darwin", "linux"]) + def test_a_folder_is_opened(self, tmp_path, platform): + assert ctx.reveal_command(tmp_path, platform)[1:] == [str(tmp_path)] + + def test_elsewhere_a_files_folder_is_opened(self, tmp_path): + target = tmp_path / "a.py" + target.write_text("", encoding="utf-8") + + assert ctx.reveal_command(target, "linux") == ["xdg-open", str(tmp_path)] + + def test_explorer_is_the_systems(self, tmp_path, monkeypatch): + monkeypatch.setenv("SystemRoot", str(tmp_path)) + + assert ctx.reveal_command(tmp_path, "win32")[0] == str(tmp_path / "explorer.exe") + + def test_a_missing_file_manager_is_shown_not_raised(self, tree, tmp_path, monkeypatch, warnings): + # No xdg-open: FileNotFoundError left the slot as a traceback + def missing(_command): + raise FileNotFoundError(2, "No such file or directory", "xdg-open") + + monkeypatch.setattr(ctx.subprocess, "Popen", missing) + + ctx._action_reveal_in_explorer(tree, tmp_path) + + assert len(warnings) == 1 and "xdg-open" in warnings[0] From ff5c5f0e207ff583294c14c5a93be80bd1128289 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:57:35 +0800 Subject: [PATCH 295/312] Refactor: fill the AI review method box from SUPPORTED_METHODS --- docs/updates/2026-09-b.md | 8 ++++++++ docs/updates/README.md | 3 ++- .../pybreeze_ui/connect_gui/url/ai_code_review_gui.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 6aed5600..5b653d68 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -485,3 +485,11 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2158 tests in 122 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_file_tree_context_menu.py`, `README.md`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-253 · 2026-09-24 · The AI review panel lists its methods from one place · #refactor #ai + +- **What**: the AI code review panel's method box listed `GET`, `POST`, `PUT`, `DELETE` as a literal next to `SUPPORTED_METHODS`, which `send_request` checks against; a method added to one and not the other would be offered and then refused, or never offered. +- **Change**: the box is filled from `SUPPORTED_METHODS`. +- **Tests**: the panel's tests (40) pass unchanged. No behaviour change. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index a3b081d1..85022507 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-253 | 2026-09-24 | The AI review panel lists its methods from one place | #refactor #ai | [2026-09-b](2026-09-b.md) | | U-20260924-252 | 2026-09-24 | Reveal in file explorer shows a file selected, and says when it cannot | #fix #editor #ui | [2026-09-b](2026-09-b.md) | | U-20260924-251 | 2026-09-24 | Enter in the regex pattern runs it | #feature #tools #ui | [2026-09-b](2026-09-b.md) | | U-20260924-250 | 2026-09-24 | SSH login: Enter connects, and the key file can be browsed for | #feature #ssh #ui | [2026-09-b](2026-09-b.md) | @@ -334,4 +335,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 52 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 53 | 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 6a355fb3..d62e2929 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 @@ -185,7 +185,7 @@ def _build_request_row(self) -> QHBoxLayout: method_layout = QHBoxLayout() method_layout.addWidget(QLabel(self.word_dict.get("ai_code_review_gui_label_method"))) self.method_box = QComboBox() - self.method_box.addItems(["GET", "POST", "PUT", "DELETE"]) + self.method_box.addItems(list(SUPPORTED_METHODS)) method_layout.addWidget(self.method_box) top_layout.addLayout(method_layout) return top_layout From 68fff5e954ff4232b0d5ee12b97f3290db00b37d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 11:59:02 +0800 Subject: [PATCH 296/312] Show why a tool refused its input in the IDE language --- CLAUDE.md | 1 + README.md | 2 +- architecture_explore.md | 12 ++- docs/updates/2026-09-b.md | 9 ++ docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 9 ++ .../extend_traditional_chinese.py | 39 ++++++++ pybreeze/pybreeze_ui/error_text.py | 56 +++++++++++ .../pybreeze_ui/tools_gui/curl_import_gui.py | 3 +- .../pybreeze_ui/tools_gui/har_import_gui.py | 3 +- .../pybreeze_ui/tools_gui/json_format_gui.py | 3 +- .../pybreeze_ui/tools_gui/jwt_decoder_gui.py | 3 +- .../pybreeze_ui/tools_gui/query_json_gui.py | 5 +- pybreeze/pybreeze_ui/tools_gui/regex_gui.py | 3 +- .../pybreeze_ui/tools_gui/timestamp_gui.py | 3 +- .../pybreeze_ui/tools_gui/url_builder_gui.py | 5 +- pybreeze/utils/exception/error_templates.py | 26 +++++ test/test_utils/test_error_text.py | 94 +++++++++++++++++++ 18 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 pybreeze/pybreeze_ui/error_text.py create mode 100644 pybreeze/utils/exception/error_templates.py create mode 100644 test/test_utils/test_error_text.py diff --git a/CLAUDE.md b/CLAUDE.md index 47ed005e..2a7a472a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ pybreeze/ │ ├── gui_thread_gc.py # Garbage collected on a GUI-thread timer, never on a worker │ ├── plain_text.py # as_text: server/file text shown in message boxes as text, not markup │ ├── exact_text.py # exact_text: a text box read as typed (toPlainText changes U+00A0, U+2028) +│ ├── error_text.py # error_text: a tool's English error (exception_tags) in the IDE language │ ├── closing.py # may_close / AskingDock: tabs and docks with unsaved work are asked first │ ├── dialog/ # prthinker settings dialog │ └── syntax/ # Automation keyword highlighting definitions diff --git a/README.md b/README.md index 56c92ad7..f9525fd2 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 646 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 679 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index 287d1618..fa1cd0da 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 646 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 679 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 646 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 679 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -342,7 +342,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `terminal_text.py` | 終端輸出的 escape 與控制字元:`strip_terminal_controls()`(CSI、OSC/DCS 等控制字串、nF、兩位元組 escape 剝除,backspace 套用,其餘 C0 丟掉)、`split_incomplete_escape()`(讀取切斷在 escape 中間時把尾巴留給下一次)、`split_unfinished_end()`(再加上它前面或最後的 `\r`)、`take_leading_backspaces()`(一段開頭的 backspace 留給畫面,擦掉前一段已經顯示的字,不越過行首)。SSH terminal 與執行視窗共用 | | `subprocess_util.py` | `utf8_subprocess_env()`(釘 `PYTHONIOENCODING`,解 Windows cp950 亂碼)、`no_window_creationflags()`(`CREATE_NO_WINDOW`,避免 GUI 程式彈出黑窗) | | `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | -| `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | +| `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數;`error_templates.py` 把名稱以 `_error` 結尾的常數變成語言字典的 `error_text_<名稱>`(英文字典直接取常數本身) | | `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | | `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`_NoRedirectSession`:不跟也不準備轉址,3xx 原封不讀地回來;`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`overall_deadline(seconds)`:這個執行緒在區塊內的請求總共最多這麼久,釘住的連線等回應時把 socket 登記上去,時間到就 shutdown,丟 `ReadTimeout`(讀取逾時每來一個位元組就重算,慢慢送標頭的伺服器原本可以一直拖);AI 審查、Skill、CoT 每一步都包在 `overall_deadline(DEFAULT_MAX_READ_SECONDS)` 裡。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | | `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),時間到由 `_Watchdog` 關掉連線(urllib3 的 `HTTPResponse.shutdown()` 能中斷別的執行緒上正在等的讀取),丟 `ReadTimeout`:讀取逾時只管每一塊之間,一次送一個位元組的伺服器可以一直拖下去;狀態列與標頭由呼叫端的 `public_http.overall_deadline()` 涵蓋)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | @@ -361,7 +361,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 646 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 679 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -428,6 +428,8 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 讀使用者輸入的文字一律走 `pybreeze_ui/exact_text.exact_text()`(`document().toRawText()`,區塊分隔換回 `\n`):`toPlainText()` 是顯示用的,會把不斷行空白(U+00A0)變成空白、U+2028 變成換行,Hash 算的是別的字串、Diff 看不出差別。 +工具拒絕輸入的原因:`utils` 的純邏輯用 `exception_tags` 的英文常數拋例外、原樣寫進 log;工具分頁顯示前經 `pybreeze_ui/error_text.error_text()`,它認出訊息出自哪個常數(連同填進去的值與後面附加的細節),換成語言字典裡的 `error_text_<常數名>`。新增的 `_error` 常數要在繁中字典補翻譯,`test_error_text.py` 會檢查每一個。 + 鐵律:worker thread 一律不碰 UI。普通執行緒走 Queue + QTimer,`QThread` 走 Signal/Slot。分頁或視窗關閉時還在跑的 `QThread` 交給 `thread_keeper.let_run_out()`,不等它、也不讓它在執行中被銷毀。widget 留著的 thread(或其他物件)上接的 slot 不能抓住 widget 本身:接 bound method,或用 `thread_keeper.if_alive(weakref.ref(self), ...)`;lambda 抓 `self` 是經過 Qt 的循環參照,Python 的 GC 看不到,關掉的 widget 永遠不會釋放(`test_closed_panels_are_freed.py`)。 執行器的壽命:QTimer 接到執行器的 `pull_text()` / `_pull_text()` 這條連線**不會**讓執行器活著;reader 執行緒一結束,沒人持有的執行器就會被 GC,剩下的輸出和結束那一行都不會出現。所以執行器一律掛在它寫入的 `CodeWindow.runner` 上,跟視窗同壽;視窗又掛在主視窗的 `current_run_code_window`。 @@ -452,7 +454,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 122 個 `test_*.py`、2158 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 123 個 `test_*.py`、2198 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 5b653d68..d5c74c9a 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -493,3 +493,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Tests**: the panel's tests (40) pass unchanged. No behaviour change. - **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py` - **Open items**: none. + +## U-20260924-254 · 2026-09-24 · Why a tool refused its input, in the IDE language · #fix #tools #i18n + +- **What**: the pure tools raise their reasons from the English constants in `exception_tags`, and every tool tab showed them as they were inside a translated sentence: in Traditional Chinese the Timestamp tab read `轉換失敗:not a recognized epoch number or ISO-8601 date-time`, and so did the cURL, HAR, JSON, JWT, Query, Regex and URL tabs with their own 33 reasons. Found scanning the text the tool tabs show. +- **Fix**: `utils/exception/error_templates.py` makes every constant whose name ends in `_error` a language-dictionary entry, `error_text_` (fields written without a conversion, `{key!r}` as `{key}`); the English dictionary takes the constants themselves, so nothing is copied, and the Traditional Chinese dictionary has the 33 translations. `pybreeze_ui/error_text.error_text()` finds the constant a message was made from (the one with the most fixed text first), the values put into it and any detail after it (`... (Expecting value)`), and gives the entry in the IDE language; a message from anywhere else is shown as it is. The tools still log the English. Ten display sites in eight tabs use it. +- **Tests**: `test_error_text.py` (40): every constant has a Chinese translation and is found again from a message made from it; a repr'd value, a detail after the constant and braces in a value are kept; a message from elsewhere and English are unchanged; the Timestamp tab shows its reason in Chinese. +- **Result / numbers**: 679 keys in each language (33 of them tool errors). 2198 tests in 123 files. `ruff check` clean. +- **Files**: `pybreeze/utils/exception/error_templates.py` (new), `pybreeze/pybreeze_ui/error_text.py` (new), `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `pybreeze/pybreeze_ui/tools_gui/` (`curl_import_gui.py`, `har_import_gui.py`, `json_format_gui.py`, `jwt_decoder_gui.py`, `query_json_gui.py`, `regex_gui.py`, `timestamp_gui.py`, `url_builder_gui.py`), `test/test_utils/test_error_text.py` (new), `CLAUDE.md`, `README.md`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 85022507..86a0a08b 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-254 | 2026-09-24 | Why a tool refused its input, in the IDE language | #fix #tools #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-253 | 2026-09-24 | The AI review panel lists its methods from one place | #refactor #ai | [2026-09-b](2026-09-b.md) | | U-20260924-252 | 2026-09-24 | Reveal in file explorer shows a file selected, and says when it cannot | #fix #editor #ui | [2026-09-b](2026-09-b.md) | | U-20260924-251 | 2026-09-24 | Enter in the regex pattern runs it | #feature #tools #ui | [2026-09-b](2026-09-b.md) | @@ -335,4 +336,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 53 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 54 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index c26ab502..818c3754 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -2,6 +2,11 @@ from je_editor import english_word_dict +from pybreeze.utils.exception.error_templates import ( + ERROR_TEXT_KEY_PREFIX, + error_templates, +) + _COT_PROMPT_EDITOR = "CoT Prompt Editor" _SKILL_PROMPT_EDITOR = "Skill Prompt Editor" @@ -761,6 +766,10 @@ "plugin_browser_status_installed": "Installed: {path}", "plugin_browser_restart_hint": "Plugin downloaded to:\n{path}\n\nPlease restart the editor to activate.", } +# Why a tool refused its input (pybreeze_ui/error_text.py): in English, the +# constants the tools raise +pybreeze_english_word_dict.update( + {ERROR_TEXT_KEY_PREFIX + name: template for name, template in error_templates().items()}) def update_english_word_dict(): diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 86f6d661..a977b2de 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -758,6 +758,45 @@ "plugin_browser_status_downloading": "正在下載 {name}...", "plugin_browser_status_installed": "已安裝:{path}", "plugin_browser_restart_hint": "插件已下載至:\n{path}\n\n請重新啟動編輯器以啟用。", + # 工具拒絕輸入的原因(pybreeze_ui/error_text.py):exception_tags 各常數的翻譯 + "error_text_cant_reformat_json_error": "無法重新格式化 JSON:型別正確嗎?", + "error_text_wrong_json_data_error": "無法解析 JSON", + "error_text_json_duplicate_key_error": "這份 JSON 在同一個物件裡給了兩次 {key} 鍵", + "error_text_empty_curl_command_error": "沒有提供 curl 指令", + "error_text_not_a_curl_command_error": "這看起來不是 curl 指令", + "error_text_malformed_curl_command_error": "無法解析 curl 指令:請檢查引號", + "error_text_no_url_in_curl_error": "curl 指令裡找不到 URL", + "error_text_get_with_file_body_error": + "-G 會把資料放進查詢字串,產生的腳本無法從檔案讀取:請把資料直接寫在指令裡", + "error_text_action_cannot_read_files_error": + "APITestka JSON 動作無法上傳檔案,也無法從檔案讀取內容或 Cookie:請選擇 Python 目標", + "error_text_malformed_url_error": "URL 格式錯誤(例如 [ 沒有關上,或連接埠不是數字)", + "error_text_invalid_http_method_error": + "不是 HTTP 方法:方法是由字母、數字與 !#$%&'*+.^_`|~- 組成的一個字", + "error_text_empty_har_error": "沒有提供 HAR 內容", + "error_text_invalid_har_json_error": "無法把檔案解析為 JSON:這不是有效的 HAR 匯出檔", + "error_text_not_a_har_document_error": "這份 JSON 沒有 log.entries 清單,不是 HAR 匯出檔", + "error_text_no_entries_in_har_error": "這份 HAR 匯出檔沒有任何請求", + "error_text_empty_jwt_error": "沒有提供權杖", + "error_text_malformed_jwt_error": "JWT 必須是以點分隔的三段", + "error_text_jwt_segment_decode_error": "無法解碼 JWT 的某一段:base64url 或 JSON 無效", + "error_text_empty_timestamp_error": "沒有提供數值", + "error_text_unrecognized_timestamp_error": "無法辨識為 epoch 數值或 ISO-8601 日期時間", + "error_text_invalid_json_object_error": "輸入必須是由鍵值組成的 JSON 物件", + "error_text_nested_query_value_error": "查詢值必須是字串、數字、true/false 或 null,或由它們組成的清單", + "error_text_invalid_json_for_query_error": "無法把輸入解析為 JSON", + "error_text_query_not_utf8_error": "查詢字串裡的百分比跳脫不是 UTF-8 文字(例如 %B0),因此沒有對應的 JSON", + "error_text_unencodable_text_error": "有個值含有 URL 無法表示的字元(例如 \\ud83d 這種落單的代理字元)", + "error_text_empty_regex_pattern_error": "沒有提供正規表示式", + "error_text_invalid_regex_pattern_error": "無效的正規表示式:{detail}", + "error_text_invalid_json_for_url_error": "無法把輸入解析為 JSON", + "error_text_invalid_url_components_error": "輸入必須是由 URL 各部分組成的 JSON 物件", + "error_text_unreadable_url_error": "輸入不是可以讀取的 URL", + "error_text_url_port_out_of_range_error": "連接埠必須是 0 到 65535 之間的數字", + "error_text_regex_timeout_error": + "這個正規表示式執行 {seconds} 秒後仍未結束,已被停止;(a+)+ 這類巢狀重複遇到幾乎符合的文字時," + "執行時間可能呈指數成長", + "error_text_regex_worker_error": "無法執行這個正規表示式:{detail}", } diff --git a/pybreeze/pybreeze_ui/error_text.py b/pybreeze/pybreeze_ui/error_text.py new file mode 100644 index 00000000..cc08edc7 --- /dev/null +++ b/pybreeze/pybreeze_ui/error_text.py @@ -0,0 +1,56 @@ +"""Why a tool refused its input, in the IDE language. + +The pure tools (``utils``) raise their reasons in English, from the constants in +``exception_tags``, and log them as they are. A tool tab shows the reason inside +a translated sentence, so in Traditional Chinese it read "轉換失敗:no value +provided". :func:`error_text` finds the constant a message was made from, the +values put into it and any detail after it, and gives the same message from the +language dictionary (``utils/exception/error_templates.py``). +""" +from __future__ import annotations + +import re +from functools import cache + +from je_editor import language_wrapper + +from pybreeze.utils.exception.error_templates import ( + ERROR_TEXT_KEY_PREFIX, + TEMPLATE_FIELD, + error_templates, +) + + +@cache +def _patterns() -> tuple[tuple[str, re.Pattern], ...]: + """(name, pattern) for each constant, the one with the most fixed text first.""" + patterns = [] + for name, template in error_templates().items(): + parts, position, fixed = [], 0, 0 + for field in TEMPLATE_FIELD.finditer(template): + literal = template[position:field.start()] + parts.extend((re.escape(literal), f"(?P<{field.group(1)}>.*?)")) + fixed += len(literal) + position = field.end() + parts.append(re.escape(template[position:])) + fixed += len(template) - position + # A detail may follow the constant: "can't reformat JSON: ... ()" + pattern = re.compile("".join(parts) + r"(?P<_rest>.*)\Z", re.DOTALL) + patterns.append((fixed, name, pattern)) + patterns.sort(key=lambda item: -item[0]) + return tuple((name, pattern) for _fixed, name, pattern in patterns) + + +def error_text(message: str) -> str: + """*message* in the IDE language when it was made from a tool error constant; as it is otherwise.""" + for name, pattern in _patterns(): + match = pattern.match(message) + if match is None: + continue + translation = language_wrapper.language_word_dict.get(ERROR_TEXT_KEY_PREFIX + name) + if not translation: + return message + fields = match.groupdict() + rest = fields.pop("_rest") + return translation.format(**fields) + rest + return message diff --git a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py index b8aa6f2f..6ba35ace 100644 --- a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py @@ -24,6 +24,7 @@ from pybreeze.utils.exception.exceptions import CurlParseException from pybreeze.utils.header_tools.header_merge import stored_header_name from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.error_text import error_text # The single target that generates JSON rather than Python _JSON_TARGET = "apitestka_action" @@ -126,7 +127,7 @@ def convert(self) -> None: pybreeze_logger.info("curl_import_gui.py convert failed: %r", error) self._clear_result() self.output_edit.setPlainText( - word.get("curl_import_error").format(error=str(error))) + word.get("curl_import_error").format(error=error_text(str(error)))) return self._generated_code = code self._request = request diff --git a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py index fe30c2c6..d1bc81ff 100644 --- a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py @@ -24,6 +24,7 @@ from pybreeze.utils.har_import.har_parser import HarEntry, api_entries, parse_har, summarize from pybreeze.utils.file_process.read_capped import read_text_capped from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.error_text import error_text # The single target that generates JSON rather than Python _JSON_TARGET = "apitestka_action" @@ -217,7 +218,7 @@ def _generate(self, entries: list[HarEntry], empty_hint_key: str) -> None: # A target that cannot carry a recorded file upload says so pybreeze_logger.info("har_import_gui.py generate failed: %r", error) self._generated_code = None - self.output_edit.setPlainText(word.get("har_import_generate_error").format(error=str(error))) + self.output_edit.setPlainText(word.get("har_import_generate_error").format(error=error_text(str(error)))) return self._generated_code = code self.output_edit.setPlainText(code) diff --git a/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py b/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py index e4833aea..876ecb22 100644 --- a/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py @@ -11,6 +11,7 @@ from pybreeze.utils.exception.exceptions import ITEJsonException from pybreeze.utils.json_format.json_process import minify_json, reformat_json from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.error_text import error_text class JsonFormatGUI(QWidget): @@ -75,7 +76,7 @@ def _run(self, transform) -> None: except ITEJsonException as error: pybreeze_logger.info("json_format_gui.py transform failed: %r", error) self._valid_output = False - self.output_edit.setPlainText(word.get("json_format_error").format(error=str(error))) + self.output_edit.setPlainText(word.get("json_format_error").format(error=error_text(str(error)))) return self._valid_output = True self.output_edit.setPlainText(result) diff --git a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py index 55ee8905..daef8b4c 100644 --- a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py @@ -16,6 +16,7 @@ DecodedJwt, decode_jwt, humanized_timestamp_claims, shown_json ) from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.pybreeze_ui.error_text import error_text def build_decoded_text(decoded: DecodedJwt) -> str: @@ -95,7 +96,7 @@ def decode(self) -> None: pybreeze_logger.info("jwt_decoder_gui.py decode failed: %r", error) self._valid_output = False self.output_edit.setPlainText( - word.get("jwt_decoder_error").format(error=str(error))) + word.get("jwt_decoder_error").format(error=error_text(str(error)))) return self._valid_output = True self.output_edit.setPlainText(build_decoded_text(decoded)) diff --git a/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py b/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py index 659535c9..9045374f 100644 --- a/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py @@ -11,6 +11,7 @@ from pybreeze.utils.exception.exceptions import QueryConvertException from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.query_tools.query_convert import json_to_query, query_to_json +from pybreeze.pybreeze_ui.error_text import error_text class QueryJsonGUI(QWidget): @@ -69,7 +70,7 @@ def convert_to_json(self) -> None: pybreeze_logger.info("query_json_gui.py to-json failed: %r", error) self._valid_output = False self.output_edit.setPlainText( - language_wrapper.language_word_dict.get("query_json_error").format(error=str(error))) + language_wrapper.language_word_dict.get("query_json_error").format(error=error_text(str(error)))) return self._valid_output = True self.output_edit.setPlainText(result) @@ -87,7 +88,7 @@ def convert_to_query(self) -> None: except QueryConvertException as error: pybreeze_logger.info("query_json_gui.py to-query failed: %r", error) self._valid_output = False - self.output_edit.setPlainText(word.get("query_json_error").format(error=str(error))) + self.output_edit.setPlainText(word.get("query_json_error").format(error=error_text(str(error)))) return self._valid_output = True self.output_edit.setPlainText(result) diff --git a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py index 65e495c8..c4c7b20b 100644 --- a/pybreeze/pybreeze_ui/tools_gui/regex_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py @@ -16,6 +16,7 @@ from pybreeze.utils.regex_tools.regex_tester import ( MAX_MATCHES, MatchResult, available_flags, find_matches_bounded, stop_running_workers ) +from pybreeze.pybreeze_ui.error_text import error_text class RegexMatchThread(QThread): @@ -155,7 +156,7 @@ def _show_error(self, message: str) -> None: pybreeze_logger.info("regex_gui.py test failed: %s", message) self._valid_output = False self.output_edit.setPlainText( - language_wrapper.language_word_dict.get("regex_error").format(error=message)) + language_wrapper.language_word_dict.get("regex_error").format(error=error_text(message))) def closeEvent(self, event) -> None: """Stop a pattern still running, and let its thread run out. diff --git a/pybreeze/pybreeze_ui/tools_gui/timestamp_gui.py b/pybreeze/pybreeze_ui/tools_gui/timestamp_gui.py index e2f30085..456cf21e 100644 --- a/pybreeze/pybreeze_ui/tools_gui/timestamp_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/timestamp_gui.py @@ -12,6 +12,7 @@ from pybreeze.utils.timestamp_tools.timestamp_converter import ( TimestampResult, convert_timestamp ) +from pybreeze.pybreeze_ui.error_text import error_text def build_result_text(result: TimestampResult) -> str: @@ -78,7 +79,7 @@ def convert(self) -> None: pybreeze_logger.info("timestamp_gui.py convert failed: %r", error) self._valid_output = False self.output_edit.setPlainText( - word.get("timestamp_error").format(error=str(error))) + word.get("timestamp_error").format(error=error_text(str(error)))) return self._valid_output = True self.output_edit.setPlainText(build_result_text(result)) diff --git a/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py b/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py index 772a90ea..017bd782 100644 --- a/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/url_builder_gui.py @@ -11,6 +11,7 @@ from pybreeze.utils.exception.exceptions import UrlConvertException from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.url_tools.url_convert import json_to_url, url_to_json +from pybreeze.pybreeze_ui.error_text import error_text class UrlBuilderGUI(QWidget): @@ -73,7 +74,7 @@ def convert_to_json(self) -> None: except UrlConvertException as error: pybreeze_logger.info("url_builder_gui.py to-json failed: %r", error) self._valid_output = False - self.output_edit.setPlainText(word.get("url_builder_parse_error").format(error=str(error))) + self.output_edit.setPlainText(word.get("url_builder_parse_error").format(error=error_text(str(error)))) return self._valid_output = True self.output_edit.setPlainText(result) @@ -91,7 +92,7 @@ def convert_to_url(self) -> None: except UrlConvertException as error: pybreeze_logger.info("url_builder_gui.py to-url failed: %r", error) self._valid_output = False - self.output_edit.setPlainText(word.get("url_builder_error").format(error=str(error))) + self.output_edit.setPlainText(word.get("url_builder_error").format(error=error_text(str(error)))) return self._valid_output = True self.output_edit.setPlainText(result) diff --git a/pybreeze/utils/exception/error_templates.py b/pybreeze/utils/exception/error_templates.py new file mode 100644 index 00000000..c2cf5ed6 --- /dev/null +++ b/pybreeze/utils/exception/error_templates.py @@ -0,0 +1,26 @@ +"""The tool error constants as translatable templates. + +``exception_tags`` holds the English reasons the pure tools raise. Each one whose +name ends in ``_error`` is also a language-dictionary entry, ``error_text_``: +the English dictionary takes them from here, and ``pybreeze_ui/error_text.py`` +turns a raised message back into its entry in the IDE language. +""" +from __future__ import annotations + +import re + +from pybreeze.utils.exception import exception_tags + +# The language-dictionary key of a constant is this followed by its name +ERROR_TEXT_KEY_PREFIX = "error_text_" +# A replacement field in a constant: {detail}, {key!r} +TEMPLATE_FIELD = re.compile(r"\{(\w+)(?:![rsa])?\}") + + +def error_templates() -> dict[str, str]: + """Every tool error constant by name, its fields without a conversion (``{key!r}`` as ``{key}``).""" + return { + name: TEMPLATE_FIELD.sub(r"{\1}", value) + for name, value in vars(exception_tags).items() + if name.endswith("_error") and isinstance(value, str) + } diff --git a/test/test_utils/test_error_text.py b/test/test_utils/test_error_text.py new file mode 100644 index 00000000..adc37b2d --- /dev/null +++ b/test/test_utils/test_error_text.py @@ -0,0 +1,94 @@ +"""Why a tool refused its input, shown in the IDE language rather than always in English.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict as CHINESE, +) +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui import error_text as error_text_mod +from pybreeze.pybreeze_ui.error_text import error_text +from pybreeze.utils.exception import exception_tags +from pybreeze.utils.exception.error_templates import ERROR_TEXT_KEY_PREFIX, error_templates + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def chinese(monkeypatch): + monkeypatch.setattr(error_text_mod.language_wrapper, "language_word_dict", CHINESE) + + +def _sample(template: str) -> dict[str, str]: + return {name: f"<{name}>" for name in ("key", "detail", "seconds") if "{" + name + "}" in template} + + +class TestEveryConstant: + def test_has_a_chinese_translation(self): + missing = [name for name in error_templates() if ERROR_TEXT_KEY_PREFIX + name not in CHINESE] + assert not missing + + @pytest.mark.parametrize("name", sorted(error_templates())) + def test_is_found_again_from_the_message(self, chinese, name): + constant = getattr(exception_tags, name) + fields = _sample(error_templates()[name]) + message = constant.format(**fields) + # A field written {key!r} is in the message as its repr + shown = {field: repr(value) if "{" + field + "!r}" in constant else value + for field, value in fields.items()} + + assert error_text(message) == CHINESE[ERROR_TEXT_KEY_PREFIX + name].format(**shown) + + +class TestWhatIsKept: + def test_a_value_repr_d_into_the_message(self, chinese): + message = exception_tags.json_duplicate_key_error.format(key="a") + + assert error_text(message) == "這份 JSON 在同一個物件裡給了兩次 'a' 鍵" + + def test_a_detail_after_the_constant(self, chinese): + message = f"{exception_tags.cant_reformat_json_error} (Expecting value)" + + assert error_text(message) == "無法重新格式化 JSON:型別正確嗎? (Expecting value)" + + def test_braces_in_a_value_are_not_read_as_fields(self, chinese): + message = exception_tags.invalid_regex_pattern_error.format(detail="bad {0} here") + + assert error_text(message) == "無效的正規表示式:bad {0} here" + + def test_a_message_from_elsewhere(self, chinese): + assert error_text("[Errno 2] No such file") == "[Errno 2] No such file" + + def test_english_is_the_constant_itself(self, app): + message = exception_tags.json_duplicate_key_error.format(key="'a'") + + assert error_text(message) == message + + +class TestATab: + def test_shows_the_reason_in_chinese(self, app, chinese, monkeypatch): + # "轉換失敗:not a recognized epoch number or ISO-8601 date-time" + from pybreeze.pybreeze_ui.tools_gui import timestamp_gui + from pybreeze.pybreeze_ui.tools_gui.timestamp_gui import TimestampGUI + + monkeypatch.setattr(timestamp_gui.language_wrapper, "language_word_dict", CHINESE) + tab = TimestampGUI() + tab.input_edit.setText("not a time") + + tab.convert() + + shown = tab.output_edit.toPlainText() + assert "無法辨識為 epoch 數值或 ISO-8601 日期時間" in shown + assert "recognized" not in shown + tab.deleteLater() From e0c17c33439190ebd39f4bff5a12967b360baeb4 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 12:00:38 +0800 Subject: [PATCH 297/312] Take a plugin run config's string args as one argument --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../process_executor/file_runner_process.py | 18 +++++++++++++++++- test/test_utils/test_compile_then_run.py | 17 +++++++++++++++++ 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index fa1cd0da..318bb500 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -454,7 +454,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 123 個 `test_*.py`、2198 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 123 個 `test_*.py`、2207 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index d5c74c9a..36827cca 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -502,3 +502,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 679 keys in each language (33 of them tool errors). 2198 tests in 123 files. `ruff check` clean. - **Files**: `pybreeze/utils/exception/error_templates.py` (new), `pybreeze/pybreeze_ui/error_text.py` (new), `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `pybreeze/pybreeze_ui/tools_gui/` (`curl_import_gui.py`, `har_import_gui.py`, `json_format_gui.py`, `jwt_decoder_gui.py`, `query_json_gui.py`, `regex_gui.py`, `timestamp_gui.py`, `url_builder_gui.py`), `test/test_utils/test_error_text.py` (new), `CLAUDE.md`, `README.md`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-255 · 2026-09-24 · A plugin run config's args given as a string is one argument · #fix #plugins #run + +- **What**: a plugin run config's `args` is meant to be a sequence, but JEditor does not check what a plugin registers. Given as a string (`"args": "run"`), "Run with..." took it one character at a time and ran `go r u n main.go`; a number raised `TypeError` out of the menu. Found reading the run path for what it assumes about a plugin's config. +- **Fix**: `file_runner_process.run_arguments()`: a string is one argument (none when empty), a list or tuple gives its items as text, and anything else gives none. +- **Tests**: `test_compile_then_run.py` +8 (a tuple, a list, a string, an empty string, none given, `None`, a number, items that are not text). +- **Result / numbers**: 2207 tests in 123 files, measured by collection. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/file_runner_process.py`, `test/test_utils/test_compile_then_run.py`, `architecture_explore.md` §18 +- **Open items**: JEditor's own runner (`code_exec.py`, `list(run_config.get("args", ()))`) has the same fault; recorded, not changed here. diff --git a/docs/updates/README.md b/docs/updates/README.md index 86a0a08b..df4b642f 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-255 | 2026-09-24 | A plugin run config's args given as a string is one argument | #fix #plugins #run | [2026-09-b](2026-09-b.md) | | U-20260924-254 | 2026-09-24 | Why a tool refused its input, in the IDE language | #fix #tools #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-253 | 2026-09-24 | The AI review panel lists its methods from one place | #refactor #ai | [2026-09-b](2026-09-b.md) | | U-20260924-252 | 2026-09-24 | Reveal in file explorer shows a file selected, and says when it cannot | #fix #editor #ui | [2026-09-b](2026-09-b.md) | @@ -336,4 +337,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 54 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 55 | diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index 0165a67e..1e35c276 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -36,6 +36,22 @@ COMPILE_TIME_LIMIT_SECONDS = 60 +def run_arguments(run_config: dict) -> list[str]: + """The arguments *run_config* puts between the compiler and the file. + + ``args`` is meant to be a sequence, but JEditor does not check what a + plugin registers: ``"args": "run"`` was taken one character at a time, + and ran ``go r u n main.go``. A string is one argument, and nothing that + is neither gives none. + """ + args = run_config.get("args", ()) + if isinstance(args, str): + return [args] if args else [] + if not isinstance(args, (list, tuple)): + return [] + return [str(arg) for arg in args] + + class FileRunnerProcess: """Manages subprocess execution for any language file.""" @@ -87,7 +103,7 @@ def run_file(self, run_config: dict, file_path: str) -> None: # A plugin's config is not checked by JEditor when it registers. self.main_window.append_output("[Error] The run config names no compiler\n", is_error=True) return - args = [str(arg) for arg in run_config.get("args", ())] + args = run_arguments(run_config) if compile_then_run: self._compile_and_run(compiler, args, run_config.get("output_flag", "-o"), file_path) diff --git a/test/test_utils/test_compile_then_run.py b/test/test_utils/test_compile_then_run.py index 5c7d0ebe..3bd4e19f 100644 --- a/test/test_utils/test_compile_then_run.py +++ b/test/test_utils/test_compile_then_run.py @@ -121,3 +121,20 @@ def test_a_missing_compiler_is_reported(self, qt_app, tmp_path, monkeypatch): assert "[Error] Command not found" in window.code_result.toPlainText() assert runner.process is None and runs == [] + + +class TestRunArguments: + """JEditor does not check a plugin's run config: ``"args": "run"`` ran ``go r u n main.go``.""" + + @pytest.mark.parametrize(("config", "expected"), [ + ({"args": ("run",)}, ["run"]), + ({"args": ["build", "-v"]}, ["build", "-v"]), + ({"args": "run"}, ["run"]), + ({"args": ""}, []), + ({}, []), + ({"args": None}, []), + ({"args": 3}, []), + ({"args": (1, "x")}, ["1", "x"]), + ]) + def test_what_goes_between_the_compiler_and_the_file(self, config, expected): + assert file_runner_process.run_arguments(config) == expected From 7f1a157a3bf142682a9e80a8e5b5e0f25a0b52fd Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 12:01:43 +0800 Subject: [PATCH 298/312] Show why a HAR file did not load in the IDE language --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- pybreeze/pybreeze_ui/tools_gui/har_import_gui.py | 2 +- test/test_utils/test_error_text.py | 14 ++++++++++++++ 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 318bb500..b3ad8671 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -454,7 +454,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 123 個 `test_*.py`、2207 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 123 個 `test_*.py`、2208 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 36827cca..d1138315 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -511,3 +511,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2207 tests in 123 files, measured by collection. `ruff check` clean. - **Files**: `pybreeze/extend/process_executor/file_runner_process.py`, `test/test_utils/test_compile_then_run.py`, `architecture_explore.md` §18 - **Open items**: JEditor's own runner (`code_exec.py`, `list(run_config.get("args", ()))`) has the same fault; recorded, not changed here. + +## U-20260924-256 · 2026-09-24 · A HAR file that is not one says why in the IDE language too · #fix #tools #i18n + +- **What**: U-20260924-254 put the tool tabs' reasons through `error_text()`, but missed the HAR tab's own load error, whose `format(` call runs over two lines: a file that is not a HAR export still read `匯入失敗:the JSON has no log.entries list, so it is not a HAR export` in Traditional Chinese. +- **Fix**: `HarImportGUI.load_text` shows the reason through `error_text()`. No tool tab now shows `str(error)` as it is. +- **Tests**: `test_error_text.py` +1 (a JSON document without `log.entries` loaded in Traditional Chinese). +- **Result / numbers**: 2208 tests in 123 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `test/test_utils/test_error_text.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index df4b642f..72504388 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-256 | 2026-09-24 | A HAR file that is not one says why in the IDE language too | #fix #tools #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-255 | 2026-09-24 | A plugin run config's args given as a string is one argument | #fix #plugins #run | [2026-09-b](2026-09-b.md) | | U-20260924-254 | 2026-09-24 | Why a tool refused its input, in the IDE language | #fix #tools #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-253 | 2026-09-24 | The AI review panel lists its methods from one place | #refactor #ai | [2026-09-b](2026-09-b.md) | @@ -337,4 +338,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 55 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 56 | diff --git a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py index d1bc81ff..12657620 100644 --- a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py @@ -146,7 +146,7 @@ def load_text(self, text: str) -> bool: pybreeze_logger.info("har_import_gui.py parse failed: %r", error) self._report_error( language_wrapper.language_word_dict.get("har_import_error").format( - error=str(error))) + error=error_text(str(error)))) return False self._entries = entries self._refresh_entry_list() diff --git a/test/test_utils/test_error_text.py b/test/test_utils/test_error_text.py index adc37b2d..961c91bb 100644 --- a/test/test_utils/test_error_text.py +++ b/test/test_utils/test_error_text.py @@ -92,3 +92,17 @@ def test_shows_the_reason_in_chinese(self, app, chinese, monkeypatch): assert "無法辨識為 epoch 數值或 ISO-8601 日期時間" in shown assert "recognized" not in shown tab.deleteLater() + + def test_a_har_file_that_is_not_one(self, app, chinese, monkeypatch): + from pybreeze.pybreeze_ui.tools_gui import har_import_gui + from pybreeze.pybreeze_ui.tools_gui.har_import_gui import HarImportGUI + + monkeypatch.setattr(har_import_gui.language_wrapper, "language_word_dict", CHINESE) + tab = HarImportGUI() + + assert not tab.load_text('{"log": {}}') + + shown = tab.output_edit.toPlainText() + assert "這份 JSON 沒有 log.entries 清單,不是 HAR 匯出檔" in shown + assert "entries list" not in shown + tab.deleteLater() From 1515915776c00f4023c92f202c7b3778e0fff36f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 12:06:19 +0800 Subject: [PATCH 299/312] Remove an error hook TaskProcessManager never called --- docs/updates/2026-09-b.md | 8 ++++++++ docs/updates/README.md | 3 ++- .../process_executor/python_task_process_manager.py | 6 +----- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index d1138315..2f7d430a 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -520,3 +520,11 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2208 tests in 123 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `test/test_utils/test_error_text.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260924-257 · 2026-09-24 · Drop TaskProcessManager's unused error hook · #cleanup #executor + +- **What**: `TaskProcessManager` took an `error_trigger_function` that nothing passed and nothing called: a run that fails reports its exit code and then runs the done hook, as any run does. Its `__init__` also called `super().__init__()` on `object` and carried a stale `# ite_instance param` comment. +- **Change**: the parameter, the attribute, the call and the comment are gone; `task_done_trigger_function` is typed `Callable | None`, as it may be. Only this repository constructs the class (`process_executor_utils.build_process`), and it is not a contract in `architecture.md` §6. +- **Tests**: the executor tests (112 passed, 14 skipped) pass unchanged. No behaviour change. +- **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 72504388..d1849b6e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-257 | 2026-09-24 | Drop TaskProcessManager's unused error hook | #cleanup #executor | [2026-09-b](2026-09-b.md) | | U-20260924-256 | 2026-09-24 | A HAR file that is not one says why in the IDE language too | #fix #tools #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-255 | 2026-09-24 | A plugin run config's args given as a string is one argument | #fix #plugins #run | [2026-09-b](2026-09-b.md) | | U-20260924-254 | 2026-09-24 | Why a tool refused its input, in the IDE language | #fix #tools #i18n | [2026-09-b](2026-09-b.md) | @@ -338,4 +339,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 56 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 57 | diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 07e61a21..24dc1fff 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -78,13 +78,10 @@ def __init__( self, main_window: CodeWindow, task_done_trigger_function: Callable | None = None, - error_trigger_function: Callable | None = None, program_buffer_size: int = 1024, program_encoding: str = "utf-8" ): - super().__init__() self.compiler_path = None - # ite_instance param self.read_program_error_output_from_thread: threading.Thread | None = None self.read_program_output_from_thread: threading.Thread | None = None self.main_window: CodeWindow = main_window @@ -100,8 +97,7 @@ def __init__( # The file a script too long for the command line was written to self._script_file: Path | None = None - self.task_done_trigger_function: Callable = task_done_trigger_function - self.error_trigger_function: Callable = error_trigger_function + self.task_done_trigger_function: Callable | None = task_done_trigger_function self.program_buffer_size = program_buffer_size def renew_path(self) -> bool: From 4bc2a37fc8814efc790439c9a1526c686e674c42 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 12:08:34 +0800 Subject: [PATCH 300/312] Refactor: raise the network, image, host-key and Skills messages from exception_tags --- README.md | 2 +- architecture_explore.md | 8 ++--- docs/updates/2026-09-b.md | 9 +++++ docs/updates/README.md | 3 +- .../extend_traditional_chinese.py | 23 +++++++++++++ .../connect_gui/ssh/ssh_host_key_policy.py | 5 ++- .../diagram_editor/diagram_net_utils.py | 16 ++++----- .../extend_ai_gui/skills/skills_send_gui.py | 21 ++++++++---- pybreeze/utils/exception/exception_tags.py | 33 +++++++++++++++++++ pybreeze/utils/network/http_client.py | 26 +++++++++------ pybreeze/utils/network/url_validation.py | 33 +++++++++++-------- test/test_utils/test_error_text.py | 4 +-- 12 files changed, 134 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index f9525fd2..4ed76ae7 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 679 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 702 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index b3ad8671..49fdfec5 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 679 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 702 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 679 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 702 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -361,7 +361,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 679 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 702 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -454,7 +454,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 123 個 `test_*.py`、2208 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 123 個 `test_*.py`、2231 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 2f7d430a..f252ae85 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -528,3 +528,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Tests**: the executor tests (112 passed, 14 skipped) pass unchanged. No behaviour change. - **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py` - **Open items**: none. + +## U-20260924-258 · 2026-09-24 · Refactor: the network, image, host-key and Skills messages become exception_tags constants · #refactor #i18n + +- **What**: the reasons URL validation, the capped reader, `describe_request_error`, the diagram editor's image download, the SSH host-key check and the Skills panel's status text give were English literals where they were raised, so `error_text()` (U-20260924-254) could not find them. They are 23 constants in `exception_tags` now, with the same text, raised through `.format()`; the Skills panel's `_where` fragment became `_redirect_text`, a whole message per case. +- **Behaviour**: none visible: every message reads as before, and nothing shows them through `error_text()` yet. Their `error_text_` entries are in both dictionaries (English from the constants, Traditional Chinese translated) for the next step. +- **Tests**: `test_error_text.py` fills every field a constant has, so each of the 23 is also found again from a message made from it (+23). The URL, request-error, capped-read, image, SSH and Skills tests pass unchanged. +- **Result / numbers**: 702 keys in each language. 2231 tests in 123 files. `ruff check` clean. +- **Files**: `pybreeze/utils/exception/exception_tags.py`, `pybreeze/utils/network/url_validation.py`, `pybreeze/utils/network/http_client.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py`, `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_error_text.py`, `README.md`, `architecture_explore.md` +- **Open items**: showing them in the IDE language (next entry). diff --git a/docs/updates/README.md b/docs/updates/README.md index d1849b6e..37ef1982 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-258 | 2026-09-24 | Refactor: the network, image, host-key and Skills messages become exception_tags constants | #refactor #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-257 | 2026-09-24 | Drop TaskProcessManager's unused error hook | #cleanup #executor | [2026-09-b](2026-09-b.md) | | U-20260924-256 | 2026-09-24 | A HAR file that is not one says why in the IDE language too | #fix #tools #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-255 | 2026-09-24 | A plugin run config's args given as a string is one argument | #fix #plugins #run | [2026-09-b](2026-09-b.md) | @@ -339,4 +340,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 57 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 58 | diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index a977b2de..65763bc5 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -797,6 +797,29 @@ "這個正規表示式執行 {seconds} 秒後仍未結束,已被停止;(a+)+ 這類巢狀重複遇到幾乎符合的文字時," "執行時間可能呈指數成長", "error_text_regex_worker_error": "無法執行這個正規表示式:{detail}", + "error_text_url_unsafe_characters_error": "URL 含有反斜線、空白或控制字元。", + "error_text_url_unparsable_error": "無法解析這個 URL。", + "error_text_url_ambiguous_host_error": "這個 URL 的主機名稱有兩種讀法。", + "error_text_url_scheme_not_allowed_error": "不允許 '{scheme}' 協定,請使用 http 或 https。", + "error_text_url_no_hostname_error": "URL 沒有主機名稱。", + "error_text_hostname_unresolved_error": "無法解析主機名稱 '{hostname}':{detail}", + "error_text_hostname_without_address_error": "無法解析主機名稱 '{hostname}'。", + "error_text_address_not_public_error": "不允許連到非公開位址 {address}。", + "error_text_response_too_large_error": "回應內容超過 {limit} 位元組的上限。", + "error_text_request_timed_out_error": "請求逾時", + "error_text_request_tls_failed_error": "無法建立安全連線", + "error_text_request_no_connection_error": "無法連線到伺服器", + "error_text_request_too_many_redirects_error": "重新導向次數過多", + "error_text_request_invalid_url_error": "URL 無效", + "error_text_image_is_text_error": "預期收到圖片,伺服器卻傳回 '{content_type}'。", + "error_text_image_declared_too_large_error": "圖片太大({size} 位元組,上限 {limit})。", + "error_text_image_too_large_error": "圖片超過 {megabytes} MB 的上限。", + "error_text_host_key_rejected_error": "已拒絕 {hostname} 的主機金鑰。", + "error_text_redirect_not_followed_error": "重新導向(未跟隨)至 {where}", + "error_text_redirect_same_server_error": "重新導向(未跟隨)至這台伺服器上的另一個路徑", + "error_text_redirect_nowhere_error": "重新導向(未跟隨),但沒有指出目的地", + "error_text_authorization_failed_error": "驗證或授權失敗", + "error_text_server_error_error": "伺服器錯誤:{body}", } diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py index cc4d6aa8..e4527dd5 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py @@ -24,6 +24,7 @@ from PySide6.QtWidgets import QMessageBox from pybreeze.utils.app_dirs import pybreeze_data_dir +from pybreeze.utils.exception.exception_tags import host_key_rejected_error from pybreeze.utils.file_process.replace_file import replace_written from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.pybreeze_ui.plain_text import as_text @@ -185,9 +186,7 @@ def missing_host_key( pybreeze_logger.warning( "SSH host key for %s rejected by user (%s)", hostname, fingerprint ) - raise paramiko.SSHException( - f"Host key for {hostname} rejected by user." - ) + raise paramiko.SSHException(host_key_rejected_error.format(hostname=hostname)) client.get_host_keys().add(hostname, key_type, key) _store(hostname, key) pybreeze_logger.info( diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py index 3488a07b..41702b5b 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py @@ -11,6 +11,11 @@ from urllib.request import HTTPRedirectHandler, Request, build_opener +from pybreeze.utils.exception.exception_tags import ( + image_declared_too_large_error, + image_is_text_error, + image_too_large_error, +) from pybreeze.utils.network.public_http import PublicHTTPHandler, PublicHTTPSHandler, overall_deadline from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url @@ -97,21 +102,16 @@ def safe_download_image(url: str) -> bytes: with overall_deadline(DOWNLOAD_DEADLINE_SECONDS), _OPENER.open(req, timeout=TIMEOUT_SECONDS) as resp: content_type = resp.headers.get("Content-Type", "") if _is_text_content_type(content_type): - raise ImageDownloadError( - f"Expected an image but the server returned '{content_type.strip()}'." - ) + raise ImageDownloadError(image_is_text_error.format(content_type=content_type.strip())) declared_length = _parse_content_length(resp.headers.get("Content-Length")) if declared_length is not None and declared_length > MAX_DOWNLOAD_BYTES: raise ImageDownloadError( - f"Image too large ({declared_length} bytes, max {MAX_DOWNLOAD_BYTES})." - ) + image_declared_too_large_error.format(size=declared_length, limit=MAX_DOWNLOAD_BYTES)) # Read one byte past the cap so an undersized/absent header can't smuggle # an oversized body past the check. data = resp.read(MAX_DOWNLOAD_BYTES + 1) if len(data) > MAX_DOWNLOAD_BYTES: - raise ImageDownloadError( - f"Image exceeds {MAX_DOWNLOAD_BYTES // (1024 * 1024)} MB limit." - ) + raise ImageDownloadError(image_too_large_error.format(megabytes=MAX_DOWNLOAD_BYTES // (1024 * 1024))) return data 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 222febab..40722df3 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 @@ -15,6 +15,13 @@ ) from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import load_prompt from pybreeze.pybreeze_ui.thread_keeper import let_run_out +from pybreeze.utils.exception.exception_tags import ( + authorization_failed_error, + redirect_not_followed_error, + redirect_nowhere_error, + redirect_same_server_error, + server_error_error, +) from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( DEFAULT_MAX_READ_SECONDS, ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, @@ -65,8 +72,8 @@ def run(self): self.error.emit(language_wrapper.language_word_dict.get("skills_exception").format(error=describe_request_error(e))) -def _where(location: str) -> str: - """The scheme and host a redirect names, and nothing else. +def _redirect_text(location: str) -> str: + """Say that a redirect was not followed, naming its scheme and host and nothing else. A redirect usually repeats the request's path and query (a trailing-slash redirect of ``?key=...``), and the whole URL used to be shown with the @@ -74,12 +81,12 @@ def _where(location: str) -> str: """ parts = urlsplit(location) if not parts.scheme or not parts.hostname: - return "another path on this server" if location else "an unnamed place" + return redirect_same_server_error if location else redirect_nowhere_error try: port = f":{parts.port}" if parts.port else "" except ValueError: # not a port number port = "" - return f"{parts.scheme}://{parts.hostname}{port}" + return redirect_not_followed_error.format(where=f"{parts.scheme}://{parts.hostname}{port}") def describe_failed_status(response, body: str) -> tuple[bool, str]: @@ -89,11 +96,11 @@ def describe_failed_status(response, body: str) -> tuple[bool, str]: answer; a refused request and a server error are errors. """ if response.is_redirect: - return False, f"Redirect (not followed) to {_where(response.headers.get('Location', ''))}" + return False, _redirect_text(response.headers.get("Location", "")) if response.status_code in (401, 403): - return True, "Authentication/Authorization failed" + return True, authorization_failed_error if response.status_code >= 500: - return True, f"Server error: {truncate_for_display(body)}" + return True, server_error_error.format(body=truncate_for_display(body)) return False, truncate_for_display(body) diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index 9fa43e68..07353783 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -67,3 +67,36 @@ "the pattern was still running after {seconds} s and was stopped; nested " "repetition such as (a+)+ can take exponentially long on text that almost matches") regex_worker_error: str = "the pattern could not be run: {detail}" + +# URL checks (utils/network): they name no path or query, which may hold a token +url_unsafe_characters_error: str = "URL contains a backslash, whitespace or a control character." +url_unparsable_error: str = "URL cannot be parsed." +url_ambiguous_host_error: str = "URL names its host ambiguously." +url_scheme_not_allowed_error: str = "Scheme '{scheme}' is not allowed. Use http or https." +url_no_hostname_error: str = "URL has no hostname." +hostname_unresolved_error: str = "Cannot resolve hostname '{hostname}': {detail}" +hostname_without_address_error: str = "Cannot resolve hostname '{hostname}'." +address_not_public_error: str = "Access to non-public address {address} is blocked." +response_too_large_error: str = "Response body exceeds the {limit}-byte limit." + +# What went wrong with a request (http_client.describe_request_error) +request_timed_out_error: str = "the request timed out" +request_tls_failed_error: str = "the secure connection could not be made" +request_no_connection_error: str = "could not connect to the server" +request_too_many_redirects_error: str = "too many redirects" +request_invalid_url_error: str = "the URL is not valid" + +# Image download (diagram editor) +image_is_text_error: str = "Expected an image but the server returned '{content_type}'." +image_declared_too_large_error: str = "Image too large ({size} bytes, max {limit})." +image_too_large_error: str = "Image exceeds {megabytes} MB limit." + +# SSH +host_key_rejected_error: str = "Host key for {hostname} rejected by user." + +# An answer the Skills panel got that is not 2xx +redirect_not_followed_error: str = "Redirect (not followed) to {where}" +redirect_same_server_error: str = "Redirect (not followed) to another path on this server" +redirect_nowhere_error: str = "Redirect (not followed) to an unnamed place" +authorization_failed_error: str = "Authentication/Authorization failed" +server_error_error: str = "Server error: {body}" diff --git a/pybreeze/utils/network/http_client.py b/pybreeze/utils/network/http_client.py index 1a759f48..6af9d5a3 100644 --- a/pybreeze/utils/network/http_client.py +++ b/pybreeze/utils/network/http_client.py @@ -14,6 +14,14 @@ import requests +from pybreeze.utils.exception.exception_tags import ( + request_invalid_url_error, + request_no_connection_error, + request_timed_out_error, + request_tls_failed_error, + request_too_many_redirects_error, + response_too_large_error, +) from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.url_validation import UnsafeURLError @@ -69,9 +77,7 @@ def read_capped_text( continue total += len(chunk) if total > max_bytes: - raise ResponseTooLargeError( - f"Response body exceeds the {max_bytes}-byte limit." - ) + raise ResponseTooLargeError(response_too_large_error.format(limit=max_bytes)) chunks.append(chunk) # What a read cut off by the watchdog raises depends on where it was except (requests.exceptions.RequestException, OSError, ValueError, AttributeError): @@ -140,13 +146,13 @@ def named_charset(response: requests.Response) -> str | None: # What a failed request is called for the user, most specific first: a connect # timeout is both a Timeout and a ConnectionError _REQUEST_FAILURES: tuple[tuple[type[Exception], str], ...] = ( - (requests.Timeout, "the request timed out"), - (requests.exceptions.SSLError, "the secure connection could not be made"), - (requests.ConnectionError, "could not connect to the server"), - (requests.TooManyRedirects, "too many redirects"), - (requests.exceptions.InvalidURL, "the URL is not valid"), - (requests.exceptions.MissingSchema, "the URL is not valid"), - (requests.exceptions.InvalidSchema, "the URL is not valid"), + (requests.Timeout, request_timed_out_error), + (requests.exceptions.SSLError, request_tls_failed_error), + (requests.ConnectionError, request_no_connection_error), + (requests.TooManyRedirects, request_too_many_redirects_error), + (requests.exceptions.InvalidURL, request_invalid_url_error), + (requests.exceptions.MissingSchema, request_invalid_url_error), + (requests.exceptions.InvalidSchema, request_invalid_url_error), ) diff --git a/pybreeze/utils/network/url_validation.py b/pybreeze/utils/network/url_validation.py index 1bf224c6..f5074418 100644 --- a/pybreeze/utils/network/url_validation.py +++ b/pybreeze/utils/network/url_validation.py @@ -7,6 +7,17 @@ from urllib3.exceptions import LocationParseError from urllib3.util import parse_url +from pybreeze.utils.exception.exception_tags import ( + address_not_public_error, + hostname_unresolved_error, + hostname_without_address_error, + url_ambiguous_host_error, + url_no_hostname_error, + url_scheme_not_allowed_error, + url_unparsable_error, + url_unsafe_characters_error, +) + _ALLOWED_SCHEMES = frozenset({"http", "https"}) # RFC 6598 shared address space (Carrier-Grade NAT). Not covered by @@ -75,18 +86,18 @@ def _check_one_reading(url: str) -> None: """ if any(character == "\\" or character.isspace() or ord(character) < 0x20 or ord(character) == 0x7F for character in url): - raise UnsafeURLError("URL contains a backslash, whitespace or a control character.") + raise UnsafeURLError(url_unsafe_characters_error) # Not the parsers' messages: they quote the whole URL, which may hold a token try: connected_host = (parse_url(url).host or "").strip("[]").lower() except LocationParseError: - raise UnsafeURLError("URL cannot be parsed.") from None + raise UnsafeURLError(url_unparsable_error) from None try: checked_host = (urlparse(url).hostname or "").lower() except ValueError: - raise UnsafeURLError("URL cannot be parsed.") from None + raise UnsafeURLError(url_unparsable_error) from None if _as_ascii(connected_host) != _as_ascii(checked_host): - raise UnsafeURLError("URL names its host ambiguously.") + raise UnsafeURLError(url_ambiguous_host_error) def _as_ascii(host: str) -> str: @@ -130,13 +141,11 @@ def validate_url(url: str) -> str: parsed = urlparse(url) if parsed.scheme.lower() not in _ALLOWED_SCHEMES: - raise UnsafeURLError( - f"Scheme '{parsed.scheme}' is not allowed. Use http or https." - ) + raise UnsafeURLError(url_scheme_not_allowed_error.format(scheme=parsed.scheme)) hostname = parsed.hostname if not hostname: - raise UnsafeURLError("URL has no hostname.") + raise UnsafeURLError(url_no_hostname_error) # The name urllib3 will look up, not Python's reading of a Unicode one public_address(_as_ascii(hostname)) @@ -170,17 +179,15 @@ def public_addresses(hostname: str) -> list[str]: # UnicodeError: the name cannot even be encoded for a lookup (a label # over 63 characters, or an empty one). Callers catch UnsafeURLError, so # anything else here would escape into a Qt slot. - raise UnsafeURLError(f"Cannot resolve hostname '{hostname}': {exc}") from exc + raise UnsafeURLError(hostname_unresolved_error.format(hostname=hostname, detail=exc)) from exc if not infos: - raise UnsafeURLError(f"Cannot resolve hostname '{hostname}'.") + raise UnsafeURLError(hostname_without_address_error.format(hostname=hostname)) addresses: list[str] = [] for *_unused, sockaddr in infos: ip = ipaddress.ip_address(sockaddr[0]) if _is_blocked_ip(ip): - raise UnsafeURLError( - f"Access to non-public address {ip} is blocked." - ) + raise UnsafeURLError(address_not_public_error.format(address=ip)) if sockaddr[0] not in addresses: addresses.append(sockaddr[0]) return addresses diff --git a/test/test_utils/test_error_text.py b/test/test_utils/test_error_text.py index 961c91bb..de1349ba 100644 --- a/test/test_utils/test_error_text.py +++ b/test/test_utils/test_error_text.py @@ -15,7 +15,7 @@ from pybreeze.pybreeze_ui import error_text as error_text_mod from pybreeze.pybreeze_ui.error_text import error_text from pybreeze.utils.exception import exception_tags -from pybreeze.utils.exception.error_templates import ERROR_TEXT_KEY_PREFIX, error_templates +from pybreeze.utils.exception.error_templates import ERROR_TEXT_KEY_PREFIX, TEMPLATE_FIELD, error_templates @pytest.fixture(scope="module") @@ -31,7 +31,7 @@ def chinese(monkeypatch): def _sample(template: str) -> dict[str, str]: - return {name: f"<{name}>" for name in ("key", "detail", "seconds") if "{" + name + "}" in template} + return {field.group(1): f"<{field.group(1)}>" for field in TEMPLATE_FIELD.finditer(template)} class TestEveryConstant: From 910cfdb161af5c0c400f681d5b98a1d3df0a518e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 12:12:11 +0800 Subject: [PATCH 301/312] Show network, image, host-key and Skills reasons in the IDE language --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++ docs/updates/README.md | 3 +- progress.md | 1 + .../connect_gui/ssh/ssh_command_widget.py | 3 +- .../connect_gui/url/ai_code_review_gui.py | 3 +- .../diagram_editor/diagram_scene.py | 3 +- .../code_review/code_review_thread.py | 3 +- .../extend_ai_gui/skills/skills_send_gui.py | 9 +-- test/test_utils/test_error_text.py | 64 +++++++++++++++++++ 10 files changed, 90 insertions(+), 10 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 49fdfec5..1c574801 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -454,7 +454,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 123 個 `test_*.py`、2231 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 123 個 `test_*.py`、2239 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index f252ae85..48d3bf51 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -537,3 +537,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 702 keys in each language. 2231 tests in 123 files. `ruff check` clean. - **Files**: `pybreeze/utils/exception/exception_tags.py`, `pybreeze/utils/network/url_validation.py`, `pybreeze/utils/network/http_client.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py`, `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_error_text.py`, `README.md`, `architecture_explore.md` - **Open items**: showing them in the IDE language (next entry). + +## U-20260924-259 · 2026-09-24 · Network, image, host-key and Skills reasons in the IDE language · #fix #i18n #network + +- **What**: after U-20260924-258 made them constants, the reasons were still shown in English inside translated text: an image the diagram editor could not download (`Image exceeds 20 MB limit.`), a URL the SSRF check refused (`Scheme 'ftp' is not allowed. Use http or https.`), a request that failed (`the request timed out (ReadTimeout)`) in the AI code review, CoT review and Skills panels, the Skills panel's redirect, 401/403 and 5xx texts, and a host key the user declined in the SSH shell. +- **Fix**: `ImageDownloadThread`, `AICodeReviewClient`'s request thread, `code_review_thread`, the Skills `RequestThread` and `describe_failed_status`, and `SSHCommandWidget._on_connect_failed` pass the reason through `error_text()`. A server's own text (an error page's body, paramiko's messages) is shown as it is. +- **Tests**: `test_error_text.py` +8 (the four Skills texts, a failed image download, an SSRF refusal, a timed-out request and a declined host key, all in Traditional Chinese), six of which fail on the old code. +- **Result / numbers**: 2239 tests in 123 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py`, `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_error_text.py`, `architecture_explore.md` §18 +- **Open items**: the SFTP tree's connect-failure box (`ssh_file_viewer_widget._on_connect_failed`) still shows a declined host key in English; that file had other uncommitted work in it at the time (progress.md). diff --git a/docs/updates/README.md b/docs/updates/README.md index 37ef1982..59ca8406 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-259 | 2026-09-24 | Network, image, host-key and Skills reasons in the IDE language | #fix #i18n #network | [2026-09-b](2026-09-b.md) | | U-20260924-258 | 2026-09-24 | Refactor: the network, image, host-key and Skills messages become exception_tags constants | #refactor #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-257 | 2026-09-24 | Drop TaskProcessManager's unused error hook | #cleanup #executor | [2026-09-b](2026-09-b.md) | | U-20260924-256 | 2026-09-24 | A HAR file that is not one says why in the IDE language too | #fix #tools #i18n | [2026-09-b](2026-09-b.md) | @@ -340,4 +341,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 58 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 59 | diff --git a/progress.md b/progress.md index e156a699..b1b25da1 100644 --- a/progress.md +++ b/progress.md @@ -17,3 +17,4 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#102** [BLOCKED] A report mail can wait forever: je_mail_thunder's `SMTPWrapper(host, port)` passes no timeout to `SMTP_SSL`, so a mail server that accepts the connection and then stalls holds the report-mail thread (`extend/mail_thunder_extend/mail_thunder_setting.py`, `send_report`) with no end, and the run window never says whether the report went. Needs a `timeout` argument in MailThunder (`je_mail_thunder/smtp/smtp_wrapper.py:20`); then pass 30 s here. - **#103** [BLOCKED] PyBreeze's automation keywords are never highlighted: `syntax_extend_package` registers them for `.json`, `.yml` and `.yaml` (`pybreeze_ui/syntax/syntax_extend.py`), but JEditor has built-in rules for those suffixes, so `CodeEditor.reset_highlighter` gets a `GenericHighlighter` from `highlighter_for`, and only `PythonHighlighter` reads the plugin registry (`je_editor/pyside_ui/code/syntax/generic_syntax.py:124`, `code_edit_plaintext.py:423-425` in `D:\Codes\JEDITOR` at d372c35). Needs JEditor's generic highlighter to add a registered language's words for the suffix; the `architecture.md` §6 contract changes with it. - **#106** [DECIDE] `test_pioneer` is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), and before 0.1.34 it read a YAML file in the locale's encoding: on a Traditional Chinese Windows (cp950) TestPioneer "Run YAML" of a file with a non-ASCII character, which the editor saves as UTF-8, exits 1 with `UnicodeDecodeError` (`extend/process_executor/test_pioneer/test_pioneer_process_manager.py`). This repo's `.venv` has 0.1.30; 0.1.34 reads it as UTF-8. Should the dependencies require `test_pioneer>=0.1.34`? +- **#107** The SFTP tree still shows a declined host key in English: `SSHFileTreeManager._on_connect_failed` (`pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`) puts the connect error into its box as it is, while the shell half passes it through `pybreeze_ui/error_text.error_text()`. Left out because that file held other uncommitted work at the time. 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 824deafb..f3bc5a8b 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -23,6 +23,7 @@ from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key, unloadable_key_reason from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget from pybreeze.pybreeze_ui.thread_keeper import if_alive, let_run_out +from pybreeze.pybreeze_ui.error_text import error_text from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.terminal_text import split_unfinished_end, strip_terminal_controls, take_leading_backspaces @@ -336,7 +337,7 @@ def _on_connect_failed(self, client: paramiko.SSHClient, message: str) -> None: return self.login_widget.status_label.setText( self.word_dict.get('ssh_command_widget_status_label_disconnected')) - self.append_text(f"{self.word_dict.get('ssh_command_widget_log_message_error')} {message}\n") + self.append_text(f"{self.word_dict.get('ssh_command_widget_log_message_error')} {error_text(message)}\n") self._cleanup() self.state_changed.emit() 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 d62e2929..0a259c21 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 @@ -16,6 +16,7 @@ from pybreeze.utils.app_dirs import pybreeze_data_dir from pybreeze.utils.file_process.replace_file import replace_text from pybreeze.utils.hash_tools.hash_text import hash_text +from pybreeze.pybreeze_ui.error_text import error_text from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( DEFAULT_MAX_READ_SECONDS, ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, @@ -89,7 +90,7 @@ def run(self) -> None: except (requests.RequestException, ResponseTooLargeError, UnsafeURLError) as error: # Not %r: a requests error carries the whole URL, which may hold a token. pybreeze_logger.error("AI code review request failed: %s", type(error).__name__) - self.failed.emit(describe_request_error(error)) + self.failed.emit(error_text(describe_request_error(error))) def _send(self, session: requests.Session) -> requests.Response: """Send the request with the chosen method, code in the body where there is one.""" diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 8068e7c4..c70d0f8c 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -24,6 +24,7 @@ safe_download_image, ) from pybreeze.pybreeze_ui.thread_keeper import let_run_out +from pybreeze.pybreeze_ui.error_text import error_text from pybreeze.utils.logging.logger import pybreeze_logger # Allowlist of image extensions that a saved diagram may reference on disk. @@ -88,7 +89,7 @@ def run(self) -> None: data = safe_download_image(self._source) # ValueError: a URL urllib cannot parse at all except (ImageDownloadError, OSError, HTTPException, ValueError) as err: - self.failed.emit(self._source, str(err)) + self.failed.emit(self._source, error_text(str(err))) else: self.fetched.emit(self._source, data) 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 8d065edc..493c53f7 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 @@ -8,6 +8,7 @@ from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_chain import ( CODE_DIFF, STEP_RESULT_KEY, build_prompt ) +from pybreeze.pybreeze_ui.error_text import error_text from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( DEFAULT_MAX_READ_SECONDS, ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, @@ -79,7 +80,7 @@ def _ask(self, session: requests.Session, file: str, prompt: str) -> tuple[str, pybreeze_logger.error( "CoT code review send failed for %s: %s", file, type(error).__name__) word = language_wrapper.language_word_dict - return f"{word.get('cot_gui_error_sending')} {file} {describe_request_error(error)}", False + return f"{word.get('cot_gui_error_sending')} {file} {error_text(describe_request_error(error))}", False if not succeeded(resp): # An error page or an unfollowed redirect is not an answer: counted # as one, it was quoted into every later step of the chain. 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 40722df3..8b59ebe1 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 @@ -22,6 +22,7 @@ redirect_same_server_error, server_error_error, ) +from pybreeze.pybreeze_ui.error_text import error_text from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( DEFAULT_MAX_READ_SECONDS, ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, @@ -69,7 +70,7 @@ def run(self): except (requests.RequestException, ResponseTooLargeError, UnsafeURLError) as e: # Not %r: a requests error carries the whole URL, which may hold a token. pybreeze_logger.error("Skills send request failed: %s", type(e).__name__) - self.error.emit(language_wrapper.language_word_dict.get("skills_exception").format(error=describe_request_error(e))) + self.error.emit(language_wrapper.language_word_dict.get("skills_exception").format(error=error_text(describe_request_error(e)))) def _redirect_text(location: str) -> str: @@ -96,11 +97,11 @@ def describe_failed_status(response, body: str) -> tuple[bool, str]: answer; a refused request and a server error are errors. """ if response.is_redirect: - return False, _redirect_text(response.headers.get("Location", "")) + return False, error_text(_redirect_text(response.headers.get("Location", ""))) if response.status_code in (401, 403): - return True, authorization_failed_error + return True, error_text(authorization_failed_error) if response.status_code >= 500: - return True, server_error_error.format(body=truncate_for_display(body)) + return True, error_text(server_error_error.format(body=truncate_for_display(body))) return False, truncate_for_display(body) diff --git a/test/test_utils/test_error_text.py b/test/test_utils/test_error_text.py index de1349ba..649d2f2d 100644 --- a/test/test_utils/test_error_text.py +++ b/test/test_utils/test_error_text.py @@ -106,3 +106,67 @@ def test_a_har_file_that_is_not_one(self, app, chinese, monkeypatch): assert "這份 JSON 沒有 log.entries 清單,不是 HAR 匯出檔" in shown assert "entries list" not in shown tab.deleteLater() + + +class TestTheNetworkReasons: + """URL checks, request failures, image downloads, host keys and Skills' status texts.""" + + @pytest.mark.parametrize(("status", "headers", "body", "expected"), [ + (302, {"Location": "https://elsewhere.example/x?key=1"}, "", "重新導向(未跟隨)至 https://elsewhere.example"), + (302, {"Location": "/next"}, "", "重新導向(未跟隨)至這台伺服器上的另一個路徑"), + (401, {}, "", "驗證或授權失敗"), + (503, {}, "overloaded", "伺服器錯誤:overloaded"), + ]) + def test_a_skills_answer_that_is_not_2xx(self, chinese, status, headers, body, expected): + from types import SimpleNamespace + + from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import describe_failed_status + + response = SimpleNamespace(status_code=status, headers=headers, is_redirect=status == 302) + + assert describe_failed_status(response, body)[1] == expected + + def test_an_image_that_did_not_download(self, app, chinese, monkeypatch): + from pybreeze.pybreeze_ui.diagram_editor import diagram_scene + from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import ImageDownloadError + + def refuse(_source): + raise ImageDownloadError(exception_tags.image_too_large_error.format(megabytes=20)) + + monkeypatch.setattr(diagram_scene, "safe_download_image", refuse) + thread = diagram_scene.ImageDownloadThread("https://example.org/a.png") + failed: list = [] + thread.failed.connect(lambda _source, message: failed.append(message)) + + thread.run() # on this thread: a direct connection + + assert failed == ["圖片超過 20 MB 的上限。"] + + def test_a_url_the_ssrf_check_refuses(self, chinese): + from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url + + with pytest.raises(UnsafeURLError) as refused: + validate_url("ftp://example.org/") + + assert error_text(str(refused.value)) == "不允許 'ftp' 協定,請使用 http 或 https。" + + def test_a_request_that_timed_out(self, chinese): + import requests + + from pybreeze.utils.network.http_client import describe_request_error + + assert error_text(describe_request_error(requests.ReadTimeout())) == "請求逾時 (ReadTimeout)" + + def test_a_host_key_the_user_declined(self, app, chinese): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import SSHCommandWidget + + widget = SSHCommandWidget() + client = object() + widget.ssh_client = client + widget._cleanup = lambda: None + + widget._on_connect_failed(client, exception_tags.host_key_rejected_error.format(hostname="example.org")) + + assert "已拒絕 example.org 的主機金鑰。" in widget.terminal.toPlainText() + widget.ssh_client = None + widget.close() From be9bd3bb1169f3ac786746be867caeb4935f5854 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 12:19:56 +0800 Subject: [PATCH 302/312] Open the SFTP menu with exec instead of the deprecated exec_ --- docs/updates/2026-09-b.md | 8 ++++++++ docs/updates/README.md | 3 ++- .../connect_gui/ssh/ssh_file_viewer_widget.py | 2 +- test/test_utils/test_sftp_tree_actions.py | 11 +++++++---- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 48d3bf51..2e07f9cf 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -546,3 +546,11 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2239 tests in 123 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py`, `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_error_text.py`, `architecture_explore.md` §18 - **Open items**: the SFTP tree's connect-failure box (`ssh_file_viewer_widget._on_connect_failed`) still shows a declined host key in English; that file had other uncommitted work in it at the time (progress.md). + +## U-20260924-260 · 2026-09-24 · The SFTP menu opens with exec, not the deprecated exec_ · #cleanup #ssh #qt + +- **What**: the SFTP tree opened its context menu with `QMenu.exec_`, the Qt 5 spelling PySide6 keeps only as a deprecated alias; the rest of the code base uses `exec`. +- **Change**: `menu.exec(...)`. The tree's menu tests replaced `QMenu.exec_` on the class; PySide6 does not look up an `exec` patched onto `QMenu` itself (the real, modal menu opened and the test waited forever), so they now put a `QMenu` subclass whose `exec` answers at once in the module's place. +- **Tests**: `test_sftp_tree_actions.py` (29) passes. No behaviour change. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_sftp_tree_actions.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 59ca8406..aea5f808 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-260 | 2026-09-24 | The SFTP menu opens with exec, not the deprecated exec_ | #cleanup #ssh #qt | [2026-09-b](2026-09-b.md) | | U-20260924-259 | 2026-09-24 | Network, image, host-key and Skills reasons in the IDE language | #fix #i18n #network | [2026-09-b](2026-09-b.md) | | U-20260924-258 | 2026-09-24 | Refactor: the network, image, host-key and Skills messages become exception_tags constants | #refactor #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-257 | 2026-09-24 | Drop TaskProcessManager's unused error hook | #cleanup #executor | [2026-09-b](2026-09-b.md) | @@ -341,4 +342,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 59 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 60 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 746c4a0f..1cbf8dfc 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -407,7 +407,7 @@ def action(name: str): download_act = action("download") upload_act = action("upload") - action = menu.exec_(self.tree.viewport().mapToGlobal(pos)) + action = menu.exec(self.tree.viewport().mapToGlobal(pos)) if action is None: return diff --git a/test/test_utils/test_sftp_tree_actions.py b/test/test_utils/test_sftp_tree_actions.py index 8f9d0af9..db53eef7 100644 --- a/test/test_utils/test_sftp_tree_actions.py +++ b/test/test_utils/test_sftp_tree_actions.py @@ -262,11 +262,14 @@ class TestTheMenu: def _open(self, widget, monkeypatch, item, choose: int | None = None) -> list[str]: shown: list[str] = [] - def run(menu, _pos): - shown.extend(action.text() for action in menu.actions()) - return None if choose is None else menu.actions()[choose] + class AnsweringMenu(tree_mod.QMenu): + """Answers at once. PySide6 does not look up ``exec`` patched onto QMenu itself.""" - monkeypatch.setattr(tree_mod.QMenu, "exec_", run) + def exec(self, _pos): + shown.extend(action.text() for action in self.actions()) + return None if choose is None else self.actions()[choose] + + monkeypatch.setattr(tree_mod, "QMenu", AnsweringMenu) monkeypatch.setattr(widget.tree, "itemAt", lambda _pos: item) widget.on_context_menu(widget.tree.rect().center()) return shown From dd2a55b1b813b8ef28d26cebad34d2a3b487491e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 12:25:36 +0800 Subject: [PATCH 303/312] Add a Stop button to the run window --- README.md | 4 +- architecture_explore.md | 9 +-- docs/updates/2026-09-b.md | 9 +++ docs/updates/README.md | 3 +- .../process_executor/file_runner_process.py | 1 + .../python_task_process_manager.py | 1 + .../extend_multi_language/extend_english.py | 2 + .../extend_traditional_chinese.py | 2 + .../show_code_window/code_window.py | 19 +++++- test/test_utils/test_code_window.py | 59 +++++++++++++++++++ 10 files changed, 100 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4ed76ae7..8f733e7d 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Each module gets the same menu shape: **Run** (single script, batch directory, w - **Automation-aware syntax highlighting** — the `AT_*` / GUI / Web / Load keyword sets are registered for `.json`, and TestPioneer's schema for `.yml` and `.yaml`, on top of JEditor's language support - **Code editor** — built on [JEditor](https://github.com/Integration-Automation/JEDITOR): tabs, project tree, format checker, debugger, terminal, variable inspector and a git client pane -- **Script execution** — single or batch; action files are passed by path, and a script from the tab in front that is too long for a Windows command line (~32 KB) goes through a temporary file +- **Script execution** — single or batch, each run in a window of its own with a Stop button; action files are passed by path, and a script from the tab in front that is too long for a Windows command line (~32 KB) goes through a temporary file - **Report generation** — HTML / JSON / XML after a run, with optional email delivery - **Integrated JupyterLab** — launches as a tab, installing JupyterLab into the project venv if it is missing - **Virtual environment awareness** — `venv/` and `.venv/` are detected and used automatically; without one, scripts run on the interpreter the IDE runs on @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 702 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 703 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index 1c574801..e0ae75ea 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 702 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 703 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 702 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 703 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -158,6 +158,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) - `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 - `output_queue()` — 每條管線的 queue 最多 `MAX_QUEUED_MESSAGES`(10,000)則;滿了 reader 就等(每 0.2 秒看一次 `keep_reading`),子行程寫管線也跟著等,跑得跟視窗顯示一樣快,像終端機;以前不設上限,印個不停的腳本會一直吃記憶體,按 Stop 後再一口氣全倒進視窗 - `ReaderGrace` / `any_alive()` / `OUTPUT_STILL_HELD_NOTE` — 子行程結束後 reader 還能讀多久(`READER_GRACE_SECONDS = 2.0`,從結束後第一個 tick 起算)。管線要等最後一個握著它的行程結束才會 EOF,子行程開的行程沒轉向輸出時會一直握著;以前兩個執行器在 UI 執行緒上各 join 2 秒,IDE 卡 4 秒還是丟掉之後的輸出。現在由 pump 逐 tick 詢問,時間到就結束執行並在視窗註明 +- 執行視窗上方有「停止」按鈕:執行器在子行程跑起來後呼叫 `CodeWindow.run_started()` 打開它,`run_ended()` 關掉它,按下去走 `stop_runner()`(`stop_tree` 停掉子行程和它開的所有行程);關掉執行視窗不會停止執行。 - `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。終端機控制碼(CSI 顏色與游標移動、OSC 等控制字串、`ESC ( B` 這類 nF 與其他兩位元組 escape)先拿掉、backspace 套用到前一個字元、tab、換行、`\r` 以外的控制字元丟掉(與 SSH terminal 共用 `utils/terminal_text.strip_terminal_controls()`;讀取切斷在 escape 中間時,reader 把尾巴留給下一段,`queue_pump` 的 `split_incomplete_escape()`),`\r\n` 是換行,單獨的 `\r` 像終端機一樣回到行首、由後面的文字取代這一行(`_insert_rewinding()`;結尾的 `\r` 記在 `_rewind_pending`,等下一段來才套用:接著是 `\n` 就是換行,否則回捲,跑完的進度條不會被清掉);換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 --- @@ -361,7 +362,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 702 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 703 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -454,7 +455,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 123 個 `test_*.py`、2239 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 123 個 `test_*.py`、2242 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 2e07f9cf..1a527d8e 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -554,3 +554,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Tests**: `test_sftp_tree_actions.py` (29) passes. No behaviour change. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_sftp_tree_actions.py` - **Open items**: none. + +## U-20260924-261 · 2026-09-24 · A Stop button in the run window · #feature #run #ui + +- **What**: a run window had no way to stop what it showed. Closing it lets the run go on, by design (the rest of its output goes there), and `CodeWindow.stop_runner()` was called only when the IDE closed, so a script stuck in a loop -- an automation run waiting on a page that never loads, say -- could be stopped only by quitting the IDE. +- **Change**: the run window has a Stop button above its output that calls `stop_runner()` (the child and every process it started, `stop_tree`). The executors call the new `CodeWindow.run_started()` once their child is running, which turns it on; `run_ended()` turns it off. A compile-then-run turns it on for the compile and again for the run. +- **Tests**: `test_code_window.py` +3 (off until a run starts and after it ends; it stops the runner; a script that would run forever is stopped through the button and the window's run ends). +- **Result / numbers**: executor and run-window tests 172 passed, 14 skipped. 703 keys in each language. 2242 tests in 123 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/extend/process_executor/python_task_process_manager.py`, `pybreeze/extend/process_executor/file_runner_process.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_code_window.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index aea5f808..7d2fe8c1 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-261 | 2026-09-24 | A Stop button in the run window | #feature #run #ui | [2026-09-b](2026-09-b.md) | | U-20260924-260 | 2026-09-24 | The SFTP menu opens with exec, not the deprecated exec_ | #cleanup #ssh #qt | [2026-09-b](2026-09-b.md) | | U-20260924-259 | 2026-09-24 | Network, image, host-key and Skills reasons in the IDE language | #fix #i18n #network | [2026-09-b](2026-09-b.md) | | U-20260924-258 | 2026-09-24 | Refactor: the network, image, host-key and Skills messages become exception_tags constants | #refactor #i18n | [2026-09-b](2026-09-b.md) | @@ -342,4 +343,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 60 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 61 | diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index 1e35c276..3a8a6352 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -195,6 +195,7 @@ def _start_process(self, command: list[str], cleanup_dir: str | None = None, self._after_exit = after_exit self._deadline = None if time_limit is None else time.monotonic() + time_limit self.still_running = True + self.main_window.run_started() self._reading = Event() self._reading.set() self._reader_grace.restart() diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 24dc1fff..2963c665 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -217,6 +217,7 @@ def _spawn_and_pump( return self.still_run_program = True self._reader_grace.restart() + self.main_window.run_started() self.read_program_output_from_thread = Thread( target=self.read_program_output_from_process, daemon=True diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 818c3754..2a090600 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -14,6 +14,8 @@ pybreeze_english_word_dict = { # application name "application_name": "PyBreeze", + # Run window + "code_window_stop_button": "Stop", # Menubar "automation_menu_label": "Automation", "install_menu_label": "Install", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 65763bc5..474944a4 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -9,6 +9,8 @@ pybreeze_traditional_chinese_word_dict = { # application name "application_name": "PyBreeze", + # Run window + "code_window_stop_button": "停止", # Menubar "automation_menu_label": "自動化", "install_menu_label": "安裝", diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index 4e3a5011..f0b8ddc8 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -2,10 +2,11 @@ from typing import TYPE_CHECKING +from je_editor import language_wrapper from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict from PySide6.QtCore import QTimer, Signal from PySide6.QtGui import QGuiApplication, QTextCharFormat, QTextCursor -from PySide6.QtWidgets import QWidget, QGridLayout, QPlainTextEdit, QScrollArea +from PySide6.QtWidgets import QWidget, QGridLayout, QHBoxLayout, QPlainTextEdit, QPushButton, QScrollArea from pybreeze.utils.terminal_text import strip_terminal_controls, take_leading_backspaces @@ -65,6 +66,15 @@ def __init__(self): # "\r" + "\x1b[K60%" in two pieces left the old bar: "50%60%" self._rewind_pending = False self.grid_layout = QGridLayout() + # Stops the run shown here: closing the window lets it go on, and it + # could otherwise be stopped only by closing the IDE + self.stop_button = QPushButton(language_wrapper.language_word_dict.get("code_window_stop_button")) + self.stop_button.setEnabled(False) + self.stop_button.clicked.connect(self.stop_runner) + button_row = QHBoxLayout() + button_row.addWidget(self.stop_button) + button_row.addStretch() + self.grid_layout.addLayout(button_row, 0, 0) self.code_result = QPlainTextEdit() self.code_result.setLineWrapMode(self.code_result.LineWrapMode.NoWrap) self.code_result.setReadOnly(True) @@ -73,7 +83,7 @@ def __init__(self): self.code_result_scroll_area.setWidgetResizable(True) 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.grid_layout.addWidget(self.code_result_scroll_area, 1, 0) # Adaptive sizing based on screen screen = QGuiApplication.primaryScreen() if screen is not None: @@ -101,6 +111,10 @@ def closeEvent(self, event) -> None: self.finished_and_closed.emit() super().closeEvent(event) + def run_started(self) -> None: + """Called by the executor once its child is running: it may be stopped from here.""" + self.stop_button.setEnabled(True) + def run_ended(self) -> None: """Called by the executor once its run is over and its output is in. @@ -108,6 +122,7 @@ def run_ended(self) -> None: after the executor's timer slot returns, not from inside it: the list may hold the last reference to this window, and the timer is its child. """ + self.stop_button.setEnabled(False) if self._closed_while_running: self._closed_while_running = False QTimer.singleShot(0, self, self.finished_and_closed.emit) diff --git a/test/test_utils/test_code_window.py b/test/test_utils/test_code_window.py index 17ab55f5..5c09353b 100644 --- a/test/test_utils/test_code_window.py +++ b/test/test_utils/test_code_window.py @@ -362,3 +362,62 @@ def test_a_coloured_progress_bar_still_redraws_its_line(self, qt_app): window.append_output("10%\r\x1b[32m20%\x1b[0m\r\n") assert window.code_result.toPlainText() == "20%\n" + + +class TestTheStopButton: + """A run could be stopped only by closing the IDE: closing its window lets it go on.""" + + def test_is_off_until_a_run_starts_and_after_it_ends(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + assert not window.stop_button.isEnabled() + + window.run_started() + assert window.stop_button.isEnabled() + + window.run_ended() + assert not window.stop_button.isEnabled() + + def test_stops_the_runner(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + class Runner: + stopped = 0 + + def stop(self): + self.stopped += 1 + + window = CodeWindow() + window.runner = Runner() + window.run_started() + + window.stop_button.click() + + assert window.runner.stopped == 1 + + def test_stops_a_script_that_would_run_forever(self, qt_app, tmp_path): + import sys + import time + + from PySide6.QtWidgets import QApplication + + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + script = tmp_path / "forever.py" + script.write_text("import time\nwhile True:\n time.sleep(0.1)\n", encoding="utf-8") + window = CodeWindow() + window.runner = FileRunnerProcess(window) + window.runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + assert window.stop_button.isEnabled() + + window.stop_button.click() + deadline = time.monotonic() + 30 + while window.stop_button.isEnabled(): + assert time.monotonic() < deadline, "the run was not stopped" + QApplication.processEvents() + time.sleep(0.01) + + assert not window.is_running() + window.close() From 226da1b29dd337c107fcfe26b101705a92a31dd2 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 12:27:21 +0800 Subject: [PATCH 304/312] Refactor: dispatch the SFTP context menu from a table --- docs/updates/2026-09-b.md | 9 ++++ docs/updates/README.md | 3 +- .../connect_gui/ssh/ssh_file_viewer_widget.py | 41 +++++++------------ 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 1a527d8e..e8f48acd 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -563,3 +563,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: executor and run-window tests 172 passed, 14 skipped. 703 keys in each language. 2242 tests in 123 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/extend/process_executor/python_task_process_manager.py`, `pybreeze/extend/process_executor/file_runner_process.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_code_window.py`, `README.md`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-262 · 2026-09-24 · The SFTP context menu dispatches from a table · #refactor #ssh + +- **What**: the SFTP tree's context menu kept a variable per action and chose its handler through a chain of six `elif` branches, all taking the same item; a new entry meant three edits. +- **Change**: one table of (name, handler); each action added from it maps to its handler, and the chosen one is called with the item. The labels and the order are unchanged. +- **Tests**: `test_sftp_tree_actions.py` (29) passes unchanged. No behaviour change. +- **Result / numbers**: startup tests exit 0, gates and `ruff check` clean on `dd2a55b`. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 7d2fe8c1..4173947a 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-262 | 2026-09-24 | The SFTP context menu dispatches from a table | #refactor #ssh | [2026-09-b](2026-09-b.md) | | U-20260924-261 | 2026-09-24 | A Stop button in the run window | #feature #run #ui | [2026-09-b](2026-09-b.md) | | U-20260924-260 | 2026-09-24 | The SFTP menu opens with exec, not the deprecated exec_ | #cleanup #ssh #qt | [2026-09-b](2026-09-b.md) | | U-20260924-259 | 2026-09-24 | Network, image, host-key and Skills reasons in the IDE language | #fix #i18n #network | [2026-09-b](2026-09-b.md) | @@ -343,4 +344,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 61 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 62 | diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 1cbf8dfc..0da274cf 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -396,34 +396,23 @@ def on_context_menu(self, pos): # The "..." or loading row: no entry of its own, so the folder it is in item = item.parent() menu = QMenu(self) - - def action(name: str): - return menu.addAction(self.word_dict.get(f"ssh_file_viewer_context_menu_action_{name}")) - - refresh_act = action("refresh") - create_act = action("create_folder") - rename_act = action("rename") - delete_act = action("delete") - download_act = action("download") - upload_act = action("upload") - - action = menu.exec(self.tree.viewport().mapToGlobal(pos)) - if action is None: + handlers = {} + for name, handler in ( + ("refresh", self.action_refresh), + ("create_folder", self.action_create_folder), + ("rename", self.action_rename), + ("delete", self.action_delete), + ("download", self.action_download), + ("upload", self.action_upload), + ): + handlers[menu.addAction(self.word_dict.get(f"ssh_file_viewer_context_menu_action_{name}"))] = handler + + chosen = menu.exec(self.tree.viewport().mapToGlobal(pos)) + handler = handlers.get(chosen) + if handler is None: return - try: - if action == refresh_act: - self.action_refresh(item) - elif action == create_act: - self.action_create_folder(item) - elif action == rename_act: - self.action_rename(item) - elif action == delete_act: - self.action_delete(item) - elif action == download_act: - self.action_download(item) - elif action == upload_act: - self.action_upload(item) + handler(item) # what an SFTP operation raises, a closed session's RuntimeError included except CONNECT_ERRORS as e: self._operation_failed(str(e)) From 1665720faa1e8ee2e2944af0a46598e6103666b3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 12:35:46 +0800 Subject: [PATCH 305/312] Let an SFTP transfer be cancelled from the tree's menu --- README.md | 4 +- architecture_explore.md | 10 +- docs/updates/2026-09-b.md | 9 + docs/updates/README.md | 3 +- .../extend_multi_language/extend_english.py | 4 + .../extend_traditional_chinese.py | 3 + .../connect_gui/ssh/sftp_session.py | 38 ++++- .../connect_gui/ssh/ssh_file_viewer_widget.py | 19 ++- test/test_utils/test_sftp_cancel.py | 156 ++++++++++++++++++ test/test_utils/test_sftp_session_sharing.py | 2 +- test/test_utils/test_sftp_upload.py | 2 +- test/test_utils/test_ssh_teardown.py | 2 +- 12 files changed, 234 insertions(+), 18 deletions(-) create mode 100644 test/test_utils/test_sftp_cancel.py diff --git a/README.md b/README.md index 8f733e7d..75fa09b5 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ A WYSIWYG `QGraphicsScene` editor: rectangle, rounded, ellipse and diamond nodes ![SSH client](images/ssh_client.png) -Password or private-key authentication (the key file picked with Browse, starting in `~/.ssh`), an interactive shell with ANSI handling and keepalive, and a lazy-loading SFTP tree with create-folder / rename / delete / upload / download. Every SFTP request runs in the background, so a stalled link never freezes the IDE. An upload asks before it replaces a file on the server. Both directions write to a temporary file first, so a dropped link leaves the old copy whole. Unknown host keys are **not** auto-accepted: the SHA256 fingerprint is shown for confirmation on first connection (trust on first use) and persisted to `~/.pybreeze/ssh_known_hosts`. +Password or private-key authentication (the key file picked with Browse, starting in `~/.ssh`), an interactive shell with ANSI handling and keepalive, and a lazy-loading SFTP tree with create-folder / rename / delete / upload / download. Every SFTP request runs in the background, so a stalled link never freezes the IDE. An upload asks before it replaces a file on the server, and a transfer can be cancelled from the tree's menu. Both directions write to a temporary file first, so a dropped link leaves the old copy whole. Unknown host keys are **not** auto-accepted: the SHA256 fingerprint is shown for confirmation on first connection (trust on first use) and persisted to `~/.pybreeze/ssh_known_hosts`. ### And also @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 703 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 706 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index e0ae75ea..51ec106f 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 703 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 706 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 703 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 706 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -300,7 +300,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | | `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`;任一欄按 Enter 就按下連線;金鑰欄旁的「瀏覽...」從 `~/.ssh` 開檔案對話框,選了檔就勾起金鑰驗證) | | `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | -| `ssh_file_viewer_widget.py` (716) + `sftp_session.py` (447) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個;傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_file_viewer_widget.py` (722) + `sftp_session.py` (473) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個,傳輸中右鍵選單多一項「取消傳輸」(`SftpTransferThread.cancel()`:paramiko `get` / `put` 的進度回呼每塊都檢查,丟 `TransferCancelled`,暫存檔照失敗處理刪掉、要被取代的檔案不動,發 `cancelled`);傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | | `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | | `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | | `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密,而且給的密語解不開它;用 `cryptography` 試解)還是不支援的私鑰(例如加密的 DSA) | @@ -362,7 +362,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 703 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 706 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -455,7 +455,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 123 個 `test_*.py`、2242 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2245 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index e8f48acd..bc07b8b1 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -572,3 +572,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: startup tests exit 0, gates and `ruff check` clean on `dd2a55b`. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` - **Open items**: none. + +## U-20260924-263 · 2026-09-24 · An SFTP transfer can be cancelled · #feature #ssh #ui + +- **What**: an SFTP download or upload, once started, ran to its end: closing the tab lets it run out by design (cut off, it would leave half a file), and nothing else could stop it. A multi-gigabyte download started by mistake held the session, and every other transfer, until it finished. +- **Change**: while a transfer runs, the tree's context menu has "Cancel the transfer" (`取消傳輸`). `SftpTransferThread.cancel()` sets a flag that the progress callback given to paramiko's `get` / `put` checks at every block, raising `TransferCancelled`; the partial file (`..*.part`, here or on the server) is removed as for a failed transfer, a file it was to replace is left as it was, and the thread sends `cancelled`, which the tree shows as a message. `SFTPClientWrapper.download` / `upload` take the callback as `progress`. +- **Tests**: new `test_sftp_cancel.py` (3: a cancelled download removes what arrived and keeps the old file; a cancelled upload keeps the server's copy; the menu offers the cancel only while a transfer runs, and it stops it with the message). The stand-in SFTP clients in `test_sftp_session_sharing.py`, `test_sftp_upload.py` and `test_ssh_teardown.py` take the callback. +- **Result / numbers**: SFTP and SSH tests 203 passed. 706 keys in each language. 2245 tests in 124 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_sftp_cancel.py` (new), `test/test_utils/test_sftp_session_sharing.py`, `test/test_utils/test_sftp_upload.py`, `test/test_utils/test_ssh_teardown.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 4173947a..61b1331d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-263 | 2026-09-24 | An SFTP transfer can be cancelled | #feature #ssh #ui | [2026-09-b](2026-09-b.md) | | U-20260924-262 | 2026-09-24 | The SFTP context menu dispatches from a table | #refactor #ssh | [2026-09-b](2026-09-b.md) | | U-20260924-261 | 2026-09-24 | A Stop button in the run window | #feature #run #ui | [2026-09-b](2026-09-b.md) | | U-20260924-260 | 2026-09-24 | The SFTP menu opens with exec, not the deprecated exec_ | #cleanup #ssh #qt | [2026-09-b](2026-09-b.md) | @@ -344,4 +345,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 62 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 63 | diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 2a090600..9aa4194a 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -201,6 +201,10 @@ "ssh_file_viewer_context_menu_action_delete": "Delete", "ssh_file_viewer_context_menu_action_download": "Download", "ssh_file_viewer_context_menu_action_upload": "Upload to this folder", + "ssh_file_viewer_context_menu_action_cancel_transfer": "Cancel the transfer", + "ssh_file_viewer_dialog_title_transfer_cancelled": "Transfer cancelled", + "ssh_file_viewer_message_transfer_cancelled": + "The transfer was cancelled. What had arrived was removed; a file it was to replace is unchanged.", "ssh_file_viewer_dialog_title_no_selection": "No selection", "ssh_file_viewer_dialog_message_select_folder_to_create": "Select a folder to create inside.", "ssh_file_viewer_dialog_message_bad_name": "A name is one entry: it cannot contain '/', or be '.' or '..'.", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 474944a4..5a9b5dcb 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -196,6 +196,9 @@ "ssh_file_viewer_context_menu_action_delete": "刪除", "ssh_file_viewer_context_menu_action_download": "下載", "ssh_file_viewer_context_menu_action_upload": "上傳至此資料夾", + "ssh_file_viewer_context_menu_action_cancel_transfer": "取消傳輸", + "ssh_file_viewer_dialog_title_transfer_cancelled": "已取消傳輸", + "ssh_file_viewer_message_transfer_cancelled": "傳輸已取消。已傳到的部分已刪除,原本要被取代的檔案保持不變。", "ssh_file_viewer_dialog_title_no_selection": "未選取", "ssh_file_viewer_dialog_message_select_folder_to_create": "請選擇一個資料夾以建立子資料夾。", "ssh_file_viewer_dialog_message_bad_name": "名稱只能是單一項目:不能含有「/」,也不能是「.」或「..」。", diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py b/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py index fbcef5b8..4ed2c5e8 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py @@ -96,6 +96,14 @@ class ConnectAbandoned(RuntimeError): """The session came up after the connect was given up on, and was closed.""" +class TransferCancelled(Exception): + """The user cancelled the transfer; what had arrived under a temporary name is removed.""" + + +# How a transfer reports its progress to paramiko: (bytes so far, bytes in all) +Progress = Callable[[int, int], None] + + class SFTPClientWrapper: """ Lightweight wrapper around Paramiko SFTP client. @@ -265,11 +273,14 @@ def rename(self, old_path: str, new_path: str): with self._session(UI_WAIT_SECONDS) as sftp: sftp.rename(old_path, new_path) - def download(self, remote_path: str, local_path: str): + def download(self, remote_path: str, local_path: str, progress: Progress | None = None): """ Download remote to local. Worker thread. 下載遠端檔案至本地。 + *progress* is called as the bytes arrive; raising from it + (``TransferCancelled``) stops the transfer, and the partial file goes. + The file arrives beside *local_path* under a temporary name and takes its place only when complete: ``get`` empties its target before the first byte, so a dropped link or a Disconnect used to leave the file @@ -283,7 +294,7 @@ def download(self, remote_path: str, local_path: str): complete = False try: with self._session() as sftp: - sftp.get(remote_path, partial) + sftp.get(remote_path, partial, callback=progress) os.replace(partial, local_path) complete = True finally: @@ -291,7 +302,8 @@ def download(self, remote_path: str, local_path: str): with suppress(OSError): os.remove(partial) - def upload(self, local_path: str, remote_path: str, replace: bool = False) -> bool: + def upload(self, local_path: str, remote_path: str, replace: bool = False, + progress: Progress | None = None) -> bool: """ Upload local to remote. Worker thread. 上傳本地檔案至遠端。 @@ -309,7 +321,7 @@ def upload(self, local_path: str, remote_path: str, replace: bool = False) -> bo return False complete = False try: - sftp.put(local_path, partial) + sftp.put(local_path, partial, callback=progress) _move_into_place(sftp, partial, remote_path) complete = True finally: @@ -376,6 +388,8 @@ class SftpTransferThread(QThread): failed = Signal(str) # An upload whose target exists, not replaced: nothing was sent exists = Signal(str) + # Cancelled by the user: what had arrived is removed, the old copy stays + cancelled = Signal() def __init__(self, client: "SFTPClientWrapper", downloading: bool, remote_path: str, local_path: str, replace: bool = False) -> None: @@ -385,14 +399,26 @@ def __init__(self, client: "SFTPClientWrapper", downloading: bool, self._remote_path = remote_path self._local_path = local_path self._replace = replace + self._cancel = threading.Event() + + def cancel(self) -> None: + """Stop the transfer at its next block. Any thread.""" + self._cancel.set() + + def _progress(self, _sent: int, _total: int) -> None: + """paramiko's progress callback: raising here is how a transfer is cut short.""" + if self._cancel.is_set(): + raise TransferCancelled def run(self) -> None: try: if self._downloading: - self._client.download(self._remote_path, self._local_path) - elif not self._client.upload(self._local_path, self._remote_path, self._replace): + self._client.download(self._remote_path, self._local_path, self._progress) + elif not self._client.upload(self._local_path, self._remote_path, self._replace, self._progress): self.exists.emit(self._remote_path) return + except TransferCancelled: + self.cancelled.emit() except (OSError, RuntimeError, EOFError, paramiko.SSHException) as error: self.failed.emit(str(error)) else: diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 0da274cf..2aa1bfb1 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -223,7 +223,7 @@ def closeEvent(self, event) -> None: transfer = self._transfer transfer_running = transfer is not None and transfer.isRunning() if transfer_running: - let_run_out(transfer, transfer.done, transfer.failed) + let_run_out(transfer, transfer.done, transfer.failed, transfer.cancelled) transfer.finished.connect(self.client.close) for listing in list(self._listings): if listing.isRunning(): @@ -406,6 +406,10 @@ def on_context_menu(self, pos): ("upload", self.action_upload), ): handlers[menu.addAction(self.word_dict.get(f"ssh_file_viewer_context_menu_action_{name}"))] = handler + if self._transfer is not None and self._transfer.isRunning(): + menu.addSeparator() + cancel = menu.addAction(self.word_dict.get("ssh_file_viewer_context_menu_action_cancel_transfer")) + handlers[cancel] = self.action_cancel_transfer chosen = menu.exec(self.tree.viewport().mapToGlobal(pos)) handler = handlers.get(chosen) @@ -637,6 +641,7 @@ def _start_transfer(self, *, downloading: bool, remote_path: str, local_path: st self._transfer.done.connect( lambda path: self._transfer_done(title, message, path, after)) self._transfer.failed.connect(self._transfer_failed) + self._transfer.cancelled.connect(self._transfer_cancelled) self._transfer.finished.connect(self._forget_transfer) self._transfer.exists.connect(lambda path: self._ask_to_replace(path, lambda: self._start_transfer( downloading=False, remote_path=remote_path, local_path=local_path, @@ -644,6 +649,18 @@ def _start_transfer(self, *, downloading: bool, remote_path: str, local_path: st self._transfer.start() return True + def action_cancel_transfer(self, _item: QTreeWidgetItem | None = None) -> None: + """Cancel the transfer in flight; the file it was writing is removed, the old copy kept.""" + if self._transfer is not None and self._transfer.isRunning(): + self._transfer.cancel() + + def _transfer_cancelled(self) -> None: + """Say the transfer was cancelled and nothing was replaced. UI thread.""" + QMessageBox.information( + self, + self.word_dict.get("ssh_file_viewer_dialog_title_transfer_cancelled"), + self.word_dict.get("ssh_file_viewer_message_transfer_cancelled")) + def _forget_transfer(self) -> None: """Let go of the transfer that just finished. UI thread. diff --git a/test/test_utils/test_sftp_cancel.py b/test/test_utils/test_sftp_cancel.py new file mode 100644 index 00000000..afb01ada --- /dev/null +++ b/test/test_utils/test_sftp_cancel.py @@ -0,0 +1,156 @@ +"""A transfer can be cancelled: what had arrived goes, and a file it was to replace stays.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import errno +import time +from types import SimpleNamespace + +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import sftp_session +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as tree_mod + +WAIT_SECONDS = 10 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class ChunkedSftp: + """Moves a file in blocks, reporting each as paramiko does, until told the whole is there.""" + + def __init__(self, files: dict[str, bytes] | None = None, blocks: int = 1000) -> None: + self.files = dict(files or {}) + self.blocks = blocks + + def stat(self, path: str): + if path not in self.files: + raise OSError(errno.ENOENT, "No such file") + return object() + + def get(self, remote: str, local: str, callback=None) -> None: + with open(local, "wb") as target: + for block in range(self.blocks): + target.write(b"x") + if callback is not None: + callback(block + 1, self.blocks) + time.sleep(0.001) + + def put(self, _local: str, remote: str, callback=None) -> None: + self.files[remote] = b"" + for block in range(self.blocks): + self.files[remote] += b"x" + if callback is not None: + callback(block + 1, self.blocks) + time.sleep(0.001) + + def posix_rename(self, old: str, new: str) -> None: + self.files[new] = self.files.pop(old) + + def remove(self, path: str) -> None: + if path not in self.files: + raise OSError(errno.ENOENT, "No such file") + del self.files[path] + + def close(self) -> None: + """Nothing to close.""" + + +def _wrapper(sftp: ChunkedSftp) -> sftp_session.SFTPClientWrapper: + wrapper = sftp_session.SFTPClientWrapper() + wrapper._ssh = SimpleNamespace(close=lambda: None) + wrapper._sftp = sftp + return wrapper + + +def _cancel_once_under_way(app, thread) -> list[str]: + """Start *thread*, cancel it after its first blocks, and return which signals it sent.""" + sent: list[str] = [] + thread.done.connect(lambda _path: sent.append("done")) + thread.failed.connect(lambda _message: sent.append("failed")) + thread.cancelled.connect(lambda: sent.append("cancelled")) + thread.start() + time.sleep(0.05) + thread.cancel() + assert thread.wait(WAIT_SECONDS * 1000), "the transfer did not stop" + app.processEvents() + return sent + + +class TestCancellingADownload: + def test_removes_what_arrived_and_keeps_the_old_file(self, app, tmp_path): + target = tmp_path / "report.csv" + target.write_bytes(b"old report") + thread = sftp_session.SftpTransferThread( + _wrapper(ChunkedSftp()), True, "/srv/report.csv", str(target)) + + sent = _cancel_once_under_way(app, thread) + + assert sent == ["cancelled"] + assert target.read_bytes() == b"old report" + assert [path.name for path in tmp_path.iterdir()] == ["report.csv"] + + +class TestCancellingAnUpload: + def test_removes_what_arrived_and_keeps_the_servers_copy(self, app, tmp_path): + local = tmp_path / "config.yaml" + local.write_bytes(b"new config") + sftp = ChunkedSftp({"/etc/app/config.yaml": b"old config"}) + thread = sftp_session.SftpTransferThread( + _wrapper(sftp), False, "/etc/app/config.yaml", str(local), replace=True) + + sent = _cancel_once_under_way(app, thread) + + assert sent == ["cancelled"] + assert sftp.files == {"/etc/app/config.yaml": b"old config"} + + +class TestTheMenu: + def _menu_entries(self, widget, monkeypatch, choose_last: bool = False) -> list[str]: + shown: list[str] = [] + + class AnsweringMenu(tree_mod.QMenu): + def exec(self, _pos): + actions = [action for action in self.actions() if not action.isSeparator()] + shown.extend(action.text() for action in actions) + return actions[-1] if choose_last else None + + monkeypatch.setattr(tree_mod, "QMenu", AnsweringMenu) + monkeypatch.setattr(widget.tree, "itemAt", lambda _pos: None) + widget.on_context_menu(widget.tree.rect().center()) + return shown + + def test_offers_a_cancel_only_while_a_transfer_runs(self, app, monkeypatch, tmp_path): + word = tree_mod.language_wrapper.language_word_dict + cancel_label = word.get("ssh_file_viewer_context_menu_action_cancel_transfer") + shown_messages: list = [] + monkeypatch.setattr(QMessageBox, "information", lambda *args: shown_messages.append(args[1:3])) + widget = tree_mod.SSHFileTreeManager() + widget.client = _wrapper(ChunkedSftp(blocks=5000)) + assert cancel_label not in self._menu_entries(widget, monkeypatch) + + assert widget._start_transfer( + downloading=True, remote_path="/big.iso", local_path=str(tmp_path / "big.iso"), + title="Downloaded", message="Saved to") + assert self._menu_entries(widget, monkeypatch, choose_last=True)[-1] == cancel_label + + deadline = time.monotonic() + WAIT_SECONDS + while widget._transfer is not None: + assert time.monotonic() < deadline, "the transfer did not stop" + app.processEvents() + time.sleep(0.01) + assert shown_messages == [(word.get("ssh_file_viewer_dialog_title_transfer_cancelled"), + word.get("ssh_file_viewer_message_transfer_cancelled"))] + assert not (tmp_path / "big.iso").exists() + widget.close() + widget.deleteLater() diff --git a/test/test_utils/test_sftp_session_sharing.py b/test/test_utils/test_sftp_session_sharing.py index c6e018d2..bcf29abe 100644 --- a/test/test_utils/test_sftp_session_sharing.py +++ b/test/test_utils/test_sftp_session_sharing.py @@ -52,7 +52,7 @@ def listdir_attr(self, _path): def mkdir(self, _path) -> None: self._request() - def get(self, _remote, local) -> None: + def get(self, _remote, local, callback=None) -> None: with open(local, "wb") as target: if self.fail_after is not None: target.write(self.fail_after) diff --git a/test/test_utils/test_sftp_upload.py b/test/test_utils/test_sftp_upload.py index 3452b083..20583165 100644 --- a/test/test_utils/test_sftp_upload.py +++ b/test/test_utils/test_sftp_upload.py @@ -40,7 +40,7 @@ def stat(self, path: str): raise OSError(errno.ENOENT, "No such file") return object() - def put(self, local: str, remote: str) -> None: + def put(self, local: str, remote: str, callback=None) -> None: self.put_to.append(remote) with open(local, "rb") as source: data = source.read() diff --git a/test/test_utils/test_ssh_teardown.py b/test/test_utils/test_ssh_teardown.py index 0e998aa2..850ecd31 100644 --- a/test/test_utils/test_ssh_teardown.py +++ b/test/test_utils/test_ssh_teardown.py @@ -153,7 +153,7 @@ def test_a_download_does_not_hold_the_caller(self, app, monkeypatch): staticmethod(lambda *args: shown.append(args))) tree = self._tree(app) - def slow_download(_remote, _local): + def slow_download(_remote, _local, _progress=None): started.set() answering.wait(10) From 5b13ced11373624320fbf6344a5111e0c1a7b834 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 12:49:25 +0800 Subject: [PATCH 306/312] Keep the diff that changes fewer lines, trimmed or plain --- architecture_explore.md | 4 +-- docs/updates/2026-09-b.md | 9 +++++ docs/updates/README.md | 3 +- pybreeze/utils/diff_tools/text_diff.py | 49 ++++++++++++++++++++------ test/test_utils/test_text_diff.py | 11 ++++++ 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 51ec106f..d7f07f6c 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -226,7 +226,7 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | | `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組。pattern 在另一個行程裡跑(`find_matches_bounded()`:從原始碼執行時是 `python -I -S -c` 跑一段只用標準函式庫的固定腳本,工作用 JSON 從 stdin 進、結果從 stdout 出,5 秒後 kill;打包版沒有直譯器可用,仍是 multiprocessing spawn),分頁用 `RegexMatchThread` 等它:`re` 開始比對後就停不下來,災難性回溯只有整個行程能停。spawn 會重新匯入啟動 IDE 的腳本,README 那種沒有 `__main__` 防護的腳本會每跑一次就再開一個 IDE。還在跑的 worker 記在 `_RUNNING`,分頁關閉時 `stop_running_workers()` 結束它(IDE 以 `os._exit` 結束,子行程不會跟著走);執行中不能存檔,列到 `MAX_MATCHES` 上限時會註明可能還有更多 | | `HttpStatusGUI` | `utils/http_reference/` | 狀態碼參考,可依碼前綴或描述搜尋 | -| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計,`compare_texts()` 只比對一次、兩者共用同一個 `SequenceMatcher`(`_TrimmedMatcher`:先把相同的開頭結尾放一邊再比對中間,長而重複的文字改一行就是一行;autojunk 照 difflib 的預設,關掉的話重複的文字比對時間隨行數平方成長;diff 照 `difflib.unified_diff` 的格式從它的 grouped opcodes 寫出),在 `DiffThread` 上算、不佔 UI 執行緒(4 萬行要四秒多),比對中按鈕停用、關閉時交給 `let_run_out()`。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | +| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計,`compare_texts()` 的統計與 diff 共用同一次比對(`_TrimmedMatcher`:先把相同的開頭結尾放一邊再比對中間,長而重複的文字改一行就是一行;放一邊有時反而比對得更差(`b b a b a` 對 `b a c b`),所以有放一邊時 `_closest_match` 也照原樣比一次,取改動行數少的;autojunk 照 difflib 的預設,關掉的話重複的文字比對時間隨行數平方成長;diff 照 `difflib.unified_diff` 的格式從它的 grouped opcodes 寫出),在 `DiffThread` 上算、不佔 UI 執行緒(4 萬行要四秒多),比對中按鈕停用、關閉時交給 `let_run_out()`。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | | `JsonFormatGUI` | `utils/json_format/` | 美化 / 壓縮 / 驗證 | | `HeaderAnalyzerGUI` | `utils/header_tools/` | HTTP header 安全稽核(HSTS、CSP、CORS、Set-Cookie、banner…)| | `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT;`curl -i` 印出的多段回應(`100 Continue`、proxy 的 `Connection established`、`-L` 的轉址)取最後一段;只有一行又沒有狀態列就當 body | @@ -455,7 +455,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2245 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2246 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index bc07b8b1..79c4d752 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -581,3 +581,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: SFTP and SSH tests 203 passed. 706 keys in each language. 2245 tests in 124 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_sftp_cancel.py` (new), `test/test_utils/test_sftp_session_sharing.py`, `test/test_utils/test_sftp_upload.py`, `test/test_utils/test_ssh_teardown.py`, `README.md`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-264 · 2026-09-24 · A diff never changes more lines than difflib's own · #fix #diff + +- **What**: `compare_texts` set the unchanged head and tail aside before matching (`_TrimmedMatcher`, U-20260923-213), which keeps one changed line in a long repetitive text to one line, but can also leave a worse match: `b b a b a` against `b a c b` came out as five lines changed where `difflib` changes three (`-b`, `+c`, `-a`), the first `b` having been taken as unchanged. Found by the Hypothesis property in `test_text_diff.py` ("never longer than difflib's"), which failed on this example in a full run. +- **Fix**: `_closest_match` makes the plain `SequenceMatcher` match too whenever anything was set aside, and keeps the one that changes fewer lines; the counts and the diff come from the one kept. With no head or tail in common the two are the same and it matches once. The plain match keeps difflib's junk heuristic, so a repetitive text stays quick. +- **Tests**: `test_text_diff.py` +1 (that example: one line added, two removed, and the diff turns the left into the right), which fails on the old code; the property and the 2,000-line timing test pass. +- **Result / numbers**: `test_text_diff.py` runs in about 4 s. 2246 tests in 124 files. `ruff check` clean. +- **Files**: `pybreeze/utils/diff_tools/text_diff.py`, `test/test_utils/test_text_diff.py`, `architecture_explore.md` +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 61b1331d..630bf5b6 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-264 | 2026-09-24 | A diff never changes more lines than difflib's own | #fix #diff | [2026-09-b](2026-09-b.md) | | U-20260924-263 | 2026-09-24 | An SFTP transfer can be cancelled | #feature #ssh #ui | [2026-09-b](2026-09-b.md) | | U-20260924-262 | 2026-09-24 | The SFTP context menu dispatches from a table | #refactor #ssh | [2026-09-b](2026-09-b.md) | | U-20260924-261 | 2026-09-24 | A Stop button in the run window | #feature #run #ui | [2026-09-b](2026-09-b.md) | @@ -345,4 +346,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 63 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 64 | diff --git a/pybreeze/utils/diff_tools/text_diff.py b/pybreeze/utils/diff_tools/text_diff.py index 97023988..9d381d09 100644 --- a/pybreeze/utils/diff_tools/text_diff.py +++ b/pybreeze/utils/diff_tools/text_diff.py @@ -88,7 +88,8 @@ class _TrimmedMatcher(difflib.SequenceMatcher): text grows with the square of its lines (1,996 lines took 55 s). Only the opcodes cover both texts whole; ``ratio()`` and - ``get_matching_blocks()`` describe what is left to match. + ``get_matching_blocks()`` describe what is left to match. It can change + more lines than the plain match does, so ``_closest_match`` compares them. """ def __init__(self, left_lines: list[str], right_lines: list[str]) -> None: @@ -106,6 +107,10 @@ def __init__(self, left_lines: list[str], right_lines: list[str]) -> None: middle_right = right_lines[head:len(right_lines) - tail] super().__init__(None, middle_left, middle_right) + def trimmed_any(self) -> bool: + """Whether an unchanged head or tail was set aside.""" + return bool(self._head or self._tail) + def get_opcodes(self) -> list[tuple[str, int, int, int, int]]: """The opcodes over the whole two texts, the head and tail as equal blocks.""" head, tail = self._head, self._tail @@ -133,8 +138,39 @@ def _unified_range(start: int, stop: int) -> str: return f"{start if length == 0 else start + 1},{length}" +def _changed_lines(matcher: difflib.SequenceMatcher) -> tuple[int, int]: + """How many lines *matcher*'s match adds and removes.""" + added = 0 + removed = 0 + for tag, left_start, left_end, right_start, right_end in matcher.get_opcodes(): + if tag in ("replace", "delete"): + removed += left_end - left_start + if tag in ("replace", "insert"): + added += right_end - right_start + return added, removed + + +def _closest_match(left_lines: list[str], right_lines: list[str]) -> tuple[difflib.SequenceMatcher, int, int]: + """The match that changes fewer lines, with its added and removed counts. + + Setting the unchanged head and tail aside (``_TrimmedMatcher``) usually + finds the smaller diff, but not always: in ``b b a b a`` against + ``b a c b`` the first ``b`` taken as unchanged left a worse match, five + lines changed where ``difflib`` changes three. When anything was set + aside, the plain match is made too and the smaller one kept. + """ + trimmed = _TrimmedMatcher(left_lines, right_lines) + best = (trimmed, *_changed_lines(trimmed)) + if trimmed.trimmed_any(): + plain = difflib.SequenceMatcher(None, left_lines, right_lines) + added, removed = _changed_lines(plain) + if added + removed < best[1] + best[2]: + best = (plain, added, removed) + return best + + def _unified_lines( - matcher: _TrimmedMatcher, left_lines: list[str], right_lines: list[str], + matcher: difflib.SequenceMatcher, left_lines: list[str], right_lines: list[str], labels: tuple[str, str]) -> list[str]: """The unified diff lines of *matcher*'s match, as ``difflib.unified_diff`` writes them.""" lines: list[str] = [] @@ -169,14 +205,7 @@ def compare_texts( :return: the counts and the diff """ left_lines, right_lines = _line_lists(left, right) - matcher = _TrimmedMatcher(left_lines, right_lines) - added = 0 - removed = 0 - for tag, left_start, left_end, right_start, right_end in matcher.get_opcodes(): - if tag in ("replace", "delete"): - removed += left_end - left_start - if tag in ("replace", "insert"): - added += right_end - right_start + matcher, added, removed = _closest_match(left_lines, right_lines) lines = _unified_lines(matcher, left_lines, right_lines, (left_label, right_label)) # The two header lines are shown as they are: told apart by position, not # by their "---"/"+++", which a removed "--x" or an added "++x" also has diff --git a/test/test_utils/test_text_diff.py b/test/test_utils/test_text_diff.py index 390101a1..776d2301 100644 --- a/test/test_utils/test_text_diff.py +++ b/test/test_utils/test_text_diff.py @@ -186,6 +186,17 @@ def test_the_diff_turns_the_left_into_the_right_and_is_no_longer_than_difflibs(l assert changed <= sum(line[:1] in ("+", "-") for line in written) +def test_a_head_set_aside_does_not_make_the_diff_longer(): + # The first "b" taken as unchanged left a worse match: five lines changed + # where difflib changes three (-b, +c, -a) + from pybreeze.utils.diff_tools.text_diff import compare_texts + + comparison = compare_texts("b\nb\na\nb\na", "b\na\nc\nb") + + assert (comparison.summary.added, comparison.summary.removed) == (1, 2) + assert _applied(comparison.diff, ["b", "b", "a", "b", "a"]) == ["b", "a", "c", "b"] + + @pytest.mark.parametrize(("left", "right"), [ ("\n".join(["{", " a", "}"] * 100), "\n".join(["{", " a", "}"] * 100).replace(" a", " b", 1)), ("x\n" * 300, "y\n" + "x\n" * 299), From 71a668ef3934e62920bf9d77081876b4faff5328 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 12:51:01 +0800 Subject: [PATCH 307/312] Say a declined host key in the IDE language in the SFTP tree too --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- progress.md | 1 - .../connect_gui/ssh/ssh_file_viewer_widget.py | 7 +++++-- test/test_utils/test_error_text.py | 13 +++++++++++++ 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index d7f07f6c..55fbb524 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -455,7 +455,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2246 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2247 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 79c4d752..9bcd5820 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -590,3 +590,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: `test_text_diff.py` runs in about 4 s. 2246 tests in 124 files. `ruff check` clean. - **Files**: `pybreeze/utils/diff_tools/text_diff.py`, `test/test_utils/test_text_diff.py`, `architecture_explore.md` - **Open items**: none. + +## U-20260924-265 · 2026-09-24 · The SFTP tree says a host key was declined in the IDE language · #done #fix #ssh #i18n + +- **What**: progress #107. After U-20260924-259 the shell half of the SSH tab said a declined host key in the IDE language, but the SFTP tree's connect-failure box still read `連線失敗: Host key for example.org rejected by user.`: `SSHFileTreeManager._on_connect_failed` put the error in as it was. It was left out then because the file held other uncommitted work. +- **Fix**: the box passes the reason through `error_text()`; paramiko's own messages still show as they are, through `as_text()`. +- **Tests**: `test_error_text.py` +1 (the tree's connect failure for a declined host key, in Traditional Chinese). +- **Result / numbers**: 2247 tests in 124 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_error_text.py`, `progress.md`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 630bf5b6..449a726d 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-265 | 2026-09-24 | The SFTP tree says a host key was declined in the IDE language | #done #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-264 | 2026-09-24 | A diff never changes more lines than difflib's own | #fix #diff | [2026-09-b](2026-09-b.md) | | U-20260924-263 | 2026-09-24 | An SFTP transfer can be cancelled | #feature #ssh #ui | [2026-09-b](2026-09-b.md) | | U-20260924-262 | 2026-09-24 | The SFTP context menu dispatches from a table | #refactor #ssh | [2026-09-b](2026-09-b.md) | @@ -346,4 +347,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 64 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 65 | diff --git a/progress.md b/progress.md index b1b25da1..e156a699 100644 --- a/progress.md +++ b/progress.md @@ -17,4 +17,3 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#102** [BLOCKED] A report mail can wait forever: je_mail_thunder's `SMTPWrapper(host, port)` passes no timeout to `SMTP_SSL`, so a mail server that accepts the connection and then stalls holds the report-mail thread (`extend/mail_thunder_extend/mail_thunder_setting.py`, `send_report`) with no end, and the run window never says whether the report went. Needs a `timeout` argument in MailThunder (`je_mail_thunder/smtp/smtp_wrapper.py:20`); then pass 30 s here. - **#103** [BLOCKED] PyBreeze's automation keywords are never highlighted: `syntax_extend_package` registers them for `.json`, `.yml` and `.yaml` (`pybreeze_ui/syntax/syntax_extend.py`), but JEditor has built-in rules for those suffixes, so `CodeEditor.reset_highlighter` gets a `GenericHighlighter` from `highlighter_for`, and only `PythonHighlighter` reads the plugin registry (`je_editor/pyside_ui/code/syntax/generic_syntax.py:124`, `code_edit_plaintext.py:423-425` in `D:\Codes\JEDITOR` at d372c35). Needs JEditor's generic highlighter to add a registered language's words for the suffix; the `architecture.md` §6 contract changes with it. - **#106** [DECIDE] `test_pioneer` is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), and before 0.1.34 it read a YAML file in the locale's encoding: on a Traditional Chinese Windows (cp950) TestPioneer "Run YAML" of a file with a non-ASCII character, which the editor saves as UTF-8, exits 1 with `UnicodeDecodeError` (`extend/process_executor/test_pioneer/test_pioneer_process_manager.py`). This repo's `.venv` has 0.1.30; 0.1.34 reads it as UTF-8. Should the dependencies require `test_pioneer>=0.1.34`? -- **#107** The SFTP tree still shows a declined host key in English: `SSHFileTreeManager._on_connect_failed` (`pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`) puts the connect error into its box as it is, while the shell half passes it through `pybreeze_ui/error_text.error_text()`. Left out because that file held other uncommitted work at the time. diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index 2aa1bfb1..6692ca89 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -25,6 +25,7 @@ from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import CONNECT_ERRORS, SshConnectThread from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import host_key_asker from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget +from pybreeze.pybreeze_ui.error_text import error_text from pybreeze.pybreeze_ui.plain_text import as_text from pybreeze.pybreeze_ui.thread_keeper import let_run_out @@ -206,8 +207,10 @@ def _on_connect_failed(self, message: str) -> None: QMessageBox.critical( self, self.word_dict.get("ssh_file_viewer_dialog_title_connection_failed"), - # The server's text (paramiko quotes its first line): shown as text - as_text(f"{self.word_dict.get('ssh_file_viewer_dialog_message_connection_failed')}: {message}")) + # The server's text (paramiko quotes its first line): shown as text; + # a host key the user declined, in the IDE language + as_text(f"{self.word_dict.get('ssh_file_viewer_dialog_message_connection_failed')}: " + f"{error_text(message)}")) self.state_changed.emit() def closeEvent(self, event) -> None: diff --git a/test/test_utils/test_error_text.py b/test/test_utils/test_error_text.py index 649d2f2d..50eb6025 100644 --- a/test/test_utils/test_error_text.py +++ b/test/test_utils/test_error_text.py @@ -170,3 +170,16 @@ def test_a_host_key_the_user_declined(self, app, chinese): assert "已拒絕 example.org 的主機金鑰。" in widget.terminal.toPlainText() widget.ssh_client = None widget.close() + + def test_a_host_key_the_user_declined_in_the_file_tree(self, app, chinese, monkeypatch): + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as tree_mod + + shown: list = [] + monkeypatch.setattr(tree_mod.QMessageBox, "critical", lambda *args: shown.append(args[2])) + tree = tree_mod.SSHFileTreeManager() + + tree._on_connect_failed(exception_tags.host_key_rejected_error.format(hostname="example.org")) + + assert len(shown) == 1 and "已拒絕 example.org 的主機金鑰。" in shown[0] + tree.close() + tree.deleteLater() From 2c96ddf2625d929a47c92be548e3bc338a3d5d5e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 12:59:09 +0800 Subject: [PATCH 308/312] Test that no tool tab shows a reason as it was raised --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- test/test_utils/test_error_text.py | 13 +++++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 55fbb524..e9da5124 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -455,7 +455,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2247 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2248 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 9bcd5820..7ef48d33 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -599,3 +599,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2247 tests in 124 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_error_text.py`, `progress.md`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260924-266 · 2026-09-24 · A test that no tool tab shows a reason as it was raised · #test #i18n + +- **What**: U-20260924-256 fixed a tool tab (HAR) left showing `str(error)` as it was after U-20260924-254, missed because its `format(` call ran over two lines. Nothing would catch the next one. +- **Fix**: `test_error_text.py` reads every module in `pybreeze_ui/tools_gui/` and fails on `error=str(error)` (or `err`, `exc`, `e`), whatever the spacing and line breaks; a reason goes through `error_text()`. +- **Tests**: +1; it finds the HAR tab as it was before U-20260924-256. +- **Result / numbers**: 2248 tests in 124 files. +- **Files**: `test/test_utils/test_error_text.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 449a726d..2f36f7a9 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-266 | 2026-09-24 | A test that no tool tab shows a reason as it was raised | #test #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-265 | 2026-09-24 | The SFTP tree says a host key was declined in the IDE language | #done #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-264 | 2026-09-24 | A diff never changes more lines than difflib's own | #fix #diff | [2026-09-b](2026-09-b.md) | | U-20260924-263 | 2026-09-24 | An SFTP transfer can be cancelled | #feature #ssh #ui | [2026-09-b](2026-09-b.md) | @@ -347,4 +348,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 65 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 66 | diff --git a/test/test_utils/test_error_text.py b/test/test_utils/test_error_text.py index 50eb6025..b053b90e 100644 --- a/test/test_utils/test_error_text.py +++ b/test/test_utils/test_error_text.py @@ -183,3 +183,16 @@ def test_a_host_key_the_user_declined_in_the_file_tree(self, app, chinese, monke assert len(shown) == 1 and "已拒絕 example.org 的主機金鑰。" in shown[0] tree.close() tree.deleteLater() + + +def test_no_tool_tab_shows_a_reason_as_raised(): + # The HAR tab's load error was missed once: its format( call ran over two lines + import pathlib + import re + + import pybreeze + + tabs = pathlib.Path(pybreeze.__file__).parent / "pybreeze_ui" / "tools_gui" + raw = re.compile(r"error\s*=\s*str\(\s*(?:error|err|exc|e)\s*\)") + offenders = [path.name for path in tabs.glob("*.py") if raw.search(path.read_text(encoding="utf-8"))] + assert not offenders From 0ab684cecfb26a099a47ffba8c589216eda68e9f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 13:02:46 +0800 Subject: [PATCH 309/312] Refactor: raise the diagram file checks from exception_tags --- README.md | 2 +- architecture_explore.md | 8 ++++---- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../extend_multi_language/extend_traditional_chinese.py | 2 ++ pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py | 8 ++++++-- pybreeze/utils/exception/exception_tags.py | 4 ++++ 7 files changed, 28 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 75fa09b5..6bee0d70 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 706 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 708 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- diff --git a/architecture_explore.md b/architecture_explore.md index e9da5124..27bcbadb 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -50,7 +50,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 706 鍵) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 708 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -66,7 +66,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE 1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: - - `update_language_dict()` 併入 PyBreeze 的 706 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) + - `update_language_dict()` 併入 PyBreeze 的 708 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - 設定標題、Windows AppUserModelID、圖示 @@ -362,7 +362,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 706 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 708 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -455,7 +455,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2248 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2250 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 7ef48d33..75da935a 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -608,3 +608,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2248 tests in 124 files. - **Files**: `test/test_utils/test_error_text.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260924-267 · 2026-09-24 · Refactor: the diagram file checks raise from exception_tags · #refactor #i18n #diagram + +- **What**: `DiagramScene._check_is_a_diagram` refused a file that is not a diagram with two English literals (`a diagram file holds an object, not a list`, `a diagram's 'nodes' is a list, not a str`), which `error_text()` cannot find. They are `diagram_not_an_object_error` and `diagram_section_not_a_list_error` in `exception_tags` now, with the same text. +- **Behaviour**: none visible; the Traditional Chinese entries are in the dictionary for the next step. +- **Tests**: `test_error_text.py` finds both again from a message made from them (+2); the diagram tests pass unchanged. +- **Result / numbers**: 708 keys in each language. 2250 tests in 124 files. `ruff check` clean. +- **Files**: `pybreeze/utils/exception/exception_tags.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `README.md`, `architecture_explore.md` +- **Open items**: showing them in the IDE language (next entry). diff --git a/docs/updates/README.md b/docs/updates/README.md index 2f36f7a9..240a40e6 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-267 | 2026-09-24 | Refactor: the diagram file checks raise from exception_tags | #refactor #i18n #diagram | [2026-09-b](2026-09-b.md) | | U-20260924-266 | 2026-09-24 | A test that no tool tab shows a reason as it was raised | #test #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-265 | 2026-09-24 | The SFTP tree says a host key was declined in the IDE language | #done #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-264 | 2026-09-24 | A diff never changes more lines than difflib's own | #fix #diff | [2026-09-b](2026-09-b.md) | @@ -348,4 +349,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 66 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 67 | diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 5a9b5dcb..d4a96197 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -825,6 +825,8 @@ "error_text_redirect_nowhere_error": "重新導向(未跟隨),但沒有指出目的地", "error_text_authorization_failed_error": "驗證或授權失敗", "error_text_server_error_error": "伺服器錯誤:{body}", + "error_text_diagram_not_an_object_error": "架構圖檔案應該是一個物件,而不是 {kind}", + "error_text_diagram_section_not_a_list_error": "架構圖的 '{section}' 應該是清單,而不是 {kind}", } diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index c70d0f8c..cc85caab 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -25,6 +25,10 @@ ) from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.pybreeze_ui.error_text import error_text +from pybreeze.utils.exception.exception_tags import ( + diagram_not_an_object_error, + diagram_section_not_a_list_error, +) from pybreeze.utils.logging.logger import pybreeze_logger # Allowlist of image extensions that a saved diagram may reference on disk. @@ -227,11 +231,11 @@ def undo_scope(self, description: str, merge_key: str | None = None): def _check_is_a_diagram(data: dict) -> None: """Raise ``ValueError`` unless *data* has the shape of a diagram.""" if not isinstance(data, dict): - raise ValueError("a diagram file holds an object, not a %s" % type(data).__name__) + raise ValueError(diagram_not_an_object_error.format(kind=type(data).__name__)) for section in ("nodes", "connections", "images"): value = data.get(section, []) if not isinstance(value, list): - raise ValueError(f"a diagram's '{section}' is a list, not a {type(value).__name__}") + raise ValueError(diagram_section_not_a_list_error.format(section=section, kind=type(value).__name__)) def _restore_from_dict(self, data: dict) -> None: """Rebuild scene from serialised data (used by undo/redo).""" diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index 07353783..7779d2f8 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -100,3 +100,7 @@ redirect_nowhere_error: str = "Redirect (not followed) to an unnamed place" authorization_failed_error: str = "Authentication/Authorization failed" server_error_error: str = "Server error: {body}" + +# A diagram file (.diagram.json) that is not one +diagram_not_an_object_error: str = "a diagram file holds an object, not a {kind}" +diagram_section_not_a_list_error: str = "a diagram's '{section}' is a list, not a {kind}" From 573253f5e713651d1ecf227776ea1016ca4ba323 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 13:04:43 +0800 Subject: [PATCH 310/312] Say why a diagram file or a CoT URL was refused in the IDE language --- architecture_explore.md | 2 +- docs/updates/2026-09-b.md | 9 +++++++++ docs/updates/README.md | 3 ++- .../diagram_editor/diagram_editor_widget.py | 5 +++-- .../code_review/code_review_thread.py | 2 +- test/test_utils/test_error_text.py | 19 +++++++++++++++++++ 6 files changed, 35 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 27bcbadb..858bd70d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -455,7 +455,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2250 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2251 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index 75da935a..c2a834a3 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -617,3 +617,12 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 708 keys in each language. 2250 tests in 124 files. `ruff check` clean. - **Files**: `pybreeze/utils/exception/exception_tags.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `README.md`, `architecture_explore.md` - **Open items**: showing them in the IDE language (next entry). + +## U-20260924-268 · 2026-09-24 · A file that is not a diagram, and a refused CoT URL, say why in the IDE language · #fix #i18n #diagram #ai + +- **What**: opening a `.diagram.json` that is not a diagram showed `a diagram file holds an object, not a list` whatever the IDE spoke, and the Mermaid import showed its reasons the same way. The CoT review panel showed a URL the SSRF check refused in English too (`Scheme 'ftp' is not allowed. Use http or https.`): its thread emitted `str(error)` as it was, where the request failures beside it already went through `error_text()`. +- **Fix**: the diagram editor's open and Mermaid-import warnings, and `code_review_thread`'s URL refusal, pass the reason through `error_text()`; an OS error still shows its own text. +- **Tests**: `test_error_text.py` +1 (opening a file that holds `[]`, in Traditional Chinese). +- **Result / numbers**: 2251 tests in 124 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py`, `test/test_utils/test_error_text.py`, `architecture_explore.md` §18 +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index 240a40e6..d4f7dd0e 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-268 | 2026-09-24 | A file that is not a diagram, and a refused CoT URL, say why in the IDE language | #fix #i18n #diagram #ai | [2026-09-b](2026-09-b.md) | | U-20260924-267 | 2026-09-24 | Refactor: the diagram file checks raise from exception_tags | #refactor #i18n #diagram | [2026-09-b](2026-09-b.md) | | U-20260924-266 | 2026-09-24 | A test that no tool tab shows a reason as it was raised | #test #i18n | [2026-09-b](2026-09-b.md) | | U-20260924-265 | 2026-09-24 | The SFTP tree says a host key was declined in the IDE language | #done #fix #ssh #i18n | [2026-09-b](2026-09-b.md) | @@ -349,4 +350,4 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| | [2026-09.md](2026-09.md) | 2026-09 | 218 | -| [2026-09-b.md](2026-09-b.md) | 2026-09 | 67 | +| [2026-09-b.md](2026-09-b.md) | 2026-09 | 68 | diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index e776bd09..bbdf989c 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -29,6 +29,7 @@ from pybreeze.pybreeze_ui.diagram_editor.diagram_property_panel import DiagramPropertyPanel from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene, ImageDownloadThread, ToolMode from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView +from pybreeze.pybreeze_ui.error_text import error_text from pybreeze.pybreeze_ui.thread_keeper import let_run_out from pybreeze.pybreeze_ui.plain_text import as_text from pybreeze.utils.file_process.read_capped import read_text_capped @@ -423,7 +424,7 @@ def _open_diagram(self) -> None: # RecursionError JSON nested deeper than the parser goes except (OSError, ValueError, TypeError, KeyError, RecursionError, MemoryError) as e: pybreeze_logger.error("Open diagram failed: %r", e) - reason = e.strerror if isinstance(e, OSError) and e.strerror else str(e) or type(e).__name__ + reason = e.strerror if isinstance(e, OSError) and e.strerror else error_text(str(e)) or type(e).__name__ QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), as_text(reason)) def _save_diagram(self) -> None: @@ -470,7 +471,7 @@ def _import_mermaid(self) -> None: QMessageBox.warning( self, _lang("diagram_editor_import_error", "Parse Error"), - as_text(str(e)), + as_text(error_text(str(e))), ) def may_close(self) -> bool: 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 493c53f7..22d3c0ac 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 @@ -33,7 +33,7 @@ def run(self): validate_url(self.url) except UnsafeURLError as error: pybreeze_logger.error("CoT code review URL rejected: %r", error) - self.update_response.emit("error", str(error)) + self.update_response.emit("error", error_text(str(error))) return # One session reuses a single TCP/TLS connection across all the # sequential per-template POSTs to the same endpoint. diff --git a/test/test_utils/test_error_text.py b/test/test_utils/test_error_text.py index b053b90e..bec3a280 100644 --- a/test/test_utils/test_error_text.py +++ b/test/test_utils/test_error_text.py @@ -196,3 +196,22 @@ def test_no_tool_tab_shows_a_reason_as_raised(): raw = re.compile(r"error\s*=\s*str\(\s*(?:error|err|exc|e)\s*\)") offenders = [path.name for path in tabs.glob("*.py") if raw.search(path.read_text(encoding="utf-8"))] assert not offenders + + +class TestADiagramFile: + def test_that_is_not_a_diagram(self, app, chinese, monkeypatch, tmp_path): + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + + target = tmp_path / "list.diagram.json" + target.write_text("[]", encoding="utf-8") + shown: list = [] + monkeypatch.setattr(diagram_editor_widget.QMessageBox, "warning", lambda *args: shown.append(args[2])) + monkeypatch.setattr(diagram_editor_widget.QFileDialog, "getOpenFileName", + staticmethod(lambda *args, **kwargs: (str(target), ""))) + editor = DiagramEditorWidget() + + editor._open_diagram() + + assert len(shown) == 1 and "架構圖檔案應該是一個物件,而不是 list" in shown[0] + editor.deleteLater() From f04dfbfe0833fb5b5af25e49cc5f4a62befdd387 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 13:48:31 +0800 Subject: [PATCH 311/312] Adopt je_editor 1.0.27 docked-editor contract --- dev.toml | 2 +- docs/updates/2026-09-b.md | 7 +++++++ docs/updates/README.md | 1 + progress.md | 1 - pyproject.toml | 2 +- requirements.txt | 2 +- test/test_utils/test_file_tree_rename.py | 3 +++ test/test_utils/test_jeditor_contract.py | 2 +- 8 files changed, 15 insertions(+), 5 deletions(-) diff --git a/dev.toml b/dev.toml index 997770c1..b6d349fb 100644 --- a/dev.toml +++ b/dev.toml @@ -14,7 +14,7 @@ description = "IDE for multi automation" requires-python = ">=3.10" license-files = ["LICENSE"] dependencies = [ - "je-editor", "je_auto_control", "je_web_runner", + "je-editor>=1.0.27", "je_auto_control", "je_web_runner", "je_load_density", "je_api_testka", "je-mail-thunder", "automation-file", "PySide6==6.11.2", "test_pioneer", "paramiko", "jupyterlab", diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index c2a834a3..cb90b3ee 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -626,3 +626,10 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: 2251 tests in 124 files. `ruff check` clean. - **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py`, `test/test_utils/test_error_text.py`, `architecture_explore.md` §18 - **Open items**: none. + +## U-20260924-269 · 2026-09-24 · Adopt je_editor 1.0.27 docked-editor contract · #done #cross-project + +- **What**: je_editor 1.0.27 changed `FullEditorWidget.__init__` to `(current_file, encoding, line_ending)` and made its `closeEvent` write back only when the document is modified, in the file's own encoding and line ending (JEDITOR U-20260923-07). Bumped the `je-editor` dependency floor to `>=1.0.27` and adapted the two PyBreeze tests that pinned the old contract: `test_jeditor_contract` now expects the three-parameter signature, and the docked-rename test marks the document modified after `setPlainText` (which resets the modified flag) so the dock's close writes the edit under the new "write only when edited" rule. +- **Result / numbers**: `test/test_utils/` green on the PR CI matrix (3.10–3.14); the dock no longer rewrites an unedited LF file as CRLF on IDE close. +- **Files**: `requirements.txt`, `pyproject.toml`, `dev.toml`, `test/test_utils/test_jeditor_contract.py`, `test/test_utils/test_file_tree_rename.py` +- **Evidence**: closes `progress.md` #47; je_editor `FullEditorWidget.closeEvent` gates on `document().isModified()`. diff --git a/docs/updates/README.md b/docs/updates/README.md index d4f7dd0e..24aafa53 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-269 | 2026-09-24 | Adopt je_editor 1.0.27 docked-editor contract | #done #cross-project | [2026-09-b](2026-09-b.md) | | U-20260924-268 | 2026-09-24 | A file that is not a diagram, and a refused CoT URL, say why in the IDE language | #fix #i18n #diagram #ai | [2026-09-b](2026-09-b.md) | | U-20260924-267 | 2026-09-24 | Refactor: the diagram file checks raise from exception_tags | #refactor #i18n #diagram | [2026-09-b](2026-09-b.md) | | U-20260924-266 | 2026-09-24 | A test that no tool tab shows a reason as it was raised | #test #i18n | [2026-09-b](2026-09-b.md) | diff --git a/progress.md b/progress.md index e156a699..07a0af4f 100644 --- a/progress.md +++ b/progress.md @@ -8,7 +8,6 @@ Cross-repo and workspace items live in `D:\Codes\progress.md` (relevant here: X- - **#2** [DECIDE] Closing one run window leaves its child running, with no window and no way to stop it short of a task manager; only closing the whole IDE stops it (`PyBreezeMainWindow.closeEvent`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`). Should closing a run window stop its run (`CodeWindow.stop_runner()` exists; `CodeWindow.closeEvent` today only lets the main window forget a finished one), ask first, or keep going on purpose (for example a long load test that mails its report)? - **#27** [DECIDE] paramiko is not pinned (`requirements.txt`, `pyproject.toml`, `dev.toml`), so an install may get 4.x, which still has CVE-2026-44405 (SHA-1 RSA signatures). The code refuses SHA-1 on every connect whatever the version (`SHA1_ALGORITHMS`, U-20260923-40), and the SSH tests pass on 5.0.0. Should the dependency say `paramiko>=5.0.0`, or be pinned exactly like PySide6? -- **#47** [BLOCKED] Closing the IDE closes JEditor's docked file editors (`editor_main/main_ui.py`, `_close_tool_tabs_and_docks`), and the published `je_editor` dock (`FullEditorWidget.closeEvent`) writes its buffer back on close whether edited or not, in UTF-8 with platform line endings, so exiting rewrites an LF file as CRLF. Fixed in JEditor (JEDITOR U-20260923-07: writes only when edited, in the file's own encoding and line ending); waits for the `je_editor` release that carries it, then bump the pin and add a PyBreeze test. - **#49** [DECIDE] jupyterlab is unpinned (`requirements.txt`, `pyproject.toml`, `dev.toml`) and the JupyterLab tab installs it only when it is missing (`jupyter_lab_gui/jupyter_lab_thread.py`, `is_jupyter_installed`), so an existing environment keeps whatever jupyter_server it has; this repo's `.venv` has 2.17.0. Fixed upstream: path traversal (CVE-2026-5422, fixed in 2.18.2), stored XSS through the nbconvert handlers (CVE-2026-44727 / GHSA-fcw5-x6j4-ccmp, 2.20.0; the embedded server has no token, so a script in a rendered notebook can drive it) and tokens logged from the Referer on 5xx (CVE-2026-86049, 2.21.0). jupyterlab itself is 4.5.6 there, below the fixes for a sanitizer-allowed command-linker button that runs commands on one click (CVE-2026-42557, 4.5.7), an SVG opened in the image viewer keeping a same-origin script context (CVE-2026-73415) and an imported `overrides.json` (CVE-2026-73417), both fixed in 4.5.10 / 4.6.2; with no token, a script running in the page can drive the server. Should the dependencies require `jupyter_server>=2.21.0` and `jupyterlab>=4.5.10`, and should the tab check the installed version and offer to upgrade? - **#53** [DECIDE] `requests` and `urllib3` are imported directly (`utils/network/public_http.py`, `http_client.py`, `url_validation.py`, the three AI panels) but declared nowhere (`requirements.txt`, `pyproject.toml`, `dev.toml`); they arrive through the automation packages, so their versions are whatever those allow. This repo's `.venv` has urllib3 2.6.3, below the 2.7.0 that fixes CVE-2026-44431 (Authorization and Cookie forwarded on a cross-origin redirect; these requests do not follow redirects) and CVE-2026-44432 (a Brotli response decompressed whole on a second `read(amt)`; not reproduced here with brotli 1.2.0 installed, `read_capped_text` stopped at its 16 MB cap). `public_http` also relies on urllib3's `_dns_host` and `http.client`'s `_create_connection` (`test_public_http.py` guards both; passes on urllib3 2.6.3 and 2.8.0). Should the dependencies declare `requests` and `urllib3>=2.7.0`, and pin them like PySide6? - **#54** [DECIDE] The release path trusts more than it needs to. `stable.yml`'s `publish` job uploads with a long-lived `secrets.PYPI_API_TOKEN` (`.github/workflows/stable.yml:136-140`); PyPI's trusted publishing (OIDC, `pypa/gh-action-pypi-publish` with `id-token: write`) would need no stored token, but it has to be set up on PyPI by the owner first. Every action in `dev.yml` and `stable.yml` is pinned by a movable tag (`actions/checkout@v4`, `SonarSource/sonarqube-scan-action@v8.2.1`, ...), not a commit SHA, and the job holding the PyPI token and `contents: write` runs them. CI also installs prthinker from the head of its `main` branch (`dev.yml:39`, `stable.yml:39`). Should the publish job move to trusted publishing and the actions be pinned by SHA? diff --git a/pyproject.toml b/pyproject.toml index 6562da86..d01a1eef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ description = "IDE for multi automation" requires-python = ">=3.10" license-files = ["LICENSE"] dependencies = [ - "je-editor", "je_auto_control", "je_web_runner", + "je-editor>=1.0.27", "je_auto_control", "je_web_runner", "je_load_density", "je_api_testka", "je-mail-thunder", "automation-file", "PySide6==6.11.2", "test_pioneer", "paramiko", "jupyterlab", diff --git a/requirements.txt b/requirements.txt index 09e9ef7d..62552beb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ # and dev.toml. It does not list pybreeze itself: that would install the published # build, so CI would be testing PyPI's code instead of the working tree. PySide6==6.11.2 -je-editor +je-editor>=1.0.27 je_auto_control je_web_runner je_load_density diff --git a/test/test_utils/test_file_tree_rename.py b/test/test_utils/test_file_tree_rename.py index 355325f8..d1be249a 100644 --- a/test/test_utils/test_file_tree_rename.py +++ b/test/test_utils/test_file_tree_rename.py @@ -88,6 +88,9 @@ def test_a_file_in_a_renamed_folder_follows_it(tmp_path): other_dock.setWidget(docked_elsewhere) window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, other_dock) docked.code_edit.setPlainText("edited in the dock\\n") +# setPlainText leaves the document unmodified; a real edit marks it, and the +# dock now writes on close only when modified (je_editor >= 1.0.27) +docked.code_edit.document().setModified(True) menu.QInputDialog.getText = staticmethod(lambda *args, **kwargs: ("b.py", True)) menu._action_rename(QTreeView(), window, original) diff --git a/test/test_utils/test_jeditor_contract.py b/test/test_utils/test_jeditor_contract.py index ea4efefd..af4d3b97 100644 --- a/test/test_utils/test_jeditor_contract.py +++ b/test/test_utils/test_jeditor_contract.py @@ -164,7 +164,7 @@ def test_a_tab_closes_through_close_tab(self): def test_a_docked_editor_writes_to_the_file_it_names_when_it_closes(self): # A rename re-points current_file; the dock saves nothing else docked = _internal("je_editor.pyside_ui.main_ui.editor.editor_widget_dock", "FullEditorWidget") - assert _parameters(docked.__init__) == ["current_file"] + assert _parameters(docked.__init__) == ["current_file", "encoding", "line_ending"] assert "self.current_file" in inspect.getsource(docked.closeEvent) def test_the_language_wrapper_has_what_pybreeze_reads(self): From 07b76b275116efb7e1a314f00bbd51f0abc7571e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 24 Sep 2026 14:02:41 +0800 Subject: [PATCH 312/312] Report a deeply nested regex instead of crashing on CPython 3.10 --- docs/updates/2026-09-b.md | 7 ++++ docs/updates/README.md | 1 + pybreeze/utils/regex_tools/regex_tester.py | 42 ++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md index cb90b3ee..66e6d9f7 100644 --- a/docs/updates/2026-09-b.md +++ b/docs/updates/2026-09-b.md @@ -633,3 +633,10 @@ Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and - **Result / numbers**: `test/test_utils/` green on the PR CI matrix (3.10–3.14); the dock no longer rewrites an unedited LF file as CRLF on IDE close. - **Files**: `requirements.txt`, `pyproject.toml`, `dev.toml`, `test/test_utils/test_jeditor_contract.py`, `test/test_utils/test_file_tree_rename.py` - **Evidence**: closes `progress.md` #47; je_editor `FullEditorWidget.closeEvent` gates on `document().isModified()`. + +## U-20260924-270 · 2026-09-24 · Report a deeply nested regex instead of crashing on CPython 3.10 · #fix #regex + +- **What**: `compile_pattern` caught `RecursionError` for over-nested groups, but on CPython 3.10 a pattern nesting thousands of groups overflows the C stack of `re`'s parser and hard-crashes the interpreter before any catchable error is raised (the test `test_groups_nested_too_deep_are_reported` took the 3.10 CI leg down with an `sre_parse` stack overflow). Added an iterative depth scan (`_group_nesting_too_deep`, cap `MAX_GROUP_NESTING = 100`) that skips escaped parens and character-class contents and raises `RegexTesterException` before the pattern reaches `re.compile`. +- **Result / numbers**: `test/test_utils/test_regex_tester.py` passes on the 3.10 matrix leg; normal patterns still compile, escaped `\(` and `[(]` are not counted. +- **Files**: `pybreeze/utils/regex_tools/regex_tester.py` +- **Evidence**: crash trace `sre_parse.py _parse_sub/_parse` on Python 3.10.11; ruff clean. diff --git a/docs/updates/README.md b/docs/updates/README.md index 24aafa53..cf018570 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20260924-270 | 2026-09-24 | Report a deeply nested regex instead of crashing on CPython 3.10 | #fix #regex | [2026-09-b](2026-09-b.md) | | U-20260924-269 | 2026-09-24 | Adopt je_editor 1.0.27 docked-editor contract | #done #cross-project | [2026-09-b](2026-09-b.md) | | U-20260924-268 | 2026-09-24 | A file that is not a diagram, and a refused CoT URL, say why in the IDE language | #fix #i18n #diagram #ai | [2026-09-b](2026-09-b.md) | | U-20260924-267 | 2026-09-24 | Refactor: the diagram file checks raise from exception_tags | #refactor #i18n #diagram | [2026-09-b](2026-09-b.md) | diff --git a/pybreeze/utils/regex_tools/regex_tester.py b/pybreeze/utils/regex_tools/regex_tester.py index 9b452e5e..395fce22 100644 --- a/pybreeze/utils/regex_tools/regex_tester.py +++ b/pybreeze/utils/regex_tools/regex_tester.py @@ -34,6 +34,11 @@ # How long a pattern may run, in its own process, before it is stopped. MATCH_TIMEOUT_SECONDS = 5.0 +# Groups nested past this overflow the C stack of ``re``'s parser on CPython +# 3.10 — a hard interpreter crash, not a catchable ``RecursionError`` — so the +# depth is checked here before the pattern ever reaches ``re.compile``. +MAX_GROUP_NESTING = 100 + # Human-facing flag names mapped to their ``re`` values. _FLAG_NAMES: dict[str, int] = { "IGNORECASE": re.IGNORECASE, @@ -78,6 +83,38 @@ def build_flags(flag_names: list[str] | set[str]) -> int: return combined +def _group_nesting_too_deep(pattern: str) -> bool: + """Return whether *pattern* nests groups past :data:`MAX_GROUP_NESTING`. + + The scan is iterative, so it cannot itself overflow the stack. It skips + escaped characters and the contents of character classes, where a + parenthesis is a literal rather than a group. + + :param pattern: the regular expression to scan + :return: ``True`` when the open-group nesting depth exceeds the cap + """ + depth = 0 + in_class = False + escaped = False + for char in pattern: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif in_class: + if char == "]": + in_class = False + elif char == "[": + in_class = True + elif char == "(": + depth += 1 + if depth > MAX_GROUP_NESTING: + return True + elif char == ")" and depth > 0: + depth -= 1 + return False + + def compile_pattern(pattern: str, flag_names: list[str] | set[str] | None = None) -> re.Pattern: """Compile *pattern*, raising a friendly error on failure. @@ -89,6 +126,11 @@ def compile_pattern(pattern: str, flag_names: list[str] | set[str] | None = None if pattern == "": pybreeze_logger.error(empty_regex_pattern_error) raise RegexTesterException(empty_regex_pattern_error) + if _group_nesting_too_deep(pattern): + message = invalid_regex_pattern_error.format( + detail=f"groups nested more than {MAX_GROUP_NESTING} deep") + pybreeze_logger.error(message) + raise RegexTesterException(message) try: return re.compile(pattern, build_flags(flag_names or [])) # OverflowError: a repeat count past what re takes (a{4294967296});