diff --git a/CLAUDE.md b/CLAUDE.md index 91fdc12..517dfa5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -229,6 +229,8 @@ All code must satisfy common static-analysis rules enforced by SonarQube and Cod ## Commit & PR rules - Commit messages: short imperative sentence (e.g., "Update stable version", "Fix github actions") -- Do not mention any AI tools, assistants, or co-authors in commit messages or PR descriptions -- Do not add `Co-Authored-By` headers referencing any AI +- **No AI attribution (mandatory)**: Never mention any AI tool, assistant, agent, model name, or vendor in commit messages, commit trailers, branch names, PR titles, PR descriptions, issue text, code comments, or documentation + - Do not add `Co-Authored-By` headers referencing any AI + - No "Generated with ...", "Created by ...", or similar footers in commits or PR bodies + - PR titles and bodies describe **what changed and why** — nothing about how the change was authored - PR target: `dev` for development work, `main` for stable releases diff --git a/README.md b/README.md index 3377d7e..950f77b 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,16 @@ PyBreeze is not just a code editor — it is a command center for the automation - Mermaid `flowchart` / `graph` import - Save/Open as `.diagram.json`, export to PNG or SVG - Undo/redo, align, distribute, grid, snap, and zoom controls +- **cURL Import** — Paste a `curl` command copied from your browser's dev tools and generate a ready-to-run script for the target of your choice: **Python `requests`**, an **APITestka Python** snippet (`test_api_method_requests(...)`), an **APITestka JSON action** (`[["AT_test_api_method", {...}]]`) that `execute_files` runs directly, or a **LoadDensity** Locust load-test (`start_test(...)`). (These are the HTTP-oriented modules; a curl request has no meaningful mapping to the browser or desktop-GUI automation modules.) Parses the method, URL, headers, body, basic auth, `-G` query parameters, `-F` multipart form fields (file uploads become `files=open(...)`), the `--json` shortcut (which also sets the JSON `Content-Type`/`Accept`), and `-d @file` bodies (which become `open(...).read()`, while `--data-raw` stays literal) — all with multi-line `\` / `^` continuations. It knows the arity of curl's common flags, so value-taking options like `--max-time 30` or `-o out.json` never leak into the URL; it URL-encodes `--data-urlencode` values the way curl does; and it infers `POST` when a body or form is present and sends JSON bodies as JSON. Targets include Python `requests`, a ready-to-run **pytest test** (a named `test_...` function that sends the request and asserts the status), APITestka (Python and JSON action), and a LoadDensity load test. Pick one from the dropdown, then copy the result, **open it straight into a new editor tab**, or **save it to a `.py` / `.json` file** (the extension follows the chosen target). The parser is pure logic and never executes the command. +- **JWT Decoder** — Paste a JSON Web Token and read its header and payload as pretty-printed JSON, with standard timestamp claims (`exp`, `iat`, `nbf`, `auth_time`) rendered as readable UTC times. Inspection only — the signature is never verified and the token is never trusted. +- **Timestamp Converter** — Enter a Unix epoch value (seconds or milliseconds, auto-detected) or an ISO-8601 date-time (with `Z` or an offset) and get every representation back in UTC. Deterministic and independent of the local time zone. +- **Hash Generator** — Compute SHA-256, SHA-512, SHA-1 and MD5 digests of any text at once. A general-purpose checksum tool (MD5/SHA-1 are provided for interoperability with `usedforsecurity=False`, never for security decisions). +- **Query ⇄ JSON** — Convert an `application/x-www-form-urlencoded` query string to pretty JSON and back, URL-decoding and -encoding as needed. Repeated keys become JSON arrays and vice versa. Copy the result, open it in an editor tab, or save it to a file. +- **Regex Tester** — Try a regular expression against sample text with `IGNORECASE` / `MULTILINE` / `DOTALL` / `VERBOSE` flags, and see every match with its offsets, numbered groups and named groups. Invalid patterns report a friendly error instead of crashing. +- **HTTP Status Reference** — Search the full HTTP status code table (sourced from the standard library, so it stays current) by code prefix or keyword, with the reason phrase, description and status class for each. +- **Text Diff** — Compare two pieces of text (for example an expected vs. actual API response) side by side and get a unified diff plus a one-line summary of how many lines were added or removed. Copy the diff, open it in an editor tab, or save it to a file. +- **JSON Format** — Pretty-print or minify a JSON payload, with a clear validation error when the input is not valid JSON. Copy the result, open it in a new editor tab, or save it to a file. +- **Response Inspector** — Paste a raw HTTP response and get a decoded summary in one step: the status code is looked up in the HTTP reference, headers are parsed, a JSON body is pretty-printed, and any JWTs anywhere in the text (for example an `Authorization: Bearer` header) are decoded with their timestamp claims shown in UTC. Ties the HTTP-status, JSON-format and JWT tools together, and can open a found status code or JWT directly in its dedicated tool tab, pre-filled. - **File Tree Context Menu** — Right-click any file or folder in the project tree to create files/folders, rename, delete, copy absolute or relative paths, or reveal the item in your platform file manager. Renaming or deleting a file that is currently open in an editor tab keeps the tab in sync. - **Package Manager** — Install automation modules and build tools directly from the IDE menu without leaving the editor. - **Integrated Documentation** — Quick access to documentation and GitHub pages for each automation module directly from the menu bar. @@ -206,6 +216,16 @@ PyBreeze UI (PySide6) │ ├── Skill Prompt Editor │ ├── Skill Send GUI │ ├── Diagram Editor (WYSIWYG, Mermaid import, PNG/SVG export) +│ ├── cURL Import (curl → Python requests code) +│ ├── JWT Decoder (header/payload inspection) +│ ├── Timestamp Converter (epoch ⇄ ISO-8601) +│ ├── Hash Generator (SHA-256/512, SHA-1, MD5) +│ ├── Query ⇄ JSON Converter +│ ├── Regex Tester +│ ├── HTTP Status Reference +│ ├── Text Diff +│ ├── JSON Format (pretty / minify) +│ ├── Response Inspector (status + JSON + JWT) │ └── JupyterLab Integration └── Install Menu ├── Automation Module Installers diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index ef3e67a..88e64e2 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -313,6 +313,151 @@ "extend_tools_menu_diagram_editor_tab_label": "Diagram Editor", "extend_tools_menu_diagram_editor_dock_action": "Diagram Editor Dock", "extend_tools_menu_diagram_editor_dock_title": "Diagram Editor", + # cURL Import — Menu + "extend_tools_menu_curl_import_tab_action": "cURL Import Tab", + "extend_tools_menu_curl_import_tab_label": "cURL Import", + "extend_tools_menu_curl_import_dock_action": "cURL Import Dock", + "extend_tools_menu_curl_import_dock_title": "cURL Import", + # cURL Import — Widget + "curl_import_input_label": "Paste a curl command:", + "curl_import_input_placeholder": "curl 'https://api.example.com/v1/items' -H 'Accept: application/json'", + "curl_import_convert_button": "Convert to Python requests", + "curl_import_output_label": "Generated code:", + "curl_import_error": "Could not parse the curl command: {error}", + "curl_import_empty_hint": "Paste a curl command above, then click convert.", + "curl_import_target_label": "Generate for:", + "curl_import_target_requests": "Python requests", + "curl_import_target_pytest": "pytest test", + "curl_import_target_apitestka_python": "APITestka (Python)", + "curl_import_target_apitestka_action": "APITestka (JSON action)", + "curl_import_target_loaddensity_python": "LoadDensity (Python)", + # Shared output actions (copy / open in editor / save to file) + "output_actions_copy": "Copy", + "output_actions_open_editor": "Open in editor tab", + "output_actions_save": "Save to file...", + "output_actions_editor_tab_label": "Generated", + "output_actions_save_dialog_title": "Save output", + "output_actions_filter_python": "Python (*.py)", + "output_actions_filter_json": "JSON (*.json)", + "output_actions_filter_text": "Text (*.txt)", + # JWT Decoder — Menu + "extend_tools_menu_jwt_decoder_tab_action": "JWT Decoder Tab", + "extend_tools_menu_jwt_decoder_tab_label": "JWT Decoder", + "extend_tools_menu_jwt_decoder_dock_action": "JWT Decoder Dock", + "extend_tools_menu_jwt_decoder_dock_title": "JWT Decoder", + # JWT Decoder — Widget + "jwt_decoder_input_label": "Paste a JWT (header.payload.signature):", + "jwt_decoder_input_placeholder": "..", + "jwt_decoder_decode_button": "Decode (no signature check)", + "jwt_decoder_output_label": "Decoded token:", + "jwt_decoder_header_label": "== Header ==", + "jwt_decoder_payload_label": "== Payload ==", + "jwt_decoder_times_label": "== Timestamps (UTC) ==", + "jwt_decoder_error": "Could not decode the token: {error}", + "jwt_decoder_empty_hint": "Paste a JWT above, then click decode.", + # Timestamp Converter — Menu + "extend_tools_menu_timestamp_tab_action": "Timestamp Converter Tab", + "extend_tools_menu_timestamp_tab_label": "Timestamp", + "extend_tools_menu_timestamp_dock_action": "Timestamp Converter Dock", + "extend_tools_menu_timestamp_dock_title": "Timestamp", + # Timestamp Converter — Widget + "timestamp_input_label": "Epoch (seconds or ms) or ISO-8601 date-time:", + "timestamp_input_placeholder": "1609459200 or 2021-01-01T00:00:00Z", + "timestamp_convert_button": "Convert", + "timestamp_output_label": "All representations (UTC):", + "timestamp_epoch_seconds_label": "Epoch (seconds)", + "timestamp_epoch_millis_label": "Epoch (milliseconds)", + "timestamp_iso_label": "ISO-8601 (UTC)", + "timestamp_error": "Could not convert the value: {error}", + "timestamp_empty_hint": "Enter an epoch value or an ISO date-time above.", + # Hash Generator — Menu + "extend_tools_menu_hash_tab_action": "Hash Generator Tab", + "extend_tools_menu_hash_tab_label": "Hash", + "extend_tools_menu_hash_dock_action": "Hash Generator Dock", + "extend_tools_menu_hash_dock_title": "Hash", + # Hash Generator — Widget + "hash_input_label": "Text to hash:", + "hash_input_placeholder": "Type or paste text to hash...", + "hash_button": "Compute hashes", + "hash_output_label": "Digests:", + # Query <-> JSON — Menu + "extend_tools_menu_query_json_tab_action": "Query / JSON Tab", + "extend_tools_menu_query_json_tab_label": "Query / JSON", + "extend_tools_menu_query_json_dock_action": "Query / JSON Dock", + "extend_tools_menu_query_json_dock_title": "Query / JSON", + # Query <-> JSON — Widget + "query_json_input_label": "Query string or JSON object:", + "query_json_input_placeholder": "a=1&b=2 or {\"a\": \"1\", \"b\": \"2\"}", + "query_json_to_json_button": "Query → JSON", + "query_json_to_query_button": "JSON → Query", + "query_json_output_label": "Result:", + "query_json_error": "Could not convert: {error}", + "query_json_empty_hint": "Enter a query string or a JSON object above.", + # Regex Tester — Menu + "extend_tools_menu_regex_tab_action": "Regex Tester Tab", + "extend_tools_menu_regex_tab_label": "Regex", + "extend_tools_menu_regex_dock_action": "Regex Tester Dock", + "extend_tools_menu_regex_dock_title": "Regex", + # Regex Tester — Widget + "regex_pattern_label": "Pattern:", + "regex_pattern_placeholder": r"(\d{4})-(\d{2})-(\d{2})", + "regex_text_label": "Test text:", + "regex_text_placeholder": "Paste the text to search...", + "regex_test_button": "Find matches", + "regex_output_label": "Matches:", + "regex_match_count": "{count} match(es):", + "regex_no_match": "No matches.", + "regex_error": "Regex error: {error}", + # HTTP Status Reference — Menu + "extend_tools_menu_http_status_tab_action": "HTTP Status Reference Tab", + "extend_tools_menu_http_status_tab_label": "HTTP Status", + "extend_tools_menu_http_status_dock_action": "HTTP Status Reference Dock", + "extend_tools_menu_http_status_dock_title": "HTTP Status", + # HTTP Status Reference — Widget + "http_status_search_label": "Search by code or keyword:", + "http_status_search_placeholder": "404 or not found", + "http_status_no_match": "No matching status codes.", + # Text Diff — Menu + "extend_tools_menu_diff_tab_action": "Text Diff Tab", + "extend_tools_menu_diff_tab_label": "Text Diff", + "extend_tools_menu_diff_dock_action": "Text Diff Dock", + "extend_tools_menu_diff_dock_title": "Text Diff", + # Text Diff — Widget + "diff_left_label": "Expected:", + "diff_right_label": "Actual:", + "diff_compare_button": "Compare", + "diff_identical": "The two texts are identical.", + "diff_summary": "{added} line(s) added, {removed} line(s) removed.", + # JSON Format — Menu + "extend_tools_menu_json_format_tab_action": "JSON Format Tab", + "extend_tools_menu_json_format_tab_label": "JSON Format", + "extend_tools_menu_json_format_dock_action": "JSON Format Dock", + "extend_tools_menu_json_format_dock_title": "JSON Format", + # JSON Format — Widget + "json_format_input_label": "JSON:", + "json_format_input_placeholder": "{\"a\": 1, \"b\": [2, 3]}", + "json_format_format_button": "Format", + "json_format_minify_button": "Minify", + "json_format_output_label": "Result:", + "json_format_error": "Invalid JSON: {error}", + "json_format_empty_hint": "Paste JSON above, then format or minify.", + # Response Inspector — Menu + "extend_tools_menu_response_tab_action": "Response Inspector Tab", + "extend_tools_menu_response_tab_label": "Response Inspector", + "extend_tools_menu_response_dock_action": "Response Inspector Dock", + "extend_tools_menu_response_dock_title": "Response Inspector", + # Response Inspector — Widget + "response_input_label": "Paste a raw HTTP response:", + "response_input_placeholder": "HTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"ok\": true}", + "response_analyze_button": "Analyze", + "response_output_label": "Analysis:", + "response_open_jwt_button": "Open JWT in decoder", + "response_open_status_button": "Open status in reference", + "response_empty_hint": "Paste a response above, then click analyze.", + "response_status_label": "== Status ==", + "response_headers_label": "== Headers ==", + "response_jwt_label": "== JWT(s) found ==", + "response_body_label": "== Body ==", # Diagram Editor — Tools "diagram_editor_tool_select": "Select", "diagram_editor_tool_rect": "Rect", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index a32e214..c2390a0 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -291,6 +291,151 @@ "extend_tools_menu_diagram_editor_tab_label": "架構圖編輯器", "extend_tools_menu_diagram_editor_dock_action": "架構圖編輯器停駐窗格", "extend_tools_menu_diagram_editor_dock_title": "架構圖編輯器", + # cURL 匯入 — 選單 + "extend_tools_menu_curl_import_tab_action": "cURL 匯入分頁", + "extend_tools_menu_curl_import_tab_label": "cURL 匯入", + "extend_tools_menu_curl_import_dock_action": "cURL 匯入停駐窗格", + "extend_tools_menu_curl_import_dock_title": "cURL 匯入", + # cURL 匯入 — 介面 + "curl_import_input_label": "貼上 curl 指令:", + "curl_import_input_placeholder": "curl 'https://api.example.com/v1/items' -H 'Accept: application/json'", + "curl_import_convert_button": "轉換為 Python requests", + "curl_import_output_label": "產生的程式碼:", + "curl_import_error": "無法解析 curl 指令:{error}", + "curl_import_empty_hint": "請先在上方貼上 curl 指令,再按轉換。", + "curl_import_target_label": "產生目標:", + "curl_import_target_requests": "Python requests", + "curl_import_target_pytest": "pytest 測試", + "curl_import_target_apitestka_python": "APITestka(Python)", + "curl_import_target_apitestka_action": "APITestka(JSON action)", + "curl_import_target_loaddensity_python": "LoadDensity(Python)", + # 共用輸出動作(複製 / 開成分頁 / 存檔) + "output_actions_copy": "複製", + "output_actions_open_editor": "開成編輯器分頁", + "output_actions_save": "存成檔案...", + "output_actions_editor_tab_label": "產生內容", + "output_actions_save_dialog_title": "儲存輸出", + "output_actions_filter_python": "Python (*.py)", + "output_actions_filter_json": "JSON (*.json)", + "output_actions_filter_text": "Text (*.txt)", + # JWT 解碼器 — 選單 + "extend_tools_menu_jwt_decoder_tab_action": "JWT 解碼器分頁", + "extend_tools_menu_jwt_decoder_tab_label": "JWT 解碼器", + "extend_tools_menu_jwt_decoder_dock_action": "JWT 解碼器停駐窗格", + "extend_tools_menu_jwt_decoder_dock_title": "JWT 解碼器", + # JWT 解碼器 — 介面 + "jwt_decoder_input_label": "貼上 JWT(header.payload.signature):", + "jwt_decoder_input_placeholder": "..", + "jwt_decoder_decode_button": "解碼(不驗證簽章)", + "jwt_decoder_output_label": "解碼結果:", + "jwt_decoder_header_label": "== 標頭 Header ==", + "jwt_decoder_payload_label": "== 內容 Payload ==", + "jwt_decoder_times_label": "== 時間戳記(UTC)==", + "jwt_decoder_error": "無法解碼此 token:{error}", + "jwt_decoder_empty_hint": "請先在上方貼上 JWT,再按解碼。", + # 時間戳記轉換器 — 選單 + "extend_tools_menu_timestamp_tab_action": "時間戳記轉換器分頁", + "extend_tools_menu_timestamp_tab_label": "時間戳記", + "extend_tools_menu_timestamp_dock_action": "時間戳記轉換器停駐窗格", + "extend_tools_menu_timestamp_dock_title": "時間戳記", + # 時間戳記轉換器 — 介面 + "timestamp_input_label": "Epoch(秒或毫秒)或 ISO-8601 日期時間:", + "timestamp_input_placeholder": "1609459200 或 2021-01-01T00:00:00Z", + "timestamp_convert_button": "轉換", + "timestamp_output_label": "所有表示法(UTC):", + "timestamp_epoch_seconds_label": "Epoch(秒)", + "timestamp_epoch_millis_label": "Epoch(毫秒)", + "timestamp_iso_label": "ISO-8601(UTC)", + "timestamp_error": "無法轉換此值:{error}", + "timestamp_empty_hint": "請在上方輸入 epoch 值或 ISO 日期時間。", + # 雜湊產生器 — 選單 + "extend_tools_menu_hash_tab_action": "雜湊產生器分頁", + "extend_tools_menu_hash_tab_label": "雜湊", + "extend_tools_menu_hash_dock_action": "雜湊產生器停駐窗格", + "extend_tools_menu_hash_dock_title": "雜湊", + # 雜湊產生器 — 介面 + "hash_input_label": "要雜湊的文字:", + "hash_input_placeholder": "輸入或貼上要雜湊的文字...", + "hash_button": "計算雜湊", + "hash_output_label": "摘要:", + # Query <-> JSON — 選單 + "extend_tools_menu_query_json_tab_action": "Query / JSON 分頁", + "extend_tools_menu_query_json_tab_label": "Query / JSON", + "extend_tools_menu_query_json_dock_action": "Query / JSON 停駐窗格", + "extend_tools_menu_query_json_dock_title": "Query / JSON", + # Query <-> JSON — 介面 + "query_json_input_label": "查詢字串或 JSON 物件:", + "query_json_input_placeholder": "a=1&b=2 或 {\"a\": \"1\", \"b\": \"2\"}", + "query_json_to_json_button": "Query → JSON", + "query_json_to_query_button": "JSON → Query", + "query_json_output_label": "結果:", + "query_json_error": "無法轉換:{error}", + "query_json_empty_hint": "請在上方輸入查詢字串或 JSON 物件。", + # 正規表示式測試器 — 選單 + "extend_tools_menu_regex_tab_action": "正規表示式測試器分頁", + "extend_tools_menu_regex_tab_label": "Regex", + "extend_tools_menu_regex_dock_action": "正規表示式測試器停駐窗格", + "extend_tools_menu_regex_dock_title": "Regex", + # 正規表示式測試器 — 介面 + "regex_pattern_label": "樣式:", + "regex_pattern_placeholder": r"(\d{4})-(\d{2})-(\d{2})", + "regex_text_label": "測試文字:", + "regex_text_placeholder": "貼上要搜尋的文字...", + "regex_test_button": "尋找符合項", + "regex_output_label": "符合項:", + "regex_match_count": "{count} 個符合:", + "regex_no_match": "沒有符合項。", + "regex_error": "正規表示式錯誤:{error}", + # HTTP 狀態碼參考 — 選單 + "extend_tools_menu_http_status_tab_action": "HTTP 狀態碼參考分頁", + "extend_tools_menu_http_status_tab_label": "HTTP 狀態碼", + "extend_tools_menu_http_status_dock_action": "HTTP 狀態碼參考停駐窗格", + "extend_tools_menu_http_status_dock_title": "HTTP 狀態碼", + # HTTP 狀態碼參考 — 介面 + "http_status_search_label": "以代碼或關鍵字搜尋:", + "http_status_search_placeholder": "404 或 not found", + "http_status_no_match": "沒有符合的狀態碼。", + # 文字差異 — 選單 + "extend_tools_menu_diff_tab_action": "文字差異分頁", + "extend_tools_menu_diff_tab_label": "文字差異", + "extend_tools_menu_diff_dock_action": "文字差異停駐窗格", + "extend_tools_menu_diff_dock_title": "文字差異", + # 文字差異 — 介面 + "diff_left_label": "預期:", + "diff_right_label": "實際:", + "diff_compare_button": "比較", + "diff_identical": "兩段文字完全相同。", + "diff_summary": "新增 {added} 行,移除 {removed} 行。", + # JSON 格式化 — 選單 + "extend_tools_menu_json_format_tab_action": "JSON 格式化分頁", + "extend_tools_menu_json_format_tab_label": "JSON 格式化", + "extend_tools_menu_json_format_dock_action": "JSON 格式化停駐窗格", + "extend_tools_menu_json_format_dock_title": "JSON 格式化", + # JSON 格式化 — 介面 + "json_format_input_label": "JSON:", + "json_format_input_placeholder": "{\"a\": 1, \"b\": [2, 3]}", + "json_format_format_button": "格式化", + "json_format_minify_button": "壓縮", + "json_format_output_label": "結果:", + "json_format_error": "無效的 JSON:{error}", + "json_format_empty_hint": "請在上方貼上 JSON,再按格式化或壓縮。", + # 回應檢視器 — 選單 + "extend_tools_menu_response_tab_action": "回應檢視器分頁", + "extend_tools_menu_response_tab_label": "回應檢視器", + "extend_tools_menu_response_dock_action": "回應檢視器停駐窗格", + "extend_tools_menu_response_dock_title": "回應檢視器", + # 回應檢視器 — 介面 + "response_input_label": "貼上原始 HTTP 回應:", + "response_input_placeholder": "HTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"ok\": true}", + "response_analyze_button": "分析", + "response_output_label": "分析結果:", + "response_open_jwt_button": "在解碼器開啟 JWT", + "response_open_status_button": "在參考開啟狀態碼", + "response_empty_hint": "請先在上方貼上回應,再按分析。", + "response_status_label": "== 狀態 Status ==", + "response_headers_label": "== 標頭 Headers ==", + "response_jwt_label": "== 找到的 JWT ==", + "response_body_label": "== 內容 Body ==", # Diagram Editor — 工具 "diagram_editor_tool_select": "選取", "diagram_editor_tool_rect": "矩形", diff --git a/pybreeze/pybreeze_ui/menu/tools/tools_menu.py b/pybreeze/pybreeze_ui/menu/tools/tools_menu.py index 6fbe057..a27c5cd 100644 --- a/pybreeze/pybreeze_ui/menu/tools/tools_menu.py +++ b/pybreeze/pybreeze_ui/menu/tools/tools_menu.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from typing import TYPE_CHECKING from PySide6.QtGui import QAction, Qt @@ -14,6 +15,16 @@ from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.skills_prompt_editor_widget import \ SkillPromptEditor from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import SkillsSendGUI +from pybreeze.pybreeze_ui.tools_gui.curl_import_gui import CurlImportGUI +from pybreeze.pybreeze_ui.tools_gui.diff_gui import DiffGUI +from pybreeze.pybreeze_ui.tools_gui.json_format_gui import JsonFormatGUI +from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI +from pybreeze.pybreeze_ui.tools_gui.hash_gui import HashGUI +from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI +from pybreeze.pybreeze_ui.tools_gui.query_json_gui import QueryJsonGUI +from pybreeze.pybreeze_ui.tools_gui.regex_gui import RegexGUI +from pybreeze.pybreeze_ui.tools_gui.response_inspector_gui import ResponseInspectorGUI +from pybreeze.pybreeze_ui.tools_gui.timestamp_gui import TimestampGUI if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -90,6 +101,116 @@ def build_tools_menu(ui_we_want_to_set: PyBreezeMainWindow): )) ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_diagram_editor_action) + # cURL Import + ui_we_want_to_set.tools_curl_import_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_curl_import_tab_action" + )) + ui_we_want_to_set.tools_curl_import_action.triggered.connect(lambda: ui_we_want_to_set.tab_widget.addTab( + CurlImportGUI(ui_we_want_to_set), language_wrapper.language_word_dict.get( + "extend_tools_menu_curl_import_tab_label" + ) + )) + ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_curl_import_action) + + # JWT Decoder + ui_we_want_to_set.tools_jwt_decoder_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_jwt_decoder_tab_action" + )) + ui_we_want_to_set.tools_jwt_decoder_action.triggered.connect(lambda: ui_we_want_to_set.tab_widget.addTab( + JwtDecoderGUI(main_window=ui_we_want_to_set), language_wrapper.language_word_dict.get( + "extend_tools_menu_jwt_decoder_tab_label" + ) + )) + ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_jwt_decoder_action) + + # Timestamp Converter + ui_we_want_to_set.tools_timestamp_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_timestamp_tab_action" + )) + ui_we_want_to_set.tools_timestamp_action.triggered.connect(lambda: ui_we_want_to_set.tab_widget.addTab( + TimestampGUI(ui_we_want_to_set), language_wrapper.language_word_dict.get( + "extend_tools_menu_timestamp_tab_label" + ) + )) + ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_timestamp_action) + + # Hash Generator + ui_we_want_to_set.tools_hash_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_hash_tab_action" + )) + ui_we_want_to_set.tools_hash_action.triggered.connect(lambda: ui_we_want_to_set.tab_widget.addTab( + HashGUI(ui_we_want_to_set), language_wrapper.language_word_dict.get( + "extend_tools_menu_hash_tab_label" + ) + )) + ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_hash_action) + + # Query <-> JSON + ui_we_want_to_set.tools_query_json_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_query_json_tab_action" + )) + ui_we_want_to_set.tools_query_json_action.triggered.connect(lambda: ui_we_want_to_set.tab_widget.addTab( + QueryJsonGUI(ui_we_want_to_set), language_wrapper.language_word_dict.get( + "extend_tools_menu_query_json_tab_label" + ) + )) + ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_query_json_action) + + # Regex Tester + ui_we_want_to_set.tools_regex_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_regex_tab_action" + )) + ui_we_want_to_set.tools_regex_action.triggered.connect(lambda: ui_we_want_to_set.tab_widget.addTab( + RegexGUI(ui_we_want_to_set), language_wrapper.language_word_dict.get( + "extend_tools_menu_regex_tab_label" + ) + )) + ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_regex_action) + + # HTTP Status Reference + ui_we_want_to_set.tools_http_status_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_http_status_tab_action" + )) + ui_we_want_to_set.tools_http_status_action.triggered.connect(lambda: ui_we_want_to_set.tab_widget.addTab( + HttpStatusGUI(main_window=ui_we_want_to_set), language_wrapper.language_word_dict.get( + "extend_tools_menu_http_status_tab_label" + ) + )) + ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_http_status_action) + + # Text Diff + ui_we_want_to_set.tools_diff_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_diff_tab_action" + )) + ui_we_want_to_set.tools_diff_action.triggered.connect(lambda: ui_we_want_to_set.tab_widget.addTab( + DiffGUI(ui_we_want_to_set), language_wrapper.language_word_dict.get( + "extend_tools_menu_diff_tab_label" + ) + )) + ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_diff_action) + + # JSON Format + ui_we_want_to_set.tools_json_format_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_json_format_tab_action" + )) + ui_we_want_to_set.tools_json_format_action.triggered.connect(lambda: ui_we_want_to_set.tab_widget.addTab( + JsonFormatGUI(ui_we_want_to_set), language_wrapper.language_word_dict.get( + "extend_tools_menu_json_format_tab_label" + ) + )) + ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_json_format_action) + + # Response Inspector + ui_we_want_to_set.tools_response_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_response_tab_action" + )) + ui_we_want_to_set.tools_response_action.triggered.connect(lambda: ui_we_want_to_set.tab_widget.addTab( + ResponseInspectorGUI(ui_we_want_to_set), language_wrapper.language_word_dict.get( + "extend_tools_menu_response_tab_label" + ) + )) + ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_response_action) + def extend_dock_menu(ui_we_want_to_set: PyBreezeMainWindow): # Sub menu @@ -140,6 +261,107 @@ def extend_dock_menu(ui_we_want_to_set: PyBreezeMainWindow): lambda: add_dock(ui_we_want_to_set, "DiagramEditor")) ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_diagram_editor_dock_action) + # cURL Import Dock + ui_we_want_to_set.tools_curl_import_dock_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_curl_import_dock_action")) + ui_we_want_to_set.tools_curl_import_dock_action.triggered.connect( + lambda: add_dock(ui_we_want_to_set, "CurlImport")) + ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_curl_import_dock_action) + + # JWT Decoder Dock + ui_we_want_to_set.tools_jwt_decoder_dock_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_jwt_decoder_dock_action")) + ui_we_want_to_set.tools_jwt_decoder_dock_action.triggered.connect( + lambda: add_dock(ui_we_want_to_set, "JwtDecoder")) + ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_jwt_decoder_dock_action) + + # Timestamp Converter Dock + ui_we_want_to_set.tools_timestamp_dock_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_timestamp_dock_action")) + ui_we_want_to_set.tools_timestamp_dock_action.triggered.connect( + lambda: add_dock(ui_we_want_to_set, "Timestamp")) + ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_timestamp_dock_action) + + # Hash Generator Dock + ui_we_want_to_set.tools_hash_dock_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_hash_dock_action")) + ui_we_want_to_set.tools_hash_dock_action.triggered.connect( + lambda: add_dock(ui_we_want_to_set, "Hash")) + ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_hash_dock_action) + + # Query <-> JSON Dock + ui_we_want_to_set.tools_query_json_dock_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_query_json_dock_action")) + ui_we_want_to_set.tools_query_json_dock_action.triggered.connect( + lambda: add_dock(ui_we_want_to_set, "QueryJson")) + ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_query_json_dock_action) + + # Regex Tester Dock + ui_we_want_to_set.tools_regex_dock_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_regex_dock_action")) + ui_we_want_to_set.tools_regex_dock_action.triggered.connect( + lambda: add_dock(ui_we_want_to_set, "Regex")) + ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_regex_dock_action) + + # HTTP Status Reference Dock + ui_we_want_to_set.tools_http_status_dock_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_http_status_dock_action")) + ui_we_want_to_set.tools_http_status_dock_action.triggered.connect( + lambda: add_dock(ui_we_want_to_set, "HttpStatus")) + ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_http_status_dock_action) + + # Text Diff Dock + ui_we_want_to_set.tools_diff_dock_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_diff_dock_action")) + ui_we_want_to_set.tools_diff_dock_action.triggered.connect( + lambda: add_dock(ui_we_want_to_set, "Diff")) + ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_diff_dock_action) + + # JSON Format Dock + ui_we_want_to_set.tools_json_format_dock_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_json_format_dock_action")) + ui_we_want_to_set.tools_json_format_dock_action.triggered.connect( + lambda: add_dock(ui_we_want_to_set, "JsonFormat")) + ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_json_format_dock_action) + + # Response Inspector Dock + ui_we_want_to_set.tools_response_dock_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_response_dock_action")) + ui_we_want_to_set.tools_response_dock_action.triggered.connect( + lambda: add_dock(ui_we_want_to_set, "ResponseInspector")) + ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_response_dock_action) + +# widget_type -> (dock-title language key, factory taking the main window). +# A dispatch table keeps ``add_dock`` flat instead of a long if/elif chain. +_DOCK_WIDGETS: dict[str, tuple[str, Callable[[PyBreezeMainWindow], object]]] = { + "SSH": ("extend_tools_menu_ssh_client_dock_title", lambda _win: SSHMainWidget()), + "AICodeReview": ( + "extend_tools_menu_ai_code_review_dock_title", lambda _win: AICodeReviewClient()), + "CoTPromptEditor": ( + "extend_tools_menu_cot_prompt_editor_dock_title", lambda _win: CoTPromptEditor()), + "SkillPromptEditor": ( + "extend_tools_menu_skill_prompt_editor_dock_title", lambda _win: SkillPromptEditor()), + "SkillSendGUI": ( + "extend_tools_menu_skill_prompt_send_dock_title", lambda _win: SkillsSendGUI()), + "DiagramEditor": ( + "extend_tools_menu_diagram_editor_dock_title", lambda _win: DiagramEditorWidget()), + "CurlImport": ( + "extend_tools_menu_curl_import_dock_title", lambda win: CurlImportGUI(win)), + "JwtDecoder": ( + "extend_tools_menu_jwt_decoder_dock_title", lambda win: JwtDecoderGUI(main_window=win)), + "Timestamp": ("extend_tools_menu_timestamp_dock_title", lambda win: TimestampGUI(win)), + "Hash": ("extend_tools_menu_hash_dock_title", lambda win: HashGUI(win)), + "QueryJson": ("extend_tools_menu_query_json_dock_title", lambda win: QueryJsonGUI(win)), + "Regex": ("extend_tools_menu_regex_dock_title", lambda win: RegexGUI(win)), + "HttpStatus": ( + "extend_tools_menu_http_status_dock_title", lambda win: HttpStatusGUI(main_window=win)), + "Diff": ("extend_tools_menu_diff_dock_title", lambda win: DiffGUI(win)), + "JsonFormat": ("extend_tools_menu_json_format_dock_title", lambda win: JsonFormatGUI(win)), + "ResponseInspector": ( + "extend_tools_menu_response_dock_title", lambda win: ResponseInspectorGUI(win)), +} + + def add_dock(ui_we_want_to_set: PyBreezeMainWindow, widget_type: str | None = None): jeditor_logger.info("build_dock_menu.py add_dock_widget " f"ui_we_want_to_set: {ui_we_want_to_set} " @@ -149,36 +371,11 @@ def add_dock(ui_we_want_to_set: PyBreezeMainWindow, widget_type: str | None = No # Create a destroyable dock container dock_widget = DestroyDock() - if widget_type == "SSH": - dock_widget.setWindowTitle(language_wrapper.language_word_dict.get( - "extend_tools_menu_ssh_client_dock_title" - )) - dock_widget.setWidget(SSHMainWidget()) - elif widget_type == "AICodeReview": - dock_widget.setWindowTitle(language_wrapper.language_word_dict.get( - "extend_tools_menu_ai_code_review_dock_title" - )) - dock_widget.setWidget(AICodeReviewClient()) - elif widget_type == "CoTPromptEditor": - dock_widget.setWindowTitle(language_wrapper.language_word_dict.get( - "extend_tools_menu_cot_prompt_editor_dock_title" - )) - dock_widget.setWidget(CoTPromptEditor()) - elif widget_type == "SkillPromptEditor": - dock_widget.setWindowTitle(language_wrapper.language_word_dict.get( - "extend_tools_menu_skill_prompt_editor_dock_title" - )) - dock_widget.setWidget(SkillPromptEditor()) - elif widget_type == "SkillSendGUI": - dock_widget.setWindowTitle(language_wrapper.language_word_dict.get( - "extend_tools_menu_skill_prompt_send_dock_title" - )) - dock_widget.setWidget(SkillsSendGUI()) - elif widget_type == "DiagramEditor": - dock_widget.setWindowTitle(language_wrapper.language_word_dict.get( - "extend_tools_menu_diagram_editor_dock_title" - )) - dock_widget.setWidget(DiagramEditorWidget()) + entry = _DOCK_WIDGETS.get(widget_type) + if entry is not None: + title_key, widget_factory = entry + dock_widget.setWindowTitle(language_wrapper.language_word_dict.get(title_key)) + dock_widget.setWidget(widget_factory(ui_we_want_to_set)) # 如果成功建立了 widget,將其加到主視窗右側 Dock 區域 # If widget is created, add it to the right dock area of the main window diff --git a/pybreeze/pybreeze_ui/tools_gui/__init__.py b/pybreeze/pybreeze_ui/tools_gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py new file mode 100644 index 0000000..9087907 --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py @@ -0,0 +1,101 @@ +"""A tool tab that turns a pasted ``curl`` command into automation scripts. + +Automation engineers routinely copy a request as ``curl`` from browser dev tools. +This widget parses that command and generates a runnable script for a chosen +target (Python ``requests``, APITestka, LoadDensity), which can then be copied, +opened straight into a new editor tab, or saved to a ``.py`` / ``.json`` file +via the shared output actions. +""" +from __future__ import annotations + +from PySide6.QtWidgets import ( + QComboBox, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget +) +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions +from pybreeze.utils.curl_import.curl_parser import parse_curl +from pybreeze.utils.curl_import.script_templates import TEMPLATE_TARGETS, generate_template +from pybreeze.utils.exception.exceptions import CurlParseException +from pybreeze.utils.logging.logger import pybreeze_logger + +# The single target that generates JSON rather than Python +_JSON_TARGET = "apitestka_action" + + +class CurlImportGUI(QWidget): + """Paste a ``curl`` command, generate a script, and copy / open / save it.""" + + def __init__(self, main_window=None) -> None: + """ + :param main_window: the window whose ``tab_widget`` "open in editor" uses + """ + super().__init__() + # The last successfully generated code (not an error or hint message). + self._generated_code: str | None = None + word = language_wrapper.language_word_dict + + self.input_label = QLabel(word.get("curl_import_input_label")) + self.input_edit = QTextEdit() + self.input_edit.setPlaceholderText(word.get("curl_import_input_placeholder")) + self.input_edit.setAcceptRichText(False) + + # Target selector: each item stores its template key as user data. + self.target_label = QLabel(word.get("curl_import_target_label")) + self.target_select = QComboBox() + for target_key, label_key in TEMPLATE_TARGETS: + self.target_select.addItem(word.get(label_key), target_key) + self.target_select.currentIndexChanged.connect(self._on_target_changed) + + self.convert_button = QPushButton(word.get("curl_import_convert_button")) + self.convert_button.clicked.connect(self.convert) + + self.output_label = QLabel(word.get("curl_import_output_label")) + self.output_edit = QTextEdit() + self.output_edit.setReadOnly(True) + + # Shared copy / open-in-editor / save actions. The extension and basename + # follow the selected target; open/save are no-ops until a valid template. + self.actions = OutputActions( + self, self.output_edit, main_window=main_window, + basename=lambda: "action" if self.selected_target() == _JSON_TARGET else "request", + extension=lambda: "json" if self.selected_target() == _JSON_TARGET else "py", + is_valid=lambda: self._generated_code is not None) + + layout = QVBoxLayout() + for widget in ( + self.input_label, self.input_edit, + self.target_label, self.target_select, self.convert_button, + self.output_label, self.output_edit, + ): + layout.addWidget(widget) + layout.addLayout(self.actions.button_row()) + self.setLayout(layout) + + def selected_target(self) -> str: + """Return the template key of the currently selected target.""" + return self.target_select.currentData() + + def _on_target_changed(self, _index: int) -> None: + """Regenerate when the target changes, if there is already input.""" + if self.input_edit.toPlainText().strip(): + self.convert() + + 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() + if not command: + self._generated_code = None + self.output_edit.setPlainText(word.get("curl_import_empty_hint")) + return + try: + code = generate_template(self.selected_target(), parse_curl(command)) + except CurlParseException as error: + pybreeze_logger.info("curl_import_gui.py convert failed: %r", error) + self._generated_code = None + self.output_edit.setPlainText( + word.get("curl_import_error").format(error=str(error))) + return + self._generated_code = code + self.output_edit.setPlainText(code) diff --git a/pybreeze/pybreeze_ui/tools_gui/diff_gui.py b/pybreeze/pybreeze_ui/tools_gui/diff_gui.py new file mode 100644 index 0000000..1c9fd60 --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/diff_gui.py @@ -0,0 +1,76 @@ +"""A tool tab that shows a unified diff between two pieces of text.""" +from __future__ import annotations + +from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget +) +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions +from pybreeze.utils.diff_tools.text_diff import DiffSummary, diff_summary, unified_diff + + +def build_summary_line(summary: DiffSummary) -> str: + """Render a one-line summary of a diff. + + :param summary: the diff counts + :return: a short summary string + """ + word = language_wrapper.language_word_dict + if summary.is_equal: + return word.get("diff_identical") + return word.get("diff_summary").format(added=summary.added, removed=summary.removed) + + +class DiffGUI(QWidget): + """Paste two texts and see how they differ.""" + + def __init__(self, main_window=None) -> None: + """ + :param main_window: window whose ``tab_widget`` "open in editor" uses + """ + super().__init__() + word = language_wrapper.language_word_dict + + self.left_label = QLabel(word.get("diff_left_label")) + self.left_edit = QTextEdit() + self.left_edit.setAcceptRichText(False) + self.right_label = QLabel(word.get("diff_right_label")) + self.right_edit = QTextEdit() + self.right_edit.setAcceptRichText(False) + + inputs = QHBoxLayout() + left_column = QVBoxLayout() + left_column.addWidget(self.left_label) + left_column.addWidget(self.left_edit) + right_column = QVBoxLayout() + right_column.addWidget(self.right_label) + right_column.addWidget(self.right_edit) + inputs.addLayout(left_column) + inputs.addLayout(right_column) + + self.compare_button = QPushButton(word.get("diff_compare_button")) + self.compare_button.clicked.connect(self.compare) + + self.summary_label = QLabel("") + self.output_edit = QTextEdit() + self.output_edit.setReadOnly(True) + + self.actions = OutputActions( + self, self.output_edit, main_window=main_window, + basename="diff", extension="txt") + + layout = QVBoxLayout() + layout.addLayout(inputs) + layout.addWidget(self.compare_button) + layout.addWidget(self.summary_label) + layout.addWidget(self.output_edit) + layout.addLayout(self.actions.button_row()) + self.setLayout(layout) + + 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() + 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/hash_gui.py b/pybreeze/pybreeze_ui/tools_gui/hash_gui.py new file mode 100644 index 0000000..b55e9c1 --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/hash_gui.py @@ -0,0 +1,57 @@ +"""A tool tab that shows common hash digests of the entered text.""" +from __future__ import annotations + +from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions +from pybreeze.utils.hash_tools.hash_text import hash_all + + +def build_hash_text(digests: dict[str, str]) -> str: + """Render digests as one ``ALGORITHM: value`` line each. + + :param digests: ``algorithm -> hex digest`` + :return: display text, one algorithm per line + """ + return "\n".join(f"{name.upper()}: {value}" for name, value in digests.items()) + + +class HashGUI(QWidget): + """Type or paste text and read its digests under several algorithms.""" + + def __init__(self, main_window=None) -> None: + """ + :param main_window: window whose ``tab_widget`` "open in editor" uses + """ + super().__init__() + word = language_wrapper.language_word_dict + + self.input_label = QLabel(word.get("hash_input_label")) + self.input_edit = QTextEdit() + self.input_edit.setPlaceholderText(word.get("hash_input_placeholder")) + self.input_edit.setAcceptRichText(False) + + self.hash_button = QPushButton(word.get("hash_button")) + self.hash_button.clicked.connect(self.compute) + + self.output_label = QLabel(word.get("hash_output_label")) + self.output_edit = QTextEdit() + self.output_edit.setReadOnly(True) + + self.actions = OutputActions( + self, self.output_edit, main_window=main_window, + basename="hashes", extension="txt") + + layout = QVBoxLayout() + for widget in ( + self.input_label, self.input_edit, self.hash_button, + self.output_label, self.output_edit, + ): + layout.addWidget(widget) + layout.addLayout(self.actions.button_row()) + self.setLayout(layout) + + 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()))) diff --git a/pybreeze/pybreeze_ui/tools_gui/http_status_gui.py b/pybreeze/pybreeze_ui/tools_gui/http_status_gui.py new file mode 100644 index 0000000..1b1b320 --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/http_status_gui.py @@ -0,0 +1,67 @@ +"""A tool tab that searches the HTTP status code reference.""" +from __future__ import annotations + +from PySide6.QtWidgets import QLabel, QLineEdit, QTextEdit, QVBoxLayout, QWidget +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions +from pybreeze.utils.http_reference.status_codes import StatusInfo, search + + +def build_status_text(statuses: list[StatusInfo], empty_message: str) -> str: + """Render a list of statuses into a readable block. + + :param statuses: the statuses to render + :param empty_message: text shown when there are no matches + :return: display text, one status per stanza + """ + if not statuses: + return empty_message + lines: list[str] = [] + for info in statuses: + lines.append(f"{info.code} {info.phrase} [{info.category}]") + if info.description: + lines.append(f" {info.description}") + return "\n".join(lines) + + +class HttpStatusGUI(QWidget): + """Type a code or keyword and see the matching HTTP statuses live.""" + + def __init__(self, initial_search: str = "", main_window=None) -> None: + """ + :param initial_search: a code or keyword to pre-fill the search with + :param main_window: window whose ``tab_widget`` "open in editor" uses + """ + super().__init__() + word = language_wrapper.language_word_dict + + self.search_label = QLabel(word.get("http_status_search_label")) + self.search_edit = QLineEdit() + self.search_edit.setPlaceholderText(word.get("http_status_search_placeholder")) + self.search_edit.textChanged.connect(self.refresh) + + self.output_edit = QTextEdit() + self.output_edit.setReadOnly(True) + + self.actions = OutputActions( + self, self.output_edit, main_window=main_window, + basename="http_status", extension="txt") + + layout = QVBoxLayout() + layout.addWidget(self.search_label) + layout.addWidget(self.search_edit) + layout.addWidget(self.output_edit) + layout.addLayout(self.actions.button_row()) + self.setLayout(layout) + + # Setting the text triggers refresh; an empty value shows the whole table. + if initial_search: + self.search_edit.setText(initial_search) + else: + self.refresh("") + + def refresh(self, query: str) -> None: + """Update the list to the statuses matching *query*.""" + empty_message = language_wrapper.language_word_dict.get("http_status_no_match") + self.output_edit.setPlainText(build_status_text(search(query), empty_message)) diff --git a/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py b/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py new file mode 100644 index 0000000..49f2fbc --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py @@ -0,0 +1,83 @@ +"""A tool tab that pretty-prints, minifies and validates JSON.""" +from __future__ import annotations + +from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget +) +from je_editor import language_wrapper + +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 +from pybreeze.utils.logging.logger import pybreeze_logger + + +class JsonFormatGUI(QWidget): + """Paste JSON, then format it, minify it, or read its validation error.""" + + def __init__(self, main_window=None) -> None: + """ + :param main_window: window whose ``tab_widget`` "open in editor" uses + """ + super().__init__() + # The last valid result (not an error/hint), gating open/save. + self._valid_output = False + word = language_wrapper.language_word_dict + + self.input_label = QLabel(word.get("json_format_input_label")) + self.input_edit = QTextEdit() + self.input_edit.setPlaceholderText(word.get("json_format_input_placeholder")) + self.input_edit.setAcceptRichText(False) + + self.format_button = QPushButton(word.get("json_format_format_button")) + self.format_button.clicked.connect(self.format_json) + self.minify_button = QPushButton(word.get("json_format_minify_button")) + self.minify_button.clicked.connect(self.minify) + + buttons = QHBoxLayout() + buttons.addWidget(self.format_button) + buttons.addWidget(self.minify_button) + + self.output_label = QLabel(word.get("json_format_output_label")) + self.output_edit = QTextEdit() + self.output_edit.setReadOnly(True) + + self.actions = OutputActions( + self, self.output_edit, main_window=main_window, + basename="formatted", extension="json", + is_valid=lambda: self._valid_output) + + layout = QVBoxLayout() + layout.addWidget(self.input_label) + layout.addWidget(self.input_edit) + layout.addLayout(buttons) + layout.addWidget(self.output_label) + layout.addWidget(self.output_edit) + layout.addLayout(self.actions.button_row()) + self.setLayout(layout) + + 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() + if not text: + self._valid_output = False + self.output_edit.setPlainText(word.get("json_format_empty_hint")) + return + try: + result = transform(text) + 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))) + return + self._valid_output = True + self.output_edit.setPlainText(result) + + def format_json(self) -> None: + """Pretty-print the input JSON.""" + self._run(reformat_json) + + def minify(self) -> None: + """Minify the input JSON onto a single line.""" + self._run(minify_json) diff --git a/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py new file mode 100644 index 0000000..7379902 --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/jwt_decoder_gui.py @@ -0,0 +1,101 @@ +"""A tool tab that decodes a JWT's header and payload for inspection. + +The signature is never verified — this is for reading a token's claims (issuer, +subject, expiry) while building or debugging an API test, not for trusting it. +""" +from __future__ import annotations + +import json + +from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget +from je_editor import language_wrapper + +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 ( + DecodedJwt, decode_jwt, humanized_timestamp_claims +) +from pybreeze.utils.logging.logger import pybreeze_logger + + +def build_decoded_text(decoded: DecodedJwt) -> str: + """Render a decoded JWT into a readable, sectioned string. + + :param decoded: the decoded token parts + :return: display text with header, payload and readable timestamps + """ + 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), + "", + word.get("jwt_decoder_payload_label"), + json.dumps(decoded.payload, indent=4, sort_keys=True, ensure_ascii=False), + ] + readable = humanized_timestamp_claims(decoded.payload) + if readable: + sections.append("") + sections.append(word.get("jwt_decoder_times_label")) + sections.extend(f"{claim}: {value}" for claim, value in readable.items()) + return "\n".join(sections) + + +class JwtDecoderGUI(QWidget): + """Paste a JWT, decode it, and read its claims.""" + + def __init__(self, initial_token: str | None = None, main_window=None) -> None: + """ + :param initial_token: a token to pre-fill and decode on open, if given + :param main_window: window whose ``tab_widget`` "open in editor" uses + """ + super().__init__() + self._valid_output = False + word = language_wrapper.language_word_dict + + self.input_label = QLabel(word.get("jwt_decoder_input_label")) + self.input_edit = QTextEdit() + self.input_edit.setPlaceholderText(word.get("jwt_decoder_input_placeholder")) + self.input_edit.setAcceptRichText(False) + + self.decode_button = QPushButton(word.get("jwt_decoder_decode_button")) + self.decode_button.clicked.connect(self.decode) + + self.output_label = QLabel(word.get("jwt_decoder_output_label")) + self.output_edit = QTextEdit() + self.output_edit.setReadOnly(True) + + self.actions = OutputActions( + self, self.output_edit, main_window=main_window, + basename="jwt", extension="txt", is_valid=lambda: self._valid_output) + + layout = QVBoxLayout() + for widget in ( + self.input_label, self.input_edit, self.decode_button, + self.output_label, self.output_edit, + ): + layout.addWidget(widget) + layout.addLayout(self.actions.button_row()) + self.setLayout(layout) + + if initial_token: + self.input_edit.setPlainText(initial_token) + self.decode() + + 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() + if not token: + self._valid_output = False + self.output_edit.setPlainText(word.get("jwt_decoder_empty_hint")) + return + try: + decoded = decode_jwt(token) + except JwtDecodeException as error: + 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))) + return + self._valid_output = True + self.output_edit.setPlainText(build_decoded_text(decoded)) diff --git a/pybreeze/pybreeze_ui/tools_gui/output_actions.py b/pybreeze/pybreeze_ui/tools_gui/output_actions.py new file mode 100644 index 0000000..82aa7fe --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/output_actions.py @@ -0,0 +1,131 @@ +"""Reusable copy / open-in-editor / save-to-file actions for a tool's output. + +Several tools produce a block of generated text (code, JSON, a diff). This helper +gives them a consistent row of actions — copy to clipboard, open in a new editor +tab, save to a file — without each tool re-implementing the same logic. + +Attach one to a tool by creating an :class:`OutputActions` bound to the tool's +read-only output ``QTextEdit`` and adding its button row to the layout. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Callable + +from PySide6.QtWidgets import ( + QApplication, QFileDialog, QHBoxLayout, QPushButton, QTextEdit, QWidget +) +from je_editor import language_wrapper + +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]" + + +def _resolve(value) -> str: + """Return *value*, calling it first if it is a callable.""" + return value() if callable(value) else value + + +class OutputActions: + """Copy / open-in-editor / save-to-file actions bound to an output widget.""" + + def __init__( + self, parent: QWidget, output_edit: QTextEdit, *, + main_window=None, + basename="output", + extension="txt", + is_valid: Callable[[], bool] | None = None) -> None: + """ + :param parent: the tool widget the file dialog is parented to + :param output_edit: the read-only output the actions operate on + :param main_window: window whose ``tab_widget`` "open in editor" uses + :param basename: default file basename (a str, or a callable returning one) + :param extension: file extension without the dot (str or callable) + :param is_valid: optional predicate; when it returns ``False`` the + open/save actions are no-ops (used to avoid saving an error message) + """ + self._parent = parent + self._output = output_edit + self._main_window = main_window + self._basename = basename + self._extension = extension + self._is_valid = is_valid + word = language_wrapper.language_word_dict + + self.copy_button = QPushButton(word.get("output_actions_copy")) + self.copy_button.clicked.connect(self.copy) + self.open_button = QPushButton(word.get("output_actions_open_editor")) + self.open_button.clicked.connect(self.open_in_editor) + self.save_button = QPushButton(word.get("output_actions_save")) + self.save_button.clicked.connect(self.save_to_file) + + def button_row(self) -> QHBoxLayout: + """Return a horizontal layout holding the three action buttons.""" + row = QHBoxLayout() + row.addWidget(self.copy_button) + row.addWidget(self.open_button) + row.addWidget(self.save_button) + return row + + def _has_output(self) -> bool: + """Whether the output has real content the actions should act on.""" + if not self._output.toPlainText().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() + clipboard = QApplication.clipboard() + if text and clipboard is not None: + clipboard.setText(text) + + def open_in_editor(self) -> QWidget | None: + """Open the output in a new editor tab, if a window is available.""" + if not self._has_output(): + return None + tab_widget = getattr(self._main_window, "tab_widget", 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 + editor = EditorWidget(self._main_window) + editor.code_edit.setPlainText(self._output.toPlainText()) + tab_widget.addTab( + editor, language_wrapper.language_word_dict.get("output_actions_editor_tab_label")) + tab_widget.setCurrentWidget(editor) + return editor + + def suggested_filename(self) -> str: + """Return the default filename for the current basename and extension.""" + return f"{_resolve(self._basename)}.{_resolve(self._extension)}" + + def _file_filter(self, extension: str) -> str: + """Return the file-dialog filter string for an extension.""" + word = language_wrapper.language_word_dict + filters = { + "py": word.get("output_actions_filter_python"), + "json": word.get("output_actions_filter_json"), + } + return filters.get(extension, word.get("output_actions_filter_text")) + + def save_to_file(self) -> str | None: + """Save the output to a user-chosen file; return the path or ``None``.""" + if not self._has_output(): + return None + extension = _resolve(self._extension) + path, _selected = QFileDialog.getSaveFileName( + self._parent, + language_wrapper.language_word_dict.get("output_actions_save_dialog_title"), + self.suggested_filename(), + self._file_filter(extension)) + if not path: + return None + try: + Path(path).write_text(self._output.toPlainText(), encoding="utf-8") + except OSError as error: + pybreeze_logger.error("output_actions.py save failed: %r", error) + return None + return path diff --git a/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py b/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py new file mode 100644 index 0000000..c8b60cd --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/query_json_gui.py @@ -0,0 +1,84 @@ +"""A tool tab that converts between URL query strings and JSON.""" +from __future__ import annotations + +from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget +) +from je_editor import language_wrapper + +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 +from pybreeze.utils.query_tools.query_convert import json_to_query, query_to_json + + +class QueryJsonGUI(QWidget): + """Convert a query string to JSON and back, in either direction.""" + + def __init__(self, main_window=None) -> None: + """ + :param main_window: window whose ``tab_widget`` "open in editor" uses + """ + super().__init__() + self._valid_output = False + word = language_wrapper.language_word_dict + + self.input_label = QLabel(word.get("query_json_input_label")) + self.input_edit = QTextEdit() + self.input_edit.setPlaceholderText(word.get("query_json_input_placeholder")) + self.input_edit.setAcceptRichText(False) + + self.to_json_button = QPushButton(word.get("query_json_to_json_button")) + self.to_json_button.clicked.connect(self.convert_to_json) + self.to_query_button = QPushButton(word.get("query_json_to_query_button")) + self.to_query_button.clicked.connect(self.convert_to_query) + + buttons = QHBoxLayout() + buttons.addWidget(self.to_json_button) + buttons.addWidget(self.to_query_button) + + self.output_label = QLabel(word.get("query_json_output_label")) + self.output_edit = QTextEdit() + self.output_edit.setReadOnly(True) + + self.actions = OutputActions( + self, self.output_edit, main_window=main_window, + basename="query", extension="txt", is_valid=lambda: self._valid_output) + + layout = QVBoxLayout() + layout.addWidget(self.input_label) + layout.addWidget(self.input_edit) + layout.addLayout(buttons) + layout.addWidget(self.output_label) + layout.addWidget(self.output_edit) + layout.addLayout(self.actions.button_row()) + self.setLayout(layout) + + def convert_to_json(self) -> None: + """Convert the input query string to JSON.""" + text = self.input_edit.toPlainText().strip() + if not text: + self._valid_output = False + self.output_edit.setPlainText( + language_wrapper.language_word_dict.get("query_json_empty_hint")) + return + self._valid_output = True + self.output_edit.setPlainText(query_to_json(text)) + + 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() + if not text: + self._valid_output = False + self.output_edit.setPlainText(word.get("query_json_empty_hint")) + return + try: + result = json_to_query(text) + 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))) + 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 new file mode 100644 index 0000000..e0975c0 --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/regex_gui.py @@ -0,0 +1,106 @@ +"""A tool tab that tests a regular expression against sample text.""" +from __future__ import annotations + +from PySide6.QtWidgets import ( + QCheckBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QTextEdit, QVBoxLayout, QWidget +) +from je_editor import language_wrapper + +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 +) + + +def build_matches_text(matches: list[MatchResult], no_match_message: str) -> str: + """Render matches into a readable, numbered report. + + :param matches: the matches to render + :param no_match_message: text shown when there are no matches + :return: display text + """ + if not matches: + return no_match_message + word = language_wrapper.language_word_dict + lines = [word.get("regex_match_count").format(count=len(matches)), ""] + 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): + lines.append(f" group {group_index}: {value!r}") + for name, value in match.named_groups.items(): + lines.append(f" {name}: {value!r}") + return "\n".join(lines) + + +class RegexGUI(QWidget): + """Enter a pattern and sample text, then see every match and its groups.""" + + def __init__(self, main_window=None) -> None: + """ + :param main_window: window whose ``tab_widget`` "open in editor" uses + """ + super().__init__() + self._valid_output = False + word = language_wrapper.language_word_dict + + self.pattern_label = QLabel(word.get("regex_pattern_label")) + self.pattern_edit = QLineEdit() + self.pattern_edit.setPlaceholderText(word.get("regex_pattern_placeholder")) + + self.flag_checkboxes: dict[str, QCheckBox] = {} + flags_row = QHBoxLayout() + for flag_name in available_flags(): + checkbox = QCheckBox(flag_name) + self.flag_checkboxes[flag_name] = checkbox + flags_row.addWidget(checkbox) + flags_row.addStretch() + + self.text_label = QLabel(word.get("regex_text_label")) + self.text_edit = QTextEdit() + self.text_edit.setPlaceholderText(word.get("regex_text_placeholder")) + self.text_edit.setAcceptRichText(False) + + self.test_button = QPushButton(word.get("regex_test_button")) + self.test_button.clicked.connect(self.test) + + self.output_label = QLabel(word.get("regex_output_label")) + self.output_edit = QTextEdit() + self.output_edit.setReadOnly(True) + + self.actions = OutputActions( + self, self.output_edit, main_window=main_window, + basename="matches", extension="txt", is_valid=lambda: self._valid_output) + + layout = QVBoxLayout() + layout.addWidget(self.pattern_label) + layout.addWidget(self.pattern_edit) + layout.addLayout(flags_row) + layout.addWidget(self.text_label) + layout.addWidget(self.text_edit) + layout.addWidget(self.test_button) + layout.addWidget(self.output_label) + layout.addWidget(self.output_edit) + layout.addLayout(self.actions.button_row()) + self.setLayout(layout) + + def selected_flags(self) -> list[str]: + """Return the flag names whose checkbox is ticked.""" + 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))) + return + self._valid_output = True + self.output_edit.setPlainText( + build_matches_text(matches, word.get("regex_no_match"))) diff --git a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py new file mode 100644 index 0000000..0ac03a4 --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py @@ -0,0 +1,167 @@ +"""A tool tab that inspects a pasted HTTP response. + +Detects the status code, headers, a JSON body, and any JWTs in the text, tying +together the HTTP status, JSON format and JWT decoder tools. +""" +from __future__ import annotations + +import json + +from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget +) +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI +from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI +from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions +from pybreeze.utils.jwt_tools.jwt_decoder import humanized_timestamp_claims +from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.utils.response_inspector.response_analyzer import ( + ResponseAnalysis, analyze_response +) + + +def _status_section(analysis: ResponseAnalysis) -> list[str]: + """Build the status lines of the report, or an empty list.""" + if analysis.status is None: + return [] + word = language_wrapper.language_word_dict + status = analysis.status + lines = [word.get("response_status_label"), f"{status.code} {status.phrase} [{status.category}]"] + if status.description: + lines.append(f" {status.description}") + lines.append("") + return lines + + +def _jwt_section(analysis: ResponseAnalysis) -> list[str]: + """Build the decoded-JWT lines of the report, or an empty list.""" + if not analysis.jwt_findings: + return [] + 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)) + for claim, value in humanized_timestamp_claims(finding.decoded.payload).items(): + lines.append(f" {claim}: {value}") + lines.append("") + return lines + + +def build_report_text(analysis: ResponseAnalysis) -> str: + """Render a response analysis into a readable, sectioned report. + + :param analysis: the analysed response + :return: display text + """ + word = language_wrapper.language_word_dict + lines: list[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()) + lines.append("") + lines.extend(_jwt_section(analysis)) + lines.append(word.get("response_body_label")) + lines.append(analysis.pretty_body if analysis.is_json_body else analysis.body) + return "\n".join(lines) + + +class ResponseInspectorGUI(QWidget): + """Paste a raw HTTP response and read a decoded summary of it.""" + + def __init__(self, main_window=None) -> None: + """ + :param main_window: the window whose ``tab_widget`` new tool tabs open in; + when ``None``, the "open in tool" actions are disabled + """ + super().__init__() + self._main_window = main_window + self._analysis: ResponseAnalysis | None = None + word = language_wrapper.language_word_dict + + self.input_label = QLabel(word.get("response_input_label")) + self.input_edit = QTextEdit() + self.input_edit.setPlaceholderText(word.get("response_input_placeholder")) + self.input_edit.setAcceptRichText(False) + + self.analyze_button = QPushButton(word.get("response_analyze_button")) + self.analyze_button.clicked.connect(self.analyze) + + self.output_label = QLabel(word.get("response_output_label")) + self.output_edit = QTextEdit() + self.output_edit.setReadOnly(True) + + # Cross-tool actions: open a finding in its dedicated tool, pre-filled. + self.open_jwt_button = QPushButton(word.get("response_open_jwt_button")) + self.open_jwt_button.clicked.connect(self.open_jwt_in_decoder) + self.open_jwt_button.setEnabled(False) + self.open_status_button = QPushButton(word.get("response_open_status_button")) + self.open_status_button.clicked.connect(self.open_status_in_reference) + self.open_status_button.setEnabled(False) + + # Shared copy / open-in-editor / save actions, valid once analysed. + self.actions = OutputActions( + self, self.output_edit, main_window=main_window, + basename="response", extension="txt", + is_valid=lambda: self._analysis is not None) + + cross_tool = QHBoxLayout() + cross_tool.addWidget(self.open_status_button) + cross_tool.addWidget(self.open_jwt_button) + + layout = QVBoxLayout() + layout.addWidget(self.input_label) + layout.addWidget(self.input_edit) + layout.addWidget(self.analyze_button) + layout.addWidget(self.output_label) + layout.addWidget(self.output_edit) + layout.addLayout(cross_tool) + layout.addLayout(self.actions.button_row()) + self.setLayout(layout) + + 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() + if not text: + self._analysis = None + self.open_jwt_button.setEnabled(False) + self.open_status_button.setEnabled(False) + self.output_edit.setPlainText(word.get("response_empty_hint")) + return + self._analysis = analyze_response(text) + self.output_edit.setPlainText(build_report_text(self._analysis)) + self.open_jwt_button.setEnabled(bool(self._analysis.jwt_findings)) + self.open_status_button.setEnabled(self._analysis.status is not None) + + def _open_tool_tab(self, widget: QWidget, tab_label_key: str) -> QWidget | None: + """Add *widget* as a new tab in the main window, if there is one.""" + tab_widget = getattr(self._main_window, "tab_widget", None) + if tab_widget is None: + pybreeze_logger.info("response_inspector_gui.py no tab_widget to open tool in") + return None + tab_widget.addTab(widget, language_wrapper.language_word_dict.get(tab_label_key)) + tab_widget.setCurrentWidget(widget) + return widget + + def open_jwt_in_decoder(self) -> QWidget | None: + """Open the first found JWT in the JWT decoder tool, pre-filled.""" + if self._analysis is None or not self._analysis.jwt_findings: + return None + token = self._analysis.jwt_findings[0].token + return self._open_tool_tab( + JwtDecoderGUI(initial_token=token, main_window=self._main_window), + "extend_tools_menu_jwt_decoder_tab_label") + + def open_status_in_reference(self) -> QWidget | None: + """Open the detected status code in the HTTP status reference, pre-filled.""" + if self._analysis is None or self._analysis.status is None: + return None + return self._open_tool_tab( + HttpStatusGUI( + initial_search=str(self._analysis.status.code), + main_window=self._main_window), + "extend_tools_menu_http_status_tab_label") diff --git a/pybreeze/pybreeze_ui/tools_gui/timestamp_gui.py b/pybreeze/pybreeze_ui/tools_gui/timestamp_gui.py new file mode 100644 index 0000000..e2f3008 --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/timestamp_gui.py @@ -0,0 +1,84 @@ +"""A tool tab that converts between epoch values and ISO-8601 date-times.""" +from __future__ import annotations + +from PySide6.QtWidgets import ( + QLabel, QLineEdit, QPushButton, QTextEdit, QVBoxLayout, QWidget +) +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions +from pybreeze.utils.exception.exceptions import TimestampParseException +from pybreeze.utils.logging.logger import pybreeze_logger +from pybreeze.utils.timestamp_tools.timestamp_converter import ( + TimestampResult, convert_timestamp +) + + +def build_result_text(result: TimestampResult) -> str: + """Render a conversion result into a readable block. + + :param result: the converted instant + :return: display text listing every representation + """ + word = language_wrapper.language_word_dict + return "\n".join([ + f"{word.get('timestamp_epoch_seconds_label')}: {result.epoch_seconds}", + f"{word.get('timestamp_epoch_millis_label')}: {result.epoch_millis}", + f"{word.get('timestamp_iso_label')}: {result.iso_utc}", + ]) + + +class TimestampGUI(QWidget): + """Enter an epoch number or ISO date-time and see all representations.""" + + def __init__(self, main_window=None) -> None: + """ + :param main_window: window whose ``tab_widget`` "open in editor" uses + """ + super().__init__() + self._valid_output = False + word = language_wrapper.language_word_dict + + self.input_label = QLabel(word.get("timestamp_input_label")) + self.input_edit = QLineEdit() + self.input_edit.setPlaceholderText(word.get("timestamp_input_placeholder")) + self.input_edit.returnPressed.connect(self.convert) + + self.convert_button = QPushButton(word.get("timestamp_convert_button")) + self.convert_button.clicked.connect(self.convert) + + self.output_label = QLabel(word.get("timestamp_output_label")) + self.output_edit = QTextEdit() + self.output_edit.setReadOnly(True) + + self.actions = OutputActions( + self, self.output_edit, main_window=main_window, + basename="timestamp", extension="txt", is_valid=lambda: self._valid_output) + + layout = QVBoxLayout() + for widget in ( + self.input_label, self.input_edit, self.convert_button, + self.output_label, self.output_edit, + ): + layout.addWidget(widget) + layout.addLayout(self.actions.button_row()) + self.setLayout(layout) + + def convert(self) -> None: + """Convert the input and show every representation of the instant.""" + word = language_wrapper.language_word_dict + text = self.input_edit.text().strip() + if not text: + self._valid_output = False + self.output_edit.setPlainText(word.get("timestamp_empty_hint")) + return + try: + result = convert_timestamp(text) + except TimestampParseException as error: + 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))) + return + self._valid_output = True + self.output_edit.setPlainText(build_result_text(result)) diff --git a/pybreeze/utils/curl_import/__init__.py b/pybreeze/utils/curl_import/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py new file mode 100644 index 0000000..f593034 --- /dev/null +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -0,0 +1,273 @@ +"""Parse a ``curl`` command line into a structured request. + +Copying a request as ``curl`` from browser dev tools is the fastest way to +capture an API call. This module turns that command string into a +:class:`CurlRequest`, so the rest of PyBreeze can format it, generate request +code, or drive an automation module without re-typing headers by hand. + +The parser is pure logic (no Qt, no network) and never executes the command. +""" +from __future__ import annotations + +import re +import shlex +from dataclasses import dataclass, field +from urllib.parse import quote + +from pybreeze.utils.exception.exception_tags import ( + empty_curl_command_error, + malformed_curl_command_error, + not_a_curl_command_error, +) +from pybreeze.utils.exception.exceptions import CurlParseException +from pybreeze.utils.logging.logger import pybreeze_logger + +# HTTP method used when none is given and no body is present +_DEFAULT_METHOD = "GET" +# HTTP method implied when a body is present but no method is given +_METHOD_WITH_BODY = "POST" +# Header name that carries a request body's media type +_CONTENT_TYPE_HEADER = "content-type" +# Matches a backslash or caret line continuation before a newline +_LINE_CONTINUATION_RE = re.compile(r"[\\^]\r?\n") + + +@dataclass +class CurlRequest: + """A structured HTTP request extracted from a ``curl`` command. + + :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 data_parts: raw body fragments in the order they appeared + :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 data_file_refs: filenames whose content forms the body (``-d @file``) + """ + + method: str = _DEFAULT_METHOD + url: str = "" + headers: dict[str, str] = field(default_factory=dict) + params: dict[str, str] = field(default_factory=dict) + data_parts: list[str] = field(default_factory=list) + username: str | None = None + password: str | None = None + send_data_as_params: bool = False + form_fields: list[str] = field(default_factory=list) + data_file_refs: list[str] = field(default_factory=list) + + @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) + + @property + def body(self) -> str: + """Return the body fragments joined the way ``curl`` sends them.""" + return "&".join(self.data_parts) + + def header_value(self, name: str) -> str | None: + """Return a header's value by case-insensitive *name*, or ``None``.""" + lowered = name.lower() + for key, value in self.headers.items(): + if key.lower() == lowered: + return value + return None + + +# Flags that take a value and how each value is applied to the request. +# Keeping this as data (not a branch per flag) keeps parse_curl's complexity low. +_VALUE_FLAGS: dict[str, str] = { + "-X": "method", "--request": "method", + "-H": "header", "--header": "header", + # ``-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-raw": "data", "--data-urlencode": "data_urlencode", + "--json": "json_flag", + "-F": "form", "--form": "form", "--form-string": "form", + "-u": "user", "--user": "user", + "-b": "cookie", "--cookie": "cookie", + "-A": "user_agent", "--user-agent": "user_agent", + "-e": "referer", "--referer": "referer", + "--url": "url", +} + +# Value-less flags that still change behaviour. +_GET_FLAGS = frozenset({"-G", "--get"}) + +# Value-less flags to accept and skip (they do not affect the generated request). +_VALUELESS_FLAGS = frozenset({ + "--compressed", "-L", "--location", "-k", "--insecure", "-s", "--silent", + "-v", "--verbose", "-i", "--include", "-I", "--head", "-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", + "--http0.9", "--http1.0", "--http1.1", "--http2", "--http2-prior-knowledge", + "--http3", "-0", "--tlsv1", "--tlsv1.0", "--tlsv1.1", "--tlsv1.2", "--tlsv1.3", + "--raw", "--path-as-is", "-a", "--append", "--anyauth", "--basic", "--digest", + "--ntlm", "--negotiate", "-B", "--use-ascii", "--no-alpn", "--no-npn", + "--tcp-nodelay", "--tr-encoding", "-4", "-6", "-Z", "--parallel", +}) + +# Flags that take a value we do not use; the value is consumed so it cannot be +# mistaken for the URL (this is what makes ``--max-time 30 https://x`` parse right). +_IGNORED_VALUE_FLAGS = frozenset({ + "-o", "--output", "--max-time", "-m", "--connect-timeout", "--retry", + "--retry-delay", "--retry-max-time", "-w", "--write-out", "-x", "--proxy", + "--proxy-user", "-U", "--cacert", "--capath", "-E", "--cert", "--key", + "--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", +}) + + +def _normalise_command(command: str) -> str: + """Strip continuations so a multi-line pasted command tokenises as one.""" + return _LINE_CONTINUATION_RE.sub(" ", command.strip()) + + +def _tokenize(command: str) -> list[str]: + """Split *command* into shell tokens, raising on unbalanced quotes.""" + try: + return shlex.split(command, posix=True) + except ValueError as error: + pybreeze_logger.error(malformed_curl_command_error) + raise CurlParseException(malformed_curl_command_error) from error + + +def _apply_header(request: CurlRequest, raw_header: str) -> None: + """Record a ``Name: Value`` header, ignoring malformed fragments.""" + name, separator, value = raw_header.partition(":") + if separator: + request.headers[name.strip()] = value.strip() + + +def _urlencode_data_part(value: str) -> str: + """URL-encode a ``--data-urlencode`` fragment the way curl does. + + ``name=content`` keeps ``name`` literal and encodes ``content``; ``=content`` + or a bare ``content`` encodes the whole thing. ``@file`` / ``name@file`` forms + read a file, which we cannot do here, so they are left literal. + + :param value: the raw ``--data-urlencode`` fragment + :return: the fragment with its content URL-encoded + """ + name, separator, content = value.partition("=") + if separator: + return f"{name}={quote(content, safe='')}" if name else quote(content, safe="") + if "@" in value: + return value # a file form (@file / name@file) — cannot read it here + return quote(value, safe="") + + +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() + 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": + if value.startswith("@"): + request.data_file_refs.append(value[1:]) + else: + request.data_parts.append(value) + elif kind == "json_flag": + # curl --json is shorthand for --data + JSON Content-Type and Accept. + request.data_parts.append(value) + request.headers.setdefault("Content-Type", "application/json") + request.headers.setdefault("Accept", "application/json") + elif kind == "form": + request.form_fields.append(value) + elif kind == "cookie": + request.headers.setdefault("Cookie", value) + elif kind == "user_agent": + request.headers.setdefault("User-Agent", value) + elif kind == "referer": + request.headers.setdefault("Referer", value) + elif kind == "url": + request.url = value + elif kind == "user": + request.username, _sep, request.password = value.partition(":") + + +def _consume_tokens(tokens: list[str], request: CurlRequest) -> None: + """Walk *tokens*, filling *request*; positional tokens become the URL. + + Value-taking flags consume their following token, so a flag's value (like the + ``30`` in ``--max-time 30``) is never mistaken for the URL. + """ + index = 0 + while index < len(tokens): + token = tokens[index] + kind = _VALUE_FLAGS.get(token) + if kind is not None: + index += 1 + if index < len(tokens): + _apply_value_flag(request, kind, tokens[index]) + elif token in _IGNORED_VALUE_FLAGS: + index += 1 # consume and discard the value + elif token in _GET_FLAGS: + request.send_data_as_params = True + elif token in _VALUELESS_FLAGS: + pass # a known valueless flag: nothing to do + elif not token.startswith("-") and not request.url: + request.url = token + # An unknown flag (starts with '-') is treated as valueless and skipped. + index += 1 + + +def _finalise_method(request: CurlRequest) -> None: + """Infer the method and move the body to the query when ``-G`` was given.""" + 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)) + request.data_parts = [] + + +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`. + + :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) + """ + normalised = _normalise_command(command) + if not normalised: + pybreeze_logger.error(empty_curl_command_error) + raise CurlParseException(empty_curl_command_error) + + tokens = _tokenize(normalised) + if not tokens or tokens[0] != "curl": + pybreeze_logger.error(not_a_curl_command_error) + raise CurlParseException(not_a_curl_command_error) + + request = CurlRequest() + _consume_tokens(tokens[1:], request) + _finalise_method(request) + return request diff --git a/pybreeze/utils/curl_import/request_body.py b/pybreeze/utils/curl_import/request_body.py new file mode 100644 index 0000000..7a65320 --- /dev/null +++ b/pybreeze/utils/curl_import/request_body.py @@ -0,0 +1,67 @@ +"""Decide how a parsed request's body should be sent. + +Both the ``requests`` code generator and the automation-module templates need the +same decision — send the body as a JSON object or as a raw string — so it lives +here once instead of being duplicated in each generator. +""" +from __future__ import annotations + +import json + +from pybreeze.utils.curl_import.curl_parser import CurlRequest + +# 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" + +# How a body should be sent: ("json", parsed_object) or ("data", raw_string) +BodyKind = tuple[str, object] + + +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. + + :param request: the parsed curl request + :return: ``("json", obj)``, ``("data", raw)``, or ``None`` when there is no body + """ + if not request.body: + return 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): + return "data", request.body + return "data", request.body + + +# Multipart form split into plain fields and file uploads (field -> filename) +FormParts = tuple[dict[str, str], dict[str, str]] + + +def form_parts(request: CurlRequest) -> FormParts: + """Split ``-F`` form fields into plain data fields and file uploads. + + A field ``name=value`` is a plain data field; ``name=@path`` (curl's file + syntax) is a file upload whose filename is ``path`` (any ``;type=`` suffix is + dropped). + + :param request: the parsed curl request + :return: ``(data_fields, file_fields)`` where ``file_fields`` maps a field name + to its filename + """ + data_fields: dict[str, str] = {} + file_fields: dict[str, 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] + else: + 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 new file mode 100644 index 0000000..707256e --- /dev/null +++ b/pybreeze/utils/curl_import/request_codegen.py @@ -0,0 +1,144 @@ +"""Generate Python ``requests`` code from a parsed :class:`CurlRequest`. + +The output is a small, ready-to-run script that mirrors the captured request, +so an automation engineer can paste a browser ``curl`` command and get working +Python instead of hand-translating headers, bodies and multipart forms. +""" +from __future__ import annotations + +import json + +from pybreeze.utils.curl_import.curl_parser import CurlRequest +from pybreeze.utils.curl_import.request_body import body_kind, form_parts + +# Keyword argument passing the request body to ``requests.request``. +_DATA_KWARG = "data=data" + + +def _format_dict(name: str, mapping: dict[str, str]) -> str | None: + """Render ``name = { ... }`` with one entry per line, or ``None`` if empty.""" + if not mapping: + return None + lines = [f"{name} = {{"] + lines.extend(f" {json.dumps(key)}: {json.dumps(value)}," for key, value in mapping.items()) + lines.append("}") + 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' {json.dumps(field)}: open({json.dumps(filename)}, "rb"),' + for field, filename in file_fields.items() + ) + lines.append("}") + return "\n".join(lines) + + +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()``. + + :param request: the parsed curl request (must have ``data_file_refs``) + :return: a Python expression, e.g. ``open("body.json", encoding="utf-8").read()`` + """ + pieces: list[str] = [] + if request.data_parts: + pieces.append(json.dumps(request.body)) + pieces.extend( + f'open({json.dumps(name)}, encoding="utf-8").read()' + for name in request.data_file_refs + ) + return ' + "&" + '.join(pieces) + + +def payload_python_parts(request: CurlRequest) -> tuple[list[str], list[str]]: + """Return the assignment lines and call kwargs for the request payload. + + Multipart form fields (``-F``) take precedence, then a file-based body + (``-d @file``), then a plain body; the result covers ``data`` / ``files`` for + forms or ``json`` / ``data`` for a body. + + :param request: the parsed curl request + :return: ``(assignment_lines, keyword_arguments)`` + """ + if request.form_fields: + 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 + + if request.data_file_refs: + return [f"data = {data_from_file_expr(request)}"], [_DATA_KWARG] + + kind = body_kind(request) + 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] + + +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: + arguments.append("headers=headers") + if request.params: + arguments.append("params=params") + arguments.extend(payload_kwargs) + if request.username is not None: + arguments.append("auth=auth") + return arguments + + +def request_statements(request: CurlRequest) -> list[str]: + """Return the ``requests`` statements from ``url = ...`` to ``response = ...``. + + Shared by the plain script and the pytest generators; each element may itself + be a multi-line block (a dict literal). + + :param request: the parsed curl request + :return: the statement blocks in order + """ + payload_sections, payload_kwargs = payload_python_parts(request) + statements: list[str] = [f"url = {json.dumps(request.url)}"] + + headers_block = _format_dict("headers", request.headers) + if headers_block is not None: + statements.append(headers_block) + params_block = _format_dict("params", request.params) + if params_block is not None: + statements.append(params_block) + statements.extend(payload_sections) + if request.username is not None: + statements.append( + f"auth = ({json.dumps(request.username)}, {json.dumps(request.password or '')})") + + arguments = ", ".join(_call_keyword_arguments(request, payload_kwargs)) + statements.append( + f'response = requests.request({json.dumps(request.method)}, {arguments})') + return statements + + +def to_requests_code(request: CurlRequest) -> str: + """Generate a runnable ``requests`` script for *request*. + + :param request: the parsed curl request + :return: Python source using the ``requests`` library + """ + lines = ["import requests", ""] + lines.extend(request_statements(request)) + lines.append("print(response.status_code)") + lines.append("print(response.text)") + return "\n".join(lines) + "\n" diff --git a/pybreeze/utils/curl_import/script_templates.py b/pybreeze/utils/curl_import/script_templates.py new file mode 100644 index 0000000..94de4ad --- /dev/null +++ b/pybreeze/utils/curl_import/script_templates.py @@ -0,0 +1,216 @@ +"""Generate automation-module script templates from a parsed curl request. + +Beyond plain ``requests`` code, an automation engineer usually wants the request +expressed in the framework they actually run. This module turns a +:class:`CurlRequest` into: + +- an APITestka Python snippet (``test_api_method_requests(...)``), and +- an APITestka JSON *action* (the ``[["AT_test_api_method", {...}]]`` shape that + ``execute_files`` runs). + +Everything here is pure text generation; nothing is executed or sent. +""" +from __future__ import annotations + +import json +import re +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_codegen import ( + data_from_file_expr, request_statements, to_requests_code +) + +# APITestka action command that performs an HTTP request +_APITESTKA_ACTION = "AT_test_api_method" +# Default status asserted in a generated pytest test +_DEFAULT_EXPECTED_STATUS = 200 +# Indentation for statements inside a generated test function +_TEST_INDENT = " " + + +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) + + +def _apitestka_payload_lines(request: CurlRequest) -> list[str]: + """Return the inline ``data=`` / ``files=`` / ``json=`` kwargs for the payload.""" + if request.form_fields: + data_fields, file_fields = form_parts(request) + lines = [] + 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}}},") + return lines + 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 [] + + +def to_apitestka_python(request: CurlRequest) -> str: + """Generate an APITestka Python snippet for *request*. + + :param request: the parsed curl request + :return: Python source calling ``test_api_method_requests`` + """ + lines = [ + "from je_api_testka import test_api_method_requests", + "", + "response = test_api_method_requests(", + f" {_inline_json(request.method)},", + f" test_url={_inline_json(request.url)},", + ] + if request.headers: + lines.append(f" headers={_inline_json(request.headers)},") + if request.params: + lines.append(f" params={_inline_json(request.params)},") + lines.extend(_apitestka_payload_lines(request)) + if request.username is not None: + lines.append( + f" auth=({_inline_json(request.username)}, " + f"{_inline_json(request.password or '')}),") + lines.append(")") + lines.append("") + lines.append("print(response)") + return "\n".join(lines) + "\n" + + +def to_loaddensity_python(request: CurlRequest) -> str: + """Generate a LoadDensity (Locust) load-test snippet for *request*. + + The basic Locust task issues the request by URL; headers and bodies are noted + but not sent, because the shared task template drives requests by URL only. + + :param request: the parsed curl request + :return: Python source calling ``start_test`` + """ + method_key = request.method.lower() + task = f"{{{_inline_json(method_key)}: {{\"request_url\": {_inline_json(request.url)}}}}}" + lines = [ + "from je_load_density import start_test", + "", + "# Load-test the endpoint captured from the curl command.", + "# This basic task issues the request by URL; add headers/body in a custom", + "# Locust task if the endpoint needs them.", + "start_test(", + ' {"user": "fast_http_user"},', + " user_count=50,", + " spawn_rate=10,", + " test_time=60,", + f" tasks={task},", + ")", + ] + return "\n".join(lines) + "\n" + + +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 request.params: + params["params"] = request.params + _apply_action_payload(request, params) + if request.username is not None: + params["auth"] = [request.username, request.password or ""] + return params + + +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. + """ + if request.form_fields: + data_fields, _file_fields = form_parts(request) + if data_fields: + params["data"] = data_fields + return + kind = body_kind(request) + if kind is not None: + params[kind[0]] = kind[1] + + +def to_apitestka_action_json(request: CurlRequest) -> str: + """Generate an APITestka JSON action list for *request*. + + The result is a JSON document ``execute_files`` can run directly. + + :param request: the parsed curl request + :return: a formatted JSON action list + """ + action = [[_APITESTKA_ACTION, _apitestka_action_params(request)]] + return json.dumps(action, indent=4, ensure_ascii=False) + "\n" + + +def test_function_name(request: CurlRequest) -> str: + """Derive a pytest function name from the request's method and URL path. + + :param request: the parsed curl request + :return: a valid ``test_...`` identifier, e.g. ``test_get_v1_items`` + """ + parsed = urlparse(request.url) + 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("_") + return name or "test_request" + + +def _indent(block: str) -> str: + """Indent every non-empty line of *block* for a function body.""" + return "\n".join( + f"{_TEST_INDENT}{line}" if line else line for line in block.split("\n")) + + +def to_pytest_test(request: CurlRequest) -> str: + """Generate a runnable pytest test that sends *request* and asserts the status. + + :param request: the parsed curl request + :return: Python source defining a ``test_...`` function + """ + lines = ["import requests", "", "", f"def {test_function_name(request)}():"] + lines.extend(_indent(statement) for statement in request_statements(request)) + lines.append(f"{_TEST_INDENT}assert response.status_code == {_DEFAULT_EXPECTED_STATUS}") + lines.append(f"{_TEST_INDENT}# Add assertions on response.json() / response.text as needed") + return "\n".join(lines) + "\n" + + +# Target key -> (i18n label key, generator). The first entry is the default. +# Only HTTP-oriented modules are offered: a curl command is an HTTP request, which +# maps to APITestka and LoadDensity but not to browser (WebRunner) or desktop-GUI +# (AutoControl) automation. +TEMPLATE_TARGETS: list[tuple[str, str]] = [ + ("requests", "curl_import_target_requests"), + ("pytest", "curl_import_target_pytest"), + ("apitestka_python", "curl_import_target_apitestka_python"), + ("apitestka_action", "curl_import_target_apitestka_action"), + ("loaddensity_python", "curl_import_target_loaddensity_python"), +] + +_GENERATORS = { + "requests": to_requests_code, + "pytest": to_pytest_test, + "apitestka_python": to_apitestka_python, + "apitestka_action": to_apitestka_action_json, + "loaddensity_python": to_loaddensity_python, +} + + +def generate_template(target: str, request: CurlRequest) -> str: + """Generate the template for *target*, falling back to ``requests`` code. + + :param target: a target key from :data:`TEMPLATE_TARGETS` + :param request: the parsed curl request + :return: the generated template text + """ + generator = _GENERATORS.get(target, to_requests_code) + return generator(request) diff --git a/pybreeze/utils/diff_tools/__init__.py b/pybreeze/utils/diff_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/utils/diff_tools/text_diff.py b/pybreeze/utils/diff_tools/text_diff.py new file mode 100644 index 0000000..dfac84f --- /dev/null +++ b/pybreeze/utils/diff_tools/text_diff.py @@ -0,0 +1,76 @@ +"""Produce a line-by-line diff between two pieces of text. + +Comparing an expected response with an actual one is a daily automation task. +This wraps the standard library's ``difflib`` so the IDE can show a unified diff +and a one-line summary of how many lines were added or removed. +""" +from __future__ import annotations + +import difflib +from dataclasses import dataclass + +# Labels shown in the unified diff header for the two inputs +_LEFT_LABEL = "expected" +_RIGHT_LABEL = "actual" +# Lines of unchanged context kept around each change in the unified diff +_CONTEXT_LINES = 3 + + +@dataclass(frozen=True) +class DiffSummary: + """Counts describing how two texts differ. + + :param added: lines present in the right text but not the left + :param removed: lines present in the left text but not the right + :param is_equal: whether the two texts are identical + """ + + added: int + removed: int + 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 unified_diff( + left: str, right: str, + left_label: str = _LEFT_LABEL, right_label: str = _RIGHT_LABEL) -> str: + """Return a unified diff between *left* and *right*. + + :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 unified diff text (empty when the inputs are identical) + """ + diff_lines = difflib.unified_diff( + _split_lines(left), + _split_lines(right), + fromfile=left_label, + tofile=right_label, + lineterm="", + n=_CONTEXT_LINES, + ) + return "\n".join(diff_lines) + + +def diff_summary(left: str, right: str) -> DiffSummary: + """Summarise how *left* and *right* differ, line by line. + + :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) + added = 0 + removed = 0 + for line in difflib.ndiff(left_lines, right_lines): + if line.startswith("+ "): + added += 1 + elif line.startswith("- "): + removed += 1 + return DiffSummary(added=added, removed=removed, is_equal=left == right) diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index a9bd778..ede3f44 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -36,3 +36,26 @@ # 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" +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" + +# JWT decode +empty_jwt_error: str = "no token provided" +malformed_jwt_error: str = "a JWT must have three dot-separated parts" +jwt_segment_decode_error: str = "can't decode a JWT segment: invalid base64url or JSON" + +# Timestamp conversion +empty_timestamp_error: str = "no value provided" +unrecognized_timestamp_error: str = "not a recognized epoch number or ISO-8601 date-time" + +# Query <-> JSON conversion +invalid_json_object_error: str = "the input must be a JSON object of key/value pairs" +invalid_json_for_query_error: str = "can't parse the input as JSON" + +# Regex testing +empty_regex_pattern_error: str = "no pattern provided" +invalid_regex_pattern_error: str = "invalid regular expression: {detail}" diff --git a/pybreeze/utils/exception/exceptions.py b/pybreeze/utils/exception/exceptions.py index 72b88a1..1d0788e 100644 --- a/pybreeze/utils/exception/exceptions.py +++ b/pybreeze/utils/exception/exceptions.py @@ -50,3 +50,33 @@ class XMLException(ITEException): class XMLTypeException(XMLException): pass + + +# cURL import + +class CurlParseException(ITEException): + pass + + +# JWT decode + +class JwtDecodeException(ITEException): + pass + + +# Timestamp conversion + +class TimestampParseException(ITEException): + pass + + +# Query <-> JSON conversion + +class QueryConvertException(ITEException): + pass + + +# Regex testing + +class RegexTesterException(ITEException): + pass diff --git a/pybreeze/utils/hash_tools/__init__.py b/pybreeze/utils/hash_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/utils/hash_tools/hash_text.py b/pybreeze/utils/hash_tools/hash_text.py new file mode 100644 index 0000000..f7872b7 --- /dev/null +++ b/pybreeze/utils/hash_tools/hash_text.py @@ -0,0 +1,49 @@ +"""Compute common hash digests of a piece of text. + +Handy for building or checking automation fixtures: content checksums, cache +keys, ETag comparisons, and the like. This is a general-purpose digest tool, not +a password or signature facility. + +MD5 and SHA-1 are offered for interoperability with existing systems only. Every +digest is built through ``hashlib.new(name, ..., usedforsecurity=False)`` so the +weak algorithms are never used for a security decision here. +""" +from __future__ import annotations + +import hashlib + +# Text encoding used before hashing +_ENCODING = "utf-8" + +# Supported algorithm names, strongest first (drives any UI listing). +_ALGORITHMS: tuple[str, ...] = ("sha256", "sha512", "sha1", "md5") + + +def available_algorithms() -> list[str]: + """Return the supported algorithm names, strongest first.""" + return list(_ALGORITHMS) + + +def hash_text(text: str, algorithm: str) -> str: + """Return the hex digest of *text* under *algorithm*. + + :param text: the text to hash (encoded as UTF-8) + :param algorithm: one of :func:`available_algorithms` + :return: the lower-case hex digest + :raises ValueError: when *algorithm* is not supported + """ + if algorithm not in _ALGORITHMS: + raise ValueError(f"unsupported hash algorithm: {algorithm}") + # md5/sha1 are non-security interop digests only; usedforsecurity=False. + digest = hashlib.new( # nosemgrep + algorithm, text.encode(_ENCODING), usedforsecurity=False) + return digest.hexdigest() + + +def hash_all(text: str) -> dict[str, str]: + """Return the hex digest of *text* under every supported algorithm. + + :param text: the text to hash + :return: ``algorithm -> hex digest`` for each supported algorithm + """ + return {name: hash_text(text, name) for name in _ALGORITHMS} diff --git a/pybreeze/utils/http_reference/__init__.py b/pybreeze/utils/http_reference/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/utils/http_reference/status_codes.py b/pybreeze/utils/http_reference/status_codes.py new file mode 100644 index 0000000..a07cd30 --- /dev/null +++ b/pybreeze/utils/http_reference/status_codes.py @@ -0,0 +1,91 @@ +"""A searchable reference of HTTP status codes. + +Debugging an API test constantly raises "what does 409 mean again?". This module +answers that from the standard library's ``http.HTTPStatus`` table, so the list +stays correct and maintained without a hand-written mapping. +""" +from __future__ import annotations + +from dataclasses import dataclass +from http import HTTPStatus + +# The class of a status is its first digit (1xx..5xx). +_CLASS_DIVISOR = 100 + + +@dataclass(frozen=True) +class StatusInfo: + """One HTTP status code. + + :param code: the numeric status code (e.g. ``404``) + :param phrase: the reason phrase (e.g. ``Not Found``) + :param description: a short description of the status + :param category: the status class label (e.g. ``Client Error``) + """ + + code: int + phrase: str + description: str + category: str + + +_CATEGORY_BY_CLASS: dict[int, str] = { + 1: "Informational", + 2: "Success", + 3: "Redirection", + 4: "Client Error", + 5: "Server Error", +} + + +def _category_for(code: int) -> str: + """Return the human label for a status code's class.""" + return _CATEGORY_BY_CLASS.get(code // _CLASS_DIVISOR, "Unknown") + + +def _to_info(status: HTTPStatus) -> StatusInfo: + """Convert an ``HTTPStatus`` member into a :class:`StatusInfo`.""" + return StatusInfo( + code=int(status), + phrase=status.phrase, + description=status.description, + category=_category_for(int(status)), + ) + + +def all_statuses() -> list[StatusInfo]: + """Return every known status, ordered by code.""" + return [_to_info(status) for status in sorted(HTTPStatus, key=int)] + + +def lookup(code: int) -> StatusInfo | None: + """Return the status for an exact *code*, or ``None`` if unknown. + + :param code: the numeric status code + :return: the matching status, or ``None`` + """ + try: + return _to_info(HTTPStatus(code)) + except ValueError: + return None + + +def search(query: str) -> list[StatusInfo]: + """Search statuses by code prefix or phrase/description substring. + + An empty query returns every status. Otherwise a status matches when its code + starts with the query digits, or the query text appears in its phrase or + description (case-insensitive). + + :param query: the search text (digits or words) + :return: the matching statuses, ordered by code + """ + stripped = query.strip().lower() + if not stripped: + return all_statuses() + matches: list[StatusInfo] = [] + for info in all_statuses(): + haystack = f"{info.phrase} {info.description}".lower() + if str(info.code).startswith(stripped) or stripped in haystack: + matches.append(info) + return matches diff --git a/pybreeze/utils/json_format/json_process.py b/pybreeze/utils/json_format/json_process.py index 39fc416..bcc040c 100644 --- a/pybreeze/utils/json_format/json_process.py +++ b/pybreeze/utils/json_format/json_process.py @@ -30,3 +30,21 @@ def reformat_json(json_string: str, **kwargs) -> str: return _process_json(json_string, **kwargs) except ITEJsonException as err: raise ITEJsonException(cant_reformat_json_error) from err + + +# Compact separators for minified JSON: no spaces after ',' or ':'. +_MINIFY_SEPARATORS = (",", ":") + + +def minify_json(json_string: str) -> str: + """Return *json_string* re-serialised with no insignificant whitespace. + + :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 + """ + try: + return dumps(loads(json_string), separators=_MINIFY_SEPARATORS) + except json.JSONDecodeError as error: + pybreeze_logger.error(wrong_json_data_error) + raise ITEJsonException(wrong_json_data_error) from error diff --git a/pybreeze/utils/jwt_tools/__init__.py b/pybreeze/utils/jwt_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/utils/jwt_tools/jwt_decoder.py b/pybreeze/utils/jwt_tools/jwt_decoder.py new file mode 100644 index 0000000..a106d63 --- /dev/null +++ b/pybreeze/utils/jwt_tools/jwt_decoder.py @@ -0,0 +1,116 @@ +"""Decode a JSON Web Token's header and payload for inspection. + +API automation frequently carries bearer tokens; being able to see what a JWT +actually claims (issuer, subject, expiry) without pasting it into an external +website is a small but real convenience. + +This decoder is purely for inspection: it base64url-decodes the header and +payload segments and does **not** verify the signature. It never trusts the +token for any security decision. +""" +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass +from datetime import datetime, timezone + +from pybreeze.utils.exception.exception_tags import ( + empty_jwt_error, + jwt_segment_decode_error, + malformed_jwt_error, +) +from pybreeze.utils.exception.exceptions import JwtDecodeException +from pybreeze.utils.logging.logger import pybreeze_logger + +# A JWT is three base64url segments joined by dots +_JWT_SEGMENT_COUNT = 3 +# Standard claim names that hold Unix timestamps, shown as readable dates +_TIMESTAMP_CLAIMS = ("exp", "iat", "nbf", "auth_time") + + +@dataclass +class DecodedJwt: + """The inspectable parts of a JWT. + + :param header: decoded JOSE header (algorithm, type, ...) + :param payload: decoded claims set + :param signature: the raw (still-encoded) signature segment + """ + + header: dict + payload: dict + signature: str + + +def _decode_segment(segment: str) -> dict: + """Base64url-decode one JWT segment into a JSON object. + + :param segment: a single base64url-encoded segment + :return: the decoded JSON object + :raises JwtDecodeException: when the segment is not valid base64url/JSON + or does not decode to a JSON object + """ + # base64url omits padding; restore it so the stdlib decoder accepts the input. + padding = "=" * (-len(segment) % 4) + 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: + pybreeze_logger.error(jwt_segment_decode_error) + raise JwtDecodeException(jwt_segment_decode_error) from error + if not isinstance(decoded, dict): + raise JwtDecodeException(jwt_segment_decode_error) + return decoded + + +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`` + :return: the decoded parts + :raises JwtDecodeException: when the token is empty or not three segments, + or a segment cannot be decoded + """ + stripped = token.strip() + if not stripped: + pybreeze_logger.error(empty_jwt_error) + raise JwtDecodeException(empty_jwt_error) + + segments = stripped.split(".") + if len(segments) != _JWT_SEGMENT_COUNT: + 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]) + + +def format_timestamp_claim(value: object) -> str | None: + """Render a Unix-timestamp claim as an ISO UTC string, or ``None``. + + :param value: a claim value that may be a Unix timestamp + :return: the formatted UTC time, or ``None`` when *value* is not a timestamp + """ + 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): + return None + + +def humanized_timestamp_claims(payload: dict) -> dict[str, str]: + """Return readable UTC strings for the standard timestamp claims present. + + :param payload: the decoded claim set + :return: ``claim_name -> ISO UTC time`` for each recognised timestamp claim + """ + readable: dict[str, str] = {} + for claim in _TIMESTAMP_CLAIMS: + formatted = format_timestamp_claim(payload.get(claim)) + if formatted is not None: + readable[claim] = formatted + return readable diff --git a/pybreeze/utils/query_tools/__init__.py b/pybreeze/utils/query_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/utils/query_tools/query_convert.py b/pybreeze/utils/query_tools/query_convert.py new file mode 100644 index 0000000..0631396 --- /dev/null +++ b/pybreeze/utils/query_tools/query_convert.py @@ -0,0 +1,89 @@ +"""Convert between URL query strings and JSON objects. + +Form bodies and query strings (``a=1&b=2``) and JSON bodies are the two shapes an +API request most often takes. This module converts either way, URL-decoding and +-encoding as needed, so an automation engineer can reshape a captured request +without hand-editing. + +Repeated keys round-trip through JSON as arrays: ``a=1&a=2`` becomes +``{"a": ["1", "2"]}`` and back. +""" +from __future__ import annotations + +import json +from urllib.parse import parse_qsl, urlencode + +from pybreeze.utils.exception.exception_tags import ( + invalid_json_for_query_error, + invalid_json_object_error, +) +from pybreeze.utils.exception.exceptions import QueryConvertException +from pybreeze.utils.logging.logger import pybreeze_logger + + +def query_to_dict(query: str) -> dict[str, str | list[str]]: + """Parse a URL query string into a dict, URL-decoding values. + + A key that appears more than once maps to a list of its values, preserving + order; a key that appears once maps to its single value. + + :param query: a query string such as ``a=1&b=2`` (a leading ``?`` is ignored) + :return: the parsed mapping + """ + stripped = query.strip().lstrip("?") + pairs = parse_qsl(stripped, keep_blank_values=True) + result: dict[str, str | list[str]] = {} + for key, value in pairs: + if key in result: + existing = result[key] + if isinstance(existing, list): + existing.append(value) + else: + result[key] = [existing, value] + else: + result[key] = value + return result + + +def query_to_json(query: str) -> str: + """Convert a URL query string into pretty-printed JSON. + + :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) + + +def _coerce_scalar(value: object) -> str: + """Render a scalar JSON value as the string a query string would carry.""" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def json_to_query(json_text: str) -> str: + """Convert a JSON object into a URL query string, URL-encoding values. + + List values expand to a repeated key (``{"a": [1, 2]}`` -> ``a=1&a=2``). + + :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 + """ + try: + parsed = json.loads(json_text) + # json.JSONDecodeError derives from ValueError. + except ValueError as error: + pybreeze_logger.error(invalid_json_for_query_error) + raise QueryConvertException(invalid_json_for_query_error) from error + if not isinstance(parsed, dict): + pybreeze_logger.error(invalid_json_object_error) + raise QueryConvertException(invalid_json_object_error) + + 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) + else: + pairs.append((key, _coerce_scalar(value))) + return urlencode(pairs) diff --git a/pybreeze/utils/regex_tools/__init__.py b/pybreeze/utils/regex_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/utils/regex_tools/regex_tester.py b/pybreeze/utils/regex_tools/regex_tester.py new file mode 100644 index 0000000..3720c5d --- /dev/null +++ b/pybreeze/utils/regex_tools/regex_tester.py @@ -0,0 +1,113 @@ +"""Test a regular expression against sample text and report the matches. + +Automation work leans on regexes constantly — extracting values from responses, +building element locators, parsing log output. Being able to try a pattern +against real sample text inside the IDE shortens that loop. + +This module is pure logic: it compiles the pattern and reports matches; it never +touches the UI or the network. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from pybreeze.utils.exception.exception_tags import ( + empty_regex_pattern_error, + invalid_regex_pattern_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. +_MAX_MATCHES = 1000 + +# Human-facing flag names mapped to their ``re`` values. +_FLAG_NAMES: dict[str, int] = { + "IGNORECASE": re.IGNORECASE, + "MULTILINE": re.MULTILINE, + "DOTALL": re.DOTALL, + "VERBOSE": re.VERBOSE, +} + + +@dataclass +class MatchResult: + """One regex match and its captured groups. + + :param matched_text: the full matched substring + :param start: match start offset in the text + :param end: match end offset in the text + :param groups: numbered capture groups (``None`` for groups that did not match) + :param named_groups: named capture groups by name + """ + + matched_text: str + start: int + end: int + groups: list[str | None] = field(default_factory=list) + named_groups: dict[str, str | None] = field(default_factory=dict) + + +def available_flags() -> list[str]: + """Return the supported flag names.""" + return list(_FLAG_NAMES) + + +def build_flags(flag_names: list[str] | set[str]) -> int: + """Combine flag names into a single ``re`` flags integer. + + :param flag_names: names from :func:`available_flags` (unknown names ignored) + :return: the combined flags value + """ + combined = 0 + for name in flag_names: + combined |= _FLAG_NAMES.get(name, 0) + return combined + + +def compile_pattern(pattern: str, flag_names: list[str] | set[str] | None = None) -> re.Pattern: + """Compile *pattern*, raising a friendly error on failure. + + :param pattern: the regular expression + :param flag_names: optional flag names to apply + :return: the compiled pattern + :raises RegexTesterException: when the pattern is empty or invalid + """ + if pattern == "": + pybreeze_logger.error(empty_regex_pattern_error) + raise RegexTesterException(empty_regex_pattern_error) + try: + return re.compile(pattern, build_flags(flag_names or [])) + except re.error as error: + message = invalid_regex_pattern_error.format(detail=str(error)) + pybreeze_logger.error(message) + raise RegexTesterException(message) from error + + +def find_matches( + pattern: str, text: str, + flag_names: list[str] | set[str] | None = None) -> list[MatchResult]: + """Find every match of *pattern* in *text*. + + :param pattern: the regular expression + :param text: the sample text to search + :param flag_names: optional flag names to apply + :return: the matches, up to an internal cap + :raises RegexTesterException: when the pattern is empty or invalid + """ + compiled = compile_pattern(pattern, flag_names) + results: list[MatchResult] = [] + for match in compiled.finditer(text): + results.append( + MatchResult( + matched_text=match.group(0), + start=match.start(), + end=match.end(), + groups=list(match.groups()), + named_groups=dict(match.groupdict()), + ) + ) + if len(results) >= _MAX_MATCHES: + break + return results diff --git a/pybreeze/utils/response_inspector/__init__.py b/pybreeze/utils/response_inspector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/utils/response_inspector/response_analyzer.py b/pybreeze/utils/response_inspector/response_analyzer.py new file mode 100644 index 0000000..791940e --- /dev/null +++ b/pybreeze/utils/response_inspector/response_analyzer.py @@ -0,0 +1,135 @@ +"""Analyse a pasted HTTP response, pulling together the other tools. + +Paste a raw response (status line + headers + body, or just a JSON body) and this +module works out what is in it: the status code (looked up in the HTTP reference), +the headers, a pretty-printed JSON body when the body is JSON, and any JWTs found +in the text (decoded via the JWT tool). + +It reuses the existing utilities rather than re-implementing them, so the response +inspector stays consistent with the standalone tools. +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field + +from pybreeze.utils.http_reference.status_codes import StatusInfo, lookup +from pybreeze.utils.jwt_tools.jwt_decoder import DecodedJwt, decode_jwt +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") +# Matches a header line, e.g. "Content-Type: application/json" +_HEADER_RE = re.compile(r"^([A-Za-z0-9!#$%&'*+.^_`|~-]+):[ \t]?(.*)$") +# Matches a JWT-looking token (three base64url segments; the signature may be empty) +_JWT_RE = re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*") + + +@dataclass +class JwtFinding: + """A JWT discovered in the response text and its decoded form. + + :param token: the raw token as it appeared + :param decoded: the decoded header/payload/signature + """ + + token: str + decoded: DecodedJwt + + +@dataclass +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 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 + :param jwt_findings: JWTs found anywhere in the text, decoded + """ + + status: StatusInfo | None = None + headers: dict[str, str] = field(default_factory=dict) + body: str = "" + pretty_body: str | None = None + is_json_body: bool = False + jwt_findings: list[JwtFinding] = field(default_factory=list) + + +def _normalise(text: str) -> str: + """Normalise line endings to ``\\n``.""" + 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 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]): + status_code = int(_STATUS_LINE_RE.match(lines[0]).group(1)) + index = 1 + + headers: dict[str, str] = {} + while index < len(lines): + line = lines[index] + if line.strip() == "": + index += 1 + break + match = _HEADER_RE.match(line) + if match is None: + break + headers[match.group(1)] = match.group(2).strip() + index += 1 + + body = "\n".join(lines[index:]).strip() + return status_code, headers, body + + +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 None + return json.dumps(parsed, indent=4, ensure_ascii=False) + + +def _find_jwts(text: str) -> list[JwtFinding]: + """Find and decode every JWT-looking token in *text* (deduplicated).""" + findings: list[JwtFinding] = [] + seen: set[str] = set() + for token in _JWT_RE.findall(text): + if token in seen: + continue + seen.add(token) + try: + findings.append(JwtFinding(token=token, decoded=decode_jwt(token))) + except JwtDecodeException: + # A token that looks like a JWT but does not decode is simply skipped. + continue + return findings + + +def analyze_response(text: str) -> ResponseAnalysis: + """Inspect a pasted HTTP response and report what it contains. + + :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) + pretty_body = _pretty_json(body) + return ResponseAnalysis( + status=lookup(status_code) if status_code is not None else None, + headers=headers, + body=body, + pretty_body=pretty_body, + is_json_body=pretty_body is not None, + jwt_findings=_find_jwts(text), + ) diff --git a/pybreeze/utils/timestamp_tools/__init__.py b/pybreeze/utils/timestamp_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/utils/timestamp_tools/timestamp_converter.py b/pybreeze/utils/timestamp_tools/timestamp_converter.py new file mode 100644 index 0000000..3b8dd5b --- /dev/null +++ b/pybreeze/utils/timestamp_tools/timestamp_converter.py @@ -0,0 +1,104 @@ +"""Convert between Unix epoch values and ISO-8601 date-times. + +Requests, tokens and log lines constantly mix epoch seconds, epoch milliseconds +and ISO date-times. This module accepts any of those forms and reports all of +them, so an automation engineer never has to reach for an external converter. + +All output times are in UTC; the module never depends on the local time zone, +which keeps the conversions deterministic and testable. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone + +from pybreeze.utils.exception.exception_tags import ( + empty_timestamp_error, + unrecognized_timestamp_error, +) +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. +_MILLISECONDS_THRESHOLD = 10 ** 11 +# Milliseconds per second +_MS_PER_SECOND = 1000 + + +@dataclass(frozen=True) +class TimestampResult: + """Every representation of one instant. + + :param epoch_seconds: whole Unix seconds + :param epoch_millis: whole Unix milliseconds + :param iso_utc: ISO-8601 string in UTC + """ + + epoch_seconds: int + epoch_millis: int + iso_utc: str + + +def detect_epoch_unit(value: float) -> str: + """Return ``"ms"`` or ``"s"`` 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 "ms" if abs(value) >= _MILLISECONDS_THRESHOLD else "s" + + +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: + pybreeze_logger.error(unrecognized_timestamp_error) + raise TimestampParseException(unrecognized_timestamp_error) from error + + +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 + try: + parsed = datetime.fromisoformat(normalised) + except ValueError 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: + """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) + + +def convert_timestamp(text: str) -> TimestampResult: + """Convert an epoch value or ISO date-time into every representation. + + :param text: an epoch number (seconds or milliseconds) or an ISO-8601 string + :return: the same instant as epoch seconds, epoch milliseconds and ISO UTC + :raises TimestampParseException: when *text* is empty or unrecognised + """ + moment = _parse(text) + epoch_seconds = int(moment.timestamp()) + return TimestampResult( + epoch_seconds=epoch_seconds, + epoch_millis=epoch_seconds * _MS_PER_SECOND, + iso_utc=moment.isoformat(), + ) diff --git a/test/test_utils/test_curl_import.py b/test/test_utils/test_curl_import.py new file mode 100644 index 0000000..a943742 --- /dev/null +++ b/test/test_utils/test_curl_import.py @@ -0,0 +1,365 @@ +"""Tests for the cURL command parser and requests-code generator.""" +from __future__ import annotations + +import json + +import pytest + +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 + + +class TestParseCurlBasics: + def test_simple_get(self): + request = parse_curl("curl https://example.com/api") + assert request.method == "GET" + assert request.url == "https://example.com/api" + + def test_explicit_method(self): + request = parse_curl("curl -X DELETE https://example.com/x") + assert request.method == "DELETE" + + def test_method_is_uppercased(self): + request = parse_curl("curl -X post https://example.com/x") + assert request.method == "POST" + + def test_long_request_flag(self): + request = parse_curl("curl --request PUT https://example.com/x") + assert request.method == "PUT" + + def test_url_flag(self): + request = parse_curl("curl --url https://example.com/x") + assert request.url == "https://example.com/x" + + def test_data_implies_post(self): + request = parse_curl("curl https://example.com/x -d 'a=1'") + assert request.method == "POST" + assert request.body == "a=1" + + +class TestParseCurlHeaders: + def test_single_header(self): + request = parse_curl("curl -H 'Accept: application/json' https://x") + assert request.headers["Accept"] == "application/json" + + def test_multiple_headers(self): + request = parse_curl( + "curl -H 'Accept: application/json' -H 'X-Token: abc' https://x") + assert request.headers == {"Accept": "application/json", "X-Token": "abc"} + + def test_header_value_with_colon(self): + request = parse_curl("curl -H 'Referer: https://a.com/b' https://x") + assert request.headers["Referer"] == "https://a.com/b" + + def test_malformed_header_ignored(self): + request = parse_curl("curl -H 'no-colon-here' https://x") + assert request.headers == {} + + def test_header_value_lookup_is_case_insensitive(self): + request = parse_curl("curl -H 'Content-Type: application/json' https://x") + assert request.header_value("content-type") == "application/json" + + def test_user_agent_flag(self): + request = parse_curl("curl -A 'my-agent' https://x") + assert request.headers["User-Agent"] == "my-agent" + + def test_cookie_flag(self): + request = parse_curl("curl -b 'session=1' https://x") + assert request.headers["Cookie"] == "session=1" + + +class TestParseCurlBodyAndAuth: + def test_multiple_data_joined(self): + request = parse_curl("curl https://x -d 'a=1' -d 'b=2'") + assert request.body == "a=1&b=2" + + def test_data_raw(self): + request = parse_curl("curl https://x --data-raw '{\"a\": 1}'") + assert request.body == '{"a": 1}' + + def test_basic_auth(self): + request = parse_curl("curl -u user:pass https://x") + assert request.username == "user" + assert request.password == "pass" + + def test_auth_without_password(self): + request = parse_curl("curl -u user https://x") + assert request.username == "user" + assert request.password == "" + + def test_get_flag_moves_data_to_params(self): + request = parse_curl("curl -G https://x -d 'a=1' -d 'b=2'") + assert request.method == "GET" + assert request.params == {"a": "1", "b": "2"} + assert request.body == "" + + +class TestParseCurlRobustness: + def test_line_continuations(self): + command = "curl https://x \\\n -H 'Accept: application/json' \\\n -d 'a=1'" + request = parse_curl(command) + assert request.url == "https://x" + assert request.headers["Accept"] == "application/json" + + def test_caret_continuation(self): + command = "curl https://x ^\n -H 'Accept: application/json'" + request = parse_curl(command) + assert request.headers["Accept"] == "application/json" + + def test_leading_and_trailing_whitespace(self): + request = parse_curl(" curl https://x ") + assert request.url == "https://x" + + def test_empty_command_raises(self): + with pytest.raises(CurlParseException): + parse_curl(" ") + + def test_non_curl_command_raises(self): + with pytest.raises(CurlParseException): + parse_curl("wget https://x") + + def test_unbalanced_quotes_raise(self): + with pytest.raises(CurlParseException): + parse_curl("curl 'https://x") + + def test_missing_flag_value_is_ignored(self): + # A trailing flag with no value must not crash the parser. + request = parse_curl("curl https://x -H") + assert request.url == "https://x" + + +class TestParseCurlFlagArity: + def test_ignored_value_flag_does_not_leak_to_url(self): + # Regression: '--max-time 30' must not make the URL "30". + request = parse_curl("curl --max-time 30 https://api.example.com/x") + assert request.url == "https://api.example.com/x" + + def test_connect_timeout_consumed(self): + request = parse_curl("curl --connect-timeout 5 https://x") + assert request.url == "https://x" + + def test_output_flag_consumed(self): + request = parse_curl("curl -o result.json https://x") + assert request.url == "https://x" + + def test_valueless_flags_skipped(self): + request = parse_curl("curl --compressed -L -k -s https://x -H 'Accept: application/json'") + assert request.url == "https://x" + assert request.headers["Accept"] == "application/json" + + def test_remote_name_is_valueless(self): + # -O (remote name) takes no value; the URL is the next token. + request = parse_curl("curl -O https://x/file.zip") + assert request.url == "https://x/file.zip" + + def test_unknown_flag_is_treated_valueless(self): + request = parse_curl("curl --totally-unknown-flag https://x") + assert request.url == "https://x" + + def test_multiple_ignored_flags(self): + request = parse_curl( + "curl -s --compressed --max-time 30 -o out -H 'X: 1' https://x -d 'a=1'") + assert request.url == "https://x" + assert request.method == "POST" + assert request.headers == {"X": "1"} + assert request.body == "a=1" + + +class TestParseCurlJsonFlag: + def test_json_flag_does_not_leak_to_url(self): + # Regression: '--json {...}' must not become the URL. + request = parse_curl("curl --json '{\"a\": 1}' https://api.example.com/x") + assert request.url == "https://api.example.com/x" + + def test_json_flag_sets_body(self): + request = parse_curl("curl --json '{\"a\": 1}' https://x") + assert request.body == '{"a": 1}' + + def test_json_flag_sets_content_type_and_accept(self): + request = parse_curl("curl --json '{\"a\": 1}' https://x") + assert request.header_value("content-type") == "application/json" + assert request.header_value("accept") == "application/json" + + def test_json_flag_implies_post(self): + request = parse_curl("curl --json '{}' https://x") + assert request.method == "POST" + + def test_explicit_headers_win_over_json_defaults(self): + # setdefault: an explicit Content-Type is not overwritten by --json. + request = parse_curl("curl -H 'Content-Type: text/plain' --json '{}' https://x") + assert request.header_value("content-type") == "text/plain" + + +class TestParseCurlDataFile: + def test_data_at_file_becomes_file_ref(self): + request = parse_curl("curl -d @body.json https://x") + assert request.data_file_refs == ["body.json"] + assert request.body == "" + + def test_data_file_does_not_leak_to_url(self): + request = parse_curl("curl -d @body.json https://x") + assert request.url == "https://x" + + def test_data_file_implies_post(self): + request = parse_curl("curl -d @body.json https://x") + assert request.method == "POST" + + def test_data_raw_at_is_literal_not_file(self): + # --data-raw never reads a file, even with a leading '@'. + request = parse_curl("curl --data-raw '@literal' https://x") + assert request.data_file_refs == [] + assert request.body == "@literal" + + def test_data_binary_file(self): + request = parse_curl("curl --data-binary @payload.bin https://x") + assert request.data_file_refs == ["payload.bin"] + + def test_inline_data_still_works(self): + request = parse_curl("curl -d 'a=1' https://x") + assert request.data_file_refs == [] + assert request.body == "a=1" + + +class TestParseCurlDataUrlencode: + def test_encodes_value_part(self): + request = parse_curl("curl --data-urlencode 'q=hello world' https://x") + assert request.body == "q=hello%20world" + + def test_keeps_name_literal(self): + request = parse_curl("curl --data-urlencode 'name=a b' https://x") + assert request.body == "name=a%20b" + + def test_encodes_slash_in_content(self): + request = parse_curl("curl --data-urlencode 'q=a/b' https://x") + assert request.body == "q=a%2Fb" + + def test_leading_equals_encodes_all(self): + request = parse_curl("curl --data-urlencode '=a b' https://x") + assert request.body == "a%20b" + + def test_no_equals_encodes_whole(self): + request = parse_curl("curl --data-urlencode 'hello world' https://x") + assert request.body == "hello%20world" + + def test_at_in_content_is_encoded(self): + request = parse_curl("curl --data-urlencode 'q=a@b.com' https://x") + assert request.body == "q=a%40b.com" + + def test_file_form_left_literal(self): + # 'name@file' (no '=') is a file form curl reads; we cannot, so leave it. + request = parse_curl("curl --data-urlencode 'field@data.txt' https://x") + assert request.body == "field@data.txt" + + +class TestParseCurlForm: + def test_form_field_recorded(self): + request = parse_curl("curl -F 'name=widget' https://x") + assert request.form_fields == ["name=widget"] + + def test_form_implies_post(self): + request = parse_curl("curl -F 'name=widget' https://x") + assert request.method == "POST" + + def test_form_does_not_leak_to_url(self): + # Regression: '-F photo=@a.jpg' must not become the URL. + request = parse_curl("curl -F 'photo=@a.jpg' https://up.example.com") + assert request.url == "https://up.example.com" + + def test_multiple_form_fields(self): + request = parse_curl("curl -F 'a=1' -F 'b=@f.txt' https://x") + assert request.form_fields == ["a=1", "b=@f.txt"] + + def test_form_string_flag(self): + request = parse_curl("curl --form-string 'a=1' https://x") + 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")) + assert "import requests" in code + assert 'url = "https://example.com/api"' in code + assert 'requests.request("GET"' in code + + def test_headers_rendered(self): + code = to_requests_code(parse_curl("curl -H 'Accept: application/json' https://x")) + assert "headers = {" in code + assert "headers=headers" in code + + def test_json_body_uses_json_kwarg(self): + command = "curl -H 'Content-Type: application/json' -d '{\"a\": 1}' https://x" + code = to_requests_code(parse_curl(command)) + assert "json=json_body" in code + assert "json_body = {" in code + + def test_non_json_body_uses_data_kwarg(self): + code = to_requests_code(parse_curl("curl -d 'a=1&b=2' https://x")) + assert "data=data" in code + assert "json=" not in code + + def test_auth_rendered(self): + code = to_requests_code(parse_curl("curl -u user:pass https://x")) + assert "auth = (" in code + assert "auth=auth" in code + + def test_params_rendered(self): + code = to_requests_code(parse_curl("curl -G https://x -d 'a=1'")) + assert "params = {" in code + assert "params=params" in code + + def test_generated_code_is_valid_python(self): + command = ( + "curl -X POST https://example.com/api " + "-H 'Content-Type: application/json' " + "-H 'Authorization: Bearer tok' " + "-d '{\"name\": \"a\"}'" + ) + code = to_requests_code(parse_curl(command)) + # Must at least compile as valid Python source. + compile(code, "", "exec") + + def test_json_body_is_pretty_printed(self): + command = "curl -H 'Content-Type: application/json' -d '{\"a\":1,\"b\":2}' https://x" + code = to_requests_code(parse_curl(command)) + # Pretty-printing indents nested keys onto their own lines. + assert '"a": 1' in code + + +class TestCurlRequestDataclass: + def test_body_joins_parts(self): + request = CurlRequest(data_parts=["a=1", "b=2"]) + assert request.body == "a=1&b=2" + + def test_has_body_reflects_payload(self): + assert not CurlRequest().has_body + assert CurlRequest(data_parts=["a=1"]).has_body + assert CurlRequest(form_fields=["a=1"]).has_body + assert CurlRequest(data_file_refs=["f.json"]).has_body + + def test_header_value_missing_returns_none(self): + request = CurlRequest(headers={"Accept": "text/html"}) + assert request.header_value("content-type") is None + + def test_defaults(self): + request = CurlRequest() + assert request.method == "GET" + assert request.url == "" + assert request.headers == {} + # The JSON round-trip in codegen should never see a stale shared dict. + assert CurlRequest().headers is not request.headers diff --git a/test/test_utils/test_curl_import_gui.py b/test/test_utils/test_curl_import_gui.py new file mode 100644 index 0000000..2a4a6a7 --- /dev/null +++ b/test/test_utils/test_curl_import_gui.py @@ -0,0 +1,198 @@ +"""Tests for the cURL import tool widget.""" +from __future__ import annotations + +import os +from unittest.mock import patch + +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([]) + # Merge PyBreeze's translations into the shared dict, as the main window does + # on startup, so the widget's labels and messages resolve. + update_language_dict() + return instance + + +@pytest.fixture() +def widget(app): + from pybreeze.pybreeze_ui.tools_gui.curl_import_gui import CurlImportGUI + gui = CurlImportGUI() + yield gui + gui.close() + gui.deleteLater() + + +class TestCurlImportGUI: + def test_convert_produces_code(self, widget): + widget.input_edit.setPlainText("curl https://example.com/api") + widget.convert() + output = widget.output_edit.toPlainText() + assert "import requests" in output + assert "https://example.com/api" in output + + def test_convert_reports_parse_error(self, widget): + widget.input_edit.setPlainText("wget https://example.com") + widget.convert() + # The output should be an error message, not generated code. + assert "import requests" not in widget.output_edit.toPlainText() + + def test_empty_input_shows_hint(self, widget): + widget.input_edit.setPlainText(" ") + widget.convert() + assert widget.output_edit.toPlainText() != "" + assert "import requests" not in widget.output_edit.toPlainText() + + def test_copy_output_sets_clipboard(self, app, widget): + widget.input_edit.setPlainText("curl https://example.com/api") + widget.convert() + widget.actions.copy() + assert "import requests" in QApplication.clipboard().text() + + def test_copy_with_empty_output_is_safe(self, widget): + widget.output_edit.setPlainText("") + widget.actions.copy() # must not raise + + def test_json_body_conversion(self, widget): + widget.input_edit.setPlainText( + "curl -X POST https://x -H 'Content-Type: application/json' -d '{\"a\": 1}'") + widget.convert() + assert "json=json_body" in widget.output_edit.toPlainText() + + def _select_target(self, widget, target_key): + index = widget.target_select.findData(target_key) + widget.target_select.setCurrentIndex(index) + + def test_target_selector_has_all_targets(self, widget): + keys = [widget.target_select.itemData(i) for i in range(widget.target_select.count())] + assert keys == [ + "requests", "pytest", "apitestka_python", "apitestka_action", "loaddensity_python"] + + def test_generate_apitestka_python(self, widget): + widget.input_edit.setPlainText("curl https://example.com/api") + self._select_target(widget, "apitestka_python") + widget.convert() + assert "test_api_method_requests" in widget.output_edit.toPlainText() + + def test_generate_apitestka_action(self, widget): + widget.input_edit.setPlainText("curl https://example.com/api") + self._select_target(widget, "apitestka_action") + widget.convert() + assert "AT_test_api_method" in widget.output_edit.toPlainText() + + def test_changing_target_regenerates(self, widget): + widget.input_edit.setPlainText("curl https://example.com/api") + self._select_target(widget, "requests") + widget.convert() + assert "import requests" in widget.output_edit.toPlainText() + # Switching target auto-regenerates because there is input. + self._select_target(widget, "apitestka_action") + assert "AT_test_api_method" in widget.output_edit.toPlainText() + + +class _FakeTabWidget: + def __init__(self): + self.added = [] + self.current = None + + def addTab(self, widget, label): + self.added.append((widget, label)) + + def setCurrentWidget(self, widget): + self.current = widget + + +class _FakeMainWindow: + def __init__(self): + self.tab_widget = _FakeTabWidget() + + +class _FakeEditor: + """Stand-in for a JEditor EditorWidget with a code_edit.""" + + def __init__(self, _main_window): + self.code_edit = type("CodeEdit", (), {"setPlainText": lambda self, t: setattr(self, "text", t)})() + + +@pytest.fixture() +def widget_with_window(app): + from pybreeze.pybreeze_ui.tools_gui.curl_import_gui import CurlImportGUI + window = _FakeMainWindow() + gui = CurlImportGUI(window) + yield gui, window + gui.close() + gui.deleteLater() + + +class TestCurlImportActions: + def _select_target(self, widget, target_key): + widget.target_select.setCurrentIndex(widget.target_select.findData(target_key)) + + def test_open_in_editor_adds_tab_with_code(self, widget_with_window): + gui, window = 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 + ): + editor = gui.actions.open_in_editor() + assert len(window.tab_widget.added) == 1 + assert "import requests" in editor.code_edit.text + + def test_open_in_editor_without_code_is_noop(self, widget_with_window): + gui, window = widget_with_window + assert gui.actions.open_in_editor() is None + assert window.tab_widget.added == [] + + def test_open_after_parse_error_is_noop(self, widget_with_window): + gui, window = widget_with_window + gui.input_edit.setPlainText("wget https://x") + gui.convert() + assert gui.actions.open_in_editor() is None + assert window.tab_widget.added == [] + + def test_open_in_editor_without_window_is_safe(self, widget): + widget.input_edit.setPlainText("curl https://x") + widget.convert() + assert widget.actions.open_in_editor() is None # no main window + + def test_suggested_filename_python(self, widget): + self._select_target(widget, "requests") + assert widget.actions.suggested_filename() == "request.py" + + def test_suggested_filename_json(self, widget): + self._select_target(widget, "apitestka_action") + assert widget.actions.suggested_filename() == "action.json" + + def test_save_to_file_writes(self, widget, tmp_path): + widget.input_edit.setPlainText("curl https://x") + widget.convert() + target = tmp_path / "out.py" + with patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", + return_value=(str(target), "Python (*.py)"), + ): + result = widget.actions.save_to_file() + assert result == str(target) + assert "import requests" in target.read_text(encoding="utf-8") + + def test_save_cancelled_returns_none(self, widget, tmp_path): + widget.input_edit.setPlainText("curl https://x") + widget.convert() + with patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", + return_value=("", ""), + ): + assert widget.actions.save_to_file() is None + + def test_save_after_parse_error_is_noop(self, widget): + widget.input_edit.setPlainText("wget https://x") + widget.convert() + assert widget.actions.save_to_file() is None diff --git a/test/test_utils/test_diff_gui.py b/test/test_utils/test_diff_gui.py new file mode 100644 index 0000000..8c2b8e4 --- /dev/null +++ b/test/test_utils/test_diff_gui.py @@ -0,0 +1,64 @@ +"""Tests for the text diff tool widget.""" +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 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def widget(app): + from pybreeze.pybreeze_ui.tools_gui.diff_gui import DiffGUI + gui = DiffGUI() + yield gui + gui.close() + gui.deleteLater() + + +class TestDiffGUI: + def test_shows_diff(self, widget): + widget.left_edit.setPlainText("a\nb") + widget.right_edit.setPlainText("a\nc") + widget.compare() + output = widget.output_edit.toPlainText() + assert "-b" in output + assert "+c" in output + + def test_identical_summary(self, widget): + widget.left_edit.setPlainText("same") + widget.right_edit.setPlainText("same") + widget.compare() + 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() + # 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() + widget.actions.copy() + assert QApplication.clipboard().text() != "" + + def test_build_summary_line_identical(self, app): + from pybreeze.pybreeze_ui.tools_gui.diff_gui import build_summary_line + from pybreeze.utils.diff_tools.text_diff import DiffSummary + text = build_summary_line(DiffSummary(added=0, removed=0, is_equal=True)) + assert text != "" diff --git a/test/test_utils/test_hash_gui.py b/test/test_utils/test_hash_gui.py new file mode 100644 index 0000000..a1c0934 --- /dev/null +++ b/test/test_utils/test_hash_gui.py @@ -0,0 +1,56 @@ +"""Tests for the hash generator tool widget.""" +from __future__ import annotations + +import hashlib +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 widget(app): + from pybreeze.pybreeze_ui.tools_gui.hash_gui import HashGUI + gui = HashGUI() + yield gui + gui.close() + gui.deleteLater() + + +class TestHashGUI: + def test_compute_shows_all_digests(self, widget): + widget.input_edit.setPlainText("hello") + widget.compute() + output = widget.output_edit.toPlainText() + assert hashlib.sha256(b"hello").hexdigest() in output + assert "SHA256" in output + assert "MD5" in output + + def test_empty_input_still_hashes(self, widget): + widget.input_edit.setPlainText("") + widget.compute() + # The empty string has well-defined digests. + assert hashlib.sha256(b"").hexdigest() in widget.output_edit.toPlainText() + + def test_copy_output(self, app, widget): + widget.input_edit.setPlainText("hello") + widget.compute() + widget.actions.copy() + assert hashlib.sha256(b"hello").hexdigest() in QApplication.clipboard().text() + + def test_build_hash_text_formats_lines(self): + from pybreeze.pybreeze_ui.tools_gui.hash_gui import build_hash_text + text = build_hash_text({"sha256": "abc", "md5": "def"}) + assert "SHA256: abc" in text + assert "MD5: def" in text diff --git a/test/test_utils/test_hash_text.py b/test/test_utils/test_hash_text.py new file mode 100644 index 0000000..102a67d --- /dev/null +++ b/test/test_utils/test_hash_text.py @@ -0,0 +1,61 @@ +"""Tests for the text hash utility.""" +from __future__ import annotations + +import hashlib + +import pytest + +from pybreeze.utils.hash_tools.hash_text import available_algorithms, hash_all, hash_text + + +class TestHashText: + def test_sha256_matches_hashlib(self): + assert hash_text("hello", "sha256") == hashlib.sha256(b"hello").hexdigest() + + def test_sha512_matches_hashlib(self): + assert hash_text("hello", "sha512") == hashlib.sha512(b"hello").hexdigest() + + def test_md5_matches_hashlib(self): + expected = hashlib.md5(b"hello", usedforsecurity=False).hexdigest() + assert hash_text("hello", "md5") == expected + + def test_sha1_matches_hashlib(self): + expected = hashlib.sha1(b"hello", usedforsecurity=False).hexdigest() + assert hash_text("hello", "sha1") == expected + + def test_unicode_is_utf8_encoded(self): + assert hash_text("測試", "sha256") == hashlib.sha256("測試".encode("utf-8")).hexdigest() + + def test_empty_text(self): + assert hash_text("", "sha256") == hashlib.sha256(b"").hexdigest() + + def test_unknown_algorithm_raises(self): + with pytest.raises(ValueError): + hash_text("x", "crc32") + + def test_is_deterministic(self): + # Hashing the same input twice must yield the same digest. + first = hash_text("repeat", "sha256") + second = hash_text("repeat", "sha256") + assert first == second + + +class TestAvailableAlgorithms: + def test_lists_strong_first(self): + algorithms = available_algorithms() + assert algorithms[0] == "sha256" + assert set(algorithms) == {"sha256", "sha512", "sha1", "md5"} + + +class TestHashAll: + def test_covers_every_algorithm(self): + digests = hash_all("hello") + assert set(digests) == set(available_algorithms()) + + def test_values_match_individual(self): + digests = hash_all("hello") + assert digests["sha256"] == hash_text("hello", "sha256") + + def test_empty_text(self): + digests = hash_all("") + assert digests["md5"] == hashlib.md5(b"", usedforsecurity=False).hexdigest() diff --git a/test/test_utils/test_http_status.py b/test/test_utils/test_http_status.py new file mode 100644 index 0000000..17dcbd4 --- /dev/null +++ b/test/test_utils/test_http_status.py @@ -0,0 +1,68 @@ +"""Tests for the HTTP status code reference.""" +from __future__ import annotations + +from pybreeze.utils.http_reference.status_codes import ( + all_statuses, + lookup, + search, +) + + +class TestAllStatuses: + def test_includes_common_codes(self): + codes = {info.code for info in all_statuses()} + assert {200, 301, 404, 418, 500} <= codes + + def test_is_sorted_by_code(self): + codes = [info.code for info in all_statuses()] + assert codes == sorted(codes) + + def test_categories_assigned(self): + by_code = {info.code: info for info in all_statuses()} + assert by_code[200].category == "Success" + assert by_code[404].category == "Client Error" + assert by_code[500].category == "Server Error" + assert by_code[301].category == "Redirection" + + +class TestLookup: + def test_known_code(self): + info = lookup(404) + assert info is not None + assert info.phrase == "Not Found" + + def test_unknown_code_returns_none(self): + assert lookup(299) is None + + def test_teapot(self): + assert lookup(418).phrase == "I'm a Teapot" + + +class TestSearch: + def test_empty_returns_all(self): + assert len(search("")) == len(all_statuses()) + + def test_by_code_prefix(self): + results = search("40") + codes = {info.code for info in results} + assert 404 in codes and 400 in codes + assert all(str(info.code).startswith("40") for info in results) + + def test_by_exact_code(self): + results = search("404") + assert any(info.code == 404 for info in results) + + def test_by_phrase(self): + results = search("not found") + assert any(info.code == 404 for info in results) + + def test_case_insensitive(self): + assert any(info.code == 404 for info in search("NOT FOUND")) + + def test_no_match(self): + assert search("zzzzz nonexistent") == [] + + def test_by_description_keyword(self): + # 'Unauthorized' (401) description mentions authentication. + results = search("unauthorized") + assert any(info.code == 401 for info in results) diff --git a/test/test_utils/test_http_status_gui.py b/test/test_utils/test_http_status_gui.py new file mode 100644 index 0000000..a5e4167 --- /dev/null +++ b/test/test_utils/test_http_status_gui.py @@ -0,0 +1,72 @@ +"""Tests for the HTTP status reference tool widget.""" +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 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def widget(app): + from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI + gui = HttpStatusGUI() + yield gui + gui.close() + gui.deleteLater() + + +class TestHttpStatusGUI: + def test_shows_table_on_open(self, widget): + assert "200" in widget.output_edit.toPlainText() + assert "404" in widget.output_edit.toPlainText() + + def test_search_filters(self, widget): + widget.search_edit.setText("404") + output = widget.output_edit.toPlainText() + assert "404 Not Found" in output + assert "200 OK" not in output + + def test_keyword_search(self, widget): + widget.search_edit.setText("teapot") + assert "418" in widget.output_edit.toPlainText() + + def test_no_match_message(self, widget): + widget.search_edit.setText("zzzzz") + assert "200" not in widget.output_edit.toPlainText() + assert widget.output_edit.toPlainText() != "" + + def test_clearing_search_shows_all_again(self, widget): + widget.search_edit.setText("404") + widget.search_edit.setText("") + assert "200" in widget.output_edit.toPlainText() + + def test_build_status_text_empty(self, app): + from pybreeze.pybreeze_ui.tools_gui.http_status_gui import build_status_text + assert build_status_text([], "none") == "none" + + def test_initial_search_prefills(self, app): + from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI + gui = HttpStatusGUI(initial_search="404") + assert gui.search_edit.text() == "404" + assert "404 Not Found" in gui.output_edit.toPlainText() + assert "200 OK" not in gui.output_edit.toPlainText() + gui.close() + gui.deleteLater() + + def test_has_output_actions(self, widget): + # The reference table is always content, so save/copy operate on it. + assert widget.actions.suggested_filename() == "http_status.txt" + widget.actions.copy() + assert "200 OK" in QApplication.clipboard().text() diff --git a/test/test_utils/test_json_format_gui.py b/test/test_utils/test_json_format_gui.py new file mode 100644 index 0000000..93fc48d --- /dev/null +++ b/test/test_utils/test_json_format_gui.py @@ -0,0 +1,58 @@ +"""Tests for the JSON format/minify tool widget.""" +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 widget(app): + from pybreeze.pybreeze_ui.tools_gui.json_format_gui import JsonFormatGUI + gui = JsonFormatGUI() + yield gui + gui.close() + gui.deleteLater() + + +class TestJsonFormatGUI: + def test_format_pretty_prints(self, widget): + widget.input_edit.setPlainText('{"a":1,"b":2}') + widget.format_json() + assert "\n" in widget.output_edit.toPlainText() + assert json.loads(widget.output_edit.toPlainText()) == {"a": 1, "b": 2} + + def test_minify_compacts(self, widget): + widget.input_edit.setPlainText('{ "a" : 1 }') + widget.minify() + assert widget.output_edit.toPlainText() == '{"a":1}' + + def test_invalid_shows_error(self, widget): + widget.input_edit.setPlainText("{bad}") + widget.format_json() + assert widget.output_edit.toPlainText() != "" + assert "{bad}" not in widget.output_edit.toPlainText() + + def test_empty_shows_hint(self, widget): + widget.input_edit.setPlainText(" ") + widget.minify() + assert widget.output_edit.toPlainText() != "" + + def test_copy_output(self, app, widget): + widget.input_edit.setPlainText('{"a":1}') + widget.minify() + widget.actions.copy() + assert '{"a":1}' in QApplication.clipboard().text() diff --git a/test/test_utils/test_json_minify.py b/test/test_utils/test_json_minify.py new file mode 100644 index 0000000..a87b74f --- /dev/null +++ b/test/test_utils/test_json_minify.py @@ -0,0 +1,32 @@ +"""Tests for JSON minify (pretty-printing is covered by test_json_process).""" +from __future__ import annotations + +import json + +import pytest + +from pybreeze.utils.exception.exceptions import ITEJsonException +from pybreeze.utils.json_format.json_process import minify_json + + +class TestMinifyJson: + def test_removes_whitespace(self): + assert minify_json('{ "a" : 1 , "b" : 2 }') == '{"a":1,"b":2}' + + def test_nested(self): + assert minify_json('{"a": [1, 2, {"b": 3}]}') == '{"a":[1,2,{"b":3}]}' + + def test_round_trips_semantically(self): + original = '{"x": [1, 2], "y": {"z": true}}' + assert json.loads(minify_json(original)) == json.loads(original) + + def test_preserves_unicode(self): + # ensure_ascii default keeps non-ASCII as escapes; content must round-trip. + assert json.loads(minify_json('{"n": "測試"}')) == {"n": "測試"} + + def test_invalid_raises(self): + with pytest.raises(ITEJsonException): + minify_json("{not valid}") + + def test_empty_object(self): + assert minify_json("{}") == "{}" diff --git a/test/test_utils/test_jwt_decoder.py b/test/test_utils/test_jwt_decoder.py new file mode 100644 index 0000000..8093019 --- /dev/null +++ b/test/test_utils/test_jwt_decoder.py @@ -0,0 +1,109 @@ +"""Tests for the JWT decoder (inspection only, no signature verification).""" +from __future__ import annotations + +import base64 +import json + +import pytest + +from pybreeze.utils.exception.exceptions import JwtDecodeException +from pybreeze.utils.jwt_tools.jwt_decoder import ( + decode_jwt, + format_timestamp_claim, + humanized_timestamp_claims, +) + + +def _segment(obj: dict) -> str: + """Encode a dict as a base64url JWT segment (no padding), as real JWTs do.""" + raw = json.dumps(obj).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _make_jwt(header: dict, payload: dict, signature: str = "sig") -> str: + return f"{_segment(header)}.{_segment(payload)}.{signature}" + + +class TestDecodeJwt: + def test_decodes_header_and_payload(self): + token = _make_jwt({"alg": "HS256", "typ": "JWT"}, {"sub": "123", "name": "a"}) + decoded = decode_jwt(token) + assert decoded.header == {"alg": "HS256", "typ": "JWT"} + assert decoded.payload == {"sub": "123", "name": "a"} + + def test_signature_segment_preserved(self): + token = _make_jwt({"alg": "none"}, {"sub": "1"}, signature="abc") + assert decode_jwt(token).signature == "abc" + + def test_handles_missing_padding(self): + # Segments without '=' padding (the normal JWT form) must still decode. + token = _make_jwt({"a": 1}, {"b": 2}) + assert "=" not in token.split(".")[0] + assert decode_jwt(token).payload == {"b": 2} + + def test_unicode_claims(self): + token = _make_jwt({"alg": "HS256"}, {"name": "測試"}) + assert decode_jwt(token).payload["name"] == "測試" + + def test_whitespace_is_trimmed(self): + token = _make_jwt({"alg": "HS256"}, {"sub": "1"}) + assert decode_jwt(f" {token} ").payload == {"sub": "1"} + + def test_empty_token_raises(self): + with pytest.raises(JwtDecodeException): + decode_jwt(" ") + + def test_wrong_segment_count_raises(self): + with pytest.raises(JwtDecodeException): + decode_jwt("only.two") + + def test_invalid_base64_raises(self): + with pytest.raises(JwtDecodeException): + decode_jwt("!!!.!!!.sig") + + def test_non_json_segment_raises(self): + not_json = base64.urlsafe_b64encode(b"hello").decode("ascii").rstrip("=") + with pytest.raises(JwtDecodeException): + decode_jwt(f"{not_json}.{not_json}.sig") + + def test_non_object_segment_raises(self): + # A segment that decodes to a JSON array, not an object. + array_seg = base64.urlsafe_b64encode(b"[1, 2]").decode("ascii").rstrip("=") + with pytest.raises(JwtDecodeException): + decode_jwt(f"{array_seg}.{array_seg}.sig") + + +class TestFormatTimestampClaim: + def test_formats_unix_timestamp(self): + # 2021-01-01T00:00:00Z + assert format_timestamp_claim(1609459200).startswith("2021-01-01T00:00:00") + + def test_non_number_returns_none(self): + assert format_timestamp_claim("nope") is None + + def test_bool_returns_none(self): + # bool is a subclass of int, but a boolean claim is not a timestamp. + assert format_timestamp_claim(True) is None + + def test_none_returns_none(self): + assert format_timestamp_claim(None) is None + + def test_out_of_range_returns_none(self): + assert format_timestamp_claim(10 ** 30) is None + + +class TestHumanizedTimestampClaims: + def test_extracts_known_claims(self): + payload = {"exp": 1609459200, "iat": 1609455600, "sub": "x"} + readable = humanized_timestamp_claims(payload) + assert set(readable) == {"exp", "iat"} + + def test_ignores_non_timestamp_values(self): + assert humanized_timestamp_claims({"exp": "soon"}) == {} + + def test_empty_payload(self): + assert humanized_timestamp_claims({}) == {} + + def test_nbf_and_auth_time(self): + payload = {"nbf": 1609459200, "auth_time": 1609459200} + assert set(humanized_timestamp_claims(payload)) == {"nbf", "auth_time"} diff --git a/test/test_utils/test_jwt_decoder_gui.py b/test/test_utils/test_jwt_decoder_gui.py new file mode 100644 index 0000000..610eea1 --- /dev/null +++ b/test/test_utils/test_jwt_decoder_gui.py @@ -0,0 +1,81 @@ +"""Tests for the JWT decoder tool widget.""" +from __future__ import annotations + +import base64 +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 + + +def _make_jwt(header: dict, payload: dict, signature: str = "sig") -> str: + def seg(obj): + raw = json.dumps(obj).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + return f"{seg(header)}.{seg(payload)}.{signature}" + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def widget(app): + from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI + gui = JwtDecoderGUI() + yield gui + gui.close() + gui.deleteLater() + + +class TestJwtDecoderGUI: + def test_decode_shows_claims(self, widget): + widget.input_edit.setPlainText(_make_jwt({"alg": "HS256"}, {"sub": "42"})) + widget.decode() + output = widget.output_edit.toPlainText() + assert '"sub": "42"' in output + assert '"alg": "HS256"' in output + + def test_decode_shows_timestamps(self, widget): + widget.input_edit.setPlainText(_make_jwt({"alg": "HS256"}, {"exp": 1609459200})) + widget.decode() + assert "2021-01-01T00:00:00" in widget.output_edit.toPlainText() + + def test_invalid_token_shows_error(self, widget): + widget.input_edit.setPlainText("only.two") + widget.decode() + assert '"sub"' not in widget.output_edit.toPlainText() + assert widget.output_edit.toPlainText() != "" + + def test_empty_input_shows_hint(self, widget): + widget.input_edit.setPlainText(" ") + widget.decode() + assert widget.output_edit.toPlainText() != "" + + def test_copy_output(self, app, widget): + widget.input_edit.setPlainText(_make_jwt({"alg": "HS256"}, {"sub": "42"})) + widget.decode() + widget.actions.copy() + assert '"sub": "42"' in QApplication.clipboard().text() + + def test_build_decoded_text_without_timestamps(self, app): + from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import build_decoded_text + from pybreeze.utils.jwt_tools.jwt_decoder import DecodedJwt + text = build_decoded_text(DecodedJwt(header={"alg": "none"}, payload={"a": 1}, signature="s")) + assert '"a": 1' in text + + def test_initial_token_is_decoded_on_open(self, app): + from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI + token = _make_jwt({"alg": "HS256"}, {"sub": "seed"}) + gui = JwtDecoderGUI(initial_token=token) + assert '"sub": "seed"' in gui.output_edit.toPlainText() + gui.close() + gui.deleteLater() diff --git a/test/test_utils/test_output_actions.py b/test/test_utils/test_output_actions.py new file mode 100644 index 0000000..7504d24 --- /dev/null +++ b/test/test_utils/test_output_actions.py @@ -0,0 +1,145 @@ +"""Tests for the reusable output actions (copy / open-in-editor / save).""" +from __future__ import annotations + +import os +from unittest.mock import patch + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QTextEdit, QWidget + +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 + + +class _FakeTabWidget: + def __init__(self): + self.added = [] + self.current = None + + def addTab(self, widget, label): + self.added.append((widget, label)) + + def setCurrentWidget(self, widget): + self.current = widget + + +class _FakeMainWindow: + def __init__(self): + self.tab_widget = _FakeTabWidget() + + +class _FakeEditor: + def __init__(self, _main_window): + self.code_edit = type( + "CodeEdit", (), {"setPlainText": lambda self, t: setattr(self, "text", t)})() + + +def _make(app, main_window=None, **kwargs): + from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions + parent = QWidget() + output = QTextEdit(parent) + output.setReadOnly(True) + actions = OutputActions(parent, output, main_window=main_window, **kwargs) + return parent, output, actions + + +class TestCopy: + def test_copies_text(self, app): + parent, output, actions = _make(app) + output.setPlainText("hello") + actions.copy() + assert QApplication.clipboard().text() == "hello" + parent.deleteLater() + + def test_empty_is_safe(self, app): + parent, output, actions = _make(app) + output.setPlainText("") + actions.copy() # must not raise + parent.deleteLater() + + +class TestOpenInEditor: + def test_opens_tab_with_text(self, app): + window = _FakeMainWindow() + 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 + ): + editor = actions.open_in_editor() + assert editor.code_edit.text == "some code" + assert len(window.tab_widget.added) == 1 + parent.deleteLater() + + def test_no_window_is_safe(self, app): + parent, output, actions = _make(app) + output.setPlainText("code") + assert actions.open_in_editor() is None + parent.deleteLater() + + def test_empty_output_is_noop(self, app): + window = _FakeMainWindow() + parent, output, actions = _make(app, main_window=window) + output.setPlainText(" ") + assert actions.open_in_editor() is None + assert window.tab_widget.added == [] + parent.deleteLater() + + def test_is_valid_false_blocks_open(self, app): + window = _FakeMainWindow() + parent, output, actions = _make(app, main_window=window, is_valid=lambda: False) + output.setPlainText("looks like content") + assert actions.open_in_editor() is None + parent.deleteLater() + + +class TestSaveToFile: + def test_writes_file(self, app, tmp_path): + parent, output, actions = _make(app, basename="thing", extension="txt") + output.setPlainText("saved content") + target = tmp_path / "out.txt" + with patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", + return_value=(str(target), "Text (*.txt)"), + ): + result = actions.save_to_file() + assert result == str(target) + assert target.read_text(encoding="utf-8") == "saved content" + parent.deleteLater() + + def test_cancel_returns_none(self, app, tmp_path): + parent, output, actions = _make(app) + output.setPlainText("content") + with patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", + return_value=("", ""), + ): + assert actions.save_to_file() is None + parent.deleteLater() + + def test_empty_output_is_noop(self, app): + parent, output, actions = _make(app) + output.setPlainText("") + assert actions.save_to_file() is None + parent.deleteLater() + + +class TestSuggestedFilename: + def test_static(self, app): + parent, _output, actions = _make(app, basename="report", extension="json") + assert actions.suggested_filename() == "report.json" + parent.deleteLater() + + def test_callable(self, app): + parent, _output, actions = _make( + app, basename=lambda: "dyn", extension=lambda: "py") + assert actions.suggested_filename() == "dyn.py" + parent.deleteLater() diff --git a/test/test_utils/test_query_convert.py b/test/test_utils/test_query_convert.py new file mode 100644 index 0000000..49bbcee --- /dev/null +++ b/test/test_utils/test_query_convert.py @@ -0,0 +1,78 @@ +"""Tests for query-string <-> JSON conversion.""" +from __future__ import annotations + +import json + +import pytest + +from pybreeze.utils.exception.exceptions import QueryConvertException +from pybreeze.utils.query_tools.query_convert import ( + json_to_query, + query_to_dict, + query_to_json, +) + + +class TestQueryToDict: + def test_simple_pairs(self): + assert query_to_dict("a=1&b=2") == {"a": "1", "b": "2"} + + def test_leading_question_mark_ignored(self): + assert query_to_dict("?a=1") == {"a": "1"} + + def test_url_decoding(self): + assert query_to_dict("q=a%20b%2Fc") == {"q": "a b/c"} + + def test_repeated_key_becomes_list(self): + assert query_to_dict("a=1&a=2&a=3") == {"a": ["1", "2", "3"]} + + def test_blank_value_kept(self): + assert query_to_dict("a=&b=2") == {"a": "", "b": "2"} + + def test_empty_string(self): + assert query_to_dict("") == {} + + +class TestQueryToJson: + def test_produces_json(self): + result = query_to_json("a=1&b=2") + assert json.loads(result) == {"a": "1", "b": "2"} + + def test_is_pretty_printed(self): + assert "\n" in query_to_json("a=1&b=2") + + def test_repeated_key_is_array(self): + assert json.loads(query_to_json("a=1&a=2")) == {"a": ["1", "2"]} + + +class TestJsonToQuery: + def test_simple_object(self): + assert json_to_query('{"a": "1", "b": "2"}') == "a=1&b=2" + + def test_url_encoding(self): + assert json_to_query('{"q": "a b/c"}') == "q=a+b%2Fc" + + def test_list_becomes_repeated_key(self): + assert json_to_query('{"a": [1, 2]}') == "a=1&a=2" + + def test_numbers_coerced_to_strings(self): + assert json_to_query('{"n": 42}') == "n=42" + + def test_bool_coerced(self): + assert json_to_query('{"ok": true}') == "ok=true" + + def test_invalid_json_raises(self): + with pytest.raises(QueryConvertException): + json_to_query("not json") + + def test_non_object_json_raises(self): + with pytest.raises(QueryConvertException): + json_to_query("[1, 2, 3]") + + +class TestRoundTrip: + def test_query_json_query(self): + assert json_to_query(query_to_json("a=1&b=2")) == "a=1&b=2" + + def test_repeated_key_round_trip(self): + assert json_to_query(query_to_json("a=1&a=2")) == "a=1&a=2" diff --git a/test/test_utils/test_query_json_gui.py b/test/test_utils/test_query_json_gui.py new file mode 100644 index 0000000..f86852b --- /dev/null +++ b/test/test_utils/test_query_json_gui.py @@ -0,0 +1,62 @@ +"""Tests for the query <-> JSON tool widget.""" +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 widget(app): + from pybreeze.pybreeze_ui.tools_gui.query_json_gui import QueryJsonGUI + gui = QueryJsonGUI() + yield gui + gui.close() + gui.deleteLater() + + +class TestQueryJsonGUI: + def test_query_to_json(self, widget): + widget.input_edit.setPlainText("a=1&b=2") + widget.convert_to_json() + assert json.loads(widget.output_edit.toPlainText()) == {"a": "1", "b": "2"} + + def test_json_to_query(self, widget): + widget.input_edit.setPlainText('{"a": "1", "b": "2"}') + widget.convert_to_query() + assert widget.output_edit.toPlainText() == "a=1&b=2" + + def test_invalid_json_shows_error(self, widget): + widget.input_edit.setPlainText("not json") + widget.convert_to_query() + assert widget.output_edit.toPlainText() != "" + assert "a=1" not in widget.output_edit.toPlainText() + + def test_empty_to_json_shows_hint(self, widget): + widget.input_edit.setPlainText(" ") + widget.convert_to_json() + assert widget.output_edit.toPlainText() != "" + + def test_empty_to_query_shows_hint(self, widget): + widget.input_edit.setPlainText(" ") + widget.convert_to_query() + assert widget.output_edit.toPlainText() != "" + + def test_copy_output(self, app, widget): + widget.input_edit.setPlainText("a=1&b=2") + widget.convert_to_json() + widget.actions.copy() + assert "a" in QApplication.clipboard().text() diff --git a/test/test_utils/test_regex_gui.py b/test/test_utils/test_regex_gui.py new file mode 100644 index 0000000..f638cfa --- /dev/null +++ b/test/test_utils/test_regex_gui.py @@ -0,0 +1,74 @@ +"""Tests for the regex tester tool widget.""" +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 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def widget(app): + from pybreeze.pybreeze_ui.tools_gui.regex_gui import RegexGUI + gui = RegexGUI() + yield gui + gui.close() + gui.deleteLater() + + +class TestRegexGUI: + def test_finds_matches(self, widget): + widget.pattern_edit.setText(r"\d+") + widget.text_edit.setPlainText("a1 b22") + widget.test() + output = widget.output_edit.toPlainText() + assert "'1'" in output + assert "'22'" in output + + def test_no_match_message(self, widget): + widget.pattern_edit.setText(r"z") + widget.text_edit.setPlainText("abc") + widget.test() + 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() + 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() + assert "'ABC'" in widget.output_edit.toPlainText() + widget.flag_checkboxes["IGNORECASE"].setChecked(False) + + def test_selected_flags(self, widget): + widget.flag_checkboxes["DOTALL"].setChecked(True) + assert "DOTALL" in widget.selected_flags() + widget.flag_checkboxes["DOTALL"].setChecked(False) + + def test_copy_output(self, app, widget): + widget.pattern_edit.setText(r"\d+") + widget.text_edit.setPlainText("a1") + widget.test() + 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" diff --git a/test/test_utils/test_regex_tester.py b/test/test_utils/test_regex_tester.py new file mode 100644 index 0000000..875a265 --- /dev/null +++ b/test/test_utils/test_regex_tester.py @@ -0,0 +1,94 @@ +"""Tests for the regular expression tester.""" +from __future__ import annotations + +import pytest + +from pybreeze.utils.exception.exceptions import RegexTesterException +from pybreeze.utils.regex_tools.regex_tester import ( + available_flags, + build_flags, + compile_pattern, + find_matches, +) + + +class TestBuildFlags: + def test_no_flags(self): + assert build_flags([]) == 0 + + def test_ignorecase(self): + import re + assert build_flags(["IGNORECASE"]) & re.IGNORECASE + + def test_multiple(self): + import re + combined = build_flags(["IGNORECASE", "DOTALL"]) + assert combined & re.IGNORECASE and combined & re.DOTALL + + def test_unknown_ignored(self): + assert build_flags(["NOPE"]) == 0 + + def test_available_flags(self): + assert set(available_flags()) == {"IGNORECASE", "MULTILINE", "DOTALL", "VERBOSE"} + + +class TestCompilePattern: + def test_valid(self): + assert compile_pattern(r"\d+").pattern == r"\d+" + + def test_empty_raises(self): + with pytest.raises(RegexTesterException): + compile_pattern("") + + def test_invalid_raises(self): + with pytest.raises(RegexTesterException): + compile_pattern("(") + + +class TestFindMatches: + def test_simple(self): + matches = find_matches(r"\d+", "a1 b22 c333") + assert [m.matched_text for m in matches] == ["1", "22", "333"] + + def test_offsets(self): + matches = find_matches(r"b", "abc") + assert (matches[0].start, matches[0].end) == (1, 2) + + def test_numbered_groups(self): + matches = find_matches(r"(\d)(\d)", "12") + assert matches[0].groups == ["1", "2"] + + def test_named_groups(self): + matches = find_matches(r"(?P\d{4})", "2021") + assert matches[0].named_groups == {"year": "2021"} + + def test_no_matches(self): + assert find_matches(r"z", "abc") == [] + + def test_ignorecase_flag(self): + assert len(find_matches(r"abc", "ABC abc", ["IGNORECASE"])) == 2 + + def test_multiline_flag(self): + matches = find_matches(r"^\d", "1\n2\n3", ["MULTILINE"]) + assert len(matches) == 3 + + def test_dotall_flag(self): + assert find_matches(r"a.b", "a\nb", ["DOTALL"])[0].matched_text == "a\nb" + + def test_invalid_pattern_raises(self): + with pytest.raises(RegexTesterException): + find_matches("(", "text") + + def test_empty_pattern_raises(self): + with pytest.raises(RegexTesterException): + find_matches("", "text") + + def test_group_that_did_not_match_is_none(self): + matches = find_matches(r"(a)|(b)", "a") + assert matches[0].groups == ["a", None] + + 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 diff --git a/test/test_utils/test_response_analyzer.py b/test/test_utils/test_response_analyzer.py new file mode 100644 index 0000000..26c628b --- /dev/null +++ b/test/test_utils/test_response_analyzer.py @@ -0,0 +1,122 @@ +"""Tests for the HTTP response analyzer.""" +from __future__ import annotations + +import base64 +import json + +from pybreeze.utils.response_inspector.response_analyzer import analyze_response + + +def _jwt(header: dict, payload: dict) -> str: + def seg(obj): + raw = json.dumps(obj).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + return f"{seg(header)}.{seg(payload)}.sig" + + +class TestStatusDetection: + def test_detects_status_line(self): + analysis = analyze_response("HTTP/1.1 200 OK\n\n{}") + assert analysis.status is not None + assert analysis.status.code == 200 + assert analysis.status.phrase == "OK" + + def test_status_11_or_2(self): + analysis = analyze_response("HTTP/2 404 Not Found\n\n") + assert analysis.status.code == 404 + + def test_no_status_line(self): + analysis = analyze_response('{"a": 1}') + assert analysis.status is None + + def test_unknown_status_code(self): + analysis = analyze_response("HTTP/1.1 299 Weird\n\n") + assert analysis.status is None # 299 is not a known code + + +class TestHeaderParsing: + def test_parses_headers(self): + text = "HTTP/1.1 200 OK\nContent-Type: application/json\nX-Token: abc\n\n{}" + analysis = analyze_response(text) + assert analysis.headers == {"Content-Type": "application/json", "X-Token": "abc"} + + def test_headers_without_status_line(self): + text = "Content-Type: text/html\n\n" + analysis = analyze_response(text) + assert analysis.headers == {"Content-Type": "text/html"} + + def test_header_value_with_colon(self): + text = "HTTP/1.1 200 OK\nDate: Mon, 01 Jan 2021 00:00:00 GMT\n\n" + analysis = analyze_response(text) + assert analysis.headers["Date"] == "Mon, 01 Jan 2021 00:00:00 GMT" + + def test_crlf_line_endings(self): + text = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{}" + analysis = analyze_response(text) + assert analysis.headers == {"Content-Type": "application/json"} + + +class TestBodyDetection: + def test_json_body_is_pretty_printed(self): + analysis = analyze_response("HTTP/1.1 200 OK\n\n{\"a\":1,\"b\":2}") + assert analysis.is_json_body + assert '"a": 1' in analysis.pretty_body + + def test_body_only_json(self): + analysis = analyze_response('{"a": 1}') + assert analysis.is_json_body + assert analysis.body == '{"a": 1}' + + def test_non_json_body(self): + analysis = analyze_response("HTTP/1.1 200 OK\n\nhi") + assert not analysis.is_json_body + assert analysis.pretty_body is None + assert analysis.body == "hi" + + def test_json_key_order_preserved(self): + analysis = analyze_response('{"z": 1, "a": 2}') + # Keys should appear in original order, not sorted. + assert analysis.pretty_body.index('"z"') < analysis.pretty_body.index('"a"') + + 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}" + analysis = analyze_response(text) + assert analysis.is_json_body + assert analysis.headers == {"Content-Type": "application/json"} + + +class TestJwtDetection: + def test_finds_jwt_in_header(self): + token = _jwt({"alg": "HS256"}, {"sub": "42"}) + text = f"HTTP/1.1 200 OK\nAuthorization: Bearer {token}\n\n{{}}" + analysis = analyze_response(text) + assert len(analysis.jwt_findings) == 1 + assert analysis.jwt_findings[0].decoded.payload == {"sub": "42"} + + def test_finds_jwt_in_body(self): + token = _jwt({"alg": "HS256"}, {"token": "yes"}) + analysis = analyze_response(f'{{"access_token": "{token}"}}') + assert any(f.decoded.payload == {"token": "yes"} for f in analysis.jwt_findings) + + def test_no_jwt(self): + assert analyze_response("HTTP/1.1 200 OK\n\n{}").jwt_findings == [] + + def test_duplicate_jwt_reported_once(self): + token = _jwt({"alg": "HS256"}, {"sub": "1"}) + text = f"Authorization: Bearer {token}\nX-Copy: {token}\n\n{{}}" + analysis = analyze_response(text) + assert len(analysis.jwt_findings) == 1 + + def test_jwt_like_but_undecodable_is_skipped(self): + # Starts like a JWT but the segments are not valid base64url JSON. + text = "eyJxx.yyy.zzz appears here" + assert analyze_response(text).jwt_findings == [] + + +class TestEmptyInput: + def test_empty(self): + analysis = analyze_response("") + assert analysis.status is None + assert analysis.headers == {} + assert analysis.body == "" diff --git a/test/test_utils/test_response_inspector_gui.py b/test/test_utils/test_response_inspector_gui.py new file mode 100644 index 0000000..8ecf2b3 --- /dev/null +++ b/test/test_utils/test_response_inspector_gui.py @@ -0,0 +1,163 @@ +"""Tests for the response inspector tool widget.""" +from __future__ import annotations + +import base64 +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 + + +def _jwt(header: dict, payload: dict) -> str: + def seg(obj): + raw = json.dumps(obj).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + return f"{seg(header)}.{seg(payload)}.sig" + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def widget(app): + from pybreeze.pybreeze_ui.tools_gui.response_inspector_gui import ResponseInspectorGUI + gui = ResponseInspectorGUI() + yield gui + gui.close() + gui.deleteLater() + + +class TestResponseInspectorGUI: + def test_full_response(self, widget): + widget.input_edit.setPlainText( + "HTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"ok\": true}") + widget.analyze() + output = widget.output_edit.toPlainText() + assert "200 OK" in output + assert "Content-Type: application/json" in output + assert '"ok": true' in output + + def test_json_only(self, widget): + widget.input_edit.setPlainText('{"a": 1}') + widget.analyze() + assert '"a": 1' in widget.output_edit.toPlainText() + + def test_jwt_is_decoded(self, widget): + token = _jwt({"alg": "HS256"}, {"sub": "42"}) + widget.input_edit.setPlainText(f"HTTP/1.1 200 OK\nAuthorization: Bearer {token}\n\n{{}}") + widget.analyze() + assert '"sub": "42"' in widget.output_edit.toPlainText() + + def test_empty_shows_hint(self, widget): + widget.input_edit.setPlainText(" ") + widget.analyze() + assert widget.output_edit.toPlainText() != "" + + def test_copy_output(self, app, widget): + widget.input_edit.setPlainText('{"a": 1}') + widget.analyze() + widget.actions.copy() + assert '"a": 1' in QApplication.clipboard().text() + + def test_build_report_text_minimal(self, 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("plain text body")) + assert "plain text body" in text + + +class _FakeTabWidget: + """Records tabs added and the current widget.""" + + def __init__(self): + self.added = [] + self.current = None + + def addTab(self, widget, label): + self.added.append((widget, label)) + + def setCurrentWidget(self, widget): + self.current = widget + + +class _FakeMainWindow: + def __init__(self): + self.tab_widget = _FakeTabWidget() + + +@pytest.fixture() +def widget_with_window(app): + from pybreeze.pybreeze_ui.tools_gui.response_inspector_gui import ResponseInspectorGUI + window = _FakeMainWindow() + gui = ResponseInspectorGUI(window) + yield gui, window + gui.close() + gui.deleteLater() + + +class TestResponseInspectorActions: + def test_buttons_disabled_before_analyze(self, app): + from pybreeze.pybreeze_ui.tools_gui.response_inspector_gui import ResponseInspectorGUI + gui = ResponseInspectorGUI() + assert not gui.open_jwt_button.isEnabled() + assert not gui.open_status_button.isEnabled() + gui.close() + gui.deleteLater() + + def test_status_button_enabled_after_status(self, widget_with_window): + gui, _window = widget_with_window + gui.input_edit.setPlainText("HTTP/1.1 404 Not Found\n\n{}") + gui.analyze() + assert gui.open_status_button.isEnabled() + + def test_jwt_button_enabled_after_jwt(self, widget_with_window): + gui, _window = widget_with_window + token = _jwt({"alg": "HS256"}, {"sub": "1"}) + gui.input_edit.setPlainText(f"Authorization: Bearer {token}\n\n{{}}") + gui.analyze() + assert gui.open_jwt_button.isEnabled() + + def test_open_status_opens_prefilled_tab(self, widget_with_window): + from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI + gui, window = widget_with_window + gui.input_edit.setPlainText("HTTP/1.1 404 Not Found\n\n{}") + gui.analyze() + gui.open_status_in_reference() + assert len(window.tab_widget.added) == 1 + opened = window.tab_widget.added[0][0] + assert isinstance(opened, HttpStatusGUI) + assert "404 Not Found" in opened.output_edit.toPlainText() + + def test_open_jwt_opens_prefilled_tab(self, widget_with_window): + from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI + gui, window = widget_with_window + token = _jwt({"alg": "HS256"}, {"sub": "99"}) + gui.input_edit.setPlainText(f"Authorization: Bearer {token}\n\n{{}}") + gui.analyze() + gui.open_jwt_in_decoder() + opened = window.tab_widget.added[0][0] + assert isinstance(opened, JwtDecoderGUI) + assert '"sub": "99"' in opened.output_edit.toPlainText() + + def test_open_without_window_is_safe(self, app): + from pybreeze.pybreeze_ui.tools_gui.response_inspector_gui import ResponseInspectorGUI + gui = ResponseInspectorGUI() # no main window + gui.input_edit.setPlainText("HTTP/1.1 200 OK\n\n{}") + gui.analyze() + assert gui.open_status_in_reference() is None + gui.close() + gui.deleteLater() + + def test_open_before_analyze_is_noop(self, widget_with_window): + gui, window = widget_with_window + assert gui.open_status_in_reference() is None + assert window.tab_widget.added == [] diff --git a/test/test_utils/test_script_templates.py b/test/test_utils/test_script_templates.py new file mode 100644 index 0000000..c1b703e --- /dev/null +++ b/test/test_utils/test_script_templates.py @@ -0,0 +1,274 @@ +"""Tests for automation-module script templates generated from curl.""" +from __future__ import annotations + +import json + +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 +from pybreeze.utils.curl_import.script_templates import ( + TEMPLATE_TARGETS, + generate_template, + to_apitestka_action_json, + to_apitestka_python, + to_loaddensity_python, + to_pytest_test, +) +# Aliased so pytest does not collect this ``test_``-prefixed helper as a test. +from pybreeze.utils.curl_import.script_templates import ( + test_function_name as derive_test_function_name, +) + + +class TestBodyKind: + def test_no_body(self): + assert body_kind(parse_curl("curl https://x")) is None + + def test_json_body(self): + request = parse_curl( + "curl -H 'Content-Type: application/json' -d '{\"a\": 1}' https://x") + assert body_kind(request) == ("json", {"a": 1}) + + def test_non_json_body_is_data(self): + assert body_kind(parse_curl("curl -d 'a=1&b=2' https://x")) == ("data", "a=1&b=2") + + 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") + + +class TestFormParts: + def test_plain_field(self): + data, files = form_parts(parse_curl("curl -F 'name=widget' https://x")) + assert data == {"name": "widget"} + assert files == {} + + def test_file_field(self): + data, files = form_parts(parse_curl("curl -F 'photo=@a.jpg' https://x")) + assert data == {} + assert files == {"photo": "a.jpg"} + + def test_file_type_suffix_stripped(self): + data, files = form_parts(parse_curl("curl -F 'f=@a.png;type=image/png' https://x")) + assert files == {"f": "a.png"} + + def test_mixed(self): + data, files = form_parts(parse_curl("curl -F 'a=1' -F 'b=@f.txt' https://x")) + assert data == {"a": "1"} + assert files == {"b": "f.txt"} + + +class TestRequestsCodeForm: + def test_form_uses_data_and_files(self): + code = to_requests_code(parse_curl("curl -F 'name=x' -F 'photo=@a.jpg' https://up")) + assert "data=data" in code + assert "files=files" in code + assert 'open("a.jpg", "rb")' in code + + def test_form_code_is_valid_python(self): + code = to_requests_code(parse_curl("curl -F 'name=x' -F 'photo=@a.jpg' https://up")) + compile(code, "", "exec") + + def test_ignored_flag_url_correct_in_code(self): + code = to_requests_code(parse_curl("curl --max-time 30 https://api.example.com/x")) + assert 'url = "https://api.example.com/x"' in code + + +class TestRequestsCodeJsonFlagAndDataFile: + def test_json_flag_uses_json_kwarg(self): + code = to_requests_code(parse_curl("curl --json '{\"a\": 1}' https://x")) + assert "json=json_body" in code + assert "json_body = {" in code + + def test_data_file_reads_file(self): + 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=data" in code + + def test_data_file_code_is_valid_python(self): + compile(to_requests_code(parse_curl("curl -d @body.json https://x")), "", "exec") + + 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 + compile(code, "", "exec") + + +class TestApitestkaPython: + def test_imports_and_calls(self): + code = to_apitestka_python(parse_curl("curl https://example.com/api")) + assert "from je_api_testka import test_api_method_requests" in code + assert "test_api_method_requests(" in code + assert 'test_url="https://example.com/api"' in code + + def test_method(self): + code = to_apitestka_python(parse_curl("curl -X DELETE https://x")) + assert '"DELETE"' in code + + def test_headers(self): + code = to_apitestka_python(parse_curl("curl -H 'Accept: application/json' https://x")) + assert "headers=" in code + + def test_json_body_uses_json_kwarg(self): + code = to_apitestka_python( + parse_curl("curl -H 'Content-Type: application/json' -d '{\"a\": 1}' https://x")) + assert "json=" in code + + def test_form_body_uses_data_kwarg(self): + code = to_apitestka_python(parse_curl("curl -d 'a=1' https://x")) + assert "data=" in code + + def test_auth(self): + code = to_apitestka_python(parse_curl("curl -u user:pass https://x")) + assert "auth=(" in code + + def test_params(self): + code = to_apitestka_python(parse_curl("curl -G https://x -d 'a=1'")) + assert "params=" in code + + def test_is_valid_python(self): + command = ( + "curl -X POST https://example.com/api " + "-H 'Content-Type: application/json' -d '{\"name\": \"a\"}'" + ) + compile(to_apitestka_python(parse_curl(command)), "", "exec") + + def test_form_data_and_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 'open("a.jpg", "rb")' in code + compile(code, "", "exec") + + +class TestApitestkaActionJson: + def test_is_valid_json(self): + action = json.loads(to_apitestka_action_json(parse_curl("curl https://x"))) + assert action == [["AT_test_api_method", {"http_method": "GET", "test_url": "https://x"}]] + + def test_headers_included(self): + action = json.loads( + to_apitestka_action_json(parse_curl("curl -H 'Accept: text/html' https://x"))) + assert action[0][1]["headers"] == {"Accept": "text/html"} + + def test_post_with_json_body(self): + command = "curl -H 'Content-Type: application/json' -d '{\"a\": 1}' https://x" + action = json.loads(to_apitestka_action_json(parse_curl(command))) + params = action[0][1] + assert params["http_method"] == "POST" + assert params["json"] == {"a": 1} + + def test_form_body_uses_data_key(self): + action = json.loads(to_apitestka_action_json(parse_curl("curl -d 'a=1' https://x"))) + assert action[0][1]["data"] == "a=1" + + def test_auth_as_list(self): + action = json.loads(to_apitestka_action_json(parse_curl("curl -u user:pass https://x"))) + assert action[0][1]["auth"] == ["user", "pass"] + + 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"} + + +class TestLoadDensityPython: + def test_imports_and_calls_start_test(self): + code = to_loaddensity_python(parse_curl("curl https://example.com/api")) + assert "from je_load_density import start_test" in code + assert "start_test(" in code + + def test_task_has_method_and_url(self): + code = to_loaddensity_python(parse_curl("curl -X POST https://x/api -d 'a=1'")) + assert '"post": {"request_url": "https://x/api"}' in code + + def test_get_method(self): + code = to_loaddensity_python(parse_curl("curl https://x")) + assert '"get": {"request_url": "https://x"}' in code + + def test_is_valid_python(self): + compile(to_loaddensity_python(parse_curl("curl https://x")), "", "exec") + + +class TestTestFunctionName: + def test_from_path(self): + assert derive_test_function_name(parse_curl("curl https://x/v1/items")) == "test_get_v1_items" + + def test_method_prefix(self): + assert derive_test_function_name(parse_curl("curl -X POST https://x/a")) == "test_post_a" + + def test_sanitises_non_identifier_chars(self): + name = derive_test_function_name(parse_curl("curl 'https://x/a-b.c/d'")) + assert name.isidentifier() + assert name == "test_get_a_b_c_d" + + def test_no_path_uses_host(self): + name = derive_test_function_name(parse_curl("curl https://api.example.com")) + assert name == "test_get_api_example_com" + + +class TestToPytestTest: + def test_defines_test_function(self): + code = to_pytest_test(parse_curl("curl https://x/v1/items")) + assert "def test_get_v1_items():" in code + + def test_imports_requests(self): + assert "import requests" in to_pytest_test(parse_curl("curl https://x")) + + def test_asserts_status(self): + assert "assert response.status_code == 200" in to_pytest_test(parse_curl("curl https://x")) + + def test_statements_are_indented(self): + code = to_pytest_test(parse_curl("curl https://x")) + assert " url = " in code + assert " response = requests.request(" in code + + def test_is_valid_python(self): + command = ( + "curl -X POST https://example.com/api " + "-H 'Content-Type: application/json' -d '{\"name\": \"a\"}'" + ) + compile(to_pytest_test(parse_curl(command)), "", "exec") + + def test_with_headers_and_auth(self): + command = "curl -u user:pass -H 'Accept: application/json' https://x/data" + code = to_pytest_test(parse_curl(command)) + assert " headers = {" in code + assert " auth = (" in code + compile(code, "", "exec") + + +class TestGenerateTemplate: + def test_targets_are_registered(self): + keys = [key for key, _label in TEMPLATE_TARGETS] + assert keys == [ + "requests", "pytest", "apitestka_python", "apitestka_action", "loaddensity_python"] + + def test_pytest_target(self): + code = generate_template("pytest", parse_curl("curl https://x")) + assert "def test_" in code + + def test_loaddensity_target(self): + code = generate_template("loaddensity_python", parse_curl("curl https://x")) + assert "start_test" in code + + def test_requests_target(self): + code = generate_template("requests", parse_curl("curl https://x")) + assert "import requests" in code + + def test_apitestka_python_target(self): + code = generate_template("apitestka_python", parse_curl("curl https://x")) + assert "test_api_method_requests" in code + + def test_apitestka_action_target(self): + code = generate_template("apitestka_action", parse_curl("curl https://x")) + assert "AT_test_api_method" in code + + def test_unknown_target_falls_back_to_requests(self): + code = generate_template("nope", parse_curl("curl https://x")) + assert "import requests" in code diff --git a/test/test_utils/test_text_diff.py b/test/test_utils/test_text_diff.py new file mode 100644 index 0000000..112de0c --- /dev/null +++ b/test/test_utils/test_text_diff.py @@ -0,0 +1,58 @@ +"""Tests for the text diff utility.""" +from __future__ import annotations + +from pybreeze.utils.diff_tools.text_diff import diff_summary, unified_diff + + +class TestUnifiedDiff: + def test_identical_is_empty(self): + assert unified_diff("a\nb", "a\nb") == "" + + def test_shows_added_line(self): + diff = unified_diff("a\nb", "a\nb\nc") + assert "+c" in diff + + def test_shows_removed_line(self): + diff = unified_diff("a\nb\nc", "a\nb") + assert "-c" in diff + + def test_shows_changed_line(self): + diff = unified_diff("hello", "world") + assert "-hello" in diff + assert "+world" in diff + + def test_uses_labels(self): + diff = unified_diff("a", "b", left_label="expected", right_label="actual") + assert "expected" in diff + assert "actual" in diff + + def test_empty_inputs(self): + assert unified_diff("", "") == "" + + +class TestDiffSummary: + def test_identical(self): + summary = diff_summary("a\nb", "a\nb") + assert summary.is_equal + assert summary.added == 0 + assert summary.removed == 0 + + def test_added_line(self): + summary = diff_summary("a", "a\nb") + assert summary.added == 1 + assert summary.removed == 0 + assert not summary.is_equal + + def test_removed_line(self): + summary = diff_summary("a\nb", "a") + assert summary.removed == 1 + assert summary.added == 0 + + def test_changed_line_counts_as_add_and_remove(self): + summary = diff_summary("hello", "world") + assert summary.added == 1 + assert summary.removed == 1 + + def test_whitespace_only_difference(self): + summary = diff_summary("a", "a ") + assert not summary.is_equal diff --git a/test/test_utils/test_timestamp_converter.py b/test/test_utils/test_timestamp_converter.py new file mode 100644 index 0000000..9d0413d --- /dev/null +++ b/test/test_utils/test_timestamp_converter.py @@ -0,0 +1,84 @@ +"""Tests for the epoch / ISO-8601 timestamp converter.""" +from __future__ import annotations + +import pytest + +from pybreeze.utils.exception.exceptions import TimestampParseException +from pybreeze.utils.timestamp_tools.timestamp_converter import ( + convert_timestamp, + detect_epoch_unit, +) + +# 2021-01-01T00:00:00Z +_EPOCH_2021 = 1609459200 + + +class TestDetectEpochUnit: + def test_seconds(self): + assert detect_epoch_unit(_EPOCH_2021) == "s" + + def test_milliseconds(self): + assert detect_epoch_unit(_EPOCH_2021 * 1000) == "ms" + + def test_zero_is_seconds(self): + assert detect_epoch_unit(0) == "s" + + +class TestConvertFromEpoch: + def test_seconds_input(self): + result = convert_timestamp(str(_EPOCH_2021)) + assert result.epoch_seconds == _EPOCH_2021 + assert result.iso_utc.startswith("2021-01-01T00:00:00") + + def test_milliseconds_input(self): + result = convert_timestamp(str(_EPOCH_2021 * 1000)) + assert result.epoch_seconds == _EPOCH_2021 + assert result.epoch_millis == _EPOCH_2021 * 1000 + + def test_float_seconds(self): + result = convert_timestamp("1609459200.0") + assert result.epoch_seconds == _EPOCH_2021 + + def test_millis_output_is_seconds_times_1000(self): + result = convert_timestamp(str(_EPOCH_2021)) + assert result.epoch_millis == result.epoch_seconds * 1000 + + def test_whitespace_trimmed(self): + assert convert_timestamp(f" {_EPOCH_2021} ").epoch_seconds == _EPOCH_2021 + + +class TestConvertFromIso: + def test_iso_with_z(self): + assert convert_timestamp("2021-01-01T00:00:00Z").epoch_seconds == _EPOCH_2021 + + def test_iso_with_offset(self): + # +00:00 offset is the same instant as Z. + assert convert_timestamp("2021-01-01T00:00:00+00:00").epoch_seconds == _EPOCH_2021 + + def test_iso_naive_treated_as_utc(self): + assert convert_timestamp("2021-01-01T00:00:00").epoch_seconds == _EPOCH_2021 + + def test_iso_with_nonzero_offset(self): + # 01:00 at +01:00 is midnight UTC. + assert convert_timestamp("2021-01-01T01:00:00+01:00").epoch_seconds == _EPOCH_2021 + + def test_iso_date_only(self): + assert convert_timestamp("2021-01-01").epoch_seconds == _EPOCH_2021 + + def test_round_trip(self): + result = convert_timestamp(str(_EPOCH_2021)) + assert convert_timestamp(result.iso_utc).epoch_seconds == _EPOCH_2021 + + +class TestConvertErrors: + def test_empty_raises(self): + with pytest.raises(TimestampParseException): + convert_timestamp(" ") + + def test_garbage_raises(self): + with pytest.raises(TimestampParseException): + convert_timestamp("not-a-timestamp") + + def test_out_of_range_epoch_raises(self): + with pytest.raises(TimestampParseException): + convert_timestamp("1" * 40) diff --git a/test/test_utils/test_timestamp_gui.py b/test/test_utils/test_timestamp_gui.py new file mode 100644 index 0000000..4fb4ec3 --- /dev/null +++ b/test/test_utils/test_timestamp_gui.py @@ -0,0 +1,72 @@ +"""Tests for the timestamp converter tool widget.""" +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 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def widget(app): + from pybreeze.pybreeze_ui.tools_gui.timestamp_gui import TimestampGUI + gui = TimestampGUI() + yield gui + gui.close() + gui.deleteLater() + + +class TestTimestampGUI: + def test_convert_epoch(self, widget): + widget.input_edit.setText("1609459200") + widget.convert() + output = widget.output_edit.toPlainText() + assert "1609459200" in output + assert "2021-01-01T00:00:00" in output + + def test_convert_iso(self, widget): + widget.input_edit.setText("2021-01-01T00:00:00Z") + widget.convert() + assert "1609459200" in widget.output_edit.toPlainText() + + def test_invalid_shows_error(self, widget): + widget.input_edit.setText("nope") + widget.convert() + assert "1609459200" not in widget.output_edit.toPlainText() + assert widget.output_edit.toPlainText() != "" + + def test_empty_shows_hint(self, widget): + widget.input_edit.setText(" ") + widget.convert() + assert widget.output_edit.toPlainText() != "" + + def test_copy_output(self, app, widget): + widget.input_edit.setText("1609459200") + widget.convert() + widget.actions.copy() + assert "1609459200" in QApplication.clipboard().text() + + def test_save_after_error_is_noop(self, widget, tmp_path): + # A failed conversion shows an error; open/save must not act on it. + from unittest.mock import patch + widget.input_edit.setText("not-a-timestamp") + widget.convert() + with patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", + return_value=(str(tmp_path / "x.txt"), "Text (*.txt)"), + ): + assert widget.actions.save_to_file() is None + + def test_suggested_filename(self, widget): + assert widget.actions.suggested_filename() == "timestamp.txt" diff --git a/test/test_utils/test_tools_menu_docks.py b/test/test_utils/test_tools_menu_docks.py new file mode 100644 index 0000000..8455dde --- /dev/null +++ b/test/test_utils/test_tools_menu_docks.py @@ -0,0 +1,67 @@ +"""Guard: add_dock builds the right widget for each new tool dock type.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMainWindow + +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.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 +from pybreeze.pybreeze_ui.tools_gui.hash_gui import HashGUI +from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI +from pybreeze.pybreeze_ui.tools_gui.json_format_gui import JsonFormatGUI +from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI +from pybreeze.pybreeze_ui.tools_gui.query_json_gui import QueryJsonGUI +from pybreeze.pybreeze_ui.tools_gui.regex_gui import RegexGUI +from pybreeze.pybreeze_ui.tools_gui.response_inspector_gui import ResponseInspectorGUI +from pybreeze.pybreeze_ui.tools_gui.timestamp_gui import TimestampGUI + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.mark.parametrize( + "widget_type,widget_class", + [ + ("CurlImport", CurlImportGUI), + ("JwtDecoder", JwtDecoderGUI), + ("Timestamp", TimestampGUI), + ("Hash", HashGUI), + ("QueryJson", QueryJsonGUI), + ("Regex", RegexGUI), + ("HttpStatus", HttpStatusGUI), + ("Diff", DiffGUI), + ("JsonFormat", JsonFormatGUI), + ("ResponseInspector", ResponseInspectorGUI), + ], +) +def test_add_dock_creates_expected_widget(app, widget_type, widget_class): + window = QMainWindow() + try: + add_dock(window, widget_type) + docks = window.findChildren(DestroyDock) + assert any(isinstance(dock.widget(), widget_class) for dock in docks) + finally: + window.deleteLater() + + +def test_add_dock_unknown_type_adds_nothing(app): + window = QMainWindow() + try: + add_dock(window, "DefinitelyNotAToolType") + # An unknown type leaves the dock without a widget, so it is not attached. + attached = [d for d in window.findChildren(DestroyDock) if d.widget() is not None] + assert attached == [] + finally: + window.deleteLater()