diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ba1c6b80..18017409 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,7 @@ updates: directory: "/" # Location of package manifests schedule: interval: "daily" + - package-ecosystem: "github-actions" # Keep workflow action versions current + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/e2e_browser.yml b/.github/workflows/e2e_browser.yml index dfed0d3f..1da5cde0 100644 --- a/.github/workflows/e2e_browser.yml +++ b/.github/workflows/e2e_browser.yml @@ -41,6 +41,8 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.12" + cache: 'pip' + cache-dependency-path: dev_requirements.txt - name: Install dependencies run: | diff --git a/.github/workflows/test_dev.yml b/.github/workflows/test_dev.yml index 8895c019..8e6e825c 100644 --- a/.github/workflows/test_dev.yml +++ b/.github/workflows/test_dev.yml @@ -27,12 +27,13 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: dev_requirements.txt - name: Install dependencies run: | python -m pip install --upgrade pip wheel pip install -r dev_requirements.txt - pip install pytest - name: Run unit tests run: python -m pytest test/unit_test/test_*.py -v @@ -63,6 +64,8 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: dev_requirements.txt - name: Install dependencies run: | diff --git a/.github/workflows/test_stable.yml b/.github/workflows/test_stable.yml index bd45f157..f3fb543d 100644 --- a/.github/workflows/test_stable.yml +++ b/.github/workflows/test_stable.yml @@ -27,6 +27,8 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: requirements.txt - name: Install dependencies run: | @@ -63,6 +65,8 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: requirements.txt - name: Install dependencies run: | diff --git a/.gitignore b/.gitignore index 0a24e33d..62fd0650 100644 --- a/.gitignore +++ b/.gitignore @@ -123,9 +123,21 @@ dmypy.json # Driver *.zip *.exe +# webdriver-manager driver cache (set_driver caches drivers under cwd) +.wdm/ /.claude/ /.claude issues.json hotspots.json codacy.json + +# Static-analysis exports (SonarCloud / Codacy / pyflakes scratch files) +.codacy_*.json +.sonar_*.json +.pyflakes_files*.txt + +# Local report-generation scratch output +default_name*.html +default_name*.json +default_name*.xml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..b60ec6ad --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing to WebRunner + +Thanks for your interest in improving WebRunner (`je_web_runner`). This +guide covers the local setup, the checks we expect to pass, and the branch +/ PR conventions. + +## Development setup + +WebRunner targets **Python 3.10+**. + +```bash +# Runtime dependencies +pip install -r requirements.txt + +# Development dependencies (tests, linters, docs, build tooling) +pip install -r dev_requirements.txt +``` + +## Running the tests + +```bash +# Whole suite (unit + integration; e2e tests skip without a Selenium hub) +python -m pytest test/ + +# Unit tests only +python -m pytest test/unit_test/test_*.py +``` + +The pure-Python unit suite needs no browser. Integration tests spawn +short-lived subprocesses. The real-browser e2e tests under +`test/e2e_test/` skip cleanly unless `WEBRUNNER_E2E_HUB` points at a +reachable Selenium Grid (see `docker/`). + +## Before opening a pull request + +Run the linters and keep them clean (no new findings): + +```bash +python -m flake8 je_web_runner/ --max-line-length=120 --max-complexity=10 +python -m pylint je_web_runner/ +python -m bandit -r je_web_runner/ -ll +python -m mypy je_web_runner/ +``` + +Please also: + +- Add or update tests for any behaviour you change. +- Keep public functions type-hinted and documented. +- Avoid introducing new static-analysis issues (SonarQube / Codacy). + +## Branches and commits + +- `main` is the stable branch; `dev` is the development branch. +- Open pull requests **from `dev` into `main`**. +- Write concise, imperative commit messages (e.g. "Add element + validation", "Fix driver cleanup on timeout"). + +## Reporting security issues + +Do **not** file security vulnerabilities as public issues — see +[SECURITY.md](SECURITY.md) for the private disclosure process. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..7d9e1a5c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,42 @@ +# Security Policy + +## Supported Versions + +WebRunner (`je_web_runner`) is distributed on PyPI. Security fixes are +applied to the latest released version. Please upgrade to the most recent +release before reporting an issue, as the problem may already be resolved. + +| Version | Supported | +| -------------- | ------------------ | +| Latest release | :white_check_mark: | +| Older releases | :x: | + +## Reporting a Vulnerability + +Please report security vulnerabilities **privately** — do not open a public +issue, pull request, or discussion for a suspected vulnerability. + +Use either of the following private channels: + +- **GitHub Security Advisories** — open a private report via the + repository's *Security* tab → *Report a vulnerability*. This is the + preferred channel. +- **Email** — `jechenmailman@gmail.com` with a subject line beginning + `[SECURITY]`. + +To help triage quickly, please include where practical: + +- A description of the vulnerability and its impact. +- The affected version(s) and platform. +- Step-by-step reproduction instructions or a minimal proof of concept. +- Any suggested remediation. + +## Disclosure Process + +- We aim to acknowledge a report within **5 business days**. +- We target a fix and coordinated disclosure within **90 days** of the + initial report, sooner for actively exploited issues. +- We will credit reporters who wish to be acknowledged once a fix is + released. Please let us know your preference. + +Thank you for helping keep WebRunner and its users safe. diff --git a/dev_requirements.txt b/dev_requirements.txt index b8216ae2..c0226e14 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -7,4 +7,9 @@ Pyside6 defusedxml Pillow faker -sqlalchemy \ No newline at end of file +sqlalchemy +pytest +pylint +flake8 +bandit +mypy \ No newline at end of file diff --git a/docs/source/Zh/doc/specialized_modules/specialized_modules_doc.rst b/docs/source/Zh/doc/specialized_modules/specialized_modules_doc.rst index 98733a9d..938392f1 100644 --- a/docs/source/Zh/doc/specialized_modules/specialized_modules_doc.rst +++ b/docs/source/Zh/doc/specialized_modules/specialized_modules_doc.rst @@ -514,7 +514,7 @@ CODEOWNERS 解析器(GitHub 語意:最後一條 match 的規則勝出)+ 每 * ``web_push_assert`` —— Push subscription VAPID key 匹配、endpoint 白名單、``userVisibleOnly``、``showNotification`` payload。 * ``background_sync_assert`` —— Background Sync register / fire / - retry / ``lastChance``(quota 耗盡)斷言。 + retry / ``lastChance``\ (quota 耗盡)斷言。 * ``wake_lock_assert`` —— Screen wake lock acquire / release / 漏掉 / 切回前景時 re-acquire 偵測。 * ``pip_assert`` —— Picture-in-Picture(影片 + Document PiP) @@ -569,7 +569,7 @@ Email 與通知送達 ================ * ``email_deliverability`` —— SPF / DKIM / DMARC header + - ``List-Unsubscribe``(Gmail/Yahoo 大量寄件規則)+ BCC 外洩稽核。 + ``List-Unsubscribe``\ (Gmail/Yahoo 大量寄件規則)+ BCC 外洩稽核。 * ``inbox_render_outlook`` —— Outlook(Word 引擎)/ Gmail / Apple Mail 渲染相容性 pre-flight 檢查。 * ``push_delivery`` —— FCM / APNs payload 大小 + 必填欄位 + PII diff --git a/docs/source/Zh/doc/webdriver_wrapper/webdriver_wrapper_doc.rst b/docs/source/Zh/doc/webdriver_wrapper/webdriver_wrapper_doc.rst index 827d0b2d..33aca7c5 100644 --- a/docs/source/Zh/doc/webdriver_wrapper/webdriver_wrapper_doc.rst +++ b/docs/source/Zh/doc/webdriver_wrapper/webdriver_wrapper_doc.rst @@ -304,7 +304,7 @@ CDP Fetch 攔截 -------------- ``Fetch.*`` CDP 命令薄包裝。要實際收事件 (``Fetch.requestPaused``) 請使用 -``CDPEventListener``(或 Selenium trio-based devtools listener)自行訂閱: +``CDPEventListener``\ (或 Selenium trio-based devtools listener)自行訂閱: .. code-block:: python diff --git a/je_web_runner/__main__.py b/je_web_runner/__main__.py index a44f2901..bd4d7e8d 100644 --- a/je_web_runner/__main__.py +++ b/je_web_runner/__main__.py @@ -9,6 +9,6 @@ if __name__ == "__main__": try: sys.exit(main()) - except Exception as error: # noqa: BLE001 — surface any failure as exit-1 to the shell + except Exception as error: print(repr(error), file=sys.stderr) sys.exit(1) diff --git a/je_web_runner/action_lsp/server.py b/je_web_runner/action_lsp/server.py index 82ca8697..7f8f98ee 100644 --- a/je_web_runner/action_lsp/server.py +++ b/je_web_runner/action_lsp/server.py @@ -17,7 +17,7 @@ import json import sys from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, TextIO +from typing import Any, TextIO from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -36,11 +36,11 @@ class _Document: @dataclass class ActionLspServer: - documents: Dict[str, _Document] = field(default_factory=dict) + documents: dict[str, _Document] = field(default_factory=dict) initialized: bool = False - _command_names: Optional[List[str]] = field(default=None, init=False, repr=False) + _command_names: list[str] | None = field(default=None, init=False, repr=False) - def command_names(self) -> List[str]: + def command_names(self) -> list[str]: if self._command_names is None: try: from je_web_runner.utils.executor.action_executor import executor @@ -53,7 +53,7 @@ def command_names(self) -> List[str]: # --- Top-level dispatch ---------------------------------------------- - def handle(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def handle(self, message: dict[str, Any]) -> dict[str, Any] | None: method = message.get("method") request_id = message.get("id") params = message.get("params") or {} @@ -78,7 +78,7 @@ def handle(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]: # --- Handlers -------------------------------------------------------- - def _initialize(self) -> Dict[str, Any]: + def _initialize(self) -> dict[str, Any]: return { "capabilities": { "textDocumentSync": 1, # full sync @@ -87,14 +87,14 @@ def _initialize(self) -> Dict[str, Any]: "serverInfo": {"name": "webrunner-action-lsp", "version": "0.1.0"}, } - def _on_did_open(self, params: Dict[str, Any]) -> Dict[str, Any]: + def _on_did_open(self, params: dict[str, Any]) -> dict[str, Any]: document = params.get("textDocument") or {} uri = str(document.get("uri", "")) text = str(document.get("text", "")) self.documents[uri] = _Document(uri=uri, text=text, version=int(document.get("version", 0))) return self._diagnostics_notification(uri, text) - def _on_did_change(self, params: Dict[str, Any]) -> Dict[str, Any]: + def _on_did_change(self, params: dict[str, Any]) -> dict[str, Any]: document = params.get("textDocument") or {} uri = str(document.get("uri", "")) changes = params.get("contentChanges") or [] @@ -108,12 +108,12 @@ def _on_did_change(self, params: Dict[str, Any]) -> Dict[str, Any]: self.documents[uri].version = int(document.get("version", 0)) return self._diagnostics_notification(uri, full_text) - def _on_did_close(self, params: Dict[str, Any]) -> None: + def _on_did_close(self, params: dict[str, Any]) -> None: uri = str((params.get("textDocument") or {}).get("uri", "")) self.documents.pop(uri, None) - return None + return - def _completion(self, _params: Dict[str, Any]) -> Dict[str, Any]: + def _completion(self, _params: dict[str, Any]) -> dict[str, Any]: # ``_params`` is part of the LSP request shape but the suggestion # list is identical for every cursor position, so the textDocument # / position payload is intentionally ignored. @@ -130,7 +130,7 @@ def _completion(self, _params: Dict[str, Any]) -> Dict[str, Any]: # --- Diagnostics ----------------------------------------------------- - def _diagnostics_notification(self, uri: str, text: str) -> Dict[str, Any]: + def _diagnostics_notification(self, uri: str, text: str) -> dict[str, Any]: return { "jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", @@ -140,7 +140,7 @@ def _diagnostics_notification(self, uri: str, text: str) -> Dict[str, Any]: }, } - def _lint_diagnostics(self, text: str) -> List[Dict[str, Any]]: + def _lint_diagnostics(self, text: str) -> list[dict[str, Any]]: if not text.strip(): return [] try: @@ -151,7 +151,7 @@ def _lint_diagnostics(self, text: str) -> List[Dict[str, Any]]: if not isinstance(actions, list): return [_diagnostic("Action document root must be a JSON array.", line=0, severity=1)] - diagnostics: List[Dict[str, Any]] = [] + diagnostics: list[dict[str, Any]] = [] try: from je_web_runner.utils.linter.action_linter import lint_action except Exception: # pylint: disable=broad-except @@ -168,18 +168,18 @@ def _lint_diagnostics(self, text: str) -> List[Dict[str, Any]]: # --- Helpers --------------------------------------------------------- @staticmethod - def _respond(request_id: Any, result: Any) -> Dict[str, Any]: + def _respond(request_id: Any, result: Any) -> dict[str, Any]: return {"jsonrpc": "2.0", "id": request_id, "result": result} @staticmethod - def _error(request_id: Any, code: int, message: str) -> Dict[str, Any]: + def _error(request_id: Any, code: int, message: str) -> dict[str, Any]: return { "jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}, } -def _diagnostic(error_message: str, line: int, severity: int) -> Dict[str, Any]: +def _diagnostic(error_message: str, line: int, severity: int) -> dict[str, Any]: return { "range": { "start": {"line": max(0, line), "character": 0}, @@ -196,8 +196,8 @@ def _diagnostic(error_message: str, line: int, severity: int) -> Dict[str, Any]: _HEADER_TERMINATOR = "\r\n\r\n" -def _read_message(stdin: TextIO) -> Optional[Dict[str, Any]]: - headers: Dict[str, str] = {} +def _read_message(stdin: TextIO) -> dict[str, Any] | None: + headers: dict[str, str] = {} while True: line = stdin.readline() if line == "": @@ -224,16 +224,16 @@ def _read_message(stdin: TextIO) -> Optional[Dict[str, Any]]: raise ActionLspError(f"body is not JSON: {error}") from error -def _write_message(stdout: TextIO, message: Dict[str, Any]) -> None: +def _write_message(stdout: TextIO, message: dict[str, Any]) -> None: body = json.dumps(message, ensure_ascii=False) stdout.write(f"Content-Length: {len(body.encode('utf-8'))}\r\n\r\n{body}") stdout.flush() def serve_stdio( - stdin: Optional[TextIO] = None, - stdout: Optional[TextIO] = None, - server: Optional[ActionLspServer] = None, + stdin: TextIO | None = None, + stdout: TextIO | None = None, + server: ActionLspServer | None = None, ) -> None: """Run the LSP loop until stdin EOF or an ``exit`` notification.""" in_stream = stdin or sys.stdin diff --git a/je_web_runner/element/web_element_wrapper.py b/je_web_runner/element/web_element_wrapper.py index 83958a0f..35b62355 100644 --- a/je_web_runner/element/web_element_wrapper.py +++ b/je_web_runner/element/web_element_wrapper.py @@ -1,6 +1,5 @@ from __future__ import annotations -from typing import List, Union from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.support.ui import Select @@ -10,15 +9,15 @@ from je_web_runner.utils.test_record.test_record_class import record_action_to_list -class WebElementWrapper(object): +class WebElementWrapper: def __init__(self): # 當前操作的單一 WebElement # Current active WebElement - self.current_web_element: Union[WebElement, None] = None + self.current_web_element: WebElement | None = None # 當前操作的 WebElement 清單 # Current list of WebElements - self.current_web_element_list: Union[List[WebElement], None] = None + self.current_web_element_list: list[WebElement] | None = None def submit(self) -> None: """ @@ -30,7 +29,7 @@ def submit(self) -> None: self.current_web_element.submit() record_action_to_list("Web element submit", None, None) except Exception as error: - web_runner_logger.error(f"WebElementWrapper submit, failed: {repr(error)}") + web_runner_logger.error(f"WebElementWrapper submit, failed: {error!r}") record_action_to_list("Web element submit", None, error) def clear(self) -> None: @@ -43,7 +42,7 @@ def clear(self) -> None: self.current_web_element.clear() record_action_to_list("Web element clear", None, None) except Exception as error: - web_runner_logger.error(f"WebElementWrapper clear, failed: {repr(error)}") + web_runner_logger.error(f"WebElementWrapper clear, failed: {error!r}") record_action_to_list("Web element clear", None, error) def get_property(self, name: str) -> None | str | bool | WebElement | dict: @@ -59,7 +58,7 @@ def get_property(self, name: str) -> None | str | bool | WebElement | dict: record_action_to_list("Web element get_property", param, None) return self.current_web_element.get_property(name) except Exception as error: - web_runner_logger.error(f"WebElementWrapper get_property, name: {name}, failed: {repr(error)}") + web_runner_logger.error(f"WebElementWrapper get_property, name: {name}, failed: {error!r}") record_action_to_list("Web element get_property", param, error) def get_dom_attribute(self, name: str) -> str | None: @@ -75,7 +74,7 @@ def get_dom_attribute(self, name: str) -> str | None: record_action_to_list("Web element get_dom_attribute", param, None) return self.current_web_element.get_dom_attribute(name) except Exception as error: - web_runner_logger.error(f"WebElementWrapper get_dom_attribute, name: {name}, failed: {repr(error)}") + web_runner_logger.error(f"WebElementWrapper get_dom_attribute, name: {name}, failed: {error!r}") record_action_to_list("Web element get_dom_attribute", param, error) def get_attribute(self, name: str) -> str | None: @@ -91,7 +90,7 @@ def get_attribute(self, name: str) -> str | None: record_action_to_list("Web element get_attribute", param, None) return self.current_web_element.get_attribute(name) except Exception as error: - web_runner_logger.error(f"WebElementWrapper get_attribute, name: {name}, failed: {repr(error)}") + web_runner_logger.error(f"WebElementWrapper get_attribute, name: {name}, failed: {error!r}") record_action_to_list("Web element get_attribute", param, error) def is_selected(self) -> bool | None: @@ -105,7 +104,7 @@ def is_selected(self) -> bool | None: record_action_to_list("Web element is_selected", None, None) return self.current_web_element.is_selected() except Exception as error: - web_runner_logger.error(f"WebElementWrapper is_selected, failed: {repr(error)}") + web_runner_logger.error(f"WebElementWrapper is_selected, failed: {error!r}") record_action_to_list("Web element is_selected", None, error) def is_enabled(self) -> bool | None: @@ -119,7 +118,7 @@ def is_enabled(self) -> bool | None: record_action_to_list("Web element is_enabled", None, None) return self.current_web_element.is_enabled() except Exception as error: - web_runner_logger.error(f"WebElementWrapper is_enabled, failed: {repr(error)}") + web_runner_logger.error(f"WebElementWrapper is_enabled, failed: {error!r}") record_action_to_list("Web element is_enabled", None, error) def input_to_element(self, input_value: str) -> None: @@ -135,7 +134,7 @@ def input_to_element(self, input_value: str) -> None: record_action_to_list("Web element input_to_element", param, None) except Exception as error: web_runner_logger.error( - f"WebElementWrapper input_to_element, input_value: {input_value}, failed: {repr(error)}") + f"WebElementWrapper input_to_element, input_value: {input_value}, failed: {error!r}") record_action_to_list("Web element input_to_element", param, error) def click_element(self) -> None: @@ -148,7 +147,7 @@ def click_element(self) -> None: self.current_web_element.click() record_action_to_list("Web element click_element", None, None) except Exception as error: - web_runner_logger.error(f"WebElementWrapper click_element, failed: {repr(error)}") + web_runner_logger.error(f"WebElementWrapper click_element, failed: {error!r}") record_action_to_list("Web element click_element", None, error) def is_displayed(self) -> bool | None: @@ -162,7 +161,7 @@ def is_displayed(self) -> bool | None: record_action_to_list("Web element is_displayed", None, None) return self.current_web_element.is_displayed() except Exception as error: - web_runner_logger.error(f"WebElementWrapper is_displayed, failed: {repr(error)}") + web_runner_logger.error(f"WebElementWrapper is_displayed, failed: {error!r}") record_action_to_list("Web element is_displayed", None, error) def value_of_css_property(self, property_name: str) -> str | None: @@ -179,7 +178,7 @@ def value_of_css_property(self, property_name: str) -> str | None: return self.current_web_element.value_of_css_property(property_name) except Exception as error: web_runner_logger.error( - f"WebElementWrapper value_of_css_property, property_name: {property_name}, failed: {repr(error)}") + f"WebElementWrapper value_of_css_property, property_name: {property_name}, failed: {error!r}") record_action_to_list("Web element value_of_css_property", param, error) def screenshot(self, filename: str) -> bool | None: @@ -195,7 +194,7 @@ def screenshot(self, filename: str) -> bool | None: record_action_to_list("Web element screenshot", param, None) return self.current_web_element.screenshot(filename + ".png") except Exception as error: - web_runner_logger.info(f"WebElementWrapper screenshot, filename: {filename}, failed: {repr(error)}") + web_runner_logger.info(f"WebElementWrapper screenshot, filename: {filename}, failed: {error!r}") record_action_to_list("Web element screenshot", param, error) def change_web_element(self, element_index: int) -> None: @@ -211,7 +210,7 @@ def change_web_element(self, element_index: int) -> None: record_action_to_list("Web element change_web_element", param, None) except Exception as error: web_runner_logger.error( - f"WebElementWrapper change_web_element, element_index: {element_index}, failed: {repr(error)}") + f"WebElementWrapper change_web_element, element_index: {element_index}, failed: {error!r}") record_action_to_list("Web element change_web_element", param, error) def check_current_web_element(self, check_dict: dict) -> None: @@ -237,7 +236,7 @@ def check_current_web_element(self, check_dict: dict) -> None: # 錯誤處理與紀錄 # Handle and log error web_runner_logger.error( - f"WebElementWrapper check_current_web_element, check_dict: {check_dict}, failed: {repr(error)}" + f"WebElementWrapper check_current_web_element, check_dict: {check_dict}, failed: {error!r}" ) record_action_to_list("Web element check_current_web_element", param, error) @@ -253,7 +252,7 @@ def select_by_value(self, value: str) -> None: record_action_to_list("Web element select_by_value", param, None) except Exception as error: web_runner_logger.error( - f"WebElementWrapper select_by_value, value: {value}, failed: {repr(error)}" + f"WebElementWrapper select_by_value, value: {value}, failed: {error!r}" ) record_action_to_list("Web element select_by_value", param, error) @@ -269,7 +268,7 @@ def select_by_index(self, index: int) -> None: record_action_to_list("Web element select_by_index", param, None) except Exception as error: web_runner_logger.error( - f"WebElementWrapper select_by_index, index: {index}, failed: {repr(error)}" + f"WebElementWrapper select_by_index, index: {index}, failed: {error!r}" ) record_action_to_list("Web element select_by_index", param, error) @@ -285,7 +284,7 @@ def select_by_visible_text(self, text: str) -> None: record_action_to_list("Web element select_by_visible_text", param, None) except Exception as error: web_runner_logger.error( - f"WebElementWrapper select_by_visible_text, text: {text}, failed: {repr(error)}" + f"WebElementWrapper select_by_visible_text, text: {text}, failed: {error!r}" ) record_action_to_list("Web element select_by_visible_text", param, error) @@ -309,7 +308,7 @@ def get_select(self) -> Select | None: except Exception as error: # 錯誤處理與紀錄 # Handle and log error - web_runner_logger.error(f"WebElementWrapper get_select, failed: {repr(error)}") + web_runner_logger.error(f"WebElementWrapper get_select, failed: {error!r}") record_action_to_list("Web element get_select", None, error) diff --git a/je_web_runner/manager/webrunner_manager.py b/je_web_runner/manager/webrunner_manager.py index 1dec75b9..1155e48b 100644 --- a/je_web_runner/manager/webrunner_manager.py +++ b/je_web_runner/manager/webrunner_manager.py @@ -1,4 +1,3 @@ -from typing import List, Union from selenium.common.exceptions import WebDriverException from selenium.webdriver.remote.webdriver import WebDriver @@ -12,7 +11,7 @@ from je_web_runner.webdriver.webdriver_wrapper import webdriver_wrapper_instance -class WebdriverManager(object): +class WebdriverManager: def __init__(self, **kwargs): # 當前 WebDriver 實例清單 # List of current WebDriver instances @@ -28,9 +27,9 @@ def __init__(self, **kwargs): # 當前使用的 WebDriver # Current active WebDriver - self.current_webdriver: Union[WebDriver, None] = None + self.current_webdriver: WebDriver | None = None - def new_driver(self, webdriver_name: str, options: List[str] = None, **kwargs) -> None: + def new_driver(self, webdriver_name: str, options: list[str] | None = None, **kwargs) -> None: """ 建立新的 WebDriver 實例 Create a new WebDriver instance @@ -52,7 +51,7 @@ def new_driver(self, webdriver_name: str, options: List[str] = None, **kwargs) - # 錯誤處理與關閉所有 WebDriver # Handle error and quit all WebDrivers web_runner_logger.error( - f"WebdriverManager new_driver, webdriver_name: {webdriver_name}, params: {kwargs}, failed: {repr(error)}" + f"WebdriverManager new_driver, webdriver_name: {webdriver_name}, params: {kwargs}, failed: {error!r}" ) record_action_to_list("web runner manager new_driver", param, error) self.quit() @@ -68,11 +67,13 @@ def change_webdriver(self, index_of_webdriver: int) -> None: param = locals() try: self.current_webdriver = self._current_webdriver_list[index_of_webdriver] - self.webdriver_wrapper.current_webdriver = self.current_webdriver + # Rebind via set_active_driver so the wrapper's ActionChains follow + # the switched driver instead of staying on the previous one. + self.webdriver_wrapper.set_active_driver(self.current_webdriver) record_action_to_list("web runner manager change_webdriver", param, None) except Exception as error: web_runner_logger.error( - f"WebdriverManager change_webdriver, index_of_webdriver: {index_of_webdriver}, failed: {repr(error)}") + f"WebdriverManager change_webdriver, index_of_webdriver: {index_of_webdriver}, failed: {error!r}") record_action_to_list("web runner manager change_webdriver", param, error) def close_current_webdriver(self) -> None: @@ -86,7 +87,7 @@ def close_current_webdriver(self) -> None: self.current_webdriver.close() record_action_to_list("web runner manager close_current_webdriver", None, None) except Exception as error: - web_runner_logger.error(f"WebdriverManager close_current_webdriver, failed: {repr(error)}") + web_runner_logger.error(f"WebdriverManager close_current_webdriver, failed: {error!r}") record_action_to_list("web runner manager close_current_webdriver", None, error) def close_choose_webdriver(self, webdriver_index: int) -> None: @@ -104,7 +105,7 @@ def close_choose_webdriver(self, webdriver_index: int) -> None: self._current_webdriver_list.remove(self._current_webdriver_list[webdriver_index]) record_action_to_list("web runner manager close_choose_webdriver", param, None) except Exception as error: - web_runner_logger.error(f"WebdriverManager close_choose_webdriver, failed: {repr(error)}") + web_runner_logger.error(f"WebdriverManager close_choose_webdriver, failed: {error!r}") record_action_to_list("web runner manager close_choose_webdriver", param, error) def quit(self) -> None: @@ -129,9 +130,9 @@ def quit(self) -> None: self._current_webdriver_list = [] record_action_to_list("web runner manager quit", None, None) except Exception as error: - web_runner_logger.error(f"WebdriverManager quit, failed: {repr(error)}") + web_runner_logger.error(f"WebdriverManager quit, failed: {error!r}") record_action_to_list("web runner manager quit", None, error) - raise WebDriverException + raise WebDriverException(f"WebdriverManager quit failed: {error!r}") from error def get_webdriver_manager(webdriver_name: str, **kwargs) -> WebdriverManager: diff --git a/je_web_runner/mcp_server/browser_tools.py b/je_web_runner/mcp_server/browser_tools.py index 6c1ce676..e1dac9e4 100644 --- a/je_web_runner/mcp_server/browser_tools.py +++ b/je_web_runner/mcp_server/browser_tools.py @@ -19,7 +19,7 @@ import io from contextlib import redirect_stdout -from typing import Any, Dict, List +from typing import Any from je_web_runner.mcp_server.server import McpServerError, Tool @@ -34,11 +34,11 @@ def _serialize_value(value: Any) -> Any: return repr(value) -def _serialize_record(record: Dict[Any, Any]) -> Dict[str, Any]: +def _serialize_record(record: dict[Any, Any]) -> dict[str, Any]: return {str(key): _serialize_value(value) for key, value in record.items()} -def _tool_run_actions(arguments: Dict[str, Any]) -> Any: +def _tool_run_actions(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.executor.action_executor import execute_action actions = arguments.get("actions") if not isinstance(actions, list): @@ -49,7 +49,7 @@ def _tool_run_actions(arguments: Dict[str, Any]) -> Any: return {"stdout": buffer.getvalue(), "record": _serialize_record(record)} -def _tool_run_action_files(arguments: Dict[str, Any]) -> Any: +def _tool_run_action_files(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.executor.action_executor import execute_files files = arguments.get("files") if not isinstance(files, list): @@ -65,12 +65,12 @@ def _tool_run_action_files(arguments: Dict[str, Any]) -> Any: } -def _tool_list_commands(_arguments: Dict[str, Any]) -> Any: +def _tool_list_commands(_arguments: dict[str, Any]) -> Any: from je_web_runner.utils.executor.action_executor import executor return sorted(name for name in executor.event_dict if name.startswith("WR_")) -def build_browser_tools() -> List[Tool]: +def build_browser_tools() -> list[Tool]: """Return the browser-execution MCP tools.""" return [ Tool( diff --git a/je_web_runner/mcp_server/server.py b/je_web_runner/mcp_server/server.py index 5231f822..b2db49f5 100644 --- a/je_web_runner/mcp_server/server.py +++ b/je_web_runner/mcp_server/server.py @@ -17,7 +17,7 @@ import sys import traceback from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Optional, TextIO +from typing import Any, Callable, TextIO from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -41,10 +41,10 @@ class McpServerError(WebRunnerException): class Tool: name: str description: str - input_schema: Dict[str, Any] - handler: Callable[[Dict[str, Any]], Any] + input_schema: dict[str, Any] + handler: Callable[[dict[str, Any]], Any] - def schema(self) -> Dict[str, Any]: + def schema(self) -> dict[str, Any]: return { "name": self.name, "description": self.description, @@ -56,7 +56,7 @@ def schema(self) -> Dict[str, Any]: class McpServer: """JSON-RPC 2.0 server that speaks the MCP wire protocol over stdio.""" - tools: Dict[str, Tool] = field(default_factory=dict) + tools: dict[str, Tool] = field(default_factory=dict) initialized: bool = False def register(self, tool: Tool) -> None: @@ -64,7 +64,7 @@ def register(self, tool: Tool) -> None: raise McpServerError(f"tool {tool.name!r} already registered") self.tools[tool.name] = tool - def handle(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def handle(self, message: dict[str, Any]) -> dict[str, Any] | None: request_id = message.get("id") method = message.get("method") params = message.get("params") or {} @@ -79,9 +79,7 @@ def handle(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]: result = self._tools_call(params) elif method == "resources/list": result = {"resources": []} - elif method == "ping": - result = {} - elif method == "shutdown": + elif method == "ping" or method == "shutdown": result = {} elif method == "notifications/initialized": self.initialized = True @@ -99,7 +97,7 @@ def handle(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]: return None return {"jsonrpc": "2.0", "id": request_id, "result": result} - def _initialize(self, params: Dict[str, Any]) -> Dict[str, Any]: + def _initialize(self, params: dict[str, Any]) -> dict[str, Any]: client_version = params.get("protocolVersion") web_runner_logger.info(f"mcp initialize from clientProtocol={client_version!r}") return { @@ -108,10 +106,10 @@ def _initialize(self, params: Dict[str, Any]) -> Dict[str, Any]: "serverInfo": {"name": _SERVER_NAME, "version": _SERVER_VERSION}, } - def _tools_list(self) -> Dict[str, Any]: + def _tools_list(self) -> dict[str, Any]: return {"tools": [tool.schema() for tool in self.tools.values()]} - def _tools_call(self, params: Dict[str, Any]) -> Dict[str, Any]: + def _tools_call(self, params: dict[str, Any]) -> dict[str, Any]: name = params.get("name") arguments = params.get("arguments") or {} if not isinstance(name, str): @@ -133,7 +131,7 @@ def _tools_call(self, params: Dict[str, Any]) -> Dict[str, Any]: return {"content": [{"type": "text", "text": rendered}], "isError": False} @staticmethod - def _error(request_id: Any, code: int, message: str) -> Dict[str, Any]: + def _error(request_id: Any, code: int, message: str) -> dict[str, Any]: return { "jsonrpc": "2.0", "id": request_id, @@ -145,7 +143,7 @@ def _error(request_id: Any, code: int, message: str) -> Dict[str, Any]: # Default tool registry — wired to the existing WebRunner modules # -------------------------------------------------------------------------- -def _tool_lint_action(arguments: Dict[str, Any]) -> Any: +def _tool_lint_action(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.linter.action_linter import lint_action actions = arguments.get("actions") if not isinstance(actions, list): @@ -156,7 +154,7 @@ def _tool_lint_action(arguments: Dict[str, Any]) -> Any: return list(lint_action(actions)) -def _tool_locator_strength(arguments: Dict[str, Any]) -> Any: +def _tool_locator_strength(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.linter.locator_strength import score_locator score = score_locator( str(arguments.get("strategy", "")), @@ -170,7 +168,7 @@ def _tool_locator_strength(arguments: Dict[str, Any]) -> Any: } -def _tool_render_template(arguments: Dict[str, Any]) -> Any: +def _tool_render_template(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.action_templates.templates import render_template return render_template( str(arguments.get("template", "")), @@ -178,12 +176,12 @@ def _tool_render_template(arguments: Dict[str, Any]) -> Any: ) -def _tool_compute_trend(arguments: Dict[str, Any]) -> Any: +def _tool_compute_trend(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.trend_dashboard.trend import compute_trend return compute_trend(str(arguments.get("ledger_path", ""))) -def _tool_validate_response(arguments: Dict[str, Any]) -> Any: +def _tool_validate_response(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.contract_testing.contract import validate_response body = arguments.get("body") schema = arguments.get("schema") @@ -193,7 +191,7 @@ def _tool_validate_response(arguments: Dict[str, Any]) -> Any: return {"valid": result.valid, "errors": result.errors} -def _tool_summary_markdown(arguments: Dict[str, Any]) -> Any: +def _tool_summary_markdown(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.pr_comment.poster import ( PrSummary, build_summary_markdown, @@ -209,14 +207,14 @@ def _tool_summary_markdown(arguments: Dict[str, Any]) -> Any: return build_summary_markdown(summary, run_url=arguments.get("run_url")) -def _tool_diff_shard(arguments: Dict[str, Any]) -> Any: +def _tool_diff_shard(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.sharding.diff_shard import select_action_files candidates = arguments.get("candidates") or [] changed = arguments.get("changed") or [] return select_action_files(list(candidates), list(changed)) -def _tool_render_k8s(arguments: Dict[str, Any]) -> Any: +def _tool_render_k8s(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.k8s_runner.manifest import ( ShardJobConfig, render_job_manifests, @@ -230,7 +228,7 @@ def _tool_render_k8s(arguments: Dict[str, Any]) -> Any: return render_job_manifests(config) -def _tool_partition(arguments: Dict[str, Any]) -> Any: +def _tool_partition(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.sharding.shard import partition return partition( list(arguments.get("paths") or []), @@ -239,7 +237,7 @@ def _tool_partition(arguments: Dict[str, Any]) -> Any: ) -def _tool_format_actions(arguments: Dict[str, Any]) -> Any: +def _tool_format_actions(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.action_formatter.formatter import format_actions actions = arguments.get("actions") if not isinstance(actions, list): @@ -247,7 +245,7 @@ def _tool_format_actions(arguments: Dict[str, Any]) -> Any: return format_actions(actions, indent=int(arguments.get("indent", 2))) -def _tool_parse_markdown(arguments: Dict[str, Any]) -> Any: +def _tool_parse_markdown(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.md_authoring.markdown_to_actions import parse_markdown text = arguments.get("text") if not isinstance(text, str): @@ -255,7 +253,7 @@ def _tool_parse_markdown(arguments: Dict[str, Any]) -> Any: return parse_markdown(text) -def _tool_translate_actions_to_playwright(arguments: Dict[str, Any]) -> Any: +def _tool_translate_actions_to_playwright(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.sel_to_pw.translator import translate_action_list actions = arguments.get("actions") if not isinstance(actions, list): @@ -263,7 +261,7 @@ def _tool_translate_actions_to_playwright(arguments: Dict[str, Any]) -> Any: return translate_action_list(actions) -def _tool_translate_python_to_playwright(arguments: Dict[str, Any]) -> Any: +def _tool_translate_python_to_playwright(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.sel_to_pw.translator import translate_python_source source = arguments.get("source") if not isinstance(source, str): @@ -276,7 +274,7 @@ def _tool_translate_python_to_playwright(arguments: Dict[str, Any]) -> Any: ] -def _tool_pom_from_html(arguments: Dict[str, Any]) -> Any: +def _tool_pom_from_html(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.pom_codegen.codegen import ( discover_elements_from_html, render_pom_module, @@ -296,7 +294,7 @@ def _tool_pom_from_html(arguments: Dict[str, Any]) -> Any: } -def _tool_scan_pii(arguments: Dict[str, Any]) -> Any: +def _tool_scan_pii(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.pii_scanner.scanner import scan_text text = arguments.get("text") if not isinstance(text, str): @@ -310,7 +308,7 @@ def _tool_scan_pii(arguments: Dict[str, Any]) -> Any: ] -def _tool_redact_pii(arguments: Dict[str, Any]) -> Any: +def _tool_redact_pii(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.pii_scanner.scanner import redact_text text = arguments.get("text") if not isinstance(text, str): @@ -322,7 +320,7 @@ def _tool_redact_pii(arguments: Dict[str, Any]) -> Any: ) -def _tool_cluster_failures(arguments: Dict[str, Any]) -> Any: +def _tool_cluster_failures(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.failure_cluster.clustering import ( cluster_failures, cluster_summary, @@ -337,7 +335,7 @@ def _tool_cluster_failures(arguments: Dict[str, Any]) -> Any: return cluster_summary(clusters) -def _tool_a11y_diff(arguments: Dict[str, Any]) -> Any: +def _tool_a11y_diff(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.accessibility.a11y_diff import diff_violations baseline = arguments.get("baseline") current = arguments.get("current") @@ -352,7 +350,7 @@ def _tool_a11y_diff(arguments: Dict[str, Any]) -> Any: } -def _tool_score_action_locators(arguments: Dict[str, Any]) -> Any: +def _tool_score_action_locators(arguments: dict[str, Any]) -> Any: from je_web_runner.utils.linter.locator_strength import score_action_locators actions = arguments.get("actions") if not isinstance(actions, list): @@ -360,7 +358,7 @@ def _tool_score_action_locators(arguments: Dict[str, Any]) -> Any: return list(score_action_locators(actions)) -def build_default_tools() -> List[Tool]: +def build_default_tools() -> list[Tool]: """Construct the default tool list shipped with the server.""" return [ Tool( @@ -636,9 +634,9 @@ def make_default_server() -> McpServer: def serve_stdio( - stdin: Optional[TextIO] = None, - stdout: Optional[TextIO] = None, - server: Optional[McpServer] = None, + stdin: TextIO | None = None, + stdout: TextIO | None = None, + server: McpServer | None = None, ) -> None: """ 主迴圈:每行一個 JSON-RPC 2.0 訊息,直到 stdin EOF @@ -681,6 +679,6 @@ def _dispatch(message: Any, server: McpServer, out_stream: TextIO) -> None: _write_message(out_stream, response) -def _write_message(out_stream: TextIO, message: Dict[str, Any]) -> None: +def _write_message(out_stream: TextIO, message: dict[str, Any]) -> None: out_stream.write(json.dumps(message, ensure_ascii=False) + "\n") out_stream.flush() diff --git a/je_web_runner/py.typed b/je_web_runner/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/je_web_runner/utils/a11y_trend/trend.py b/je_web_runner/utils/a11y_trend/trend.py index 269c9a66..fba5c559 100644 --- a/je_web_runner/utils/a11y_trend/trend.py +++ b/je_web_runner/utils/a11y_trend/trend.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Any, Dict, Iterable, List, Union +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -22,7 +22,7 @@ class A11yTrendError(WebRunnerException): @dataclass class A11yTrendPoint: label: str # YYYY-MM-DD - impacts: Dict[str, int] = field(default_factory=dict) + impacts: dict[str, int] = field(default_factory=dict) @property def total(self) -> int: @@ -38,7 +38,7 @@ def _bucket_label(timestamp: Any) -> str: return timestamp[:10] if len(timestamp) >= 10 else "unknown" -def aggregate_history(history: Iterable[Dict[str, Any]]) -> List[A11yTrendPoint]: +def aggregate_history(history: Iterable[dict[str, Any]]) -> list[A11yTrendPoint]: """ 把 ``[{timestamp, violations:[{impact,...}]}, …]`` 按天彙總每個 impact 的計數。 Bucket history entries by day and count each violation's ``impact`` @@ -46,13 +46,13 @@ def aggregate_history(history: Iterable[Dict[str, Any]]) -> List[A11yTrendPoint] """ if history is None: raise A11yTrendError("history must be iterable") - buckets: Dict[str, A11yTrendPoint] = {} + buckets: dict[str, A11yTrendPoint] = {} for index, entry in enumerate(history): _absorb_entry(buckets, index, entry) return sorted(buckets.values(), key=lambda p: p.label) -def _absorb_entry(buckets: Dict[str, A11yTrendPoint], index: int, entry: Any) -> None: +def _absorb_entry(buckets: dict[str, A11yTrendPoint], index: int, entry: Any) -> None: if not isinstance(entry, dict): raise A11yTrendError(f"history[{index}] must be an object") label = _bucket_label(entry.get("timestamp")) @@ -73,10 +73,10 @@ def _count_violation(point: A11yTrendPoint, violation: Any) -> None: point.impacts[impact] = point.impacts.get(impact, 0) + count -def render_html(points: List[A11yTrendPoint], title: str = "A11y trend") -> str: +def render_html(points: list[A11yTrendPoint], title: str = "A11y trend") -> str: """Render a self-contained HTML page with table + SVG line chart.""" rows = [] - impact_keys = sorted({impact for point in points for impact in point.impacts.keys()}) + impact_keys = sorted({impact for point in points for impact in point.impacts}) for point in points: cells = "".join( f"{point.impacts.get(key, 0)}" for key in impact_keys @@ -102,7 +102,7 @@ def render_html(points: List[A11yTrendPoint], title: str = "A11y trend") -> str: """ -def _render_svg(points: List[A11yTrendPoint]) -> str: +def _render_svg(points: list[A11yTrendPoint]) -> str: if not points: return "

No history yet.

" width, height, margin = 720, 200, 30 @@ -132,7 +132,7 @@ def _render_svg(points: List[A11yTrendPoint]) -> str: ) -def write_dashboard(history: Iterable[Dict[str, Any]], output_path: Union[str, Path], +def write_dashboard(history: Iterable[dict[str, Any]], output_path: str | Path, title: str = "A11y trend") -> Path: """Aggregate ``history`` and write the HTML dashboard to ``output_path``.""" points = aggregate_history(history) @@ -143,7 +143,7 @@ def write_dashboard(history: Iterable[Dict[str, Any]], output_path: Union[str, P return target -def load_history(path: Union[str, Path]) -> List[Dict[str, Any]]: +def load_history(path: str | Path) -> list[dict[str, Any]]: """Read an ``a11y-history.json`` file (``[{timestamp, violations}, …]``).""" fp = Path(path) if not fp.is_file(): diff --git a/je_web_runner/utils/ab_run/ab_runner.py b/je_web_runner/utils/ab_run/ab_runner.py index a21a5fff..fd31da04 100644 --- a/je_web_runner/utils/ab_run/ab_runner.py +++ b/je_web_runner/utils/ab_run/ab_runner.py @@ -6,7 +6,7 @@ """ from __future__ import annotations -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -27,17 +27,17 @@ class ABRunError(WebRunnerException): _NO_EXCEPTION = "None" -def _snapshot_records() -> List[Dict[str, Any]]: +def _snapshot_records() -> list[dict[str, Any]]: """Take a deep-ish copy of the current records buffer.""" return [dict(record) for record in test_record_instance.test_record_list] def _run_one_side( label: str, - setup: Optional[Callable[[], Any]], + setup: Callable[[], Any] | None, action_data: Any, runner: Callable[[Any], Any], -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: web_runner_logger.info(f"ab_run side={label}") test_record_instance.clean_record() if setup is not None: @@ -46,14 +46,14 @@ def _run_one_side( return _snapshot_records() -def _step_status(record: Dict[str, Any]) -> str: +def _step_status(record: dict[str, Any]) -> str: return "failed" if record.get("program_exception", _NO_EXCEPTION) != _NO_EXCEPTION else "passed" def diff_records( - records_a: List[Dict[str, Any]], - records_b: List[Dict[str, Any]], -) -> Dict[str, Any]: + records_a: list[dict[str, Any]], + records_b: list[dict[str, Any]], +) -> dict[str, Any]: """ 比對兩側的 record 序列;回傳每步的差異 Compare two record sequences step-by-step and return summary + diffs. @@ -63,8 +63,8 @@ def diff_records( "len_b": len(records_b), "length_match": len(records_a) == len(records_b), } - differences: List[Dict[str, Any]] = [] - pairs = zip(records_a, records_b) + differences: list[dict[str, Any]] = [] + pairs = zip(records_a, records_b, strict=False) for index, (left, right) in enumerate(pairs): left_status = _step_status(left) right_status = _step_status(right) @@ -87,10 +87,10 @@ def diff_records( def run_ab( action_data: Any, - setup_a: Optional[Callable[[], Any]] = None, - setup_b: Optional[Callable[[], Any]] = None, - runner: Optional[Callable[[Any], Any]] = None, -) -> Dict[str, Any]: + setup_a: Callable[[], Any] | None = None, + setup_b: Callable[[], Any] | None = None, + runner: Callable[[Any], Any] | None = None, +) -> dict[str, Any]: """ 對兩個環境跑同一份 action 並回傳比對結果 Run ``action_data`` against two environments. ``setup_a`` / ``setup_b`` diff --git a/je_web_runner/utils/accessibility/a11y_diff.py b/je_web_runner/utils/accessibility/a11y_diff.py index 06505d53..8241e1f4 100644 --- a/je_web_runner/utils/accessibility/a11y_diff.py +++ b/je_web_runner/utils/accessibility/a11y_diff.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple +from typing import Any, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -21,15 +21,15 @@ class A11yDiffError(WebRunnerException): class _Finding: rule_id: str target: str - impact: Optional[str] = None - summary: Optional[str] = None + impact: str | None = None + summary: str | None = None @dataclass class A11yDiff: - added: List[Dict[str, Any]] = field(default_factory=list) - resolved: List[Dict[str, Any]] = field(default_factory=list) - persisting: List[Dict[str, Any]] = field(default_factory=list) + added: list[dict[str, Any]] = field(default_factory=list) + resolved: list[dict[str, Any]] = field(default_factory=list) + persisting: list[dict[str, Any]] = field(default_factory=list) @property def regressed(self) -> bool: @@ -44,8 +44,8 @@ def total_current(self) -> int: return len(self.added) + len(self.persisting) -def _flatten(violations: Iterable[Any]) -> List[_Finding]: - findings: List[_Finding] = [] +def _flatten(violations: Iterable[Any]) -> list[_Finding]: + findings: list[_Finding] = [] for entry in violations: if not isinstance(entry, dict): raise A11yDiffError("violations entries must be objects") @@ -76,7 +76,7 @@ def _node_target(node: Any) -> str: return "" -def _to_dict(finding: _Finding) -> Dict[str, Any]: +def _to_dict(finding: _Finding) -> dict[str, Any]: return { "rule_id": finding.rule_id, "target": finding.target, @@ -93,7 +93,7 @@ def diff_violations( baseline_findings = _flatten(baseline) current_findings = _flatten(current) - def keyed(items: Iterable[_Finding]) -> Dict[Tuple[str, str], _Finding]: + def keyed(items: Iterable[_Finding]) -> dict[tuple[str, str], _Finding]: return {(f.rule_id, f.target): f for f in items} baseline_keyed = keyed(baseline_findings) @@ -112,7 +112,7 @@ def keyed(items: Iterable[_Finding]) -> Dict[Tuple[str, str], _Finding]: def assert_no_regressions(diff: A11yDiff, - allow_rules: Optional[Sequence[str]] = None) -> None: + allow_rules: Sequence[str] | None = None) -> None: """Raise if ``diff.added`` is non-empty (after applying ``allow_rules``).""" allow = set(allow_rules or []) bad = [a for a in diff.added if a.get("rule_id") not in allow] diff --git a/je_web_runner/utils/accessibility/axe_audit.py b/je_web_runner/utils/accessibility/axe_audit.py index 2701dba2..bda59b7a 100644 --- a/je_web_runner/utils/accessibility/axe_audit.py +++ b/je_web_runner/utils/accessibility/axe_audit.py @@ -16,7 +16,7 @@ import json as _json from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -45,8 +45,8 @@ def _selenium_driver(): def selenium_run_audit( axe_source: str, - options: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: + options: dict[str, Any] | None = None, +) -> dict[str, Any]: """ 在當前 Selenium 頁面執行 axe.run,回傳結果 dict Run ``axe.run`` on the current Selenium page and return the result dict. @@ -73,8 +73,8 @@ def selenium_run_audit( def playwright_run_audit( axe_source: str, - options: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: + options: dict[str, Any] | None = None, +) -> dict[str, Any]: """ 在當前 Playwright 頁面執行 axe.run,回傳結果 dict Run ``axe.run`` on the current Playwright page. @@ -88,14 +88,14 @@ def playwright_run_audit( ) -def summarise_violations(results: Dict[str, Any]) -> List[Dict[str, Any]]: +def summarise_violations(results: dict[str, Any]) -> list[dict[str, Any]]: """ 將 axe 結果壓縮成只含 ``id`` / ``impact`` / ``help`` / ``nodes`` 數量的清單 Compress the axe results into a thin list of {id, impact, help, nodes}. """ if not isinstance(results, dict): return [] - summary: List[Dict[str, Any]] = [] + summary: list[dict[str, Any]] = [] for violation in results.get("violations", []) or []: summary.append({ "id": violation.get("id"), diff --git a/je_web_runner/utils/action_formatter/formatter.py b/je_web_runner/utils/action_formatter/formatter.py index 6b37d340..1a0f2a83 100644 --- a/je_web_runner/utils/action_formatter/formatter.py +++ b/je_web_runner/utils/action_formatter/formatter.py @@ -17,7 +17,7 @@ import json from pathlib import Path -from typing import Any, Dict, List, Tuple, Union +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -41,13 +41,13 @@ class ActionFormatterError(WebRunnerException): ) -def _sorted_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]: +def _sorted_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: """Return kwargs with the canonical key order applied.""" if not isinstance(kwargs, dict): raise ActionFormatterError("action kwargs must be a dict") preferred_present = [k for k in _PREFERRED_KWARGS_ORDER if k in kwargs] - rest = sorted(k for k in kwargs.keys() if k not in _PREFERRED_KWARGS_ORDER) - ordered: Dict[str, Any] = {} + rest = sorted(k for k in kwargs if k not in _PREFERRED_KWARGS_ORDER) + ordered: dict[str, Any] = {} for key in preferred_present + rest: value = kwargs[key] ordered[key] = _canonicalise(value) @@ -62,7 +62,7 @@ def _canonicalise(value: Any) -> Any: return value -def _format_action_line(action: List[Any]) -> str: +def _format_action_line(action: list[Any]) -> str: if not isinstance(action, list) or not action: raise ActionFormatterError(f"action must be a non-empty list: {action!r}") command = action[0] @@ -100,7 +100,7 @@ def _format_action_line(action: List[Any]) -> str: ) -def format_actions(actions: List[Any], indent: int = 2) -> str: +def format_actions(actions: list[Any], indent: int = 2) -> str: """ 把 action list 轉成 canonical 多行 JSON。``indent`` 為頂層 array 縮排空白數。 Format an action list as canonical JSON. Each action lives on its own @@ -126,8 +126,8 @@ def format_text(text: str, indent: int = 2) -> str: return format_actions(actions, indent=indent) -def format_file(path: Union[str, Path], write: bool = True, - indent: int = 2) -> Tuple[str, bool]: +def format_file(path: str | Path, write: bool = True, + indent: int = 2) -> tuple[str, bool]: """ 讀檔、格式化、(可選)寫回;回傳 ``(formatted_text, changed)``。 Reformat ``path``. When ``write`` is True the file is rewritten only @@ -141,5 +141,5 @@ def format_file(path: Union[str, Path], write: bool = True, formatted = format_text(original, indent=indent) changed = formatted != original if write and changed: - target.write_text(formatted, encoding="utf-8") + target.write_text(formatted, encoding="utf-8") # NOSONAR S2083 — developer-supplied path (own action-JSON file), not untrusted input return formatted, changed diff --git a/je_web_runner/utils/action_refactor_suggester/suggest.py b/je_web_runner/utils/action_refactor_suggester/suggest.py index 4f231fc2..125606d7 100644 --- a/je_web_runner/utils/action_refactor_suggester/suggest.py +++ b/je_web_runner/utils/action_refactor_suggester/suggest.py @@ -16,7 +16,7 @@ from collections import Counter from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Dict, Iterable, List, Sequence +from typing import Any, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -36,9 +36,9 @@ class Suggestion: rule: str severity: Severity message: str - step_indexes: List[int] = field(default_factory=list) + step_indexes: list[int] = field(default_factory=list) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "severity": self.severity.value} @@ -46,7 +46,7 @@ def to_dict(self) -> Dict[str, Any]: _ENGLISH_SENTENCE = re.compile(r"^[A-Z][\w\s\.,!?:'-]{15,}$") -def _normalize(actions: Sequence[Dict[str, Any]]) -> None: +def _normalize(actions: Sequence[dict[str, Any]]) -> None: if not isinstance(actions, (list, tuple)): raise ActionRefactorSuggesterError("actions must be a sequence") for i, action in enumerate(actions): @@ -54,7 +54,7 @@ def _normalize(actions: Sequence[Dict[str, Any]]) -> None: raise ActionRefactorSuggesterError(f"action #{i} is not a dict") -def _hard_sleep_steps(actions: Sequence[Dict[str, Any]]) -> List[int]: +def _hard_sleep_steps(actions: Sequence[dict[str, Any]]) -> list[int]: hits = [] for i, action in enumerate(actions): name = (action.get("action_name") or "").lower() @@ -66,7 +66,7 @@ def _hard_sleep_steps(actions: Sequence[Dict[str, Any]]) -> List[int]: return hits -def _positional_xpath_steps(actions: Sequence[Dict[str, Any]]) -> List[int]: +def _positional_xpath_steps(actions: Sequence[dict[str, Any]]) -> list[int]: return [ i for i, a in enumerate(actions) if (a.get("by") or "").lower() == "xpath" @@ -75,14 +75,14 @@ def _positional_xpath_steps(actions: Sequence[Dict[str, Any]]) -> List[int]: ] -def _duplicated_locators(actions: Sequence[Dict[str, Any]]) -> List[str]: +def _duplicated_locators(actions: Sequence[dict[str, Any]]) -> list[str]: locators = [a.get("by_value") for a in actions if isinstance(a.get("by_value"), str) and a.get("by_value")] counts = Counter(locators) return [k for k, v in counts.items() if v >= 3] -def _english_string_assertions(actions: Sequence[Dict[str, Any]]) -> List[int]: +def _english_string_assertions(actions: Sequence[dict[str, Any]]) -> list[int]: out = [] for i, action in enumerate(actions): name = (action.get("action_name") or "").lower() @@ -94,8 +94,8 @@ def _english_string_assertions(actions: Sequence[Dict[str, Any]]) -> List[int]: def _click_wait_click_bursts( - actions: Sequence[Dict[str, Any]], -) -> List[int]: + actions: Sequence[dict[str, Any]], +) -> list[int]: out = [] for i in range(len(actions) - 2): names = [ @@ -109,10 +109,10 @@ def _click_wait_click_bursts( return out -def analyze(actions: Sequence[Dict[str, Any]]) -> List[Suggestion]: +def analyze(actions: Sequence[dict[str, Any]]) -> list[Suggestion]: """Run all rules and return suggestions sorted by severity.""" _normalize(actions) - out: List[Suggestion] = [] + out: list[Suggestion] = [] sleeps = _hard_sleep_steps(actions) if sleeps: out.append(Suggestion( diff --git a/je_web_runner/utils/action_templates/templates.py b/je_web_runner/utils/action_templates/templates.py index 1db78910..20aa1169 100644 --- a/je_web_runner/utils/action_templates/templates.py +++ b/je_web_runner/utils/action_templates/templates.py @@ -15,7 +15,7 @@ import copy import re from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -27,7 +27,7 @@ class ActionTemplateError(WebRunnerException): @dataclass class ActionTemplate: name: str - actions: List[Any] + actions: list[Any] parameters: Sequence[str] = field(default_factory=tuple) description: str = "" @@ -35,7 +35,7 @@ class ActionTemplate: _PLACEHOLDER_RE = re.compile(r"\{\{\s*([A-Za-z_]\w*)\s*\}\}") -_BUILTIN_TEMPLATES: Dict[str, ActionTemplate] = { +_BUILTIN_TEMPLATES: dict[str, ActionTemplate] = { "login_basic": ActionTemplate( name="login_basic", parameters=("username_locator", "password_locator", "submit_locator", @@ -84,7 +84,7 @@ class ActionTemplate: } -def available_templates() -> List[str]: +def available_templates() -> list[str]: return sorted(_BUILTIN_TEMPLATES.keys()) @@ -102,7 +102,7 @@ def register_template(template: ActionTemplate) -> None: _BUILTIN_TEMPLATES[template.name] = template -def render_template(name: str, parameters: Optional[Dict[str, Any]] = None) -> List[Any]: +def render_template(name: str, parameters: dict[str, Any] | None = None) -> list[Any]: """ 把 ``{{name}}`` 替換成實際值,回傳深拷貝的 action list Substitute every ``{{name}}`` placeholder in the template with the @@ -122,7 +122,7 @@ def render_template(name: str, parameters: Optional[Dict[str, Any]] = None) -> L ] -def _substitute(node: Any, params: Dict[str, Any], template_name: str) -> Any: +def _substitute(node: Any, params: dict[str, Any], template_name: str) -> Any: if isinstance(node, str): return _substitute_text(node, params, template_name) if isinstance(node, list): @@ -132,7 +132,7 @@ def _substitute(node: Any, params: Dict[str, Any], template_name: str) -> Any: return node -def _substitute_text(text: str, params: Dict[str, Any], template_name: str) -> Any: +def _substitute_text(text: str, params: dict[str, Any], template_name: str) -> Any: matches = list(_PLACEHOLDER_RE.finditer(text)) if not matches: return text diff --git a/je_web_runner/utils/adaptive_retry/policy.py b/je_web_runner/utils/adaptive_retry/policy.py index b9793d3d..ff8e9e75 100644 --- a/je_web_runner/utils/adaptive_retry/policy.py +++ b/je_web_runner/utils/adaptive_retry/policy.py @@ -8,7 +8,7 @@ import time from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -40,7 +40,7 @@ class RetryPolicy: real_max: int = 0 base_backoff: float = 0.25 max_backoff: float = 4.0 - history: List[RetryDecision] = field(default_factory=list) + history: list[RetryDecision] = field(default_factory=list) def budget_for(self, category: str) -> int: return { @@ -58,9 +58,9 @@ def backoff_for(self, attempt: int) -> float: def run_with_retry( func: Callable[..., Any], *args: Any, - policy: Optional[RetryPolicy] = None, - ledger_path: Optional[str] = None, - file_path: Optional[str] = None, + policy: RetryPolicy | None = None, + ledger_path: str | None = None, + file_path: str | None = None, sleep: Callable[[float], None] = time.sleep, **kwargs: Any, ) -> Any: @@ -92,8 +92,8 @@ def _record_attempt( policy: RetryPolicy, attempt: int, error: BaseException, - ledger_path: Optional[str], - file_path: Optional[str], + ledger_path: str | None, + file_path: str | None, ) -> RetryDecision: error_repr = repr(error) category = classify(error_repr, ledger_path=ledger_path, file_path=file_path) @@ -113,9 +113,9 @@ def _record_attempt( return decision -def summarise_history(policy: RetryPolicy) -> Dict[str, Any]: +def summarise_history(policy: RetryPolicy) -> dict[str, Any]: """Aggregate decision counts for reporting.""" - by_category: Dict[str, int] = {} + by_category: dict[str, int] = {} for decision in policy.history: by_category[decision.category] = by_category.get(decision.category, 0) + 1 return { diff --git a/je_web_runner/utils/ai_assist/llm_assist.py b/je_web_runner/utils/ai_assist/llm_assist.py index 5f1b1cf0..2647d305 100644 --- a/je_web_runner/utils/ai_assist/llm_assist.py +++ b/je_web_runner/utils/ai_assist/llm_assist.py @@ -9,7 +9,7 @@ import json import re -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -19,10 +19,10 @@ class LLMAssistError(WebRunnerException): """Raised when no LLM callable is registered or the response is malformed.""" -_llm_callable: Optional[Callable[[str], str]] = None +_llm_callable: Callable[[str], str] | None = None -def set_llm_callable(callable_obj: Optional[Callable[[str], str]]) -> None: +def set_llm_callable(callable_obj: Callable[[str], str] | None) -> None: """登錄一個 ``Callable[[str], str]`` 用於後續所有 prompt。""" global _llm_callable _llm_callable = callable_obj @@ -52,7 +52,7 @@ def _invoke(prompt: str) -> str: ) -def suggest_locator(html: str, description: str) -> Dict[str, str]: +def suggest_locator(html: str, description: str) -> dict[str, str]: """ 讓 LLM 從 HTML 推斷一個合理的 locator Ask the registered LLM to pick a locator. Returns @@ -77,8 +77,8 @@ def suggest_locator(html: str, description: str) -> Dict[str, str]: def generate_actions_from_prompt( request: str, - context: Optional[str] = None, -) -> List[Any]: + context: str | None = None, +) -> list[Any]: """ 把自然語言敘述轉成 WR_* action JSON 草稿 Translate a natural-language request into an action JSON list. The LLM @@ -117,7 +117,7 @@ def _extract_json_array(text: str) -> Any: # ----- self-healing locator hook ------------------------------------------ -def llm_self_heal_locator(name: str, html_provider: Callable[[], str]) -> Dict[str, str]: +def llm_self_heal_locator(name: str, html_provider: Callable[[], str]) -> dict[str, str]: """ 當既有 fallback locator 都失敗時,呼叫 LLM 提供新的選擇器 Last-resort hook for the self-healing locator: when the registered @@ -148,10 +148,10 @@ def llm_self_heal_locator(name: str, html_provider: Callable[[], str]) -> Dict[s def explain_failure( test_name: str, error_repr: str, - console: Optional[List[Dict[str, Any]]] = None, - network: Optional[List[Dict[str, Any]]] = None, - steps: Optional[List[Any]] = None, -) -> Dict[str, Any]: + console: list[dict[str, Any]] | None = None, + network: list[dict[str, Any]] | None = None, + steps: list[Any] | None = None, +) -> dict[str, Any]: """ 要求 LLM 從失敗素材中產出 RCA 草稿 Ask the registered LLM to draft a root-cause analysis. Returns diff --git a/je_web_runner/utils/api/http_client.py b/je_web_runner/utils/api/http_client.py index c1afd7ca..524b5f07 100644 --- a/je_web_runner/utils/api/http_client.py +++ b/je_web_runner/utils/api/http_client.py @@ -11,7 +11,7 @@ """ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any import requests @@ -24,20 +24,20 @@ class HttpAssertionError(WebRunnerException): _DEFAULT_TIMEOUT = 30 -_state: Dict[str, Any] = {"last_response": None} +_state: dict[str, Any] = {"last_response": None} def _allowed_url(url: str) -> str: if not isinstance(url, str) or not url: raise WebRunnerException("HTTP URL must be a non-empty string") - if not (url.startswith("http://") or url.startswith("https://")): # NOSONAR — scheme allow-list, not an outbound HTTP call + if not (url.startswith(("http://", "https://"))): # NOSONAR — scheme allow-list, not an outbound HTTP call raise WebRunnerException(f"HTTP URL must start with http:// or https://: {url!r}") return url -def _summarise(response: requests.Response) -> Dict[str, Any]: +def _summarise(response: requests.Response) -> dict[str, Any]: """Return a JSON-friendly dict capturing the response state we care about.""" - summary: Dict[str, Any] = { + summary: dict[str, Any] = { "status_code": response.status_code, "headers": dict(response.headers), "elapsed_ms": int(response.elapsed.total_seconds() * 1000), @@ -55,12 +55,12 @@ def http_request( method: str, url: str, timeout: int = _DEFAULT_TIMEOUT, - headers: Optional[Dict[str, str]] = None, - params: Optional[Dict[str, Any]] = None, + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, json_body: Any = None, data: Any = None, **request_kwargs: Any, -) -> Dict[str, Any]: +) -> dict[str, Any]: """ 通用 HTTP 請求;其他 ``http_*`` 命令皆呼叫此函式 Generic HTTP entry point; the verb-specific helpers delegate here. @@ -80,27 +80,27 @@ def http_request( return _summarise(response) -def http_get(url: str, **kwargs: Any) -> Dict[str, Any]: +def http_get(url: str, **kwargs: Any) -> dict[str, Any]: return http_request("GET", url, **kwargs) -def http_post(url: str, **kwargs: Any) -> Dict[str, Any]: +def http_post(url: str, **kwargs: Any) -> dict[str, Any]: return http_request("POST", url, **kwargs) -def http_put(url: str, **kwargs: Any) -> Dict[str, Any]: +def http_put(url: str, **kwargs: Any) -> dict[str, Any]: return http_request("PUT", url, **kwargs) -def http_patch(url: str, **kwargs: Any) -> Dict[str, Any]: +def http_patch(url: str, **kwargs: Any) -> dict[str, Any]: return http_request("PATCH", url, **kwargs) -def http_delete(url: str, **kwargs: Any) -> Dict[str, Any]: +def http_delete(url: str, **kwargs: Any) -> dict[str, Any]: return http_request("DELETE", url, **kwargs) -def get_last_response() -> Optional[Dict[str, Any]]: +def get_last_response() -> dict[str, Any] | None: """Return the most recent ``_summarise`` output (or ``None``).""" return _state["last_response"] diff --git a/je_web_runner/utils/api_mock/router.py b/je_web_runner/utils/api_mock/router.py index 6c3d6b57..31e77b4d 100644 --- a/je_web_runner/utils/api_mock/router.py +++ b/je_web_runner/utils/api_mock/router.py @@ -12,7 +12,7 @@ import json import re from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -31,10 +31,10 @@ class MockResponse: status: _HttpStatus = 200 body: Any = "" - headers: Dict[str, str] = field(default_factory=dict) + headers: dict[str, str] = field(default_factory=dict) content_type: str = "application/json" - def to_payload(self) -> Dict[str, Any]: + def to_payload(self) -> dict[str, Any]: if isinstance(self.body, (dict, list)): body_text = json.dumps(self.body, ensure_ascii=False) else: @@ -51,7 +51,7 @@ class MockRoute: method: str url_pattern: str response: MockResponse - times: Optional[int] = None + times: int | None = None times_seen: int = 0 def matches(self, method: str, url: str) -> bool: @@ -86,8 +86,8 @@ class MockRouter: """Ordered list of :class:`MockRoute`.""" def __init__(self) -> None: - self._routes: List[MockRoute] = [] - self._calls: List[Tuple[str, str]] = [] + self._routes: list[MockRoute] = [] + self._calls: list[tuple[str, str]] = [] def add( self, @@ -95,9 +95,9 @@ def add( url_pattern: str, body: Any = "", status: int = 200, - headers: Optional[Dict[str, str]] = None, + headers: dict[str, str] | None = None, content_type: str = "application/json", - times: Optional[int] = None, + times: int | None = None, ) -> MockRoute: if not isinstance(method, str) or not method: raise ApiMockError("method must be a non-empty string") @@ -117,14 +117,14 @@ def add( self._routes.append(route) return route - def match(self, method: str, url: str) -> Optional[MockRoute]: + def match(self, method: str, url: str) -> MockRoute | None: self._calls.append((method.upper(), url)) for route in self._routes: if route.matches(method, url) and route.consume(): return route return None - def calls(self) -> List[Tuple[str, str]]: + def calls(self) -> list[tuple[str, str]]: return list(self._calls) def attach_to_page(self, page: Any) -> None: @@ -161,9 +161,9 @@ def _handler(route: Any, request: Any) -> None: def register_route( method: str, url_pattern: str, - body: Union[str, Dict[str, Any], List[Any]] = "", + body: str | dict[str, Any] | list[Any] = "", status: int = 200, - times: Optional[int] = None, + times: int | None = None, ) -> MockRoute: """Register a route on the module-level singleton.""" return _GLOBAL.add(method, url_pattern, body=body, status=status, times=times) diff --git a/je_web_runner/utils/api_version_compat/compat.py b/je_web_runner/utils/api_version_compat/compat.py index be48970e..2b43155a 100644 --- a/je_web_runner/utils/api_version_compat/compat.py +++ b/je_web_runner/utils/api_version_compat/compat.py @@ -17,7 +17,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, Iterable, List, Mapping +from typing import Any, Iterable, Mapping from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -38,8 +38,8 @@ class ApiContract: """The shape the old client relies on for one endpoint.""" endpoint: str - response_fields: List[FieldSpec] = field(default_factory=list) - request_fields: List[FieldSpec] = field(default_factory=list) + response_fields: list[FieldSpec] = field(default_factory=list) + request_fields: list[FieldSpec] = field(default_factory=list) _TYPE_MAP = { @@ -50,8 +50,8 @@ class ApiContract: def _check_response( contract: ApiContract, response: Mapping[str, Any], -) -> List[str]: - problems: List[str] = [] +) -> list[str]: + problems: list[str] = [] for spec in contract.response_fields: if spec.name not in response: if spec.required: @@ -71,8 +71,8 @@ def _check_response( def _check_request( contract: ApiContract, request: Mapping[str, Any], -) -> List[str]: - problems: List[str] = [] +) -> list[str]: + problems: list[str] = [] required_old = {f.name for f in contract.request_fields if f.required} for missing in required_old - set(request.keys()): problems.append( @@ -120,7 +120,7 @@ class CompatMatrixRow: notes: str = "" -def matrix_summary(rows: Iterable[CompatMatrixRow]) -> List[Dict[str, Any]]: +def matrix_summary(rows: Iterable[CompatMatrixRow]) -> list[dict[str, Any]]: return [{"client": r.client_version, "server": r.server_version, "passed": r.passed, "notes": r.notes} for r in rows] diff --git a/je_web_runner/utils/appium_integration/appium_driver.py b/je_web_runner/utils/appium_integration/appium_driver.py index e2c7a82b..8531413b 100644 --- a/je_web_runner/utils/appium_integration/appium_driver.py +++ b/je_web_runner/utils/appium_integration/appium_driver.py @@ -9,7 +9,7 @@ """ from __future__ import annotations -from typing import Any, Dict +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -21,9 +21,15 @@ class AppiumIntegrationError(WebRunnerException): def _require_appium(): + """Return ``(appium.webdriver, AppiumOptions)`` or raise an install hint. + + Appium-Python-Client v3 removed the ``desired_capabilities`` kwarg, so the + caller builds an ``AppiumOptions`` from the capability dict instead. + """ try: from appium import webdriver # type: ignore[import-not-found] - return webdriver + from appium.options.common import AppiumOptions # type: ignore[import-not-found] + return webdriver, AppiumOptions except ImportError as error: raise AppiumIntegrationError( "Appium-Python-Client is not installed. " @@ -33,7 +39,7 @@ def _require_appium(): def start_appium_session( server_url: str, - capabilities: Dict[str, Any], + capabilities: dict[str, Any], register: bool = True, ) -> Any: """ @@ -44,16 +50,19 @@ def start_appium_session( """ web_runner_logger.info(f"start_appium_session: {server_url}") if not isinstance(server_url, str) or not ( - server_url.startswith("http://") or server_url.startswith("https://") # NOSONAR — scheme allow-list, not an outbound HTTP call + server_url.startswith(("http://", "https://")) # NOSONAR — scheme allow-list, not an outbound HTTP call ): raise AppiumIntegrationError(f"server_url must be http(s): {server_url!r}") if not isinstance(capabilities, dict) or not capabilities: raise AppiumIntegrationError("capabilities must be a non-empty dict") - appium_webdriver = _require_appium() - driver = appium_webdriver.Remote(command_executor=server_url, options=None, - desired_capabilities=capabilities) + appium_webdriver, appium_options_cls = _require_appium() + options = appium_options_cls() + options.load_capabilities(capabilities) + driver = appium_webdriver.Remote(command_executor=server_url, options=options) if register: - webdriver_wrapper_instance.current_webdriver = driver + # set_active_driver (not a bare current_webdriver assignment) so the + # wrapper's ActionChains bind to this driver. + webdriver_wrapper_instance.set_active_driver(driver) return driver @@ -73,10 +82,10 @@ def build_android_caps( device_name: str = "Android Emulator", platform_version: str = "13", automation_name: str = "UiAutomator2", - extra: Dict[str, Any] = None, -) -> Dict[str, Any]: + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: """Convenience: build a capabilities dict for Android.""" - caps: Dict[str, Any] = { + caps: dict[str, Any] = { "platformName": "Android", "appium:platformVersion": platform_version, "appium:deviceName": device_name, @@ -93,9 +102,9 @@ def build_ios_caps( device_name: str = "iPhone 15", platform_version: str = "17", automation_name: str = "XCUITest", - extra: Dict[str, Any] = None, -) -> Dict[str, Any]: - caps: Dict[str, Any] = { + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + caps: dict[str, Any] = { "platformName": "iOS", "appium:platformVersion": platform_version, "appium:deviceName": device_name, diff --git a/je_web_runner/utils/appium_integration/gestures.py b/je_web_runner/utils/appium_integration/gestures.py index 31a332b9..fe98d509 100644 --- a/je_web_runner/utils/appium_integration/gestures.py +++ b/je_web_runner/utils/appium_integration/gestures.py @@ -5,14 +5,15 @@ and XCUITest (iOS) without per-platform branching. The driver is required to expose either ``execute_script`` (for the -``mobile:`` named-gesture extensions) or ``perform_actions`` (for raw -W3C input). The helpers prefer the named extension when present and -fall back to W3C otherwise. +``mobile:`` named-gesture extensions) or ``execute`` (for raw W3C input +via the ``actions`` command — Selenium/appium drivers have no +``perform_actions`` method). The helpers prefer the named extension when +present and fall back to W3C otherwise. """ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Tuple +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -33,7 +34,7 @@ class Point: def _execute_named_gesture( driver: Any, name: str, - args: Dict[str, Any], + args: dict[str, Any], ) -> bool: """Try the ``mobile:`` extension via ``execute_script``.""" if not hasattr(driver, "execute_script"): @@ -45,7 +46,7 @@ def _execute_named_gesture( return False -def _w3c_pointer_path(actions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: +def _w3c_pointer_path(actions: list[dict[str, Any]]) -> list[dict[str, Any]]: """Wrap a list of pointer actions into the W3C Actions envelope.""" return [{ "type": "pointer", @@ -55,12 +56,16 @@ def _w3c_pointer_path(actions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: }] -def _perform_w3c(driver: Any, actions: List[Dict[str, Any]]) -> None: - if not hasattr(driver, "perform_actions"): +def _perform_w3c(driver: Any, actions: list[dict[str, Any]]) -> None: + # Raw W3C actions go through the same low-level ``actions`` command that + # Selenium's ActionChains uses internally; Selenium/appium drivers expose + # ``execute`` but no ``perform_actions`` method. + if not hasattr(driver, "execute"): raise AppiumGestureError( - "driver lacks perform_actions and the mobile: gesture extension" + "driver lacks execute() and the mobile: gesture extension" ) - driver.perform_actions(actions) + from selenium.webdriver.remote.command import Command + driver.execute(Command.W3C_ACTIONS, {"actions": actions}) def swipe( @@ -99,7 +104,7 @@ def _direction_for(start: Point, end: Point) -> str: def scroll( driver: Any, direction: str, - rect: Optional[Tuple[int, int, int, int]] = None, + rect: tuple[int, int, int, int] | None = None, percent: float = 0.7, ) -> None: """Scroll ``direction`` (``up`` / ``down`` / ``left`` / ``right``).""" @@ -109,7 +114,7 @@ def scroll( ) if not 0 < percent <= 1: raise AppiumGestureError("percent must be in (0, 1]") - args: Dict[str, Any] = {"direction": direction, "percent": percent} + args: dict[str, Any] = {"direction": direction, "percent": percent} if rect is not None: left, top, width, height = rect args.update({"left": left, "top": top, "width": width, "height": height}) @@ -129,7 +134,7 @@ def scroll( swipe(driver, centre, end) -def _centre(rect: Tuple[int, int, int, int]) -> Point: +def _centre(rect: tuple[int, int, int, int]) -> Point: left, top, width, height = rect return Point(left + width // 2, top + height // 2) @@ -156,7 +161,7 @@ def long_press( def pinch( driver: Any, - rect: Tuple[int, int, int, int], + rect: tuple[int, int, int, int], scale: float = 0.5, speed: int = 1500, ) -> None: diff --git a/je_web_runner/utils/assert_value/result_check.py b/je_web_runner/utils/assert_value/result_check.py index 107fa480..5150c4d0 100644 --- a/je_web_runner/utils/assert_value/result_check.py +++ b/je_web_runner/utils/assert_value/result_check.py @@ -69,9 +69,7 @@ def check_value(element_name: str, element_value: typing.Any, result_check_dict: """ if result_check_dict.get(element_name) != element_value: raise WebRunnerAssertException( - "value should be {right_value} but value was {wrong_value}".format( - right_value=element_value, wrong_value=result_check_dict.get(element_name) - ) + f"value should be {element_value} but value was {result_check_dict.get(element_name)}" ) @@ -80,15 +78,17 @@ def check_values(check_dict: dict, result_check_dict: dict) -> None: 驗證多個屬性值是否正確 Check if multiple attribute values are correct - :param check_dict: 預期值字典 / dictionary of expected values - :param result_check_dict: 實際檢查結果字典 / dictionary of actual values + 每個 ``result_check_dict`` 的鍵值對都會與 ``check_dict`` 內同名鍵比對; + 不相符時拋出例外。錯誤訊息為「should be <預期> but value was <實際>」, + 因此 ``result_check_dict`` 為預期值、``check_dict`` 為實際值。 + + :param check_dict: 實際值字典(被查詢的一方)/ dictionary of actual values to look up + :param result_check_dict: 預期值字典(逐一斷言)/ dictionary of expected values to assert """ for key, value in result_check_dict.items(): if check_dict.get(key) != value: raise WebRunnerAssertException( - "value should be {right_value} but value was {wrong_value}".format( - right_value=value, wrong_value=check_dict.get(key) - ) + f"value should be {value} but value was {check_dict.get(key)}" ) diff --git a/je_web_runner/utils/auth/oauth.py b/je_web_runner/utils/auth/oauth.py index 1ad79289..a033f42d 100644 --- a/je_web_runner/utils/auth/oauth.py +++ b/je_web_runner/utils/auth/oauth.py @@ -12,7 +12,7 @@ from __future__ import annotations import time -from typing import Any, Dict, Optional +from typing import Any import requests @@ -25,18 +25,18 @@ class OAuthError(WebRunnerException): _DEFAULT_TIMEOUT = 30 -_token_cache: Dict[str, Dict[str, Any]] = {} +_token_cache: dict[str, dict[str, Any]] = {} def _check_token_url(url: str) -> str: if not isinstance(url, str) or not url: raise OAuthError("token_url must be a non-empty string") - if not (url.startswith("http://") or url.startswith("https://")): # NOSONAR — scheme allow-list, not an outbound HTTP call + if not (url.startswith(("http://", "https://"))): # NOSONAR — scheme allow-list, not an outbound HTTP call raise OAuthError(f"token_url must be http(s): {url!r}") return url -def _post_token(token_url: str, data: Dict[str, Any], timeout: int) -> Dict[str, Any]: +def _post_token(token_url: str, data: dict[str, Any], timeout: int) -> dict[str, Any]: safe_url = _check_token_url(token_url) web_runner_logger.info(f"oauth POST {safe_url}") response = requests.post(safe_url, data=data, timeout=timeout) @@ -55,10 +55,10 @@ def client_credentials_token( token_url: str, client_id: str, client_secret: str, - scope: Optional[str] = None, - cache_key: Optional[str] = None, + scope: str | None = None, + cache_key: str | None = None, timeout: int = _DEFAULT_TIMEOUT, -) -> Dict[str, Any]: +) -> dict[str, Any]: """ OAuth2 client-credentials 流程 Run an OAuth2 client-credentials grant and return the token response. @@ -66,7 +66,7 @@ def client_credentials_token( cached = get_cached_token(cache_key) if cache_key else None if cached and not _is_expired(cached): return cached - data: Dict[str, Any] = { + data: dict[str, Any] = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, @@ -85,10 +85,10 @@ def password_grant_token( client_secret: str, username: str, password: str, - scope: Optional[str] = None, - cache_key: Optional[str] = None, + scope: str | None = None, + cache_key: str | None = None, timeout: int = _DEFAULT_TIMEOUT, -) -> Dict[str, Any]: +) -> dict[str, Any]: """ OAuth2 password grant(多數 IdP 已棄用,僅 legacy 系統用) Run an OAuth2 password (Resource Owner Password Credentials) grant. @@ -98,7 +98,7 @@ def password_grant_token( cached = get_cached_token(cache_key) if cache_key else None if cached and not _is_expired(cached): return cached - data: Dict[str, Any] = { + data: dict[str, Any] = { "grant_type": "password", "client_id": client_id, "client_secret": client_secret, @@ -118,10 +118,10 @@ def refresh_token_grant( client_id: str, client_secret: str, refresh_token: str, - cache_key: Optional[str] = None, + cache_key: str | None = None, timeout: int = _DEFAULT_TIMEOUT, -) -> Dict[str, Any]: - data: Dict[str, Any] = { +) -> dict[str, Any]: + data: dict[str, Any] = { "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": client_id, @@ -133,7 +133,7 @@ def refresh_token_grant( return payload -def get_cached_token(cache_key: str) -> Optional[Dict[str, Any]]: +def get_cached_token(cache_key: str) -> dict[str, Any] | None: if not cache_key: return None cached = _token_cache.get(cache_key) @@ -147,12 +147,12 @@ def clear_token_cache() -> None: _token_cache.clear() -def bearer_header(access_token: str) -> Dict[str, str]: +def bearer_header(access_token: str) -> dict[str, str]: """Convenience: build the Authorization header for HTTP commands.""" return {"Authorization": f"Bearer {access_token}"} -def _is_expired(token: Dict[str, Any]) -> bool: +def _is_expired(token: dict[str, Any]) -> bool: expires_in = token.get("expires_in") obtained_at = token.get("_obtained_at", 0) if not isinstance(expires_in, (int, float)) or expires_in <= 0: diff --git a/je_web_runner/utils/backend_log_correlator/correlator.py b/je_web_runner/utils/backend_log_correlator/correlator.py index 03df2cba..947c6b4b 100644 --- a/je_web_runner/utils/backend_log_correlator/correlator.py +++ b/je_web_runner/utils/backend_log_correlator/correlator.py @@ -20,7 +20,7 @@ import re from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Union +from typing import Any, Callable, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -46,15 +46,15 @@ class CorrelatedLog: timestamp: str level: str message: str - service: Optional[str] = None - span_id: Optional[str] = None - extra: Dict[str, Any] = field(default_factory=dict) + service: str | None = None + span_id: str | None = None + extra: dict[str, Any] = field(default_factory=dict) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) -LogFetcher = Callable[[str], List[CorrelatedLog]] +LogFetcher = Callable[[str], list[CorrelatedLog]] """Signature: ``fetcher(trace_id) -> [CorrelatedLog, ...]``.""" @@ -82,7 +82,7 @@ def validate_trace_id(trace_id: str) -> str: # ---------- file adapter (offline / tests) ------------------------------- def fetch_file_log( - log_path: Union[str, Path], + log_path: str | Path, *, trace_field: str = "trace_id", fallback_to_substring: bool = True, @@ -97,9 +97,9 @@ def fetch_file_log( if not path.exists(): raise BackendLogCorrelatorError(f"log file not found: {path}") - def _fetch(trace_id: str) -> List[CorrelatedLog]: + def _fetch(trace_id: str) -> list[CorrelatedLog]: wanted = validate_trace_id(trace_id) - out: List[CorrelatedLog] = [] + out: list[CorrelatedLog] = [] with open(path, encoding="utf-8") as fp: for line in fp: stripped = line.rstrip("\r\n") @@ -118,7 +118,7 @@ def _fetch(trace_id: str) -> List[CorrelatedLog]: return _fetch -def _try_parse_json_line(line: str) -> Optional[Dict[str, Any]]: +def _try_parse_json_line(line: str) -> dict[str, Any] | None: line = line.strip() if not line.startswith("{"): return None @@ -129,7 +129,7 @@ def _try_parse_json_line(line: str) -> Optional[Dict[str, Any]]: return loaded if isinstance(loaded, dict) else None -def _log_from_dict(record: Dict[str, Any]) -> CorrelatedLog: +def _log_from_dict(record: dict[str, Any]) -> CorrelatedLog: return CorrelatedLog( timestamp=str(record.get("timestamp") or record.get("ts") or ""), level=str(record.get("level") or record.get("severity") or "info"), @@ -170,7 +170,7 @@ def fetch_loki( requests = _require_requests() url = base_url.rstrip("/") + "/loki/api/v1/query_range" - def _fetch(trace_id: str) -> List[CorrelatedLog]: + def _fetch(trace_id: str) -> list[CorrelatedLog]: wanted = validate_trace_id(trace_id) params = {"query": f'{{{label}="{wanted}"}}', "limit": int(limit)} try: @@ -184,8 +184,8 @@ def _fetch(trace_id: str) -> List[CorrelatedLog]: return _fetch -def _parse_loki_payload(payload: Any) -> List[CorrelatedLog]: - out: List[CorrelatedLog] = [] +def _parse_loki_payload(payload: Any) -> list[CorrelatedLog]: + out: list[CorrelatedLog] = [] if not isinstance(payload, dict): return out streams = ((payload.get("data") or {}).get("result")) or [] @@ -221,7 +221,7 @@ def fetch_elasticsearch( requests = _require_requests() url = f"{base_url.rstrip('/')}/{index}/_search" - def _fetch(trace_id: str) -> List[CorrelatedLog]: + def _fetch(trace_id: str) -> list[CorrelatedLog]: wanted = validate_trace_id(trace_id) body = {"size": int(size), "query": {"term": {trace_field: wanted}}} try: @@ -235,11 +235,11 @@ def _fetch(trace_id: str) -> List[CorrelatedLog]: return _fetch -def _parse_elasticsearch_payload(payload: Any) -> List[CorrelatedLog]: +def _parse_elasticsearch_payload(payload: Any) -> list[CorrelatedLog]: if not isinstance(payload, dict): return [] hits = ((payload.get("hits") or {}).get("hits")) or [] - out: List[CorrelatedLog] = [] + out: list[CorrelatedLog] = [] for hit in hits: source = hit.get("_source") if isinstance(hit, dict) else None if isinstance(source, dict): @@ -252,7 +252,7 @@ def _parse_elasticsearch_payload(payload: Any) -> List[CorrelatedLog]: def correlate( trace_id_or_header: str, fetchers: Sequence[LogFetcher], -) -> List[CorrelatedLog]: +) -> list[CorrelatedLog]: """ Resolve ``trace_id_or_header`` (raw id or full traceparent) and call every fetcher in turn, concatenating their results. @@ -261,7 +261,7 @@ def correlate( raise BackendLogCorrelatorError("at least one fetcher is required") raw = trace_id_or_header.strip() if isinstance(trace_id_or_header, str) else "" trace_id = parse_traceparent(raw) if "-" in raw else validate_trace_id(raw) - merged: List[CorrelatedLog] = [] + merged: list[CorrelatedLog] = [] for fetcher in fetchers: try: merged.extend(fetcher(trace_id)) @@ -273,7 +273,7 @@ def correlate( def attach_to_failure_bundle( - bundle_dir: Union[str, Path], + bundle_dir: str | Path, logs: Iterable[CorrelatedLog], *, filename: str = "backend_logs.json", diff --git a/je_web_runner/utils/background_sync_assert/sync.py b/je_web_runner/utils/background_sync_assert/sync.py index 11ee49f7..fc6c978d 100644 --- a/je_web_runner/utils/background_sync_assert/sync.py +++ b/je_web_runner/utils/background_sync_assert/sync.py @@ -16,7 +16,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -61,8 +61,8 @@ class SyncFire: @dataclass class SyncLog: - registered: List[str] = field(default_factory=list) - fired: List[SyncFire] = field(default_factory=list) + registered: list[str] = field(default_factory=list) + fired: list[SyncFire] = field(default_factory=list) def parse_log(payload: Any) -> SyncLog: @@ -73,7 +73,7 @@ def parse_log(payload: Any) -> SyncLog: raise BackgroundSyncAssertError( "registered list must contain strings only" ) - fired: List[SyncFire] = [] + fired: list[SyncFire] = [] for raw in payload.get("fired") or []: if not isinstance(raw, dict): continue diff --git a/je_web_runner/utils/bidi/network.py b/je_web_runner/utils/bidi/network.py index 607877bf..b378b4b0 100644 --- a/je_web_runner/utils/bidi/network.py +++ b/je_web_runner/utils/bidi/network.py @@ -41,50 +41,66 @@ def _resolve_network(driver) -> Any: return network +def _add_request_handler(driver, event: str, callback: Callable[[Any], None]) -> int: + """ + 透過 Selenium 4.x BiDi ``network.add_request_handler(event, callback)`` + 註冊一個事件 handler。legacy phase 入口以 ``event`` 區分階段 + (``before_request`` / ``auth_required``;``response_started`` 於 + Selenium 4.45 移除,改走原生 ``add_response_handler``)。 + Register a handler via Selenium 4.x BiDi ``add_request_handler(event, cb)``. + """ + network = _resolve_network(driver) + add = getattr(network, "add_request_handler", None) + if add is None: + raise BidiNetworkError( + "driver.network.add_request_handler missing; needs Selenium 4.23+" + ) + return add(event, callback) + + def add_request_handler(driver, callback: Callable[[Any], None]) -> int: """ 註冊「請求送出前」事件 handler,回傳訂閱 id。 - Register a handler for the BiDi ``network.beforeRequestSent`` event; - returns a subscription id. + Register a handler for the BiDi ``network.beforeRequestSent`` event. :param callback: 接收事件物件的可呼叫物 / callable taking an event object """ web_runner_logger.info("bidi network add_request_handler") - try: - return _resolve_network(driver).add_request_handler(callback) - except AttributeError as error: - raise BidiNetworkError( - "driver.network.add_request_handler missing; needs Selenium 4.23+" - ) from error + return _add_request_handler(driver, "before_request", callback) -def add_response_handler(driver, callback: Callable[[Any], None]) -> int: +def add_response_handler(driver, callback: Callable[[Any], None]) -> int | str: """ - 註冊「收到回應」事件 handler,回傳訂閱 id。 - Register a handler for the BiDi ``network.responseCompleted`` event; - returns a subscription id. + 註冊「回應開始」事件 handler,回傳訂閱 id。 + Register a handler for the BiDi ``network.responseStarted`` event. + + Selenium 4.45+ 提供原生 ``network.add_response_handler``(回傳字串 + handler id,callback 收到 ``Response`` 物件、Selenium 自動 continue); + 更舊的版本退回 ``add_request_handler("response_started", ...)`` + (4.45 起該 legacy phase 已被移除,僅剩 before_request / auth_required)。 + Selenium 4.45+ exposes a native ``network.add_response_handler`` (string + handler id; the callback receives a ``Response`` and Selenium continues it + automatically); older versions fall back to the legacy + ``add_request_handler("response_started", ...)`` phase, which 4.45 removed. """ web_runner_logger.info("bidi network add_response_handler") - try: - return _resolve_network(driver).add_response_handler(callback) - except AttributeError as error: - raise BidiNetworkError( - "driver.network.add_response_handler missing; needs Selenium 4.23+" - ) from error + network = _resolve_network(driver) + native_add = getattr(network, "add_response_handler", None) + if native_add is not None: + return native_add(callback=callback) + return _add_request_handler(driver, "response_started", callback) def add_auth_handler(driver, callback: Callable[[Any], None]) -> int: """ 註冊 HTTP 401 / 407 認證挑戰 handler。 - Register a handler for the BiDi ``network.authRequired`` event. + Register a handler for the BiDi ``network.authRequired`` event. (Selenium's + own ``add_auth_handler(username, password)`` auto-supplies credentials and + is a different feature; this callback-based hook routes through + ``add_request_handler('auth_required', ...)``.) """ web_runner_logger.info("bidi network add_auth_handler") - try: - return _resolve_network(driver).add_auth_handler(callback) - except AttributeError as error: - raise BidiNetworkError( - "driver.network.add_auth_handler missing; needs Selenium 4.23+" - ) from error + return _add_request_handler(driver, "auth_required", callback) def clear_network_handlers(driver) -> bool: @@ -94,15 +110,22 @@ def clear_network_handlers(driver) -> bool: """ web_runner_logger.info("bidi network clear_network_handlers") network = _resolve_network(driver) - clear = getattr(network, "clear_handlers", None) or getattr(network, "clear", None) + clear = getattr(network, "clear_request_handlers", None) if clear is None: raise BidiNetworkError( - "driver.network has neither clear_handlers nor clear; " + "driver.network.clear_request_handlers missing; " "your Selenium version may not expose handler clearing" ) + # Selenium 4.45+ 的 clear_request_handlers 不會清到原生 + # add_response_handler 註冊的 handlers,要另外呼叫。 + # On Selenium 4.45+ clear_request_handlers does not cover handlers + # registered via the native add_response_handler; clear those too. + clear_responses = getattr(network, "clear_response_handlers", None) try: clear() + if clear_responses is not None: + clear_responses() return True - except Exception as error: # noqa: BLE001 — surface a friendlier wrapper + except Exception as error: web_runner_logger.error(f"clear_network_handlers failed: {error!r}") return False diff --git a/je_web_runner/utils/bidi_backend/bridge.py b/je_web_runner/utils/bidi_backend/bridge.py index 597eb8c8..cad3f813 100644 --- a/je_web_runner/utils/bidi_backend/bridge.py +++ b/je_web_runner/utils/bidi_backend/bridge.py @@ -17,7 +17,7 @@ import itertools from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -32,7 +32,7 @@ class BidiEvent: """Backend-agnostic event payload.""" name: str - payload: Dict[str, Any] + payload: dict[str, Any] @dataclass @@ -104,7 +104,7 @@ def detach() -> None: return translator -def _extract_playwright_payload(event_name: str, payload: Any) -> Dict[str, Any]: +def _extract_playwright_payload(event_name: str, payload: Any) -> dict[str, Any]: if event_name == "console": return { "type": getattr(payload, "type", None), @@ -129,9 +129,9 @@ class BidiBridge: """Backend-detecting bridge for BiDi-style event subscription.""" def __init__(self) -> None: - self._subscriptions: Dict[int, BidiSubscription] = {} + self._subscriptions: dict[int, BidiSubscription] = {} self._counter = itertools.count(1) - self._translators: Dict[str, Dict[str, Translator]] = { + self._translators: dict[str, dict[str, Translator]] = { "selenium": {"console": _selenium_console_translator}, "playwright": { "console": _playwright_event_translator("console"), @@ -158,7 +158,7 @@ def subscribe( target: Any, event: str, callback: Callable[[BidiEvent], None], - backend: Optional[str] = None, + backend: str | None = None, ) -> BidiSubscription: used_backend = backend or self.detect_backend(target) translator = self._translators.get(used_backend, {}).get(event) @@ -191,5 +191,5 @@ def unsubscribe_all(self) -> None: for sub in snapshot: self.unsubscribe(sub) - def active_subscriptions(self) -> List[BidiSubscription]: + def active_subscriptions(self) -> list[BidiSubscription]: return list(self._subscriptions.values()) diff --git a/je_web_runner/utils/bootstrapper/bootstrapper.py b/je_web_runner/utils/bootstrapper/bootstrapper.py index 4982bab5..8c609033 100644 --- a/je_web_runner/utils/bootstrapper/bootstrapper.py +++ b/je_web_runner/utils/bootstrapper/bootstrapper.py @@ -9,7 +9,6 @@ import json from dataclasses import dataclass from pathlib import Path -from typing import Dict, List from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -102,7 +101,7 @@ class StarterFile: } -def starter_files() -> List[StarterFile]: +def starter_files() -> list[StarterFile]: """Return the full list of files written by :func:`init_workspace`.""" return [ StarterFile( @@ -131,8 +130,8 @@ def starter_files() -> List[StarterFile]: def init_workspace( directory: str, overwrite: bool = False, - files_to_write: List[StarterFile] = None, -) -> Dict[str, str]: + files_to_write: list[StarterFile] | None = None, +) -> dict[str, str]: """ 建立 starter 結構。回傳 ``{relative_path: 'created' | 'skipped'}``。 Write each starter file under ``directory``; existing files are skipped @@ -143,7 +142,7 @@ def init_workspace( raise BootstrapError(f"target {directory!r} is not a directory") base.mkdir(parents=True, exist_ok=True) files = files_to_write if files_to_write is not None else starter_files() - report: Dict[str, str] = {} + report: dict[str, str] = {} for entry in files: target = base / entry.relative_path if target.exists() and not overwrite: diff --git a/je_web_runner/utils/browser_pool/pool.py b/je_web_runner/utils/browser_pool/pool.py index 29084532..1ca4da4c 100644 --- a/je_web_runner/utils/browser_pool/pool.py +++ b/je_web_runner/utils/browser_pool/pool.py @@ -15,7 +15,7 @@ import time from dataclasses import dataclass, field from queue import Empty, Queue -from typing import Any, Callable, Iterator, List, Optional +from typing import Any, Callable, Iterator from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -46,8 +46,8 @@ class BrowserPool: def __init__( self, factory: SessionFactory, - destructor: Optional[SessionDestructor] = None, - health_check: Optional[HealthCheck] = None, + destructor: SessionDestructor | None = None, + health_check: HealthCheck | None = None, size: int = 2, max_uses: int = 50, ) -> None: @@ -60,11 +60,11 @@ def __init__( self._health_check = health_check or (lambda _instance: True) self._size = size self._max_uses = max_uses - self._available: "Queue[PooledSession]" = Queue() + self._available: Queue[PooledSession] = Queue() self._lock = threading.Lock() self._next_id = 1 self._closed = False - self._tracked: List[PooledSession] = [] + self._tracked: list[PooledSession] = [] def warm(self) -> None: """Pre-launch ``size`` instances eagerly.""" diff --git a/je_web_runner/utils/bug_repro_stability/stability.py b/je_web_runner/utils/bug_repro_stability/stability.py index 5800691f..84369de7 100644 --- a/je_web_runner/utils/bug_repro_stability/stability.py +++ b/je_web_runner/utils/bug_repro_stability/stability.py @@ -13,7 +13,7 @@ from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -39,7 +39,7 @@ class RunOutcome: """One probe outcome.""" passed: bool - error_signature: Optional[str] = None + error_signature: str | None = None duration_seconds: float = 0.0 @@ -59,13 +59,13 @@ class StabilityReport: category: ReproCategory longest_pass_streak: int = 0 longest_fail_streak: int = 0 - errors: Dict[str, int] = field(default_factory=dict) - durations: List[float] = field(default_factory=list) + errors: dict[str, int] = field(default_factory=dict) + durations: list[float] = field(default_factory=list) def passed(self) -> bool: return self.category == ReproCategory.NON_REPRODUCIBLE - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "category": self.category.value} @@ -104,7 +104,7 @@ def _probe_once(probe: ProbeFn, index: int) -> RunOutcome: def _record_outcome( - outcome: RunOutcome, state: _StreakState, errors: Dict[str, int], + outcome: RunOutcome, state: _StreakState, errors: dict[str, int], ) -> None: if outcome.passed: state.pass_streak += 1 @@ -137,8 +137,8 @@ def repeat( raise BugReproStabilityError("attempts must be > 0") state = _StreakState() - errors: Dict[str, int] = {} - durations: List[float] = [] + errors: dict[str, int] = {} + durations: list[float] = [] actual_attempts = 0 for index in range(attempts): actual_attempts += 1 diff --git a/je_web_runner/utils/bundle_budget/budget.py b/je_web_runner/utils/bundle_budget/budget.py index d4b4e82d..aeedefac 100644 --- a/je_web_runner/utils/bundle_budget/budget.py +++ b/je_web_runner/utils/bundle_budget/budget.py @@ -8,7 +8,7 @@ import json from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, List, Sequence, Tuple, Union +from typing import Any, Sequence from urllib.parse import urlparse from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -74,7 +74,7 @@ def hostname(self) -> str: return "" -def _kind_of(entry: Dict[str, Any]) -> AssetKind: +def _kind_of(entry: dict[str, Any]) -> AssetKind: resource_type = str( entry.get("_resourceType") or entry.get("resourceType") or "" ).lower() @@ -90,7 +90,7 @@ def _kind_of(entry: Dict[str, Any]) -> AssetKind: return _MIME_KIND_MAP.get(mime, AssetKind.OTHER) -def _sizes(entry: Dict[str, Any]) -> Tuple[int, int]: +def _sizes(entry: dict[str, Any]) -> tuple[int, int]: response = entry.get("response") or {} content = response.get("content") or {} transfer = response.get("_transferSize") or response.get("bodySize") @@ -101,13 +101,13 @@ def _sizes(entry: Dict[str, Any]) -> Tuple[int, int]: ) -def assets_from_har(har: Union[str, Dict[str, Any]]) -> List[Asset]: +def assets_from_har(har: str | dict[str, Any]) -> list[Asset]: """Reduce a HAR object to a flat list of :class:`Asset`.""" har_obj = _coerce_har(har) entries = ((har_obj.get("log") or {}).get("entries")) or [] if not isinstance(entries, list): raise BundleBudgetError("har log.entries must be a list") - out: List[Asset] = [] + out: list[Asset] = [] for entry in entries: if not isinstance(entry, dict): continue @@ -124,7 +124,7 @@ def assets_from_har(har: Union[str, Dict[str, Any]]) -> List[Asset]: return out -def _coerce_har(har: Union[str, Dict[str, Any]]) -> Dict[str, Any]: +def _coerce_har(har: str | dict[str, Any]) -> dict[str, Any]: if isinstance(har, str): try: parsed = json.loads(har) @@ -177,9 +177,9 @@ class BudgetBreach: class BudgetReport: """Roll-up returned by :func:`evaluate_budget`.""" - totals: Dict[AssetKind, int] = field(default_factory=dict) - breaches: List[BudgetBreach] = field(default_factory=list) - biggest_assets: List[Asset] = field(default_factory=list) + totals: dict[AssetKind, int] = field(default_factory=dict) + breaches: list[BudgetBreach] = field(default_factory=list) + biggest_assets: list[Asset] = field(default_factory=list) def passed(self) -> bool: return not self.breaches @@ -196,12 +196,12 @@ def evaluate_budget( raise BundleBudgetError("assets must be non-empty") if biggest_n < 0: raise BundleBudgetError("biggest_n must be >= 0") - totals: Dict[AssetKind, int] = {} + totals: dict[AssetKind, int] = {} for asset in assets: totals[asset.kind] = totals.get(asset.kind, 0) + max( asset.transfer_bytes, asset.content_bytes, ) - breaches: List[BudgetBreach] = [] + breaches: list[BudgetBreach] = [] for budget in budgets: if not isinstance(budget, Budget): raise BundleBudgetError("budgets entries must be Budget instances") diff --git a/je_web_runner/utils/bundle_diff_pr/diff.py b/je_web_runner/utils/bundle_diff_pr/diff.py index 3b897afd..b5060075 100644 --- a/je_web_runner/utils/bundle_diff_pr/diff.py +++ b/je_web_runner/utils/bundle_diff_pr/diff.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, List, Sequence, Union +from typing import Any, Sequence from je_web_runner.utils.bundle_budget.budget import ( Asset, AssetKind, assets_from_har, @@ -46,14 +46,14 @@ def percent(self) -> float: class BundleDiff: """Aggregate base→head diff.""" - added: List[AssetDelta] = field(default_factory=list) - removed: List[AssetDelta] = field(default_factory=list) - grew: List[AssetDelta] = field(default_factory=list) - shrunk: List[AssetDelta] = field(default_factory=list) + added: list[AssetDelta] = field(default_factory=list) + removed: list[AssetDelta] = field(default_factory=list) + grew: list[AssetDelta] = field(default_factory=list) + shrunk: list[AssetDelta] = field(default_factory=list) unchanged: int = 0 total_delta_bytes: int = 0 - def regressions(self, *, min_bytes: int = 1024) -> List[AssetDelta]: + def regressions(self, *, min_bytes: int = 1024) -> list[AssetDelta]: """Added + grew entries with delta >= ``min_bytes``.""" if min_bytes < 0: raise BundleDiffPrError("min_bytes must be >= 0") @@ -65,13 +65,13 @@ def regressions(self, *, min_bytes: int = 1024) -> List[AssetDelta]: # ---------- diff -------------------------------------------------------- -def _index(assets: Sequence[Asset]) -> Dict[str, Asset]: +def _index(assets: Sequence[Asset]) -> dict[str, Asset]: return {a.url: a for a in assets} def diff_hars( - base_har: Union[str, Dict[str, Any]], - head_har: Union[str, Dict[str, Any]], + base_har: str | dict[str, Any], + head_har: str | dict[str, Any], ) -> BundleDiff: """Compare two HAR snapshots; classify URLs as added/removed/grew/shrunk.""" base = _index(assets_from_har(base_har)) diff --git a/je_web_runner/utils/callback/callback_function_executor.py b/je_web_runner/utils/callback/callback_function_executor.py index 8410b7e9..3821c3f6 100644 --- a/je_web_runner/utils/callback/callback_function_executor.py +++ b/je_web_runner/utils/callback/callback_function_executor.py @@ -14,7 +14,7 @@ from je_web_runner.webdriver.webdriver_wrapper import webdriver_wrapper_instance -class CallbackFunctionExecutor(object): +class CallbackFunctionExecutor: def __init__(self): # 事件字典:將字串名稱對應到實際可執行的函式 @@ -142,7 +142,7 @@ def callback_function( self, trigger_function_name: str, callback_function: typing.Callable, - callback_function_param: typing.Union[dict, None] = None, + callback_function_param: dict | None = None, callback_param_method: str = "kwargs", **kwargs ): @@ -161,7 +161,7 @@ def callback_function( :return: 觸發函式的回傳值 / return value of the trigger function """ try: - if trigger_function_name not in self.event_dict.keys(): + if trigger_function_name not in self.event_dict: raise CallbackExecutorException(get_bad_trigger_function) # 執行觸發函式 diff --git a/je_web_runner/utils/cdp/cdp_commands.py b/je_web_runner/utils/cdp/cdp_commands.py index 82b77561..7be728d6 100644 --- a/je_web_runner/utils/cdp/cdp_commands.py +++ b/je_web_runner/utils/cdp/cdp_commands.py @@ -10,7 +10,7 @@ """ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -24,10 +24,10 @@ class CDPError(WebRunnerException): # Cached Playwright CDP sessions keyed by ``id(page)`` so we don't open a new # session for every command on the same page. -_pw_cdp_sessions: Dict[int, Any] = {} +_pw_cdp_sessions: dict[int, Any] = {} -def selenium_cdp(method: str, params: Optional[Dict[str, Any]] = None) -> Any: +def selenium_cdp(method: str, params: dict[str, Any] | None = None) -> Any: """ 在當前 Selenium driver 執行 CDP 命令 Issue a CDP command via the active Selenium driver. @@ -41,7 +41,7 @@ def selenium_cdp(method: str, params: Optional[Dict[str, Any]] = None) -> Any: return driver.execute_cdp_cmd(method, params or {}) -def playwright_cdp(method: str, params: Optional[Dict[str, Any]] = None) -> Any: +def playwright_cdp(method: str, params: dict[str, Any] | None = None) -> Any: """ 在當前 Playwright page 執行 CDP 命令 Issue a CDP command via the active Playwright page (cached session per page). @@ -53,7 +53,7 @@ def playwright_cdp(method: str, params: Optional[Dict[str, Any]] = None) -> Any: if session is None: try: session = playwright_wrapper_instance.context.new_cdp_session(page) - except Exception as error: # noqa: BLE001 — surface a friendlier error + except Exception as error: raise CDPError(f"failed to open CDP session (Chromium only): {error!r}") from error _pw_cdp_sessions[session_key] = session return session.send(method, params or {}) diff --git a/je_web_runner/utils/cdp/event_loop.py b/je_web_runner/utils/cdp/event_loop.py index 5b83998c..c5e1ab7f 100644 --- a/je_web_runner/utils/cdp/event_loop.py +++ b/je_web_runner/utils/cdp/event_loop.py @@ -36,7 +36,7 @@ import json import queue import threading -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -71,7 +71,7 @@ def _query_page_ws_url(debugger_address: str) -> str: url = f"http://{debugger_address}/json" # NOSONAR python:S5332 — DevTools endpoint is HTTP-only by design # Bandit B310: scheme is fixed-literal ``http://`` above, not user-controlled — only # ``debugger_address`` (host:port) varies, so no file:// or custom-scheme risk. - with urllib.request.urlopen(url, timeout=5) as response: # nosec B310 # noqa: S310 — local devtools endpoint + with urllib.request.urlopen(url, timeout=5) as response: # nosec B310 targets = json.loads(response.read()) pages = [t for t in targets if t.get("type") == "page"] if not pages: @@ -125,21 +125,21 @@ class CDPEventListener: def __init__(self, ws_url: str): self._ws_url = ws_url self._ws = None - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._stop_flag = threading.Event() - self._handlers: Dict[str, List[Callable[[dict], None]]] = {} + self._handlers: dict[str, list[Callable[[dict], None]]] = {} self._handlers_lock = threading.Lock() - self._pending: Dict[int, queue.Queue] = {} + self._pending: dict[int, queue.Queue] = {} self._pending_lock = threading.Lock() self._next_id_lock = threading.Lock() self._next_id = 1 @classmethod - def from_driver(cls, driver) -> "CDPEventListener": + def from_driver(cls, driver) -> CDPEventListener: """從現有 driver 自動解析 WebSocket URL 並建立 listener。""" return cls(resolve_cdp_ws_url(driver)) - def __enter__(self) -> "CDPEventListener": + def __enter__(self) -> CDPEventListener: self.start() return self @@ -188,7 +188,7 @@ def stop(self, join_timeout: float = 2.0) -> None: if ws is not None: try: ws.close() - except Exception as error: # noqa: BLE001 — best-effort cleanup + except Exception as error: web_runner_logger.debug(f"CDPEventListener ws.close failed: {error!r}") thread = self._thread self._thread = None @@ -198,7 +198,7 @@ def stop(self, join_timeout: float = 2.0) -> None: def send( self, method: str, - params: Optional[dict] = None, + params: dict | None = None, timeout: float = 5.0, ) -> Any: """ @@ -244,7 +244,7 @@ def _run(self) -> None: break try: raw = ws.recv() - except Exception as error: # noqa: BLE001 + except Exception as error: if not self._stop_flag.is_set(): web_runner_logger.error(f"CDPEventListener recv failed: {error!r}") break @@ -252,7 +252,7 @@ def _run(self) -> None: continue try: message = json.loads(raw) - except Exception as error: # noqa: BLE001 + except Exception as error: web_runner_logger.warning(f"CDPEventListener bad JSON: {error!r}") continue self._dispatch(message) @@ -274,7 +274,7 @@ def _dispatch(self, message: dict) -> None: for callback in handlers: try: callback(params) - except Exception as error: # noqa: BLE001 — never let a handler kill the loop + except Exception as error: web_runner_logger.error( f"CDPEventListener handler for {method!r} raised: {error!r}" ) diff --git a/je_web_runner/utils/cdp/tracing.py b/je_web_runner/utils/cdp/tracing.py index d607c81a..792fe1be 100644 --- a/je_web_runner/utils/cdp/tracing.py +++ b/je_web_runner/utils/cdp/tracing.py @@ -21,7 +21,6 @@ import json import threading import time -from typing import List, Optional from je_web_runner.utils.cdp.event_loop import CDPEventListener, CDPEventLoopError from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -35,8 +34,8 @@ class TracingError(WebRunnerException): def record_trace( driver, file_path: str, - categories: Optional[List[str]] = None, - duration: Optional[float] = None, + categories: list[str] | None = None, + duration: float | None = None, completion_timeout: float = 30.0, ) -> str: """ @@ -60,7 +59,7 @@ def record_trace( f"record_trace, file_path: {file_path}, categories: {categories}, " f"duration: {duration}" ) - events: List[dict] = [] + events: list[dict] = [] done = threading.Event() def _on_data(params: dict) -> None: diff --git a/je_web_runner/utils/cdp_tap/tap.py b/je_web_runner/utils/cdp_tap/tap.py index 27fb83da..ce3003cb 100644 --- a/je_web_runner/utils/cdp_tap/tap.py +++ b/je_web_runner/utils/cdp_tap/tap.py @@ -15,7 +15,7 @@ import time from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -29,11 +29,11 @@ class CdpTapError(WebRunnerException): class CdpRecord: timestamp: float method: str - params: Dict[str, Any] + params: dict[str, Any] return_value: Any = None - error: Optional[str] = None + error: str | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "timestamp": self.timestamp, "method": self.method, @@ -47,11 +47,11 @@ def to_dict(self) -> Dict[str, Any]: class CdpRecorder: """Wrap a driver's ``execute_cdp_cmd`` and persist every call.""" - output_path: Union[str, Path] + output_path: str | Path _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) - _records: List[CdpRecord] = field(default_factory=list, init=False, repr=False) + _records: list[CdpRecord] = field(default_factory=list, init=False, repr=False) - def attach(self, driver: Any) -> Callable[[str, Dict[str, Any]], Any]: + def attach(self, driver: Any) -> Callable[[str, dict[str, Any]], Any]: """ Replace ``driver.execute_cdp_cmd`` with a recording wrapper. Returns the *original* method so the caller can ``detach`` later. @@ -60,17 +60,17 @@ def attach(self, driver: Any) -> Callable[[str, Dict[str, Any]], Any]: raise CdpTapError("driver does not expose execute_cdp_cmd") original = driver.execute_cdp_cmd - def recorded(method: str, params: Optional[Dict[str, Any]] = None) -> Any: + def recorded(method: str, params: dict[str, Any] | None = None) -> Any: return self._invoke(original, method, params or {}) driver.execute_cdp_cmd = recorded # type: ignore[assignment] return original - def detach(self, driver: Any, original: Callable[[str, Dict[str, Any]], Any]) -> None: + def detach(self, driver: Any, original: Callable[[str, dict[str, Any]], Any]) -> None: driver.execute_cdp_cmd = original # type: ignore[assignment] self.flush() - def _invoke(self, original: Callable, method: str, params: Dict[str, Any]) -> Any: + def _invoke(self, original: Callable, method: str, params: dict[str, Any]) -> Any: timestamp = time.time() try: value = original(method, params) @@ -108,15 +108,15 @@ def flush(self) -> Path: web_runner_logger.info(f"cdp_tap flushed {len(self._records)} record(s) to {path}") return path - def records(self) -> List[CdpRecord]: + def records(self) -> list[CdpRecord]: return list(self._records) -def load_recording(path: Union[str, Path]) -> List[CdpRecord]: +def load_recording(path: str | Path) -> list[CdpRecord]: fp = Path(path) if not fp.is_file(): raise CdpTapError(f"recording file not found: {path!r}") - records: List[CdpRecord] = [] + records: list[CdpRecord] = [] for line_no, line in enumerate(fp.read_text(encoding="utf-8").splitlines(), 1): if not line.strip(): continue @@ -140,10 +140,10 @@ def load_recording(path: Union[str, Path]) -> List[CdpRecord]: class CdpReplayer: """Match incoming ``execute_cdp_cmd`` calls against a recording.""" - records: List[CdpRecord] + records: list[CdpRecord] _cursor: int = field(default=0, init=False) - def execute_cdp_cmd(self, method: str, _params: Optional[Dict[str, Any]] = None) -> Any: + def execute_cdp_cmd(self, method: str, _params: dict[str, Any] | None = None) -> Any: # ``_params`` mirrors driver.execute_cdp_cmd's signature for # duck-typing but the replay sequence is keyed only on ``method``, # so the params payload is intentionally ignored here. diff --git a/je_web_runner/utils/chaos_hooks/chaos.py b/je_web_runner/utils/chaos_hooks/chaos.py index b28ecd92..dc5ed102 100644 --- a/je_web_runner/utils/chaos_hooks/chaos.py +++ b/je_web_runner/utils/chaos_hooks/chaos.py @@ -19,7 +19,7 @@ import random from dataclasses import dataclass, field from enum import Enum -from typing import Callable, Dict, List, Optional, Sequence +from typing import Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -54,11 +54,11 @@ class ChaosEvent: class ChaosPlan: """A reproducible (seeded) injection schedule.""" - events: List[ChaosEvent] = field(default_factory=list) - seed: Optional[int] = None - skipped: List[int] = field(default_factory=list) + events: list[ChaosEvent] = field(default_factory=list) + seed: int | None = None + skipped: list[int] = field(default_factory=list) - def faults_for_step(self, index: int) -> List[ChaosFaultType]: + def faults_for_step(self, index: int) -> list[ChaosFaultType]: return [e.fault for e in self.events if e.step_index == index] def describe(self) -> str: @@ -76,10 +76,10 @@ def plan_chaos( *, faults: Sequence[ChaosFaultType] = tuple(ChaosFaultType), fault_rate: float = 0.2, - max_events: Optional[int] = None, + max_events: int | None = None, skip_first: int = 1, skip_last: int = 0, - seed: Optional[int] = None, + seed: int | None = None, ) -> ChaosPlan: """ 決定每個 step 是否注入 chaos,以及注入哪種類型。 @@ -95,16 +95,16 @@ def plan_chaos( raise ChaosHooksError("skip_first / skip_last must be >= 0") total = len(step_names) rng = random.Random(seed) # nosec B311 — deterministic test scheduling, not crypto - events: List[ChaosEvent] = [] - skipped: List[int] = [] + events: list[ChaosEvent] = [] + skipped: list[int] = [] for index, name in enumerate(step_names): if index < skip_first or index >= total - skip_last: skipped.append(index) continue # S2245 ok: deterministic seeded scheduling for tests; not cryptographic. - if rng.random() >= fault_rate: # noqa: S2245 + if rng.random() >= fault_rate: # NOSONAR S2245 — non-crypto use (chaos/sampling), not security-sensitive continue - fault = rng.choice(list(faults)) # noqa: S2245 + fault = rng.choice(list(faults)) # NOSONAR S2245 — non-crypto use (chaos/sampling), not security-sensitive events.append(ChaosEvent(step_index=index, step_name=name, fault=fault)) if max_events is not None and len(events) >= max_events: break @@ -122,7 +122,7 @@ class ChaosRunner: """Runs a :class:`ChaosPlan` by invoking the matching injector pre-step.""" plan: ChaosPlan - injectors: Dict[ChaosFaultType, Injector] = field(default_factory=dict) + injectors: dict[ChaosFaultType, Injector] = field(default_factory=dict) raise_on_missing: bool = True def __post_init__(self) -> None: @@ -138,9 +138,9 @@ def __post_init__(self) -> None: f"no injector registered for fault types: {unique}" ) - def before_step(self, index: int, name: str) -> List[ChaosEvent]: + def before_step(self, index: int, name: str) -> list[ChaosEvent]: """Fire every injector scheduled for ``index``; return events fired.""" - fired: List[ChaosEvent] = [] + fired: list[ChaosEvent] = [] for event in self.plan.events: if event.step_index != index: continue @@ -170,14 +170,14 @@ def run_with_chaos( step_fn: Callable[[int, str], None], *, plan: ChaosPlan, - injectors: Dict[ChaosFaultType, Injector], -) -> List[ChaosEvent]: + injectors: dict[ChaosFaultType, Injector], +) -> list[ChaosEvent]: """ Drive ``step_fn(index, name)`` for every step, firing scheduled injectors immediately before each step. Returns the events that fired. """ runner = ChaosRunner(plan=plan, injectors=injectors) - fired: List[ChaosEvent] = [] + fired: list[ChaosEvent] = [] for index, name in enumerate(step_names): fired.extend(runner.before_step(index, name)) step_fn(index, name) diff --git a/je_web_runner/utils/chrome_profile/profile_manager.py b/je_web_runner/utils/chrome_profile/profile_manager.py index e77e2a6c..be5154c9 100644 --- a/je_web_runner/utils/chrome_profile/profile_manager.py +++ b/je_web_runner/utils/chrome_profile/profile_manager.py @@ -19,7 +19,7 @@ from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Iterator, List, Optional, Sequence, Tuple +from typing import Any, Iterator, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -33,7 +33,7 @@ class ChromeProfileError(WebRunnerException): # sees any of these will refuse to launch with the same profile # (SessionNotCreatedException). Removing them is safe — they do NOT contain # cookies or login data. -SINGLETON_LOCK_FILES: Tuple[str, ...] = ( +SINGLETON_LOCK_FILES: tuple[str, ...] = ( "SingletonLock", "SingletonCookie", "SingletonSocket", @@ -44,7 +44,7 @@ class ChromeProfileError(WebRunnerException): # Relative paths inside the profile that we treat as session-critical: # everything we need to preserve a logged-in session. The journal sidecar # files are SQLite WAL artefacts and must be copied alongside the main DB. -SESSION_CRITICAL_PATHS: Tuple[str, ...] = ( +SESSION_CRITICAL_PATHS: tuple[str, ...] = ( "Default/Cookies", "Default/Cookies-journal", "Default/Login Data", @@ -91,14 +91,14 @@ class StealthFlags: """ user_agent: str = DEFAULT_USER_AGENT language: str = "en-US" - window_size: Optional[Tuple[int, int]] = None + window_size: tuple[int, int] | None = None disable_blink_features: bool = True exclude_automation_switches: bool = True headless: bool = False - extra_args: List[str] = field(default_factory=list) + extra_args: list[str] = field(default_factory=list) -def cleanup_chrome_locks(profile_dir: Path) -> List[str]: +def cleanup_chrome_locks(profile_dir: Path) -> list[str]: """ 清理 SingletonLock / lockfile 等殘留檔。Locked file 改用 rename 規避。 Remove the singleton lock files Chrome leaves behind. Files held by the @@ -108,7 +108,7 @@ def cleanup_chrome_locks(profile_dir: Path) -> List[str]: profile_dir = Path(profile_dir) if not profile_dir.exists(): return [] - statuses: List[str] = [] + statuses: list[str] = [] for fname in SINGLETON_LOCK_FILES: target = profile_dir / fname if not target.exists() and not target.is_symlink(): @@ -165,7 +165,7 @@ def snapshot_chrome_profile( # NOSONAR S3776 — cohesive logic; planned refact snapshot_dir.mkdir(parents=True, exist_ok=True) copied = 0 - skipped: List[str] = [] + skipped: list[str] = [] profile_root_str = str(profile_dir) for root, dirs, files in os.walk(profile_dir, topdown=True): if not full_copy: @@ -196,7 +196,7 @@ def sync_chrome_profile_back( profile_dir: Path, *, paths: Sequence[str] = SESSION_CRITICAL_PATHS, -) -> List[str]: +) -> list[str]: """ 把 snapshot 內 session-critical 檔複製回原 profile。 Copy session-critical files from the snapshot back into the persistent @@ -209,7 +209,7 @@ def sync_chrome_profile_back( raise ChromeProfileError(f"snapshot dir does not exist: {snapshot_dir}") profile_dir.mkdir(parents=True, exist_ok=True) - statuses: List[str] = [] + statuses: list[str] = [] for rel in paths: src = snapshot_dir / rel if not src.exists(): @@ -227,7 +227,7 @@ def sync_chrome_profile_back( def build_chrome_options( profile_dir: Path, - flags: Optional[StealthFlags] = None, + flags: StealthFlags | None = None, ): """ 產出帶 stealth 設定的 ChromeOptions。 @@ -261,9 +261,9 @@ def build_chrome_options( def build_stealth_chrome_driver( profile_dir: Path, *, - snapshot_dir: Optional[Path] = None, - flags: Optional[StealthFlags] = None, - chromedriver_log: Optional[Path] = None, + snapshot_dir: Path | None = None, + flags: StealthFlags | None = None, + chromedriver_log: Path | None = None, retry_once: bool = True, ): """ @@ -294,11 +294,14 @@ def build_stealth_chrome_driver( opts = build_chrome_options(run_dir, flags=flags) service_kwargs = {} if chromedriver_log is not None: - service_kwargs["log_path"] = str(chromedriver_log) + # Selenium 4.x maps a string ``log_output`` to chromedriver's + # ``--log-path``; the old ``log_path`` kwarg is swallowed by the + # Service ``**kwargs`` and silently ignored (no log written). + service_kwargs["log_output"] = str(chromedriver_log) try: service = ChromeService(**service_kwargs) driver = webdriver.Chrome(service=service, options=opts) - except Exception as first_err: # noqa: BLE001 — Selenium wraps many causes + except Exception as first_err: if not retry_once: raise ChromeProfileError( f"chrome spawn failed: {first_err!r}" @@ -315,7 +318,7 @@ def build_stealth_chrome_driver( try: service = ChromeService(**service_kwargs) driver = webdriver.Chrome(service=service, options=opts) - except Exception as second_err: # noqa: BLE001 + except Exception as second_err: raise ChromeProfileError( f"chrome spawn failed twice: {second_err!r}" ) from second_err @@ -329,9 +332,9 @@ def build_stealth_chrome_driver( def chrome_profile_session( profile_dir: Path, *, - snapshot_dir: Optional[Path] = None, - flags: Optional[StealthFlags] = None, - chromedriver_log: Optional[Path] = None, + snapshot_dir: Path | None = None, + flags: StealthFlags | None = None, + chromedriver_log: Path | None = None, sync_back: bool = True, ) -> Iterator[Any]: """ @@ -355,7 +358,7 @@ def chrome_profile_session( finally: try: driver.quit() - except Exception as error: # noqa: BLE001 — best effort on teardown + except Exception as error: web_runner_logger.warning(f"driver.quit failed: {error!r}") if sync_back and snapshot_path is not None: try: @@ -368,8 +371,8 @@ def build_playwright_persistent_context( playwright_browser_type: Any, profile_dir: Path, *, - flags: Optional[StealthFlags] = None, - extra_launch_kwargs: Optional[dict] = None, + flags: StealthFlags | None = None, + extra_launch_kwargs: dict | None = None, ) -> Any: """ 用 Playwright 的 persistent context 開瀏覽器並套 stealth flag。 @@ -385,7 +388,7 @@ def build_playwright_persistent_context( profile_dir.mkdir(parents=True, exist_ok=True) cleanup_chrome_locks(profile_dir) - args: List[str] = [f"--lang={flags.language}"] + args: list[str] = [f"--lang={flags.language}"] if flags.disable_blink_features: args.append("--disable-blink-features=AutomationControlled") args.extend(flags.extra_args) diff --git a/je_web_runner/utils/ci_annotations/github_annotations.py b/je_web_runner/utils/ci_annotations/github_annotations.py index 707dbbdf..22fc84f1 100644 --- a/je_web_runner/utils/ci_annotations/github_annotations.py +++ b/je_web_runner/utils/ci_annotations/github_annotations.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -from typing import IO, Iterable, List, Optional +from typing import IO, Iterable # defusedxml-protected XML reader; CLAUDE.md requires this for parsing. from defusedxml import ElementTree as DefusedET @@ -35,16 +35,16 @@ def _escape(message: str) -> str: def format_error_annotation( message: str, - file: Optional[str] = None, - line: Optional[int] = None, - col: Optional[int] = None, - title: Optional[str] = None, + file: str | None = None, + line: int | None = None, + col: int | None = None, + title: str | None = None, ) -> str: """ 產出 ``::error file=...::message`` 行 Format a single ``::error …::message`` workflow command line. """ - parts: List[str] = [] + parts: list[str] = [] if file: parts.append(f"file={file}") if line is not None: @@ -58,9 +58,9 @@ def format_error_annotation( def emit_failure_annotations( - stream: Optional[IO[str]] = None, - file: Optional[str] = None, -) -> List[str]: + stream: IO[str] | None = None, + file: str | None = None, +) -> list[str]: """ 對 ``test_record_instance`` 內每個失敗紀錄輸出一行 annotation Emit one annotation per failure in ``test_record_instance``. @@ -71,7 +71,7 @@ def emit_failure_annotations( """ web_runner_logger.info("emit_failure_annotations") out_stream = stream if stream is not None else sys.stdout - lines: List[str] = [] + lines: list[str] = [] for record in test_record_instance.test_record_list: if record.get("program_exception", _NO_EXCEPTION) == _NO_EXCEPTION: continue @@ -87,8 +87,8 @@ def emit_failure_annotations( def emit_from_junit_xml( junit_path: str, - stream: Optional[IO[str]] = None, -) -> List[str]: + stream: IO[str] | None = None, +) -> list[str]: """ 讀取 JUnit XML 並對其中每個 ```` 輸出一行 annotation Parse a JUnit XML report and emit ``::error::`` annotations for each @@ -103,7 +103,7 @@ def emit_from_junit_xml( except DefusedET.ParseError as error: raise AnnotationError(f"failed to parse JUnit XML: {error}") from error root = tree.getroot() - lines: List[str] = [] + lines: list[str] = [] for testcase in _iter_testcases(root): failure = testcase.find("failure") if failure is None: diff --git a/je_web_runner/utils/cli/cli_main.py b/je_web_runner/utils/cli/cli_main.py index c84865a1..805effbb 100644 --- a/je_web_runner/utils/cli/cli_main.py +++ b/je_web_runner/utils/cli/cli_main.py @@ -9,7 +9,7 @@ import sys from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor from pathlib import Path -from typing import Optional, Sequence, Tuple +from typing import Sequence from je_web_runner.utils.exception.exception_tags import argparse_get_wrong_data from je_web_runner.utils.exception.exceptions import WebRunnerExecuteException @@ -113,7 +113,7 @@ def _build_parser() -> argparse.ArgumentParser: return parser -def _split_csv(value: Optional[str]) -> list: +def _split_csv(value: str | None) -> list: if not value: return [] return [item.strip() for item in value.split(",") if item.strip()] @@ -134,7 +134,7 @@ def _run_one_file(path: str) -> bool: failed = False try: execute_action(read_action_json(path)) - except Exception: # noqa: BLE001 — record and continue (or rethrow caller-side) + except Exception: failed = True new_records = test_record_instance.test_record_list[baseline:] if any(record.get(failure_marker, "None") != "None" for record in new_records): @@ -142,7 +142,7 @@ def _run_one_file(path: str) -> bool: return not failed -def _run_one_file_isolated(path: str) -> Tuple[str, bool, list]: +def _run_one_file_isolated(path: str) -> tuple[str, bool, list]: """ Top-level picklable worker for ``ProcessPoolExecutor``: executes the file in its own process (so the WebRunner singletons are fresh) and returns @@ -173,7 +173,7 @@ def _run_one_file_isolated(path: str) -> Tuple[str, bool, list]: def _run_with_dependencies( files: Sequence[str], - ledger_path: Optional[str], + ledger_path: str | None, ) -> None: """Run files in topological order, skipping downstream when upstream fails.""" graph = build_dependency_graph(files) @@ -200,8 +200,8 @@ def _select_files( directory: str, include_tags, exclude_tags, - rerun_only: Optional[Sequence[str]], - shard_spec: Optional[str], + rerun_only: Sequence[str] | None, + shard_spec: str | None, ) -> list: """Apply rerun-only / tag / shard filters to the directory listing.""" files = get_dir_files_as_list(directory) @@ -216,7 +216,7 @@ def _select_files( return files -def _run_sequential(files, ledger_path: Optional[str]) -> None: +def _run_sequential(files, ledger_path: str | None) -> None: """Sequential execution with optional ledger recording.""" if not ledger_path: execute_files(files) @@ -226,7 +226,7 @@ def _run_sequential(files, ledger_path: Optional[str]) -> None: record_run(ledger_path, path, passed=passed) -def _run_with_process_pool(files, parallel: int, ledger_path: Optional[str]) -> None: +def _run_with_process_pool(files, parallel: int, ledger_path: str | None) -> None: """ProcessPool branch — true singleton isolation, records merged back.""" with ProcessPoolExecutor(max_workers=parallel) as pool: for path, passed, records in pool.map(_run_one_file_isolated, files): @@ -235,7 +235,7 @@ def _run_with_process_pool(files, parallel: int, ledger_path: Optional[str]) -> record_run(ledger_path, path, passed=passed) -def _run_with_thread_pool(files, parallel: int, ledger_path: Optional[str]) -> None: +def _run_with_thread_pool(files, parallel: int, ledger_path: str | None) -> None: """ThreadPool branch — shares singletons, only safe for non-browser flows.""" web_runner_logger.warning( "--parallel-mode=thread shares webdriver singletons across files; " @@ -256,10 +256,10 @@ def _run_dir( parallel: int, include_tags=None, exclude_tags=None, - ledger_path: Optional[str] = None, - rerun_only: Optional[Sequence[str]] = None, + ledger_path: str | None = None, + rerun_only: Sequence[str] | None = None, parallel_mode: str = "thread", - shard_spec: Optional[str] = None, + shard_spec: str | None = None, ) -> None: files = _select_files(directory, include_tags, exclude_tags, rerun_only, shard_spec) if any(build_dependency_graph(files).get(path) for path in files): @@ -363,7 +363,7 @@ def _do_migrate(target: str, dry_run: bool) -> None: print(f" [{change['index']}] {change['from']} -> {change['to']}") -def main(argv: Optional[Sequence[str]] = None) -> int: +def main(argv: Sequence[str] | None = None) -> int: """Entry point for ``python -m je_web_runner``.""" parser = _build_parser() args = parser.parse_args(argv) diff --git a/je_web_runner/utils/cli/watch_mode.py b/je_web_runner/utils/cli/watch_mode.py index c61313e4..456cbf0e 100644 --- a/je_web_runner/utils/cli/watch_mode.py +++ b/je_web_runner/utils/cli/watch_mode.py @@ -7,15 +7,15 @@ import time from pathlib import Path -from typing import Callable, Dict +from typing import Callable from je_web_runner.utils.logging.loggin_instance import web_runner_logger -def _snapshot(directory: str) -> Dict[str, float]: +def _snapshot(directory: str) -> dict[str, float]: """Map of relative path → mtime for every JSON file under ``directory``.""" base = Path(directory) - snapshot: Dict[str, float] = {} + snapshot: dict[str, float] = {} for path in base.rglob("*.json"): try: snapshot[str(path)] = path.stat().st_mtime diff --git a/je_web_runner/utils/clickjacking_audit/audit.py b/je_web_runner/utils/clickjacking_audit/audit.py index c2779bb1..41a0f169 100644 --- a/je_web_runner/utils/clickjacking_audit/audit.py +++ b/je_web_runner/utils/clickjacking_audit/audit.py @@ -18,7 +18,7 @@ import re from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Dict, Iterable, List, Optional, Tuple +from typing import Any, Iterable from urllib.parse import urlparse from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -48,8 +48,8 @@ class Verdict(str, Enum): class HeaderPolicy: """Parsed clickjacking-related response headers.""" - x_frame_options: Optional[str] = None - csp_frame_ancestors: Optional[str] = None + x_frame_options: str | None = None + csp_frame_ancestors: str | None = None def normalized_xfo(self) -> str: return (self.x_frame_options or "").strip().upper() @@ -59,11 +59,11 @@ def normalized_fa(self) -> str: def parse_response_headers( - headers: Iterable[Tuple[str, str]], + headers: Iterable[tuple[str, str]], ) -> HeaderPolicy: """Parse a header iterable (case-insensitive) into a :class:`HeaderPolicy`.""" - xfo: Optional[str] = None - csp_fa: Optional[str] = None + xfo: str | None = None + csp_fa: str | None = None for name, value in headers: if not isinstance(name, str) or not isinstance(value, str): continue @@ -160,8 +160,8 @@ class AuditReport: target_url: str verdict: Verdict policy: HeaderPolicy - probe_status: Optional[str] = None - notes: List[str] = field(default_factory=list) + probe_status: str | None = None + notes: list[str] = field(default_factory=list) def passed(self) -> bool: if self.verdict in (Verdict.STRICT, Verdict.SAMEORIGIN): @@ -170,7 +170,7 @@ def passed(self) -> bool: return self.probe_status.upper().startswith("BLOCKED") return False - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "target_url": self.target_url, "verdict": self.verdict.value, @@ -183,14 +183,14 @@ def to_dict(self) -> Dict[str, Any]: def audit( target_url: str, - headers: Iterable[Tuple[str, str]], + headers: Iterable[tuple[str, str]], *, - probe_status: Optional[str] = None, + probe_status: str | None = None, ) -> AuditReport: """One-shot: parse headers → classify → (optionally) consider probe.""" policy = parse_response_headers(headers) verdict = classify(policy) - notes: List[str] = [] + notes: list[str] = [] if verdict == Verdict.MISSING: notes.append("no X-Frame-Options or frame-ancestors set") if verdict == Verdict.ALLOWED: diff --git a/je_web_runner/utils/cloud_grid/cloud_drivers.py b/je_web_runner/utils/cloud_grid/cloud_drivers.py index 785293f5..91d6127f 100644 --- a/je_web_runner/utils/cloud_grid/cloud_drivers.py +++ b/je_web_runner/utils/cloud_grid/cloud_drivers.py @@ -12,7 +12,7 @@ from __future__ import annotations import urllib.parse -from typing import Any, Dict, Optional +from typing import Any from selenium import webdriver from selenium.webdriver.remote.webdriver import WebDriver @@ -26,7 +26,7 @@ class CloudGridError(WebRunnerException): """Raised when a cloud Remote driver cannot be started.""" -_DEFAULT_HUBS: Dict[str, str] = { +_DEFAULT_HUBS: dict[str, str] = { "browserstack": "https://hub-cloud.browserstack.com/wd/hub", "saucelabs": "https://ondemand.us-west-1.saucelabs.com:443/wd/hub", "lambdatest": "https://hub.lambdatest.com/wd/hub", @@ -52,20 +52,20 @@ def build_browserstack_capabilities( browser_version: str = "latest", os_name: str = "Windows", os_version: str = "11", - project: Optional[str] = None, - build: Optional[str] = None, - name: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: + project: str | None = None, + build: str | None = None, + name: str | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: """Build a W3C-style capability dict for BrowserStack.""" - bstack: Dict[str, Any] = {"os": os_name, "osVersion": os_version} + bstack: dict[str, Any] = {"os": os_name, "osVersion": os_version} if project: bstack["projectName"] = project if build: bstack["buildName"] = build if name: bstack["sessionName"] = name - caps: Dict[str, Any] = { + caps: dict[str, Any] = { "browserName": browser_name, "browserVersion": browser_version, "bstack:options": bstack, @@ -79,16 +79,16 @@ def build_saucelabs_capabilities( browser_name: str = "chrome", browser_version: str = "latest", platform_name: str = "Windows 11", - build: Optional[str] = None, - name: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - sauce: Dict[str, Any] = {} + build: str | None = None, + name: str | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + sauce: dict[str, Any] = {} if build: sauce["build"] = build if name: sauce["name"] = name - caps: Dict[str, Any] = { + caps: dict[str, Any] = { "browserName": browser_name, "browserVersion": browser_version, "platformName": platform_name, @@ -103,16 +103,16 @@ def build_lambdatest_capabilities( browser_name: str = "Chrome", browser_version: str = "latest", platform_name: str = "Windows 11", - build: Optional[str] = None, - name: Optional[str] = None, - extra: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - lt: Dict[str, Any] = {} + build: str | None = None, + name: str | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + lt: dict[str, Any] = {} if build: lt["build"] = build if name: lt["name"] = name - caps: Dict[str, Any] = { + caps: dict[str, Any] = { "browserName": browser_name, "browserVersion": browser_version, "platformName": platform_name, @@ -125,7 +125,7 @@ def build_lambdatest_capabilities( def start_remote_driver( hub_url: str, - capabilities: Dict[str, Any], + capabilities: dict[str, Any], register: bool = True, ) -> WebDriver: """ @@ -139,12 +139,14 @@ def start_remote_driver( options.set_capability(key, value) driver = webdriver.Remote(command_executor=hub_url, options=options) if register: - webdriver_wrapper_instance.current_webdriver = driver + # set_active_driver (not a bare current_webdriver assignment) so the + # wrapper's ActionChains bind to this remote driver. + webdriver_wrapper_instance.set_active_driver(driver) return driver def _connect(provider: str, username: str, access_key: str, - capabilities: Dict[str, Any], hub_url: Optional[str]) -> WebDriver: + capabilities: dict[str, Any], hub_url: str | None) -> WebDriver: target_hub = hub_url or _DEFAULT_HUBS[provider] return start_remote_driver(_hub_url_with_credentials(target_hub, username, access_key), capabilities) @@ -152,8 +154,8 @@ def _connect(provider: str, username: str, access_key: str, def connect_browserstack( username: str, access_key: str, - capabilities: Optional[Dict[str, Any]] = None, - hub_url: Optional[str] = None, + capabilities: dict[str, Any] | None = None, + hub_url: str | None = None, ) -> WebDriver: return _connect( "browserstack", @@ -167,8 +169,8 @@ def connect_browserstack( def connect_saucelabs( username: str, access_key: str, - capabilities: Optional[Dict[str, Any]] = None, - hub_url: Optional[str] = None, + capabilities: dict[str, Any] | None = None, + hub_url: str | None = None, ) -> WebDriver: return _connect( "saucelabs", @@ -182,8 +184,8 @@ def connect_saucelabs( def connect_lambdatest( username: str, access_key: str, - capabilities: Optional[Dict[str, Any]] = None, - hub_url: Optional[str] = None, + capabilities: dict[str, Any] | None = None, + hub_url: str | None = None, ) -> WebDriver: return _connect( "lambdatest", diff --git a/je_web_runner/utils/commit_msg_trigger/trigger.py b/je_web_runner/utils/commit_msg_trigger/trigger.py index a3429d4c..28cbd43e 100644 --- a/je_web_runner/utils/commit_msg_trigger/trigger.py +++ b/je_web_runner/utils/commit_msg_trigger/trigger.py @@ -16,7 +16,7 @@ import re from dataclasses import asdict, dataclass, field -from typing import Any, Dict, Optional, Set, Tuple +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -49,12 +49,12 @@ class CommitMsgTriggerError(WebRunnerException): @dataclass class TriggerPlan: skip: bool = False - only_buckets: Set[str] = field(default_factory=set) - labels: Set[str] = field(default_factory=set) - shard: Optional[Tuple[int, int]] = None - tickets: Set[str] = field(default_factory=set) + only_buckets: set[str] = field(default_factory=set) + labels: set[str] = field(default_factory=set) + shard: tuple[int, int] | None = None + tickets: set[str] = field(default_factory=set) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: d = asdict(self) d["only_buckets"] = sorted(self.only_buckets) d["labels"] = sorted(self.labels) @@ -101,7 +101,7 @@ def should_run_job(plan: TriggerPlan, job_name: str) -> bool: return True -def assigned_shard(plan: TriggerPlan, total_shards: int) -> Optional[int]: +def assigned_shard(plan: TriggerPlan, total_shards: int) -> int | None: """If commit overrides shard, return the 0-indexed shard for ``total_shards``. Returns None when no override applies.""" if total_shards <= 0: diff --git a/je_web_runner/utils/compute_pressure/pressure.py b/je_web_runner/utils/compute_pressure/pressure.py index 3c13de1b..962bed9e 100644 --- a/je_web_runner/utils/compute_pressure/pressure.py +++ b/je_web_runner/utils/compute_pressure/pressure.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -86,15 +86,15 @@ class PressureReaction: @dataclass class PressureLog: - reactions: List[PressureReaction] = field(default_factory=list) + reactions: list[PressureReaction] = field(default_factory=list) disconnect_count: int = 0 - fires: List[PressureLevel] = field(default_factory=list) + fires: list[PressureLevel] = field(default_factory=list) def parse_log(payload: Any) -> PressureLog: if not isinstance(payload, dict): raise ComputePressureError("payload must be a dict") - reactions: List[PressureReaction] = [] + reactions: list[PressureReaction] = [] for raw in payload.get("reactions") or []: if not isinstance(raw, dict): continue @@ -109,7 +109,7 @@ def parse_log(payload: Any) -> PressureLog: level=level, ts_ms=int(raw.get("ts") or 0), )) - fires: List[PressureLevel] = [] + fires: list[PressureLevel] = [] for raw in payload.get("fires") or []: try: fires.append(PressureLevel(raw)) @@ -125,7 +125,7 @@ def parse_log(payload: Any) -> PressureLog: def assert_reaction_to( - log: PressureLog, *, level: PressureLevel, name: Optional[str] = None, + log: PressureLog, *, level: PressureLevel, name: str | None = None, ) -> PressureReaction: if not isinstance(level, PressureLevel): raise ComputePressureError("level must be PressureLevel enum") diff --git a/je_web_runner/utils/consent_audit/audit.py b/je_web_runner/utils/consent_audit/audit.py index 005ae6e8..4eb2ebfc 100644 --- a/je_web_runner/utils/consent_audit/audit.py +++ b/je_web_runner/utils/consent_audit/audit.py @@ -16,7 +16,7 @@ import re from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, Iterable, List, Optional, Sequence +from typing import Any, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -42,8 +42,8 @@ class CookieCategory(str, Enum): class CookieRule: """Match by cookie name regex and / or domain suffix.""" - name_pattern: Optional[str] - domain_suffix: Optional[str] + name_pattern: str | None + domain_suffix: str | None category: CookieCategory vendor: str @@ -80,9 +80,9 @@ class Cookie: name: str domain: str = "" - value: Optional[str] = None + value: str | None = None secure: bool = True - same_site: Optional[str] = None + same_site: str | None = None def __post_init__(self) -> None: if not self.name or not isinstance(self.name, str): @@ -97,7 +97,7 @@ class ClassifiedCookie: category: CookieCategory vendor: str - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "name": self.cookie.name, "domain": self.cookie.domain, @@ -141,7 +141,7 @@ def classify_all( cookies: Iterable[Cookie], *, extra_rules: Sequence[CookieRule] = (), -) -> List[ClassifiedCookie]: +) -> list[ClassifiedCookie]: """Convenience: classify every cookie in ``cookies``.""" return [classify_cookie(c, extra_rules=extra_rules) for c in cookies] @@ -154,14 +154,14 @@ class ConsentReport: pre_consent_total: int post_consent_total: int - pre_consent_violations: List[ClassifiedCookie] = field(default_factory=list) - post_consent_reintroduced: List[ClassifiedCookie] = field(default_factory=list) - unknown_cookies: List[ClassifiedCookie] = field(default_factory=list) + pre_consent_violations: list[ClassifiedCookie] = field(default_factory=list) + post_consent_reintroduced: list[ClassifiedCookie] = field(default_factory=list) + unknown_cookies: list[ClassifiedCookie] = field(default_factory=list) def passed(self) -> bool: return not self.pre_consent_violations and not self.post_consent_reintroduced - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "pre_consent_total": self.pre_consent_total, "post_consent_total": self.post_consent_total, @@ -199,7 +199,7 @@ def audit_consent( unknown = [ c for c in before_classified if c.category == CookieCategory.UNKNOWN ] - reintroduced: List[ClassifiedCookie] = [] + reintroduced: list[ClassifiedCookie] = [] if user_rejected: reintroduced = [ c for c in after_classified if c.category in NON_ESSENTIAL @@ -231,9 +231,9 @@ def assert_passes(report: ConsentReport) -> None: raise ConsentAuditError("; ".join(parts)) -def from_selenium_cookies(cookies: Iterable[Dict[str, Any]]) -> List[Cookie]: +def from_selenium_cookies(cookies: Iterable[dict[str, Any]]) -> list[Cookie]: """Convert Selenium ``driver.get_cookies()`` dicts to :class:`Cookie`.""" - out: List[Cookie] = [] + out: list[Cookie] = [] for entry in cookies: if not isinstance(entry, dict): continue diff --git a/je_web_runner/utils/console_error_budget/budget.py b/je_web_runner/utils/console_error_budget/budget.py index 76a5e9db..52032aaf 100644 --- a/je_web_runner/utils/console_error_budget/budget.py +++ b/je_web_runner/utils/console_error_budget/budget.py @@ -20,7 +20,7 @@ import re import time from dataclasses import asdict, dataclass, field -from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Union +from typing import Any, Iterable, Pattern, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -41,10 +41,10 @@ class ConsoleMessage: severity: str text: str - url: Optional[str] = None - line: Optional[int] = None + url: str | None = None + line: int | None = None timestamp: float = field(default_factory=time.time) - source: Optional[str] = None # 'console' or 'exception' or driver-specific + source: str | None = None # 'console' or 'exception' or driver-specific def __post_init__(self) -> None: normalised = (self.severity or "").lower() @@ -56,7 +56,7 @@ def __post_init__(self) -> None: normalised = "info" object.__setattr__(self, "severity", normalised) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -68,8 +68,8 @@ class BudgetReport: error_count: int warning_count: int ignored_count: int - breaches: List[str] = field(default_factory=list) - sampled: List[ConsoleMessage] = field(default_factory=list) + breaches: list[str] = field(default_factory=list) + sampled: list[ConsoleMessage] = field(default_factory=list) def raise_if_failed(self) -> None: if not self.passed: @@ -84,7 +84,7 @@ class ErrorBudget: max_errors: int = 0 max_warnings: int = 5 count_warnings: bool = True - ignore_patterns: Sequence[Union[str, Pattern[str]]] = () + ignore_patterns: Sequence[str | Pattern[str]] = () sample_size: int = 10 def __post_init__(self) -> None: @@ -97,9 +97,9 @@ def __post_init__(self) -> None: # ---------- evaluator --------------------------------------------------- def _compiled_patterns( - patterns: Sequence[Union[str, Pattern[str]]], -) -> List[Pattern[str]]: - compiled: List[Pattern[str]] = [] + patterns: Sequence[str | Pattern[str]], +) -> list[Pattern[str]]: + compiled: list[Pattern[str]] = [] for p in patterns: if hasattr(p, "search"): compiled.append(p) # type: ignore[arg-type] @@ -111,7 +111,7 @@ def _compiled_patterns( return compiled -def _is_ignored(message: ConsoleMessage, patterns: List[Pattern[str]]) -> bool: +def _is_ignored(message: ConsoleMessage, patterns: list[Pattern[str]]) -> bool: if not patterns: return False haystack = f"{message.text}\n{message.url or ''}" @@ -126,8 +126,8 @@ def evaluate( if not isinstance(budget, ErrorBudget): raise ConsoleBudgetError("budget must be an ErrorBudget instance") patterns = _compiled_patterns(budget.ignore_patterns) - errors: List[ConsoleMessage] = [] - warnings: List[ConsoleMessage] = [] + errors: list[ConsoleMessage] = [] + warnings: list[ConsoleMessage] = [] ignored = 0 for msg in messages: if not isinstance(msg, ConsoleMessage): @@ -141,7 +141,7 @@ def evaluate( errors.append(msg) elif msg.severity == "warning" and budget.count_warnings: warnings.append(msg) - breaches: List[str] = [] + breaches: list[str] = [] if len(errors) > budget.max_errors: breaches.append( f"errors {len(errors)} > max_errors {budget.max_errors}" @@ -169,9 +169,9 @@ def evaluate( # ---------- adapters ---------------------------------------------------- -def from_selenium_log(entries: Iterable[Dict[str, Any]]) -> List[ConsoleMessage]: +def from_selenium_log(entries: Iterable[dict[str, Any]]) -> list[ConsoleMessage]: """Convert Selenium ``driver.get_log('browser')`` entries to messages.""" - out: List[ConsoleMessage] = [] + out: list[ConsoleMessage] = [] for entry in entries: if not isinstance(entry, dict): continue @@ -184,17 +184,17 @@ def from_selenium_log(entries: Iterable[Dict[str, Any]]) -> List[ConsoleMessage] return out -def from_cdp_console_events(events: Iterable[Dict[str, Any]]) -> List[ConsoleMessage]: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up +def from_cdp_console_events(events: Iterable[dict[str, Any]]) -> list[ConsoleMessage]: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up """ Convert CDP ``Runtime.consoleAPICalled`` payloads into messages. Each event dict is expected to have ``type`` and ``args`` like CDP returns. """ - out: List[ConsoleMessage] = [] + out: list[ConsoleMessage] = [] for event in events: if not isinstance(event, dict): continue args = event.get("args") or [] - text_parts: List[str] = [] + text_parts: list[str] = [] for arg in args: if isinstance(arg, dict): value = arg.get("value") @@ -212,9 +212,9 @@ def from_cdp_console_events(events: Iterable[Dict[str, Any]]) -> List[ConsoleMes return out -def from_cdp_exception_events(events: Iterable[Dict[str, Any]]) -> List[ConsoleMessage]: +def from_cdp_exception_events(events: Iterable[dict[str, Any]]) -> list[ConsoleMessage]: """Convert CDP ``Runtime.exceptionThrown`` payloads to error messages.""" - out: List[ConsoleMessage] = [] + out: list[ConsoleMessage] = [] for event in events: if not isinstance(event, dict): continue diff --git a/je_web_runner/utils/contract_testing/contract.py b/je_web_runner/utils/contract_testing/contract.py index 67993817..83f87a99 100644 --- a/je_web_runner/utils/contract_testing/contract.py +++ b/je_web_runner/utils/contract_testing/contract.py @@ -9,7 +9,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -21,7 +21,7 @@ class ContractError(WebRunnerException): @dataclass class SchemaResult: valid: bool - errors: List[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) _TYPE_CHECKS = { @@ -35,8 +35,8 @@ class SchemaResult: } -def _validate(value: Any, schema: Dict[str, Any], path: str, - errors: List[str]) -> None: +def _validate(value: Any, schema: dict[str, Any], path: str, + errors: list[str]) -> None: if not isinstance(schema, dict): errors.append(f"{path}: schema must be a dict") return @@ -54,17 +54,17 @@ def _validate(value: Any, schema: Dict[str, Any], path: str, _validate_array(value, schema, path, errors) -def _validate_one_of(value: Any, candidates: List[Any], path: str, - errors: List[str]) -> None: +def _validate_one_of(value: Any, candidates: list[Any], path: str, + errors: list[str]) -> None: for sub in candidates: - sub_errors: List[str] = [] + sub_errors: list[str] = [] _validate(value, sub, path, sub_errors) if not sub_errors: return errors.append(f"{path}: did not match any oneOf candidate") -def _check_type(value: Any, expected: str, path: str, errors: List[str]) -> bool: +def _check_type(value: Any, expected: str, path: str, errors: list[str]) -> bool: check = _TYPE_CHECKS.get(expected) if check is None: errors.append(f"{path}: unsupported type {expected!r}") @@ -75,8 +75,8 @@ def _check_type(value: Any, expected: str, path: str, errors: List[str]) -> bool return True -def _check_enum(value: Any, schema: Dict[str, Any], path: str, - errors: List[str]) -> bool: +def _check_enum(value: Any, schema: dict[str, Any], path: str, + errors: list[str]) -> bool: enum = schema.get("enum") if enum is not None and value not in enum: errors.append(f"{path}: value {value!r} not in enum {enum!r}") @@ -84,8 +84,8 @@ def _check_enum(value: Any, schema: Dict[str, Any], path: str, return True -def _validate_object(value: Dict[str, Any], schema: Dict[str, Any], - path: str, errors: List[str]) -> None: +def _validate_object(value: dict[str, Any], schema: dict[str, Any], + path: str, errors: list[str]) -> None: properties = schema.get("properties") or {} required = schema.get("required") or [] for prop in required: @@ -100,8 +100,8 @@ def _validate_object(value: Dict[str, Any], schema: Dict[str, Any], errors.append(f"{path}: unexpected properties {sorted(extras)}") -def _validate_array(value: List[Any], schema: Dict[str, Any], - path: str, errors: List[str]) -> None: +def _validate_array(value: list[Any], schema: dict[str, Any], + path: str, errors: list[str]) -> None: items_schema = schema.get("items") if items_schema is None: return @@ -109,16 +109,16 @@ def _validate_array(value: List[Any], schema: Dict[str, Any], _validate(item, items_schema, f"{path}[{index}]", errors) -def validate_response(response_body: Any, schema: Dict[str, Any]) -> SchemaResult: +def validate_response(response_body: Any, schema: dict[str, Any]) -> SchemaResult: """Validate ``response_body`` against ``schema``.""" - errors: List[str] = [] + errors: list[str] = [] _validate(response_body, schema, "$", errors) return SchemaResult(valid=not errors, errors=errors) def validate_against_openapi( response_body: Any, - openapi_doc: Dict[str, Any], + openapi_doc: dict[str, Any], path: str, method: str, status: int, @@ -149,7 +149,7 @@ def validate_against_openapi( return validate_response(response_body, _resolve_refs(schema, openapi_doc)) -def _resolve_refs(schema: Any, doc: Dict[str, Any], depth: int = 0) -> Any: +def _resolve_refs(schema: Any, doc: dict[str, Any], depth: int = 0) -> Any: """Inline ``$ref`` definitions from ``components/schemas`` (max depth 8).""" if depth > 8 or not isinstance(schema, dict): return schema @@ -162,7 +162,7 @@ def _resolve_refs(schema: Any, doc: Dict[str, Any], depth: int = 0) -> Any: if target is None: return schema return _resolve_refs(target, doc, depth + 1) - resolved: Dict[str, Any] = {} + resolved: dict[str, Any] = {} for key, value in schema.items(): if isinstance(value, dict): resolved[key] = _resolve_refs(value, doc, depth + 1) @@ -173,7 +173,7 @@ def _resolve_refs(schema: Any, doc: Dict[str, Any], depth: int = 0) -> Any: return resolved -def assert_valid(response_body: Any, schema: Dict[str, Any]) -> None: +def assert_valid(response_body: Any, schema: dict[str, Any]) -> None: """Convenience: raise on validation failure.""" result = validate_response(response_body, schema) if not result.valid: diff --git a/je_web_runner/utils/cookie_chips_audit/audit.py b/je_web_runner/utils/cookie_chips_audit/audit.py index 126bfd44..4dd6d62c 100644 --- a/je_web_runner/utils/cookie_chips_audit/audit.py +++ b/je_web_runner/utils/cookie_chips_audit/audit.py @@ -14,7 +14,7 @@ from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Iterable from urllib.parse import urlparse from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -34,7 +34,7 @@ class Severity(str, Enum): class SetCookie: name: str value: str = "" - attributes: Dict[str, Optional[str]] = field(default_factory=dict) + attributes: dict[str, str | None] = field(default_factory=dict) @property def is_partitioned(self) -> bool: @@ -56,7 +56,7 @@ def parse_set_cookie(header: str) -> SetCookie: raise CookieChipsAuditError(f"invalid Set-Cookie header: {header!r}") parts = [p.strip() for p in header.split(";")] name, _, value = parts[0].partition("=") - attrs: Dict[str, Optional[str]] = {} + attrs: dict[str, str | None] = {} for part in parts[1:]: if not part: continue @@ -77,7 +77,7 @@ class Finding: cookie_origin: str message: str - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "severity": self.severity.value} @@ -96,9 +96,9 @@ def _is_third_party(page_url: str, cookie_url: str) -> bool: def _partitioned_findings( - cookie: SetCookie, third_party: bool, common: Dict[str, str], -) -> List[Finding]: - out: List[Finding] = [] + cookie: SetCookie, third_party: bool, common: dict[str, str], +) -> list[Finding]: + out: list[Finding] = [] if not cookie.is_secure: out.append(Finding( severity=Severity.ERROR, rule="partitioned-requires-secure", @@ -123,7 +123,7 @@ def _partitioned_findings( def _check_cookie( cookie: SetCookie, page_url: str, cookie_url: str, -) -> List[Finding]: +) -> list[Finding]: third_party = _is_third_party(page_url, cookie_url) common = { "cookie": cookie.name, @@ -141,8 +141,8 @@ def _check_cookie( return [] -def _findings_for_entry(entry: Dict[str, Any], page_url: str) -> List[Finding]: - out: List[Finding] = [] +def _findings_for_entry(entry: dict[str, Any], page_url: str) -> list[Finding]: + out: list[Finding] = [] request_url = (entry.get("request") or {}).get("url", "") headers = (entry.get("response") or {}).get("headers", []) or [] for header in headers: @@ -156,7 +156,7 @@ def _findings_for_entry(entry: Dict[str, Any], page_url: str) -> List[Finding]: return out -def audit_har(har: Dict[str, Any], page_url: str) -> List[Finding]: +def audit_har(har: dict[str, Any], page_url: str) -> list[Finding]: """Walk a HAR's responses and emit findings for every Set-Cookie header.""" if not isinstance(har, dict): raise CookieChipsAuditError("har must be a dict") @@ -165,7 +165,7 @@ def audit_har(har: Dict[str, Any], page_url: str) -> List[Finding]: entries = har.get("log", {}).get("entries", []) if not isinstance(entries, list): raise CookieChipsAuditError("har.log.entries must be a list") - findings: List[Finding] = [] + findings: list[Finding] = [] for entry in entries: findings.extend(_findings_for_entry(entry, page_url)) return findings @@ -173,8 +173,8 @@ def audit_har(har: Dict[str, Any], page_url: str) -> List[Finding]: def audit_headers( headers: Iterable[str], page_url: str, cookie_url: str, -) -> List[Finding]: - findings: List[Finding] = [] +) -> list[Finding]: + findings: list[Finding] = [] for header in headers: try: cookie = parse_set_cookie(header) diff --git a/je_web_runner/utils/cookie_consent/consent.py b/je_web_runner/utils/cookie_consent/consent.py index cc4bb2ea..51d5a558 100644 --- a/je_web_runner/utils/cookie_consent/consent.py +++ b/je_web_runner/utils/cookie_consent/consent.py @@ -10,7 +10,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -20,7 +20,7 @@ class ConsentBannerError(WebRunnerException): """Raised when consent dismissal fails or driver is unsupported.""" -_DEFAULT_SELECTORS: List[str] = [ +_DEFAULT_SELECTORS: list[str] = [ "#onetrust-accept-btn-handler", "#truste-consent-button", "#CybotCookiebotDialogBodyLevelButtonAccept", @@ -41,14 +41,14 @@ class ConsentBannerError(WebRunnerException): class ConsentDismisser: """Try each selector against a driver / page; click the first hit.""" - selectors: List[str] = field(default_factory=lambda: list(_DEFAULT_SELECTORS)) + selectors: list[str] = field(default_factory=lambda: list(_DEFAULT_SELECTORS)) def add_selector(self, selector: str) -> None: if not selector or selector in self.selectors: return self.selectors.append(selector) - def dismiss(self, driver: Any, timeout_per_selector: float = 0.5) -> Optional[str]: + def dismiss(self, driver: Any, timeout_per_selector: float = 0.5) -> str | None: """ 嘗試點擊 banner 上的 Accept 按鈕;找不到時回傳 None Click the first matching consent button. Returns the selector that @@ -90,7 +90,7 @@ def _try_selector(self, driver: Any, selector: str, timeout: float) -> bool: return False -def common_dismiss_selectors() -> List[str]: +def common_dismiss_selectors() -> list[str]: return list(_DEFAULT_SELECTORS) diff --git a/je_web_runner/utils/cookie_scope_abuse/scope.py b/je_web_runner/utils/cookie_scope_abuse/scope.py index 91c1b4a6..b857be09 100644 --- a/je_web_runner/utils/cookie_scope_abuse/scope.py +++ b/je_web_runner/utils/cookie_scope_abuse/scope.py @@ -17,7 +17,7 @@ import re from dataclasses import asdict, dataclass from enum import Enum -from typing import Any, Dict, Iterable, List +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -39,7 +39,7 @@ class CookieScopeFinding: cookie: str message: str - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "severity": self.severity.value} @@ -65,19 +65,29 @@ class _SessionCookie: same_site: str -def _extract_session(cookie: Dict[str, Any]) -> _SessionCookie: +def _cookie_http_only(cookie: dict[str, Any]) -> bool: + """Honour an explicit ``httpOnly: False``; a falsy-coalesce across the two + key spellings would let a stray ``http_only: True`` mask it (and this audit + flags *missing* HttpOnly, so the direction matters).""" + value = cookie.get("httpOnly") + if value is None: + value = cookie.get("http_only") + return bool(value) + + +def _extract_session(cookie: dict[str, Any]) -> _SessionCookie: return _SessionCookie( name=str(cookie.get("name") or ""), domain=str(cookie.get("domain") or "").lstrip("."), path=str(cookie.get("path") or "/"), - http_only=bool(cookie.get("httpOnly") or cookie.get("http_only")), + http_only=_cookie_http_only(cookie), secure=bool(cookie.get("secure")), same_site=(cookie.get("sameSite") or cookie.get("same_site") or "").lower(), ) -def _scope_findings(c: _SessionCookie, page_host: str) -> List[CookieScopeFinding]: - out: List[CookieScopeFinding] = [] +def _scope_findings(c: _SessionCookie, page_host: str) -> list[CookieScopeFinding]: + out: list[CookieScopeFinding] = [] page_apex = ".".join(page_host.split(".")[-2:]) cookie_apex = ".".join(c.domain.split(".")[-2:]) if c.domain else page_apex if c.domain and c.domain != page_host and cookie_apex == page_apex: @@ -95,8 +105,8 @@ def _scope_findings(c: _SessionCookie, page_host: str) -> List[CookieScopeFindin return out -def _security_findings(c: _SessionCookie) -> List[CookieScopeFinding]: - out: List[CookieScopeFinding] = [] +def _security_findings(c: _SessionCookie) -> list[CookieScopeFinding]: + out: list[CookieScopeFinding] = [] if not c.http_only: out.append(CookieScopeFinding( severity=Severity.ERROR, rule="session-no-httponly", cookie=c.name, @@ -119,8 +129,8 @@ def _security_findings(c: _SessionCookie) -> List[CookieScopeFinding]: def audit_cookie( - cookie: Dict[str, Any], *, page_host: str, -) -> List[CookieScopeFinding]: + cookie: dict[str, Any], *, page_host: str, +) -> list[CookieScopeFinding]: if not isinstance(cookie, dict): raise CookieScopeAbuseError("cookie must be a dict") if not isinstance(page_host, str) or not page_host: @@ -133,9 +143,9 @@ def audit_cookie( def audit_many( - cookies: Iterable[Dict[str, Any]], *, page_host: str, -) -> List[CookieScopeFinding]: - out: List[CookieScopeFinding] = [] + cookies: Iterable[dict[str, Any]], *, page_host: str, +) -> list[CookieScopeFinding]: + out: list[CookieScopeFinding] = [] for c in cookies: out.extend(audit_cookie(c, page_host=page_host)) return out diff --git a/je_web_runner/utils/cookie_store_api/store.py b/je_web_runner/utils/cookie_store_api/store.py index 4ece3aa4..412745f9 100644 --- a/je_web_runner/utils/cookie_store_api/store.py +++ b/je_web_runner/utils/cookie_store_api/store.py @@ -6,7 +6,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -23,13 +23,13 @@ class CookieRecord: name: str value: str - domain: Optional[str] = None + domain: str | None = None path: str = "/" secure: bool = True same_site: str = "strict" - expires: Optional[int] = None # epoch ms + expires: int | None = None # epoch ms - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -37,8 +37,8 @@ def to_dict(self) -> Dict[str, Any]: class ChangeEvent: """One ``cookiechange`` event observed via cookieStore subscription.""" - changed: List[CookieRecord] = field(default_factory=list) - deleted: List[str] = field(default_factory=list) + changed: list[CookieRecord] = field(default_factory=list) + deleted: list[str] = field(default_factory=list) timestamp_ms: float = 0.0 @@ -80,13 +80,13 @@ def install_change_listener_script() -> str: # ---------- parsing ----------------------------------------------------- -def parse_cookies(payload: Any) -> List[CookieRecord]: +def parse_cookies(payload: Any) -> list[CookieRecord]: """Convert ``cookieStore.getAll()`` result to typed records.""" if not isinstance(payload, list): raise CookieStoreApiError( f"cookies payload must be list, got {type(payload).__name__}" ) - out: List[CookieRecord] = [] + out: list[CookieRecord] = [] for raw in payload: if not isinstance(raw, dict) or "name" not in raw: continue @@ -102,13 +102,13 @@ def parse_cookies(payload: Any) -> List[CookieRecord]: return out -def parse_change_events(payload: Any) -> List[ChangeEvent]: +def parse_change_events(payload: Any) -> list[ChangeEvent]: """Convert harvested change-event log to typed records.""" if not isinstance(payload, list): raise CookieStoreApiError( f"change events payload must be list, got {type(payload).__name__}" ) - out: List[ChangeEvent] = [] + out: list[ChangeEvent] = [] for raw in payload: if not isinstance(raw, dict): continue @@ -123,7 +123,7 @@ def parse_change_events(payload: Any) -> List[ChangeEvent]: # ---------- assertions -------------------------------------------------- def assert_cookie_present( - cookies: Iterable[CookieRecord], *, name: str, value: Optional[str] = None, + cookies: Iterable[CookieRecord], *, name: str, value: str | None = None, ) -> CookieRecord: """Assert a cookie with name (and optional value) is present.""" if not isinstance(name, str) or not name: diff --git a/je_web_runner/utils/coop_coep_audit/audit.py b/je_web_runner/utils/coop_coep_audit/audit.py index 37884320..3fcc073b 100644 --- a/je_web_runner/utils/coop_coep_audit/audit.py +++ b/je_web_runner/utils/coop_coep_audit/audit.py @@ -17,7 +17,7 @@ import json from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Dict, Iterable, List, Optional, Tuple, Union +from typing import Any, Iterable from urllib.parse import urlparse from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -49,7 +49,7 @@ class CorpValue(str, Enum): # ---------- header parsing --------------------------------------------- -def _enum_or(value: Optional[str], cls, default): +def _enum_or(value: str | None, cls, default): if value is None: return default try: @@ -73,11 +73,11 @@ def isolated(self) -> bool: def parse_page_headers( - headers: Iterable[Tuple[str, str]], + headers: Iterable[tuple[str, str]], ) -> PagePolicy: """Parse a header iterable into a :class:`PagePolicy`.""" - coop_raw: Optional[str] = None - coep_raw: Optional[str] = None + coop_raw: str | None = None + coep_raw: str | None = None for name, value in headers: if not isinstance(name, str) or not isinstance(value, str): continue @@ -100,7 +100,7 @@ class ResourceFinding: url: str reason: str - corp: Optional[str] = None + corp: str | None = None cors_present: bool = False @@ -113,8 +113,8 @@ def _same_origin(a: str, b: str) -> bool: return (pa.scheme, pa.hostname, pa.port) == (pb.scheme, pb.hostname, pb.port) -def _header_lookup(entry: Dict[str, Any]) -> Dict[str, str]: - out: Dict[str, str] = {} +def _header_lookup(entry: dict[str, Any]) -> dict[str, str]: + out: dict[str, str] = {} headers = ((entry.get("response") or {}).get("headers")) or [] if not isinstance(headers, list): return out @@ -129,9 +129,9 @@ def _header_lookup(entry: Dict[str, Any]) -> Dict[str, str]: def _evaluate_resource( - request_url: str, corp: Optional[str], cors_origin: Optional[str], + request_url: str, corp: str | None, cors_origin: str | None, coep: CoepValue, -) -> Optional[ResourceFinding]: +) -> ResourceFinding | None: """Decide if one HAR resource violates the page's COEP. None = OK.""" if coep == CoepValue.REQUIRE_CORP: if corp == CorpValue.CROSS_ORIGIN.value: @@ -158,7 +158,7 @@ def _evaluate_resource( def _entry_finding( entry: Any, page_url: str, coep: CoepValue, -) -> Optional[ResourceFinding]: +) -> ResourceFinding | None: """One HAR entry → optional :class:`ResourceFinding`.""" if not isinstance(entry, dict): return None @@ -175,11 +175,11 @@ def _entry_finding( def scan_har_resources( - har: Union[str, Dict[str, Any]], + har: str | dict[str, Any], *, page_url: str, coep: CoepValue, -) -> List[ResourceFinding]: +) -> list[ResourceFinding]: """ Walk HAR entries; any cross-origin entry must satisfy the page's COEP. Returns one :class:`ResourceFinding` per violation; empty list means OK. @@ -200,7 +200,7 @@ def scan_har_resources( ] -def _coerce_har(har: Union[str, Dict[str, Any]]) -> Dict[str, Any]: +def _coerce_har(har: str | dict[str, Any]) -> dict[str, Any]: if isinstance(har, str): try: parsed = json.loads(har) @@ -225,13 +225,13 @@ class IsolationReport: page_url: str policy: PagePolicy isolated: bool - resource_findings: List[ResourceFinding] = field(default_factory=list) - notes: List[str] = field(default_factory=list) + resource_findings: list[ResourceFinding] = field(default_factory=list) + notes: list[str] = field(default_factory=list) def passed(self) -> bool: return self.isolated and not self.resource_findings - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "page_url": self.page_url, "policy": { @@ -247,21 +247,21 @@ def to_dict(self) -> Dict[str, Any]: def audit_isolation( page_url: str, - page_headers: Iterable[Tuple[str, str]], + page_headers: Iterable[tuple[str, str]], *, - har: Optional[Union[str, Dict[str, Any]]] = None, + har: str | dict[str, Any] | None = None, ) -> IsolationReport: """Combined page + resource audit. ``har`` is optional but recommended.""" if not isinstance(page_url, str) or not page_url: raise CoopCoepAuditError("page_url must be non-empty string") policy = parse_page_headers(page_headers) isolated = policy.isolated() - notes: List[str] = [] + notes: list[str] = [] if policy.coop != CoopValue.SAME_ORIGIN: notes.append(f"COOP is {policy.coop.value}, want same-origin") if policy.coep not in (CoepValue.REQUIRE_CORP, CoepValue.CREDENTIALLESS): notes.append(f"COEP is {policy.coep.value}, want require-corp/credentialless") - resource_findings: List[ResourceFinding] = [] + resource_findings: list[ResourceFinding] = [] if har is not None and policy.coep != CoepValue.UNSAFE_NONE: resource_findings = scan_har_resources( har, page_url=page_url, coep=policy.coep, diff --git a/je_web_runner/utils/cors_matrix/matrix.py b/je_web_runner/utils/cors_matrix/matrix.py index a7089cb7..f0738335 100644 --- a/je_web_runner/utils/cors_matrix/matrix.py +++ b/je_web_runner/utils/cors_matrix/matrix.py @@ -16,7 +16,7 @@ from dataclasses import asdict, dataclass from enum import Enum from itertools import product -from typing import Any, Callable, Dict, List, Optional, Sequence +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -57,7 +57,7 @@ def build_matrix( "null", # sandboxed iframe / data: ), credentials_modes: Sequence[bool] = (False, True), -) -> List[CorsCase]: +) -> list[CorsCase]: """Cartesian product of the matrix axes.""" if not verbs: raise CorsMatrixError("verbs must be non-empty") @@ -78,7 +78,7 @@ class CorsResponse: """What the probe callable must return.""" status_code: int - allow_origin: Optional[str] + allow_origin: str | None allow_credentials: bool = False allow_methods: Sequence[str] = () allow_headers: Sequence[str] = () @@ -93,7 +93,7 @@ class CorsResult: response: CorsResponse note: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "case": asdict(self.case), "outcome": self.outcome.value, @@ -138,13 +138,13 @@ def classify(case: CorsCase, response: CorsResponse) -> CorsResult: def run_matrix( cases: Sequence[CorsCase], probe: ProbeFn, -) -> List[CorsResult]: +) -> list[CorsResult]: """Drive ``probe`` once per case and classify the response.""" if not cases: raise CorsMatrixError("cases must be non-empty") if not callable(probe): raise CorsMatrixError("probe must be callable") - out: List[CorsResult] = [] + out: list[CorsResult] = [] for case in cases: try: response = probe(case) diff --git a/je_web_runner/utils/coverage_map/coverage.py b/je_web_runner/utils/coverage_map/coverage.py index d8c59c2f..a1758a6f 100644 --- a/je_web_runner/utils/coverage_map/coverage.py +++ b/je_web_runner/utils/coverage_map/coverage.py @@ -13,7 +13,7 @@ from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Union +from typing import Any, Iterable, Sequence from urllib.parse import urlparse from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -52,7 +52,7 @@ def normalise_path(path: str, normalise_params: bool = True) -> str: return "/".join(canonical) -def _extract_url(action: List[Any]) -> Optional[str]: +def _extract_url(action: list[Any]) -> str | None: if not isinstance(action, list) or len(action) < 2: return None command = action[0] @@ -79,24 +79,24 @@ def _path_for(url: str) -> str: @dataclass class CoverageMap: - routes_by_file: Dict[str, Set[str]] = field(default_factory=dict) - files_by_route: Dict[str, Set[str]] = field(default_factory=dict) + routes_by_file: dict[str, set[str]] = field(default_factory=dict) + files_by_route: dict[str, set[str]] = field(default_factory=dict) - def files_for(self, route: str) -> List[str]: + def files_for(self, route: str) -> list[str]: return sorted(self.files_by_route.get(route, set())) - def routes_for(self, file_path: str) -> List[str]: + def routes_for(self, file_path: str) -> list[str]: return sorted(self.routes_by_file.get(file_path, set())) - def all_routes(self) -> List[str]: + def all_routes(self) -> list[str]: return sorted(self.files_by_route.keys()) - def uncovered(self, declared_routes: Iterable[str]) -> List[str]: + def uncovered(self, declared_routes: Iterable[str]) -> list[str]: return sorted(set(declared_routes) - set(self.files_by_route.keys())) def build_coverage_map( - directory: Union[str, Path], + directory: str | Path, glob: str = "**/*.json", normalise_params: bool = True, ) -> CoverageMap: @@ -104,8 +104,8 @@ def build_coverage_map( base = Path(directory) if not base.is_dir(): raise CoverageMapError(f"directory missing: {directory!r}") - routes_by_file: Dict[str, Set[str]] = defaultdict(set) - files_by_route: Dict[str, Set[str]] = defaultdict(set) + routes_by_file: dict[str, set[str]] = defaultdict(set) + files_by_route: dict[str, set[str]] = defaultdict(set) for path in sorted(base.glob(glob)): if not path.is_file(): continue @@ -121,7 +121,7 @@ def build_coverage_map( ) -def _load_action_list(path: Path) -> Optional[List[Any]]: +def _load_action_list(path: Path) -> list[Any] | None: try: actions = json.loads(path.read_text(encoding="utf-8")) except ValueError: @@ -129,7 +129,7 @@ def _load_action_list(path: Path) -> Optional[List[Any]]: return actions if isinstance(actions, list) else None -def _routes_in(actions: List[Any], normalise_params: bool): +def _routes_in(actions: list[Any], normalise_params: bool): for action in actions: if not isinstance(action, list): continue @@ -142,13 +142,13 @@ def _routes_in(actions: List[Any], normalise_params: bool): def coverage_for_routes( coverage: CoverageMap, declared_routes: Sequence[str], -) -> Dict[str, List[str]]: +) -> dict[str, list[str]]: """Return ``{route: [files]}`` for each declared route (empty list if missing).""" return {route: coverage.files_for(route) for route in declared_routes} def render_markdown(coverage: CoverageMap, - declared_routes: Optional[Sequence[str]] = None) -> str: + declared_routes: Sequence[str] | None = None) -> str: """Render a Markdown coverage report (table of route → file count).""" routes = list(declared_routes) if declared_routes else coverage.all_routes() lines = [ diff --git a/je_web_runner/utils/credential_management/credentials.py b/je_web_runner/utils/credential_management/credentials.py index 69cc7450..30c7bab9 100644 --- a/je_web_runner/utils/credential_management/credentials.py +++ b/je_web_runner/utils/credential_management/credentials.py @@ -19,7 +19,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field -from typing import Any, Dict, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -79,11 +79,11 @@ class SeedCredential: password: str = "" provider: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) -def build_seed(credentials: List[SeedCredential]) -> Dict[str, Any]: +def build_seed(credentials: list[SeedCredential]) -> dict[str, Any]: if not isinstance(credentials, list): raise CredentialManagementError("credentials must be a list") for c in credentials: @@ -104,8 +104,8 @@ class StoredCall: @dataclass class CmLog: - stored: List[StoredCall] = field(default_factory=list) - gets: List[Dict[str, Any]] = field(default_factory=list) + stored: list[StoredCall] = field(default_factory=list) + gets: list[dict[str, Any]] = field(default_factory=list) prevent_count: int = 0 diff --git a/je_web_runner/utils/critical_css_audit/audit.py b/je_web_runner/utils/critical_css_audit/audit.py index eef41b48..d28cddd8 100644 --- a/je_web_runner/utils/critical_css_audit/audit.py +++ b/je_web_runner/utils/critical_css_audit/audit.py @@ -16,7 +16,6 @@ import re from dataclasses import dataclass, field -from typing import List from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -29,8 +28,8 @@ class CriticalCssAuditError(WebRunnerException): class CssReport: inline_blocks: int = 0 inline_bytes: int = 0 - external_blocking: List[str] = field(default_factory=list) - preloaded: List[str] = field(default_factory=list) + external_blocking: list[str] = field(default_factory=list) + preloaded: list[str] = field(default_factory=list) _STYLE_BLOCK_RE = re.compile( diff --git a/je_web_runner/utils/cross_browser/parity.py b/je_web_runner/utils/cross_browser/parity.py index 47e6f0f4..c3bb1c56 100644 --- a/je_web_runner/utils/cross_browser/parity.py +++ b/je_web_runner/utils/cross_browser/parity.py @@ -15,7 +15,7 @@ import hashlib from dataclasses import dataclass, field -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -27,11 +27,11 @@ class CrossBrowserError(WebRunnerException): @dataclass class BrowserRunResult: browser: str - title: Optional[str] = None - dom_text: Optional[str] = None - console: List[Dict[str, Any]] = field(default_factory=list) - network: List[Dict[str, Any]] = field(default_factory=list) - screenshot: Optional[bytes] = None + title: str | None = None + dom_text: str | None = None + console: list[dict[str, Any]] = field(default_factory=list) + network: list[dict[str, Any]] = field(default_factory=list) + screenshot: bytes | None = None @dataclass @@ -46,20 +46,20 @@ class ParityFinding: @dataclass class ParityReport: reference: str - findings_by_browser: Dict[str, List[ParityFinding]] = field(default_factory=dict) + findings_by_browser: dict[str, list[ParityFinding]] = field(default_factory=dict) @property def matches(self) -> bool: return all(not findings for findings in self.findings_by_browser.values()) - def major_findings(self) -> List[ParityFinding]: + def major_findings(self) -> list[ParityFinding]: return [ finding for findings in self.findings_by_browser.values() for finding in findings if finding.severity == "major" ] -def _normalise_console(messages: Iterable[Dict[str, Any]]) -> List[str]: +def _normalise_console(messages: Iterable[dict[str, Any]]) -> list[str]: return sorted( f"{m.get('type')}:{m.get('text')}" for m in messages @@ -67,7 +67,7 @@ def _normalise_console(messages: Iterable[Dict[str, Any]]) -> List[str]: ) -def _network_status_set(responses: Iterable[Dict[str, Any]]) -> set: +def _network_status_set(responses: Iterable[dict[str, Any]]) -> set: """Bucket responses by status code so cross-browser ordering doesn't matter.""" return { (str(r.get("url", "")), int(r.get("status", 0))) @@ -76,20 +76,20 @@ def _network_status_set(responses: Iterable[Dict[str, Any]]) -> set: } -def _dom_hash(text: Optional[str]) -> Optional[str]: +def _dom_hash(text: str | None) -> str | None: if text is None: return None return hashlib.sha256(text.encode("utf-8")).hexdigest() -def _screenshot_hash(payload: Optional[bytes]) -> Optional[str]: +def _screenshot_hash(payload: bytes | None) -> str | None: if not payload: return None return hashlib.sha256(payload).hexdigest() -def _diff_one(reference: BrowserRunResult, other: BrowserRunResult) -> List[ParityFinding]: - findings: List[ParityFinding] = [] +def _diff_one(reference: BrowserRunResult, other: BrowserRunResult) -> list[ParityFinding]: + findings: list[ParityFinding] = [] if reference.title != other.title: findings.append(ParityFinding( browser=other.browser, field="title", @@ -130,7 +130,7 @@ def _diff_one(reference: BrowserRunResult, other: BrowserRunResult) -> List[Pari def diff_runs( runs: Iterable[BrowserRunResult], - reference_browser: Optional[str] = None, + reference_browser: str | None = None, ) -> ParityReport: """ 比對每個 run 與 ``reference_browser`` 的結果差異 @@ -139,7 +139,7 @@ def diff_runs( runs_list = list(runs) if not runs_list: raise CrossBrowserError("at least one run required") - by_browser: Dict[str, BrowserRunResult] = {} + by_browser: dict[str, BrowserRunResult] = {} for run in runs_list: if not isinstance(run, BrowserRunResult): raise CrossBrowserError("runs must be BrowserRunResult instances") @@ -160,7 +160,7 @@ def diff_runs( def assert_parity( report: ParityReport, - allow_fields: Optional[Iterable[str]] = None, + allow_fields: Iterable[str] | None = None, only_major: bool = True, ) -> None: """Raise if any disallowed finding remains.""" diff --git a/je_web_runner/utils/cross_tab_sync/sync_assertions.py b/je_web_runner/utils/cross_tab_sync/sync_assertions.py index c9218722..3c8a1f4f 100644 --- a/je_web_runner/utils/cross_tab_sync/sync_assertions.py +++ b/je_web_runner/utils/cross_tab_sync/sync_assertions.py @@ -23,7 +23,7 @@ import json import time from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Sequence +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -57,7 +57,7 @@ def set_storage_value( ) try: page.evaluate(script, {"key": key, "raw": payload}) - except Exception as error: # noqa: BLE001 — playwright surfaces many + except Exception as error: raise CrossTabSyncError(f"set_storage_value failed: {error!r}") from error web_runner_logger.info(f"set_storage_value: {storage}[{key}] = {payload[:80]}") @@ -76,7 +76,7 @@ def get_storage_value( script = f"(key) => window.{storage}.getItem(key)" try: raw = page.evaluate(script, key) - except Exception as error: # noqa: BLE001 + except Exception as error: raise CrossTabSyncError(f"get_storage_value failed: {error!r}") from error if raw is None: return None @@ -150,7 +150,7 @@ def install_broadcast_recorder(page: Any, channel_name: str) -> None: """ try: page.evaluate(script, channel_name) - except Exception as error: # noqa: BLE001 + except Exception as error: raise CrossTabSyncError( f"install_broadcast_recorder failed: {error!r}" ) from error @@ -172,7 +172,7 @@ def broadcast_message(page: Any, channel_name: str, data: Any) -> None: """ try: page.evaluate(script, {"channelName": channel_name, "payload": data}) - except Exception as error: # noqa: BLE001 + except Exception as error: raise CrossTabSyncError(f"broadcast_message failed: {error!r}") from error web_runner_logger.info( f"broadcast_message: channel={channel_name!r}" @@ -182,7 +182,7 @@ def broadcast_message(page: Any, channel_name: str, data: Any) -> None: def collect_broadcast_messages( page: Any, channel_name: str, -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: """Return everything the recorder on ``page`` has captured for ``channel_name``.""" if page is None: raise CrossTabSyncError(_PAGE_IS_NONE_MSG) @@ -194,7 +194,7 @@ def collect_broadcast_messages( """ try: result = page.evaluate(script, channel_name) - except Exception as error: # noqa: BLE001 + except Exception as error: raise CrossTabSyncError( f"collect_broadcast_messages failed: {error!r}" ) from error @@ -212,7 +212,7 @@ def wait_for_broadcast( poll_interval: float = 0.1, sleep_fn: Callable[[float], None] = time.sleep, time_fn: Callable[[], float] = time.time, -) -> Dict[str, Any]: +) -> dict[str, Any]: """ 輪詢 recorder 直到出現一條符合 ``matcher`` 的訊息。 Returns the matching message entry (with ``data`` and ``receivedAt``). @@ -225,7 +225,7 @@ def wait_for_broadcast( data = entry.get("data") if isinstance(entry, dict) else None try: hit = matcher(data) - except Exception: # noqa: BLE001 — matcher should be cheap + except Exception: hit = False if hit: return entry @@ -242,7 +242,7 @@ def wait_for_broadcast( class PropagationResult: """Outcome of one :func:`assert_state_propagates` call.""" - propagated_to: List[int] = field(default_factory=list) + propagated_to: list[int] = field(default_factory=list) elapsed_seconds: float = 0.0 @@ -325,5 +325,5 @@ def post_message_to_page( script = "({ payload, origin }) => window.postMessage(payload, origin)" try: page.evaluate(script, {"payload": data, "origin": target_origin}) - except Exception as error: # noqa: BLE001 + except Exception as error: raise CrossTabSyncError(f"post_message_to_page failed: {error!r}") from error diff --git a/je_web_runner/utils/csp_reporter/reporter.py b/je_web_runner/utils/csp_reporter/reporter.py index c9163a8a..c51bcd11 100644 --- a/je_web_runner/utils/csp_reporter/reporter.py +++ b/je_web_runner/utils/csp_reporter/reporter.py @@ -8,7 +8,7 @@ import json from dataclasses import dataclass -from typing import Any, Iterable, List, Optional +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -48,16 +48,16 @@ class CspReporterError(WebRunnerException): class CspViolation: violated_directive: str blocked_uri: str - source_file: Optional[str] - line_number: Optional[int] - sample: Optional[str] + source_file: str | None + line_number: int | None + sample: str | None class CspViolationCollector: """Inject + read CSP-violation listener for a Selenium driver / Playwright page.""" def __init__(self) -> None: - self._violations: List[CspViolation] = [] + self._violations: list[CspViolation] = [] def install(self, driver: Any) -> None: from je_web_runner.utils.driver_dispatch import ( @@ -68,7 +68,7 @@ def install(self, driver: Any) -> None: except DriverDispatchError as error: raise CspReporterError(str(error)) from error - def collect(self, driver: Any) -> List[CspViolation]: + def collect(self, driver: Any) -> list[CspViolation]: from je_web_runner.utils.driver_dispatch import ( DriverDispatchError, evaluate_expression, ) @@ -95,7 +95,7 @@ def collect(self, driver: Any) -> List[CspViolation]: ] return list(self._violations) - def violations(self) -> List[CspViolation]: + def violations(self) -> list[CspViolation]: return list(self._violations) def assert_none(self) -> None: @@ -116,11 +116,11 @@ def install_listener(driver: Any) -> None: CspViolationCollector().install(driver) -def collect_violations(driver: Any) -> List[CspViolation]: +def collect_violations(driver: Any) -> list[CspViolation]: return CspViolationCollector().collect(driver) -def assert_no_violations(driver: Any, allow_directives: Optional[Iterable[str]] = None) -> None: +def assert_no_violations(driver: Any, allow_directives: Iterable[str] | None = None) -> None: collector = CspViolationCollector() violations = collector.collect(driver) allowed = set(allow_directives or []) diff --git a/je_web_runner/utils/csp_violation_parser/parser.py b/je_web_runner/utils/csp_violation_parser/parser.py index 656cc323..8cd17c96 100644 --- a/je_web_runner/utils/csp_violation_parser/parser.py +++ b/je_web_runner/utils/csp_violation_parser/parser.py @@ -14,7 +14,7 @@ from collections import Counter, defaultdict from dataclasses import asdict, dataclass -from typing import Any, Dict, Iterable, List +from typing import Any, Iterable from urllib.parse import urlparse from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -34,10 +34,22 @@ class Violation: line_number: int = 0 disposition: str = "enforce" # "enforce" | "report" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) +def _pick_int(*candidates: Any) -> int: + """First non-``None`` candidate as int (``0`` is a valid value, not a miss); + falls back to ``0`` on absent or non-numeric input.""" + for candidate in candidates: + if candidate is not None: + try: + return int(candidate) + except (TypeError, ValueError): + return 0 + return 0 + + def parse_one(report: Any) -> Violation: if not isinstance(report, dict): raise CspViolationParserError("report must be a dict") @@ -53,19 +65,19 @@ def parse_one(report: Any) -> Violation: ), blocked_uri=str(body.get("blocked-uri") or body.get("blockedURL") or ""), source_file=str(body.get("source-file") or body.get("sourceFile") or ""), - line_number=int(body.get("line-number") or body.get("lineNumber") or 0), + line_number=_pick_int(body.get("line-number"), body.get("lineNumber")), disposition=str(body.get("disposition") or "enforce"), ) -def parse_many(reports: Iterable[Any]) -> List[Violation]: +def parse_many(reports: Iterable[Any]) -> list[Violation]: return [parse_one(r) for r in reports] def group_by_directive( violations: Iterable[Violation], -) -> Dict[str, List[Violation]]: - buckets: Dict[str, List[Violation]] = defaultdict(list) +) -> dict[str, list[Violation]]: + buckets: dict[str, list[Violation]] = defaultdict(list) for v in violations: buckets[v.violated_directive or "(unknown)"].append(v) return dict(buckets) @@ -73,7 +85,7 @@ def group_by_directive( def top_blocked_hosts( violations: Iterable[Violation], *, top_n: int = 5, -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: if top_n < 1: raise CspViolationParserError("top_n must be >= 1") counts: Counter = Counter() @@ -96,14 +108,14 @@ def assert_no_enforced_violations(violations: Iterable[Violation]) -> None: def looks_like_recon( violations: Iterable[Violation], *, distinct_hosts_threshold: int = 5, -) -> List[str]: +) -> list[str]: """Buckets per directive whose distinct blocked-host count exceeds ``distinct_hosts_threshold`` — a probable XSS / SSRF probe.""" if distinct_hosts_threshold < 2: raise CspViolationParserError( "distinct_hosts_threshold must be >= 2" ) - hosts_by_directive: Dict[str, set] = defaultdict(set) + hosts_by_directive: dict[str, set] = defaultdict(set) for v in violations: host = urlparse(v.blocked_uri).hostname or v.blocked_uri if host: diff --git a/je_web_runner/utils/dashboard/live_dashboard.py b/je_web_runner/utils/dashboard/live_dashboard.py index 37729088..5fc523e7 100644 --- a/je_web_runner/utils/dashboard/live_dashboard.py +++ b/je_web_runner/utils/dashboard/live_dashboard.py @@ -9,7 +9,7 @@ import json import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from typing import Any, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -40,13 +40,19 @@ class DashboardError(WebRunnerException): document.getElementById('summary').textContent = `total=${data.total} passed=${data.passed} failed=${data.failed}`; const rows = document.getElementById('rows'); - rows.innerHTML = ''; + rows.replaceChildren(); data.records.forEach((r, i) => { const tr = document.createElement('tr'); tr.className = r.status === 'failed' ? 'fail' : 'ok'; - tr.innerHTML = `${i+1}${r.time||''} - ${r.function_name||''}${r.status} - ${r.exception||''}`; + // Use textContent (never innerHTML) so recorded values — function + // names and exception text that may quote page content from a site + // under test — cannot inject HTML/script into this origin. + [i + 1, r.time || '', r.function_name || '', r.status, r.exception || ''] + .forEach((value) => { + const td = document.createElement('td'); + td.textContent = value; + tr.appendChild(td); + }); rows.appendChild(tr); }); } catch (e) { /* ignore — keep polling */ } @@ -84,7 +90,7 @@ def _send(self, status: int, body: bytes, content_type: str) -> None: self.end_headers() self.wfile.write(body) - def do_GET(self): # noqa: N802 — http.server convention + def do_GET(self): if self.path == "/" or self.path == "/index.html": self._send(200, _INDEX_HTML.encode("utf-8"), "text/html; charset=utf-8") return @@ -107,8 +113,8 @@ class LiveDashboard: def __init__(self, host: str = "127.0.0.1", port: int = 0) -> None: self._host = host self._port = int(port) - self._server: Optional[ThreadingHTTPServer] = None - self._thread: Optional[threading.Thread] = None + self._server: ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None @property def address(self) -> str: diff --git a/je_web_runner/utils/data_driven/data_runner.py b/je_web_runner/utils/data_driven/data_runner.py index 4b248643..cb3dcdbb 100644 --- a/je_web_runner/utils/data_driven/data_runner.py +++ b/je_web_runner/utils/data_driven/data_runner.py @@ -9,7 +9,7 @@ import json import re from pathlib import Path -from typing import Any, Callable, Dict, List +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -22,7 +22,7 @@ class DataDrivenError(WebRunnerException): _ROW_PLACEHOLDER_RE = re.compile(r"\$\{ROW\.([A-Za-z_]\w*)\}") -def load_dataset_csv(path: str, encoding: str = "utf-8") -> List[Dict[str, str]]: +def load_dataset_csv(path: str, encoding: str = "utf-8") -> list[dict[str, str]]: """ 讀取 CSV 為 list of dict(首列為欄位名) Read a CSV file into a list of dicts using the first row as headers. @@ -36,7 +36,7 @@ def load_dataset_csv(path: str, encoding: str = "utf-8") -> List[Dict[str, str]] return [dict(row) for row in reader] -def load_dataset_json(path: str, encoding: str = "utf-8") -> List[Dict[str, Any]]: +def load_dataset_json(path: str, encoding: str = "utf-8") -> list[dict[str, Any]]: """ 讀取 JSON 為 list of dict Read a JSON file into a list of dicts. @@ -52,7 +52,7 @@ def load_dataset_json(path: str, encoding: str = "utf-8") -> List[Dict[str, Any] return data -def _expand_string(text: str, row: Dict[str, Any]) -> str: +def _expand_string(text: str, row: dict[str, Any]) -> str: def _resolve(match: re.Match) -> str: column = match.group(1) if column not in row: @@ -62,7 +62,7 @@ def _resolve(match: re.Match) -> str: return _ROW_PLACEHOLDER_RE.sub(_resolve, text) -def expand_with_row(data: Any, row: Dict[str, Any]) -> Any: +def expand_with_row(data: Any, row: dict[str, Any]) -> Any: """ 遞迴展開 ``${ROW.col}`` 占位符 Recursively expand ``${ROW.col}`` placeholders against a row mapping. @@ -80,9 +80,9 @@ def expand_with_row(data: Any, row: Dict[str, Any]) -> Any: def run_with_dataset( action_data: Any, - rows: List[Dict[str, Any]], + rows: list[dict[str, Any]], runner: Callable[[Any], Any], -) -> List[Any]: +) -> list[Any]: """ 對每筆 row 展開 placeholders 後呼叫 ``runner`` For each row, expand placeholders against ``action_data`` and call diff --git a/je_web_runner/utils/database/db_validate.py b/je_web_runner/utils/database/db_validate.py index bcf4758d..22831403 100644 --- a/je_web_runner/utils/database/db_validate.py +++ b/je_web_runner/utils/database/db_validate.py @@ -12,7 +12,7 @@ """ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -35,8 +35,8 @@ def _require_sqlalchemy(): def db_query( connection_url: str, sql: str, - params: Optional[Dict[str, Any]] = None, -) -> List[Dict[str, Any]]: + params: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: """ 對 ``connection_url`` 執行帶 bound params 的 SQL,回傳結果(list of dict) Run a parameterised SQL statement against ``connection_url`` and return @@ -49,7 +49,7 @@ def db_query( with engine.connect() as connection: result = connection.execute(text(sql), params or {}) keys = list(result.keys()) - return [dict(zip(keys, row)) for row in result.fetchall()] + return [dict(zip(keys, row, strict=False)) for row in result.fetchall()] finally: engine.dispose() @@ -58,7 +58,7 @@ def db_assert_count( connection_url: str, sql: str, expected: int, - params: Optional[Dict[str, Any]] = None, + params: dict[str, Any] | None = None, ) -> None: """斷言 SQL 回傳列數等於 ``expected``。""" rows = db_query(connection_url, sql, params=params) @@ -74,7 +74,7 @@ def db_assert_value( column: str, expected: Any, row_index: int = 0, - params: Optional[Dict[str, Any]] = None, + params: dict[str, Any] | None = None, ) -> None: """斷言指定列、指定欄位的值等於 ``expected``。""" rows = db_query(connection_url, sql, params=params) @@ -99,7 +99,7 @@ def db_assert_value( def db_assert_exists( connection_url: str, sql: str, - params: Optional[Dict[str, Any]] = None, + params: dict[str, Any] | None = None, ) -> None: """斷言查詢回傳至少一列。""" rows = db_query(connection_url, sql, params=params) @@ -110,7 +110,7 @@ def db_assert_exists( def db_assert_empty( connection_url: str, sql: str, - params: Optional[Dict[str, Any]] = None, + params: dict[str, Any] | None = None, ) -> None: """斷言查詢沒有任何結果。""" rows = db_query(connection_url, sql, params=params) diff --git a/je_web_runner/utils/database/fixtures.py b/je_web_runner/utils/database/fixtures.py index 20da67c6..0ff6ce21 100644 --- a/je_web_runner/utils/database/fixtures.py +++ b/je_web_runner/utils/database/fixtures.py @@ -12,7 +12,7 @@ import json import re from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Sequence, Union +from typing import Any, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -32,7 +32,7 @@ def _safe_identifier(name: str, kind: str) -> str: return name -def load_fixture_file(path: Union[str, Path]) -> Dict[str, List[Dict[str, Any]]]: +def load_fixture_file(path: str | Path) -> dict[str, list[dict[str, Any]]]: """Read a JSON fixture file and validate its shape.""" fp = Path(path) if not fp.is_file(): @@ -44,7 +44,7 @@ def load_fixture_file(path: Union[str, Path]) -> Dict[str, List[Dict[str, Any]]] return validate_shape(data) -def validate_shape(data: Any) -> Dict[str, List[Dict[str, Any]]]: +def validate_shape(data: Any) -> dict[str, list[dict[str, Any]]]: """Make sure the loaded object matches ``{table: [rows]}``.""" if not isinstance(data, dict): raise DbFixtureError("fixture root must be an object") @@ -54,7 +54,7 @@ def validate_shape(data: Any) -> Dict[str, List[Dict[str, Any]]]: } -def _validated_tables(data: Dict[Any, Any]): +def _validated_tables(data: dict[Any, Any]): for table, rows in data.items(): if not isinstance(table, str) or not table: raise DbFixtureError(f"table name must be non-empty string, got {table!r}") @@ -63,8 +63,8 @@ def _validated_tables(data: Dict[Any, Any]): yield table, rows -def _validate_rows(table: str, rows: List[Any]) -> List[Dict[str, Any]]: - validated: List[Dict[str, Any]] = [] +def _validate_rows(table: str, rows: list[Any]) -> list[dict[str, Any]]: + validated: list[dict[str, Any]] = [] for index, row in enumerate(rows): if not isinstance(row, dict): raise DbFixtureError( @@ -81,10 +81,10 @@ def _validate_rows(table: str, rows: List[Any]) -> List[Dict[str, Any]]: def load_into_connection( connection: Any, - fixture: Dict[str, List[Dict[str, Any]]], + fixture: dict[str, list[dict[str, Any]]], quote: str = '"', - only_tables: Optional[Sequence[str]] = None, -) -> Dict[str, int]: + only_tables: Sequence[str] | None = None, +) -> dict[str, int]: """ 對每個表 batch insert 所有 rows,回傳 ``{table: rows_inserted}`` Insert every fixture row using ``INSERT INTO (...) VALUES (...)`` @@ -92,7 +92,7 @@ def load_into_connection( """ if not hasattr(connection, "execute"): raise DbFixtureError("connection must expose execute() (SQLAlchemy or PEP-249)") - inserted: Dict[str, int] = {} + inserted: dict[str, int] = {} allowed = set(only_tables) if only_tables else None for table, rows in fixture.items(): if allowed is not None and table not in allowed: @@ -100,7 +100,7 @@ def load_into_connection( if not rows: continue safe_table = _safe_identifier(table, "table") - columns = [_safe_identifier(c, "column") for c in rows[0].keys()] + columns = [_safe_identifier(c, "column") for c in rows[0]] sql = _build_insert(safe_table, columns, quote) for row in rows: # nosemgrep: python_sql_rule-hardcoded-sql-expression @@ -110,7 +110,7 @@ def load_into_connection( return inserted -def _build_insert(table: str, columns: List[str], quote: str) -> str: +def _build_insert(table: str, columns: list[str], quote: str) -> str: """Construct an INSERT with already-validated identifiers.""" placeholder = ", ".join(f":{col}" for col in columns) column_text = ", ".join(f"{quote}{col}{quote}" for col in columns) diff --git a/je_web_runner/utils/db_snapshot/snapshot.py b/je_web_runner/utils/db_snapshot/snapshot.py index dc18c494..4f0709fa 100644 --- a/je_web_runner/utils/db_snapshot/snapshot.py +++ b/je_web_runner/utils/db_snapshot/snapshot.py @@ -15,7 +15,7 @@ import contextlib import uuid from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Protocol +from typing import Any, Callable, Protocol from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -44,8 +44,8 @@ class InMemoryBackend: `--dry-run` style unit tests. """ - tables: Dict[str, Dict[Any, Any]] = field(default_factory=dict) - _snapshots: Dict[str, Dict[str, Dict[Any, Any]]] = field(default_factory=dict) + tables: dict[str, dict[Any, Any]] = field(default_factory=dict) + _snapshots: dict[str, dict[str, dict[Any, Any]]] = field(default_factory=dict) def insert(self, table: str, key: Any, value: Any) -> None: self.tables.setdefault(table, {})[key] = value @@ -87,7 +87,7 @@ class SnapshotScope: backend: SnapshotBackend prefix: str = "wr_snap" - _stack: List[SnapshotHandle] = field(default_factory=list) + _stack: list[SnapshotHandle] = field(default_factory=list) def create(self) -> SnapshotHandle: name = f"{self.prefix}_{uuid.uuid4().hex[:12]}" diff --git a/je_web_runner/utils/device_cloud/real_device.py b/je_web_runner/utils/device_cloud/real_device.py index f0755e2b..30540605 100644 --- a/je_web_runner/utils/device_cloud/real_device.py +++ b/je_web_runner/utils/device_cloud/real_device.py @@ -27,7 +27,7 @@ import time import urllib.request from dataclasses import asdict, dataclass, field -from typing import Any, Callable, Dict, Optional, Tuple +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -37,22 +37,22 @@ class DeviceCloudError(WebRunnerException): """Raised on connection / status update / metadata fetch errors.""" -SUPPORTED_PROVIDERS: Tuple[str, ...] = ("browserstack", "saucelabs", "lambdatest") +SUPPORTED_PROVIDERS: tuple[str, ...] = ("browserstack", "saucelabs", "lambdatest") -_ENV_VAR_MAP: Dict[str, Tuple[str, str]] = { +_ENV_VAR_MAP: dict[str, tuple[str, str]] = { "browserstack": ("BROWSERSTACK_USERNAME", "BROWSERSTACK_ACCESS_KEY"), "saucelabs": ("SAUCE_USERNAME", "SAUCE_ACCESS_KEY"), "lambdatest": ("LT_USERNAME", "LT_ACCESS_KEY"), } -_REST_BASES: Dict[str, str] = { +_REST_BASES: dict[str, str] = { "browserstack": "https://api.browserstack.com", "saucelabs": "https://api.us-west-1.saucelabs.com", "lambdatest": "https://api.lambdatest.com", } -_DASHBOARD_BASES: Dict[str, str] = { +_DASHBOARD_BASES: dict[str, str] = { "browserstack": "https://automate.browserstack.com/dashboard/v2/sessions", "saucelabs": "https://app.saucelabs.com/tests", "lambdatest": "https://automation.lambdatest.com/test", @@ -68,7 +68,7 @@ class CloudCredentials: username: str access_key: str - def redacted(self) -> Dict[str, str]: + def redacted(self) -> dict[str, str]: return { "username": self.username, "access_key": "***" if self.access_key else "", @@ -115,14 +115,14 @@ class RealDeviceCaps: platform_version: str browser_name: str = "Chrome" # or "Safari" on iOS real_mobile: bool = True - build: Optional[str] = None - name: Optional[str] = None - project: Optional[str] = None - extra: Dict[str, Any] = field(default_factory=dict) + build: str | None = None + name: str | None = None + project: str | None = None + extra: dict[str, Any] = field(default_factory=dict) -def _to_browserstack(caps: RealDeviceCaps) -> Dict[str, Any]: - bstack: Dict[str, Any] = { +def _to_browserstack(caps: RealDeviceCaps) -> dict[str, Any]: + bstack: dict[str, Any] = { "deviceName": caps.device_name, "osVersion": caps.platform_version, "realMobile": "true" if caps.real_mobile else "false", @@ -133,7 +133,7 @@ def _to_browserstack(caps: RealDeviceCaps) -> Dict[str, Any]: bstack["buildName"] = caps.build if caps.name: bstack["sessionName"] = caps.name - out: Dict[str, Any] = { + out: dict[str, Any] = { "browserName": caps.browser_name, "platformName": caps.platform_name, "bstack:options": bstack, @@ -142,18 +142,18 @@ def _to_browserstack(caps: RealDeviceCaps) -> Dict[str, Any]: return out -def _to_saucelabs(caps: RealDeviceCaps) -> Dict[str, Any]: - sauce: Dict[str, Any] = {} +def _to_saucelabs(caps: RealDeviceCaps) -> dict[str, Any]: + sauce: dict[str, Any] = {} if caps.build: sauce["build"] = caps.build if caps.name: sauce["name"] = caps.name - appium_caps: Dict[str, Any] = { + appium_caps: dict[str, Any] = { "appium:deviceName": caps.device_name, "appium:platformVersion": caps.platform_version, "appium:automationName": "XCUITest" if caps.platform_name.lower() == "ios" else "UiAutomator2", } - out: Dict[str, Any] = { + out: dict[str, Any] = { "browserName": caps.browser_name, "platformName": caps.platform_name, "sauce:options": sauce, @@ -163,8 +163,8 @@ def _to_saucelabs(caps: RealDeviceCaps) -> Dict[str, Any]: return out -def _to_lambdatest(caps: RealDeviceCaps) -> Dict[str, Any]: - lt: Dict[str, Any] = { +def _to_lambdatest(caps: RealDeviceCaps) -> dict[str, Any]: + lt: dict[str, Any] = { "deviceName": caps.device_name, "platformVersion": caps.platform_version, "isRealMobile": caps.real_mobile, @@ -175,7 +175,7 @@ def _to_lambdatest(caps: RealDeviceCaps) -> Dict[str, Any]: lt["name"] = caps.name if caps.project: lt["project"] = caps.project - out: Dict[str, Any] = { + out: dict[str, Any] = { "browserName": caps.browser_name, "platformName": caps.platform_name, "LT:Options": lt, @@ -184,14 +184,14 @@ def _to_lambdatest(caps: RealDeviceCaps) -> Dict[str, Any]: return out -_CAPS_DISPATCH: Dict[str, Callable[[RealDeviceCaps], Dict[str, Any]]] = { +_CAPS_DISPATCH: dict[str, Callable[[RealDeviceCaps], dict[str, Any]]] = { "browserstack": _to_browserstack, "saucelabs": _to_saucelabs, "lambdatest": _to_lambdatest, } -def build_capabilities(provider: str, caps: RealDeviceCaps) -> Dict[str, Any]: +def build_capabilities(provider: str, caps: RealDeviceCaps) -> dict[str, Any]: """Project a :class:`RealDeviceCaps` into provider-native capabilities.""" key = _normalise_provider(provider) return _CAPS_DISPATCH[key](caps) @@ -206,10 +206,10 @@ class CloudSession: provider: str session_id: str dashboard_url: str - video_url: Optional[str] = None + video_url: str | None = None status: str = "running" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -226,11 +226,11 @@ def connect_real_device( provider: str, caps: RealDeviceCaps, *, - credentials: Optional[CloudCredentials] = None, + credentials: CloudCredentials | None = None, retries: int = 2, backoff_seconds: float = 3.0, - connector: Optional[Callable[..., Any]] = None, -) -> Tuple[Any, CloudSession]: + connector: Callable[..., Any] | None = None, +) -> tuple[Any, CloudSession]: """ 開一個 real-device session,回傳 (driver, CloudSession)。 Connect to a cloud provider's real-device cloud with retries. Returns @@ -249,7 +249,7 @@ def connect_real_device( ) chosen = connector or _default_connector(key) - last_error: Optional[Exception] = None + last_error: Exception | None = None for attempt in range(max(1, retries + 1)): try: driver = chosen(creds.username, creds.access_key, capabilities) @@ -262,7 +262,7 @@ def connect_real_device( dashboard_url=_dashboard_url(key, session_id), ) return driver, session - except Exception as error: # noqa: BLE001 — cloud connect surface varies + except Exception as error: last_error = error web_runner_logger.warning( f"connect_real_device attempt {attempt + 1} failed: {error!r}" @@ -291,7 +291,7 @@ def _default_connector(provider: str) -> Callable[..., Any]: def _basic_auth_header(creds: CloudCredentials) -> str: import base64 - raw = f"{creds.username}:{creds.access_key}".encode("utf-8") + raw = f"{creds.username}:{creds.access_key}".encode() return "Basic " + base64.b64encode(raw).decode("ascii") @@ -299,7 +299,7 @@ def _rest_request( method: str, url: str, credentials: CloudCredentials, - payload: Optional[Dict[str, Any]] = None, + payload: dict[str, Any] | None = None, timeout: float = 15.0, ) -> Any: if not url.startswith("https://"): @@ -348,9 +348,9 @@ def _session_status_url(provider: str, session_id: str) -> str: def fetch_session_info( provider: str, session_id: str, - credentials: Optional[CloudCredentials] = None, + credentials: CloudCredentials | None = None, *, - request_fn: Optional[Callable[..., Any]] = None, + request_fn: Callable[..., Any] | None = None, ) -> CloudSession: """ 讀取 session 的 metadata,補上 video URL 與目前 status。 @@ -373,7 +373,7 @@ def fetch_session_info( ) -def _extract_video_url(provider: str, payload: Dict[str, Any]) -> Optional[str]: +def _extract_video_url(provider: str, payload: dict[str, Any]) -> str | None: if provider == "browserstack": info = payload.get("automation_session") or {} url = info.get("video_url") @@ -391,7 +391,7 @@ def _extract_video_url(provider: str, payload: Dict[str, Any]) -> Optional[str]: return None -def _extract_status(provider: str, payload: Dict[str, Any]) -> str: +def _extract_status(provider: str, payload: dict[str, Any]) -> str: if provider == "browserstack": info = payload.get("automation_session") or {} return str(info.get("status") or "unknown") @@ -408,9 +408,9 @@ def update_session_status( session_id: str, *, passed: bool, - reason: Optional[str] = None, - credentials: Optional[CloudCredentials] = None, - request_fn: Optional[Callable[..., Any]] = None, + reason: str | None = None, + credentials: CloudCredentials | None = None, + request_fn: Callable[..., Any] | None = None, ) -> None: """ 把測試結果回寫到 provider,讓 dashboard 從 running 變 passed / failed。 diff --git a/je_web_runner/utils/device_emulation/presets.py b/je_web_runner/utils/device_emulation/presets.py index 4bee22f2..f68f5c31 100644 --- a/je_web_runner/utils/device_emulation/presets.py +++ b/je_web_runner/utils/device_emulation/presets.py @@ -10,7 +10,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -30,7 +30,7 @@ class DevicePreset: user_agent: str -_PRESETS: Dict[str, DevicePreset] = { +_PRESETS: dict[str, DevicePreset] = { "iPhone 15 Pro": DevicePreset( name="iPhone 15 Pro", width=393, height=852, device_scale_factor=3.0, @@ -97,7 +97,7 @@ class DevicePreset: } -def available_presets() -> List[str]: +def available_presets() -> list[str]: return sorted(_PRESETS.keys()) @@ -116,7 +116,7 @@ def register_preset(preset: DevicePreset) -> None: _PRESETS[preset.name] = preset -def playwright_kwargs(preset_name: str) -> Dict[str, Any]: +def playwright_kwargs(preset_name: str) -> dict[str, Any]: """Return ``new_context`` kwargs for Playwright.""" preset = get_preset(preset_name) return { @@ -140,7 +140,7 @@ def apply_to_chrome_options(options: Any, preset_name: str) -> Any: return options -def cdp_emulation_command(preset_name: str) -> Dict[str, Any]: +def cdp_emulation_command(preset_name: str) -> dict[str, Any]: """Return the CDP ``Emulation.setDeviceMetricsOverride`` payload.""" preset = get_preset(preset_name) return { diff --git a/je_web_runner/utils/docs/command_reference.py b/je_web_runner/utils/docs/command_reference.py index 11c8badf..7cbd0303 100644 --- a/je_web_runner/utils/docs/command_reference.py +++ b/je_web_runner/utils/docs/command_reference.py @@ -8,7 +8,7 @@ import inspect from pathlib import Path -from typing import Any, Callable, List, Optional, Tuple +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -34,7 +34,7 @@ def _first_doc_line(callable_obj: Any) -> str: return "" -def _command_entries() -> List[Tuple[str, Callable[..., Any]]]: +def _command_entries() -> list[tuple[str, Callable[..., Any]]]: """Return every ``WR_*`` entry from the executor in sorted order.""" from je_web_runner.utils.executor.action_executor import executor return sorted( @@ -50,7 +50,7 @@ def build_command_reference(title: str = "WebRunner command reference") -> str: """ web_runner_logger.info("build_command_reference") entries = _command_entries() - lines: List[str] = [ + lines: list[str] = [ f"# {title}", "", f"Auto-generated from the executor's event_dict ({len(entries)} commands).", @@ -65,7 +65,7 @@ def build_command_reference(title: str = "WebRunner command reference") -> str: return "\n".join(lines) + "\n" -def export_command_reference(path: str, title: Optional[str] = None) -> str: +def export_command_reference(path: str, title: str | None = None) -> str: """Write the Markdown reference to ``path`` and return the resolved path.""" target = Path(path) target.parent.mkdir(parents=True, exist_ok=True) @@ -77,6 +77,6 @@ def export_command_reference(path: str, title: Optional[str] = None) -> str: return str(target.resolve()) -def list_commands() -> List[str]: +def list_commands() -> list[str]: """Just the command names (handy for shell completion).""" return [name for name, _ in _command_entries()] diff --git a/je_web_runner/utils/dom_traversal/shadow_pierce.py b/je_web_runner/utils/dom_traversal/shadow_pierce.py index c5b8636b..748de5c6 100644 --- a/je_web_runner/utils/dom_traversal/shadow_pierce.py +++ b/je_web_runner/utils/dom_traversal/shadow_pierce.py @@ -10,7 +10,7 @@ """ from __future__ import annotations -from typing import Any, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -91,7 +91,7 @@ def find_first(driver: Any, css_selector: str) -> Any: return _execute_js(driver, _PIERCE_FIRST_JS, css_selector) -def find_all(driver: Any, css_selector: str, limit: int = 1000) -> List[Any]: +def find_all(driver: Any, css_selector: str, limit: int = 1000) -> list[Any]: """Return up to ``limit`` matching nodes across the shadow tree.""" if not isinstance(css_selector, str) or not css_selector: raise ShadowPierceError("css_selector must be a non-empty string") diff --git a/je_web_runner/utils/dom_xss_taint/taint.py b/je_web_runner/utils/dom_xss_taint/taint.py index af1c8108..c5b0761a 100644 --- a/je_web_runner/utils/dom_xss_taint/taint.py +++ b/je_web_runner/utils/dom_xss_taint/taint.py @@ -15,7 +15,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass -from typing import Any, Dict, Iterable, List, Sequence +from typing import Any, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -71,11 +71,11 @@ class TaintFinding: canary: str snippet: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) -def make_canaries(test_name: str) -> List[str]: +def make_canaries(test_name: str) -> list[str]: """Generate a couple of unique sentinel strings to inject as source values (location.hash, postMessage payload, etc.).""" if not isinstance(test_name, str) or not test_name: @@ -86,10 +86,10 @@ def make_canaries(test_name: str) -> List[str]: ] -def parse_findings(payload: Any) -> List[TaintFinding]: +def parse_findings(payload: Any) -> list[TaintFinding]: if not isinstance(payload, list): raise DomXssTaintError("payload must be a list") - out: List[TaintFinding] = [] + out: list[TaintFinding] = [] for raw in payload: if not isinstance(raw, dict): continue diff --git a/je_web_runner/utils/download_verify/verifier.py b/je_web_runner/utils/download_verify/verifier.py index e97ce273..52ea44eb 100644 --- a/je_web_runner/utils/download_verify/verifier.py +++ b/je_web_runner/utils/download_verify/verifier.py @@ -22,7 +22,7 @@ import time from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable, Dict, Iterable, List, Optional, Pattern, Union +from typing import Any, Callable, Iterable, Pattern from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -35,9 +35,9 @@ class DownloadVerifyError(WebRunnerException): # ---------- waiting ------------------------------------------------------- def wait_for_download( # NOSONAR S3776 — cohesive logic; planned refactor in follow-up - download_dir: Union[str, Path], + download_dir: str | Path, *, - pattern: Union[str, Pattern[str]] = r".+", + pattern: str | Pattern[str] = r".+", timeout: float = 30.0, poll_interval: float = 0.5, stable_for: float = 0.5, @@ -59,8 +59,8 @@ def wait_for_download( # NOSONAR S3776 — cohesive logic; planned refactor in regex = re.compile(pattern) if isinstance(pattern, str) else pattern excluded = tuple(e.lower() for e in exclude_extensions) start = time_fn() - last_seen_size: Dict[str, int] = {} - last_seen_time: Dict[str, float] = {} + last_seen_size: dict[str, int] = {} + last_seen_time: dict[str, float] = {} while True: for entry in directory.iterdir(): if not entry.is_file(): @@ -89,7 +89,7 @@ def wait_for_download( # NOSONAR S3776 — cohesive logic; planned refactor in # ---------- hashing ------------------------------------------------------- -def sha256_of_file(path: Union[str, Path], *, chunk_size: int = 65_536) -> str: +def sha256_of_file(path: str | Path, *, chunk_size: int = 65_536) -> str: """Stream-hash a file with SHA-256.""" p = Path(path) if not p.is_file(): @@ -104,7 +104,7 @@ def sha256_of_file(path: Union[str, Path], *, chunk_size: int = 65_536) -> str: return hasher.hexdigest() -def assert_file_sha256(path: Union[str, Path], expected: str) -> None: +def assert_file_sha256(path: str | Path, expected: str) -> None: """Raise unless ``path``'s SHA-256 equals ``expected`` (case-insensitive).""" actual = sha256_of_file(path) if actual.lower() != expected.lower(): @@ -116,7 +116,7 @@ def assert_file_sha256(path: Union[str, Path], expected: str) -> None: # ---------- PDF ----------------------------------------------------------- def extract_pdf_text( - path: Union[str, Path], + path: str | Path, *, page_separator: str = "\n", ) -> str: @@ -139,7 +139,7 @@ def extract_pdf_text( pass try: import pdfplumber # type: ignore[import-not-found] - pieces: List[str] = [] + pieces: list[str] = [] with pdfplumber.open(str(p)) as pdf: for page in pdf.pages: pieces.append(page.extract_text() or "") @@ -149,11 +149,11 @@ def extract_pdf_text( "PDF text extraction requires pypdf or pdfplumber. " "Install one: pip install pypdf" ) from error - except Exception as error: # noqa: BLE001 — library-specific parse errors + except Exception as error: raise DownloadVerifyError(f"failed to extract PDF text from {p}: {error!r}") from error -def assert_pdf_contains(path: Union[str, Path], substring: str) -> None: +def assert_pdf_contains(path: str | Path, substring: str) -> None: """Raise if the extracted PDF text doesn't include ``substring``.""" text = extract_pdf_text(path) if substring not in text: @@ -162,7 +162,7 @@ def assert_pdf_contains(path: Union[str, Path], substring: str) -> None: ) -def assert_pdf_matches(path: Union[str, Path], pattern: Union[str, Pattern[str]]) -> str: +def assert_pdf_matches(path: str | Path, pattern: str | Pattern[str]) -> str: """Raise unless the PDF text matches ``pattern``; returns the match.""" text = extract_pdf_text(path) regex = re.compile(pattern) if isinstance(pattern, str) else pattern @@ -177,11 +177,11 @@ def assert_pdf_matches(path: Union[str, Path], pattern: Union[str, Pattern[str]] # ---------- CSV ----------------------------------------------------------- def read_csv_rows( - path: Union[str, Path], + path: str | Path, *, encoding: str = "utf-8-sig", dialect: str = "excel", -) -> List[Dict[str, str]]: +) -> list[dict[str, str]]: """Read a CSV file as a list of dicts (header-driven).""" p = Path(path) if not p.is_file(): @@ -194,7 +194,7 @@ def read_csv_rows( raise DownloadVerifyError(f"cannot read CSV {p}: {error!r}") from error -def assert_csv_columns(path: Union[str, Path], expected_columns: Iterable[str]) -> None: +def assert_csv_columns(path: str | Path, expected_columns: Iterable[str]) -> None: """Raise if the CSV is missing any of ``expected_columns``.""" rows = read_csv_rows(path) if not rows: @@ -208,10 +208,10 @@ def assert_csv_columns(path: Union[str, Path], expected_columns: Iterable[str]) def assert_csv_row_count( - path: Union[str, Path], + path: str | Path, *, - minimum: Optional[int] = None, - maximum: Optional[int] = None, + minimum: int | None = None, + maximum: int | None = None, ) -> int: """Raise unless the row count is within bounds. Returns the actual count.""" count = len(read_csv_rows(path)) @@ -229,10 +229,10 @@ def assert_csv_row_count( # ---------- Excel --------------------------------------------------------- def read_excel_rows( - path: Union[str, Path], + path: str | Path, *, - sheet: Optional[Union[str, int]] = None, -) -> List[Dict[str, Any]]: + sheet: str | int | None = None, +) -> list[dict[str, Any]]: """ 讀 .xlsx 為 list of dict (假設第一列是 header)。 Read an Excel sheet (defaults to the first/active one). Requires @@ -249,7 +249,7 @@ def read_excel_rows( ) from error try: wb = load_workbook(filename=str(p), read_only=True, data_only=True) - except Exception as error: # noqa: BLE001 — openpyxl raises many types + except Exception as error: raise DownloadVerifyError(f"cannot open {p}: {error!r}") from error try: if sheet is None: @@ -274,7 +274,7 @@ def read_excel_rows( # ---------- JSON ---------------------------------------------------------- -def read_json_file(path: Union[str, Path]) -> Any: +def read_json_file(path: str | Path) -> Any: p = Path(path) if not p.is_file(): raise DownloadVerifyError(f"JSON file not found: {p}") @@ -285,7 +285,7 @@ def read_json_file(path: Union[str, Path]) -> Any: raise DownloadVerifyError(f"cannot read JSON {p}: {error!r}") from error -def assert_json_matches_schema(path: Union[str, Path], schema: Dict[str, Any]) -> None: +def assert_json_matches_schema(path: str | Path, schema: dict[str, Any]) -> None: """ 用簡化版 schema 驗證 JSON:``{"key": "type" or {nested}}``。 Minimal schema validator (NO jsonschema dependency). Schema example:: @@ -299,7 +299,7 @@ def assert_json_matches_schema(path: Union[str, Path], schema: Dict[str, Any]) - _check_schema(payload, schema, path=str(path)) -_TYPE_ALIASES: Dict[str, type] = { +_TYPE_ALIASES: dict[str, type] = { "str": str, "int": int, "float": float, @@ -343,16 +343,16 @@ def _check_schema(payload: Any, schema: Any, *, path: str, prefix: str = "$") -> class DownloadAssertion: """All the constraints :func:`assert_download` can check at once.""" - filename_pattern: Optional[Union[str, Pattern[str]]] = None - sha256: Optional[str] = None - pdf_contains: Optional[str] = None - csv_columns: Optional[List[str]] = None - json_schema: Optional[Dict[str, Any]] = None - min_size_bytes: Optional[int] = None - max_size_bytes: Optional[int] = None + filename_pattern: str | Pattern[str] | None = None + sha256: str | None = None + pdf_contains: str | None = None + csv_columns: list[str] | None = None + json_schema: dict[str, Any] | None = None + min_size_bytes: int | None = None + max_size_bytes: int | None = None -def assert_download(path: Union[str, Path], assertion: DownloadAssertion) -> None: +def assert_download(path: str | Path, assertion: DownloadAssertion) -> None: """ 一次跑完整套 download 驗證,任何一條不過就 raise。 Combined check that walks every populated field of ``assertion`` and diff --git a/je_web_runner/utils/driver_pin/pinner.py b/je_web_runner/utils/driver_pin/pinner.py index d999933c..a9962c96 100644 --- a/je_web_runner/utils/driver_pin/pinner.py +++ b/je_web_runner/utils/driver_pin/pinner.py @@ -16,7 +16,7 @@ import zipfile from dataclasses import dataclass, field from pathlib import Path -from typing import Any, List, Optional, Union +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -33,8 +33,8 @@ class PinnedDriver: url: str # direct download URL (CDN, GitHub release asset, etc.) archive_format: str # "zip" | "tar.gz" binary_inside: str # filename inside the archive - platforms: List[str] = field(default_factory=list) - cache_subdir: Optional[str] = None # default: f"{name}/{version}" + platforms: list[str] = field(default_factory=list) + cache_subdir: str | None = None # default: f"{name}/{version}" def matches_current_platform(self) -> bool: if not self.platforms: @@ -56,7 +56,7 @@ def current_platform_marker() -> str: return "linux" -def load_pinfile(path: Union[str, Path]) -> List[PinnedDriver]: +def load_pinfile(path: str | Path) -> list[PinnedDriver]: fp = Path(path) if not fp.is_file(): raise DriverPinError(f"pin file not found: {path!r}") @@ -70,7 +70,7 @@ def load_pinfile(path: Union[str, Path]) -> List[PinnedDriver]: return [_pin_from_dict(index, entry) for index, entry in enumerate(drivers)] -def save_pinfile(path: Union[str, Path], drivers: List[PinnedDriver]) -> Path: +def save_pinfile(path: str | Path, drivers: list[PinnedDriver]) -> Path: fp = Path(path) fp.parent.mkdir(parents=True, exist_ok=True) document = {"drivers": [ @@ -118,8 +118,8 @@ def _pin_from_dict(index: int, entry: Any) -> PinnedDriver: def download_pinned( pinned: PinnedDriver, - cache_dir: Union[str, Path] = ".webrunner/drivers", - fetch: Optional[Any] = None, + cache_dir: str | Path = ".webrunner/drivers", + fetch: Any | None = None, ) -> Path: """ 確認對應的 driver 已下載並解壓;回傳可執行檔路徑 @@ -154,7 +154,7 @@ def download_pinned( def _default_fetch(url: str) -> bytes: - if not (url.startswith("https://") or url.startswith("http://")): # NOSONAR — guarded above + if not (url.startswith(("https://", "http://"))): # NOSONAR — guarded above raise DriverPinError(f"refusing non-http(s) url: {url!r}") ssl_context = ssl.create_default_context() # NOSONAR — Py3.10+ default enforces TLS 1.2+ with urllib.request.urlopen(url, context=ssl_context, timeout=120) as response: # nosec B310 — scheme validated @@ -195,15 +195,23 @@ def _safe_extract_tar(archive: tarfile.TarFile, target_dir: Path) -> None: candidate.relative_to(base) except ValueError as error: raise DriverPinError(f"unsafe tar member {member.name!r}") from error - archive.extractall(target_dir) # nosec B202 — members validated above + # Name validation above does not cover symlink/hardlink *targets* that + # escape ``target_dir`` (the link resolves at extract time, not by name). + # Prefer the hardened "data" filter (Python 3.12+, security-backported to + # 3.9.17 / 3.10.12 / 3.11.4) which rejects escaping links; fall back to a + # plain extract on older interpreters that lack the parameter. + try: + archive.extractall(target_dir, filter="data") # nosec B202 — members validated + data filter # NOSONAR S930 — valid tarfile 'filter' kwarg (Py3.12+), guarded by except TypeError + except TypeError: + archive.extractall(target_dir) # nosec B202 — members validated above def install_for_browser( - pin_file: Union[str, Path], + pin_file: str | Path, browser: str, - cache_dir: Union[str, Path] = ".webrunner/drivers", - fetch: Optional[Any] = None, -) -> Optional[Path]: + cache_dir: str | Path = ".webrunner/drivers", + fetch: Any | None = None, +) -> Path | None: """High-level helper: load the pin file, find the entry for ``browser``, download if needed, and return the on-disk binary path.""" drivers = load_pinfile(pin_file) diff --git a/je_web_runner/utils/dst_boundary_test/boundary.py b/je_web_runner/utils/dst_boundary_test/boundary.py index 3e34ed72..febe65ea 100644 --- a/je_web_runner/utils/dst_boundary_test/boundary.py +++ b/je_web_runner/utils/dst_boundary_test/boundary.py @@ -17,7 +17,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum -from typing import List, Sequence +from typing import Sequence try: from zoneinfo import ZoneInfo @@ -53,7 +53,7 @@ def shift(self) -> timedelta: def find_boundaries( tz_name: str, start_year: int, end_year: int, -) -> List[DstBoundary]: +) -> list[DstBoundary]: """Walk ``[start_year, end_year]`` and detect every offset change.""" if not isinstance(tz_name, str) or not tz_name: raise DstBoundaryError("tz_name must be non-empty string") @@ -66,7 +66,7 @@ def find_boundaries( except Exception as error: raise DstBoundaryError(f"unknown timezone: {tz_name!r}") from error - boundaries: List[DstBoundary] = [] + boundaries: list[DstBoundary] = [] cursor = datetime(start_year, 1, 1, tzinfo=tz) end = datetime(end_year, 12, 31, 23, 59, tzinfo=tz) step = timedelta(hours=1) @@ -125,7 +125,7 @@ class ScheduledFire: def expected_fires_around_boundary( boundary: DstBoundary, wall_clock_hour: int = 2, wall_clock_minute: int = 30, -) -> List[ScheduledFire]: +) -> list[ScheduledFire]: """For a "daily 02:30 local" job, return what should fire on this date.""" if not 0 <= wall_clock_hour <= 23 or not 0 <= wall_clock_minute <= 59: raise DstBoundaryError("wall_clock_hour/minute out of range") diff --git a/je_web_runner/utils/edge_case_generator/generator.py b/je_web_runner/utils/edge_case_generator/generator.py index 348b0fd8..0ea7608d 100644 --- a/je_web_runner/utils/edge_case_generator/generator.py +++ b/je_web_runner/utils/edge_case_generator/generator.py @@ -19,7 +19,7 @@ from dataclasses import asdict, dataclass, field from enum import Enum from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Union +from typing import Any, Sequence from je_web_runner.utils.ai_assist.llm_assist import LLMAssistError, _invoke from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -56,11 +56,11 @@ class EdgeCase: name: str category: EdgeCaseCategory rationale: str - actions: List[Any] + actions: list[Any] expected_outcome: str = "fail" # "fail" | "pass" — what the LLM thinks should happen severity: str = "medium" # "low" | "medium" | "high" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: out = asdict(self) out["category"] = self.category.value return out @@ -71,9 +71,9 @@ class EdgeCaseSuite: """A bundle of edge-case variants for one source test.""" source_test_name: str - cases: List[EdgeCase] = field(default_factory=list) + cases: list[EdgeCase] = field(default_factory=list) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "source_test_name": self.source_test_name, "cases": [c.to_dict() for c in self.cases], @@ -101,7 +101,7 @@ def to_dict(self) -> Dict[str, Any]: _JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL) -def _parse_payload(text: str) -> Dict[str, Any]: +def _parse_payload(text: str) -> dict[str, Any]: match = _JSON_OBJECT_RE.search(text) if match is None: raise EdgeCaseGeneratorError("LLM did not return a JSON object") @@ -134,7 +134,7 @@ def _coerce_outcome(value: Any) -> str: return text if text in {"pass", "fail"} else "fail" -def _parse_case(raw: Any) -> Optional[EdgeCase]: +def _parse_case(raw: Any) -> EdgeCase | None: if not isinstance(raw, dict): return None actions = raw.get("actions") @@ -152,7 +152,7 @@ def _parse_case(raw: Any) -> Optional[EdgeCase]: def generate_edge_cases( - actions: List[Any], + actions: list[Any], *, test_name: str = "", n: int = 5, @@ -189,7 +189,7 @@ def generate_edge_cases( cases_raw = payload.get("cases") if not isinstance(cases_raw, list): raise EdgeCaseGeneratorError("LLM payload missing 'cases' list") - cases: List[EdgeCase] = [] + cases: list[EdgeCase] = [] for item in cases_raw: parsed = _parse_case(item) if parsed is not None: @@ -201,7 +201,7 @@ def generate_edge_cases( def generate_edge_cases_from_file( - action_path: Union[str, Path], + action_path: str | Path, *, n: int = 5, categories: Sequence[EdgeCaseCategory] = DEFAULT_CATEGORIES, @@ -231,10 +231,10 @@ def generate_edge_cases_from_file( def write_suite_to_dir( suite: EdgeCaseSuite, - output_dir: Union[str, Path], + output_dir: str | Path, *, - filename_prefix: Optional[str] = None, -) -> List[Path]: + filename_prefix: str | None = None, +) -> list[Path]: """ 把每個 edge case 寫成一個 action JSON 檔到 ``output_dir``。 File names are slug-of-name with a numeric prefix so they sort in the @@ -243,7 +243,7 @@ def write_suite_to_dir( target = Path(output_dir) target.mkdir(parents=True, exist_ok=True) prefix = filename_prefix or _slugify(suite.source_test_name) or "edge" - written: List[Path] = [] + written: list[Path] = [] for idx, case in enumerate(suite.cases, 1): slug = _slugify(case.name) or f"case-{idx}" path = target / f"{prefix}__{idx:02d}__{slug}.json" diff --git a/je_web_runner/utils/email_deliverability/headers.py b/je_web_runner/utils/email_deliverability/headers.py index b2fb9cb7..d1874f9e 100644 --- a/je_web_runner/utils/email_deliverability/headers.py +++ b/je_web_runner/utils/email_deliverability/headers.py @@ -19,7 +19,6 @@ import re from dataclasses import dataclass, field -from typing import Dict, List, Optional from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -32,12 +31,12 @@ class EmailDeliverabilityError(WebRunnerException): class HeaderMap: """All headers as a case-insensitive multimap (header → list of values).""" - headers: Dict[str, List[str]] = field(default_factory=dict) + headers: dict[str, list[str]] = field(default_factory=dict) - def get_all(self, name: str) -> List[str]: + def get_all(self, name: str) -> list[str]: return list(self.headers.get(name.lower(), [])) - def get_first(self, name: str) -> Optional[str]: + def get_first(self, name: str) -> str | None: all_ = self.get_all(name) return all_[0] if all_ else None @@ -46,9 +45,9 @@ def parse_headers(raw: str) -> HeaderMap: """Parse RFC 5322 headers (lines, continuations) from a raw message.""" if not isinstance(raw, str): raise EmailDeliverabilityError("raw must be a string") - out: Dict[str, List[str]] = {} - cur_name: Optional[str] = None - cur_value: List[str] = [] + out: dict[str, list[str]] = {} + cur_name: str | None = None + cur_value: list[str] = [] for line in raw.splitlines(): if not line.strip(): break # end of headers diff --git a/je_web_runner/utils/email_render/render.py b/je_web_runner/utils/email_render/render.py index cd936e56..fde40e5b 100644 --- a/je_web_runner/utils/email_render/render.py +++ b/je_web_runner/utils/email_render/render.py @@ -20,7 +20,7 @@ from dataclasses import dataclass, field from email.message import Message from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Sequence, Union +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -39,11 +39,11 @@ class CapturedEmail: message_id: str subject: str from_addr: str - to: List[str] - html_body: Optional[str] = None - text_body: Optional[str] = None - headers: Dict[str, str] = field(default_factory=dict) - raw: Optional[str] = None + to: list[str] + html_body: str | None = None + text_body: str | None = None + headers: dict[str, str] = field(default_factory=dict) + raw: str | None = None def has_html(self) -> bool: return bool(self.html_body and self.html_body.strip()) @@ -57,7 +57,7 @@ class ViewportProfile: width: int height: int dark_mode: bool = False - user_agent: Optional[str] = None + user_agent: str | None = None DEFAULT_VIEWPORTS: Sequence[ViewportProfile] = ( @@ -90,7 +90,7 @@ def _require_requests() -> Any: ) from error -def _split_addresses(value: Any) -> List[str]: +def _split_addresses(value: Any) -> list[str]: if value is None: return [] if isinstance(value, list): @@ -98,7 +98,7 @@ def _split_addresses(value: Any) -> List[str]: return [part.strip() for part in str(value).split(",") if part.strip()] -def _parse_eml(raw: Union[str, bytes]) -> CapturedEmail: +def _parse_eml(raw: str | bytes) -> CapturedEmail: if isinstance(raw, bytes): msg = email.message_from_bytes(raw) else: @@ -106,9 +106,9 @@ def _parse_eml(raw: Union[str, bytes]) -> CapturedEmail: return _from_message(msg, raw_text=raw if isinstance(raw, str) else raw.decode("utf-8", "replace")) -def _from_message(msg: Message, *, raw_text: Optional[str] = None) -> CapturedEmail: - html_body: Optional[str] = None - text_body: Optional[str] = None +def _from_message(msg: Message, *, raw_text: str | None = None) -> CapturedEmail: + html_body: str | None = None + text_body: str | None = None if msg.is_multipart(): for part in msg.walk(): ctype = part.get_content_type() @@ -135,7 +135,7 @@ def _from_message(msg: Message, *, raw_text: Optional[str] = None) -> CapturedEm ) -def _decode_payload(part: Message) -> Optional[str]: +def _decode_payload(part: Message) -> str | None: payload = part.get_payload(decode=True) if payload is None: return None @@ -148,7 +148,7 @@ def _decode_payload(part: Message) -> Optional[str]: # ---------- fetchers ----------------------------------------------------- -def load_eml_file(path: Union[str, Path]) -> CapturedEmail: +def load_eml_file(path: str | Path) -> CapturedEmail: """Parse a single ``.eml`` file.""" eml_path = Path(path) if not eml_path.exists(): @@ -157,18 +157,18 @@ def load_eml_file(path: Union[str, Path]) -> CapturedEmail: return _parse_eml(raw) -def load_eml_dir(directory: Union[str, Path]) -> List[CapturedEmail]: +def load_eml_dir(directory: str | Path) -> list[CapturedEmail]: """Parse every ``.eml`` file in ``directory`` (non-recursive).""" dir_path = Path(directory) if not dir_path.is_dir(): raise EmailRenderError(f"eml directory not found: {dir_path}") - out: List[CapturedEmail] = [] + out: list[CapturedEmail] = [] for child in sorted(dir_path.glob("*.eml")): out.append(load_eml_file(child)) return out -def fetch_mailhog(base_url: str, *, timeout: float = 10.0) -> List[CapturedEmail]: +def fetch_mailhog(base_url: str, *, timeout: float = 10.0) -> list[CapturedEmail]: """Fetch messages from a MailHog server's ``/api/v2/messages`` endpoint.""" requests = _require_requests() url = base_url.rstrip("/") + "/api/v2/messages" @@ -184,7 +184,7 @@ def fetch_mailhog(base_url: str, *, timeout: float = 10.0) -> List[CapturedEmail return [_parse_mailhog_item(item) for item in items if isinstance(item, dict)] -def _parse_mailhog_item(item: Dict[str, Any]) -> CapturedEmail: +def _parse_mailhog_item(item: dict[str, Any]) -> CapturedEmail: content = item.get("Content") or {} headers = content.get("Headers") or {} raw_body = content.get("Body") or "" @@ -196,7 +196,7 @@ def _parse_mailhog_item(item: Dict[str, Any]) -> CapturedEmail: return _parse_eml(raw) -def fetch_mailpit(base_url: str, *, timeout: float = 10.0, limit: int = 50) -> List[CapturedEmail]: +def fetch_mailpit(base_url: str, *, timeout: float = 10.0, limit: int = 50) -> list[CapturedEmail]: """Fetch messages from a Mailpit server's ``/api/v1/messages`` listing.""" requests = _require_requests() list_url = f"{base_url.rstrip('/')}/api/v1/messages?limit={int(limit)}" @@ -211,7 +211,7 @@ def fetch_mailpit(base_url: str, *, timeout: float = 10.0, limit: int = 50) -> L for entry in listing_payload.get("messages", []) or []: if isinstance(entry, dict) and entry.get("ID"): ids.append(entry["ID"]) - out: List[CapturedEmail] = [] + out: list[CapturedEmail] = [] for msg_id in ids: raw_url = f"{base_url.rstrip('/')}/api/v1/message/{msg_id}/raw" try: @@ -233,10 +233,10 @@ def fetch_mailpit(base_url: str, *, timeout: float = 10.0, limit: int = 50) -> L def render_email_in_viewports( captured: CapturedEmail, driver: RenderDriver, - output_dir: Union[str, Path], + output_dir: str | Path, *, viewports: Sequence[ViewportProfile] = DEFAULT_VIEWPORTS, -) -> List[RenderArtifact]: +) -> list[RenderArtifact]: """ Render ``captured.html_body`` in each viewport via ``driver`` and write screenshots into ``output_dir``. The driver receives the HTML, viewport @@ -247,7 +247,7 @@ def render_email_in_viewports( raise EmailRenderError(f"captured email has no HTML body: {captured.message_id!r}") out_dir = Path(output_dir) out_dir.mkdir(parents=True, exist_ok=True) - artifacts: List[RenderArtifact] = [] + artifacts: list[RenderArtifact] = [] for viewport in viewports: target = out_dir / f"{_safe_slug(captured.message_id) or 'msg'}__{viewport.name}.png" written = Path(driver(captured.html_body or "", viewport, target)) @@ -279,7 +279,7 @@ def assert_subject_contains(captured: CapturedEmail, needle: str) -> None: def export_summary_json( captures: Sequence[CapturedEmail], - output_path: Union[str, Path], + output_path: str | Path, ) -> Path: """Persist a compact JSON list of captured emails for downstream tooling.""" payload = [ diff --git a/je_web_runner/utils/env_config/env_loader.py b/je_web_runner/utils/env_config/env_loader.py index 8987d7c7..e5e20e2e 100644 --- a/je_web_runner/utils/env_config/env_loader.py +++ b/je_web_runner/utils/env_config/env_loader.py @@ -9,7 +9,7 @@ import os import re from pathlib import Path -from typing import Any, Optional +from typing import Any from dotenv import load_dotenv @@ -24,7 +24,7 @@ class EnvConfigError(WebRunnerException): _PLACEHOLDER_RE = re.compile(r"\$\{ENV\.([A-Za-z_]\w*)\}") -def load_env(env_name: Optional[str] = None, env_dir: str = ".", override: bool = False) -> str: +def load_env(env_name: str | None = None, env_dir: str = ".", override: bool = False) -> str: """ 載入指定環境的 ``.env`` 檔 Load the ``.env`` file for the given environment. @@ -45,7 +45,7 @@ def load_env(env_name: Optional[str] = None, env_dir: str = ".", override: bool return str(target) -def get_env(key: str, default: Optional[str] = None) -> Optional[str]: +def get_env(key: str, default: str | None = None) -> str | None: """Return ``os.environ[key]`` with an optional default.""" return os.environ.get(key, default) diff --git a/je_web_runner/utils/event_bus/bus.py b/je_web_runner/utils/event_bus/bus.py index e9d9889d..69867f6f 100644 --- a/je_web_runner/utils/event_bus/bus.py +++ b/je_web_runner/utils/event_bus/bus.py @@ -19,7 +19,7 @@ import uuid from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, Iterable, List, Optional, Union +from typing import Any, Callable, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -32,9 +32,9 @@ class EventBusError(WebRunnerException): class EventEnvelope: event_id: str topic: str - payload: Dict[str, Any] + payload: dict[str, Any] timestamp_ms: int = field(default_factory=lambda: int(time.time() * 1000)) - sender: Optional[str] = None + sender: str | None = None def to_json_line(self) -> str: return json.dumps({ @@ -46,7 +46,7 @@ def to_json_line(self) -> str: }, ensure_ascii=False) + "\n" @staticmethod - def from_dict(data: Dict[str, Any]) -> "EventEnvelope": + def from_dict(data: dict[str, Any]) -> EventEnvelope: try: return EventEnvelope( event_id=str(data["event_id"]), @@ -63,14 +63,14 @@ def from_dict(data: Dict[str, Any]) -> "EventEnvelope": class EventBus: """File-backed publish/subscribe primitive.""" - log_path: Union[str, Path] - sender: Optional[str] = None + log_path: str | Path + sender: str | None = None _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) def _path(self) -> Path: return Path(self.log_path) - def publish(self, topic: str, payload: Optional[Dict[str, Any]] = None) -> EventEnvelope: + def publish(self, topic: str, payload: dict[str, Any] | None = None) -> EventEnvelope: if not topic: raise EventBusError("topic must be non-empty") if payload is not None and not isinstance(payload, dict): @@ -97,13 +97,13 @@ def publish(self, topic: str, payload: Optional[Dict[str, Any]] = None) -> Event def poll( self, offset: int = 0, - topics: Optional[Iterable[str]] = None, - ) -> List[EventEnvelope]: + topics: Iterable[str] | None = None, + ) -> list[EventEnvelope]: path = self._path() if not path.is_file(): return [] topics_set = set(topics) if topics else None - events: List[EventEnvelope] = [] + events: list[EventEnvelope] = [] with open(path, "rb") as handle: handle.seek(offset) for raw_line in handle: @@ -126,7 +126,7 @@ def wait_for( self, topic: str, offset: int = 0, - predicate: Optional[Callable[[EventEnvelope], bool]] = None, + predicate: Callable[[EventEnvelope], bool] | None = None, timeout: float = 30.0, poll_interval: float = 0.1, sleep: Callable[[float], None] = time.sleep, diff --git a/je_web_runner/utils/executor/action_executor.py b/je_web_runner/utils/executor/action_executor.py index 18ec14b0..a521cf5c 100644 --- a/je_web_runner/utils/executor/action_executor.py +++ b/je_web_runner/utils/executor/action_executor.py @@ -4,7 +4,7 @@ from datetime import datetime from inspect import getmembers, isbuiltin from pathlib import Path -from typing import Any, Callable, Optional, Union +from typing import Any, Callable # 禁止暴露於 JSON 動作執行器的內建函式,避免任意程式碼執行 # Builtins that must never be callable from user-supplied JSON actions, @@ -153,7 +153,7 @@ from je_web_runner.webdriver.webdriver_wrapper import webdriver_wrapper_instance -def _sleep_seconds(seconds: Union[int, float] = 1) -> float: +def _sleep_seconds(seconds: int | float = 1) -> float: """ 阻塞當前執行緒指定秒數,回傳實際睡眠的秒數。 Block the calling thread for ``seconds`` (positive number). Negative @@ -168,30 +168,30 @@ def _sleep_seconds(seconds: Union[int, float] = 1) -> float: return float(seconds) -def _try_selenium_screenshot() -> Optional[bytes]: +def _try_selenium_screenshot() -> bytes | None: try: if webdriver_wrapper_instance.current_webdriver is None: return None return webdriver_wrapper_instance.get_screenshot_as_png() - except Exception: # noqa: BLE001 — best-effort fallback path + except Exception: return None -def _try_playwright_screenshot() -> Optional[bytes]: +def _try_playwright_screenshot() -> bytes | None: try: if not _pw.playwright_wrapper_instance._pages: return None return _pw.playwright_wrapper_instance.screenshot_bytes() - except Exception: # noqa: BLE001 — best-effort fallback path + except Exception: return None -class Executor(object): +class Executor: def __init__(self): # 失敗時自動截圖目錄;None 代表停用 # Output directory for auto-captured failure screenshots; None disables it. - self.failure_screenshot_dir: Optional[str] = None + self.failure_screenshot_dir: str | None = None # 全域重試策略 (預設關閉) # Global retry policy. retries == 0 disables retry; backoff is in # seconds and is multiplied by the (1-based) attempt number. @@ -204,7 +204,7 @@ def __init__(self): # Optional context-manager factory invoked once per action so an # observability stack (OpenTelemetry, custom logging, …) can wrap # each call without the executor depending on the SDK directly. - self._action_span_factory: Optional[Callable[[str], Any]] = None + self._action_span_factory: Callable[[str], Any] | None = None # 事件字典:將字串名稱對應到實際可執行的函式 # Event dictionary: map string keys to actual callable functions self.event_dict = { @@ -818,7 +818,7 @@ def set_retry_policy(self, retries: int = 0, backoff: float = 0.0) -> None: """ self.retry_policy = {"retries": max(int(retries), 0), "backoff": max(float(backoff), 0.0)} - def set_action_span_factory(self, factory: Optional[Callable[[str], Any]]) -> None: + def set_action_span_factory(self, factory: Callable[[str], Any] | None) -> None: """ 登錄一個 context-manager factory,每個 action 會被它包起來 Register an optional ``ContextManager`` factory invoked once per @@ -839,7 +839,7 @@ def _do_with_retry(self, action): for attempt in range(retries + 1): try: return self._execute_event(action) - except Exception as error: # noqa: BLE001 — retry layer must catch all + except Exception as error: if attempt >= retries: raise if backoff > 0: @@ -850,7 +850,7 @@ def _do_with_retry(self, action): # Unreachable: ``range(retries + 1)`` always has at least one iteration. raise WebRunnerExecuteException("retry loop exited without resolution") - def set_failure_screenshot_dir(self, path: Optional[str]) -> None: + def set_failure_screenshot_dir(self, path: str | None) -> None: """ 設定 (或停用) 動作失敗時的自動截圖目錄 Configure the directory used for auto-screenshots on action failure. @@ -860,7 +860,7 @@ def set_failure_screenshot_dir(self, path: Optional[str]) -> None: Path(path).mkdir(parents=True, exist_ok=True) self.failure_screenshot_dir = path - def _capture_failure_screenshot(self, action) -> Optional[str]: + def _capture_failure_screenshot(self, action) -> str | None: """Best-effort screenshot save when an action raises. Returns path or None.""" if not self.failure_screenshot_dir: return None @@ -941,7 +941,7 @@ def _execute_event(self, action: list): # Invalid format, raise exception raise WebRunnerExecuteException(executor_data_error + " " + str(action)) - def execute_action(self, action_list: Union[list, dict]) -> dict: + def execute_action(self, action_list: list | dict) -> dict: """ 執行一系列動作 Execute a list of actions @@ -970,18 +970,15 @@ def execute_action(self, action_list: Union[list, dict]) -> dict: execute_record_dict = {} - # 檢查 action_list 是否為合法的 list - # Validate action_list - try: - if len(action_list) == 0 or isinstance(action_list, list) is False: - web_runner_logger.error( - f"execute_action, action_list: {action_list}, " - f"failed: {WebRunnerExecuteException(executor_list_error)}") - raise WebRunnerExecuteException(executor_list_error) - except Exception as error: + # action_list 必須是 list(空 list 會自然回傳空結果,迴圈不執行)。 + # 先前這裡的 raise 被同層 except 立即吞掉,等同沒驗證,導致非 list + # (例如字串) 會被逐字元迭代。改為直接拒絕非 list。 + # action_list must be a list; an empty list yields an empty result. + if not isinstance(action_list, list): web_runner_logger.error( f"execute_action, action_list: {action_list}, " - f"failed: {repr(error)}") + f"failed: {WebRunnerExecuteException(executor_list_error)}") + raise WebRunnerExecuteException(executor_list_error) # 逐一執行動作 # Execute each action in the list @@ -993,12 +990,12 @@ def execute_action(self, action_list: Union[list, dict]) -> dict: except Exception as error: web_runner_logger.error( f"execute_action, action_list: {action_list}, " - f"action: {action}, failed: {repr(error)}") + f"action: {action}, failed: {error!r}") execute_record = "execute: " + str(action) screenshot_path = self._capture_failure_screenshot(action) if screenshot_path: execute_record_dict.update({ - execute_record: f"{repr(error)} (failure screenshot: {screenshot_path})" + execute_record: f"{error!r} (failure screenshot: {screenshot_path})" }) else: execute_record_dict.update({execute_record: repr(error)}) diff --git a/je_web_runner/utils/exploratory_ai/explorer.py b/je_web_runner/utils/exploratory_ai/explorer.py index f445d588..e92c4681 100644 --- a/je_web_runner/utils/exploratory_ai/explorer.py +++ b/je_web_runner/utils/exploratory_ai/explorer.py @@ -19,7 +19,7 @@ import random from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Callable, Dict, List, Optional, Protocol, Sequence +from typing import Any, Callable, Protocol, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -48,7 +48,7 @@ class InteractiveElement: selector: str tag: str text: str = "" - role: Optional[str] = None + role: str | None = None is_visible: bool = True is_enabled: bool = True @@ -65,12 +65,12 @@ class PageObservation: url: str title: str - elements: List[InteractiveElement] = field(default_factory=list) - console_errors: List[str] = field(default_factory=list) - network_errors: List[Dict[str, Any]] = field(default_factory=list) + elements: list[InteractiveElement] = field(default_factory=list) + console_errors: list[str] = field(default_factory=list) + network_errors: list[dict[str, Any]] = field(default_factory=list) step: int = 0 - def actionable(self) -> List[InteractiveElement]: + def actionable(self) -> list[InteractiveElement]: return [e for e in self.elements if e.is_visible and e.is_enabled] @@ -79,8 +79,8 @@ class PlannedAction: """The next step the explorer wants the runner to perform.""" kind: ActionKind - selector: Optional[str] = None - value: Optional[str] = None + selector: str | None = None + value: str | None = None rationale: str = "" def __post_init__(self) -> None: @@ -101,7 +101,7 @@ class BugSignal: kind: str # 'console_error' | 'network_error' | 'planner_stuck' | ... detail: str - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -110,9 +110,9 @@ class ExplorationReport: """Roll-up returned by :func:`Explorer.run`.""" steps_taken: int - pages_visited: List[str] = field(default_factory=list) - bugs: List[BugSignal] = field(default_factory=list) - actions: List[PlannedAction] = field(default_factory=list) + pages_visited: list[str] = field(default_factory=list) + bugs: list[BugSignal] = field(default_factory=list) + actions: list[PlannedAction] = field(default_factory=list) stopped_reason: str = "" def has_bugs(self) -> bool: @@ -146,7 +146,7 @@ class RandomPlanner: def __init__( self, *, - seed: Optional[int] = None, + seed: int | None = None, sample_strings: Sequence[str] = ("test", "1234", "x"), type_bias: float = 0.3, ) -> None: @@ -207,7 +207,7 @@ def __post_init__(self) -> None: def run(self) -> ExplorationReport: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up report = ExplorationReport(steps_taken=0) - repeat_counter: Dict[str, int] = {} + repeat_counter: dict[str, int] = {} for step in range(self.max_steps): observation = self._safe_observe(step) if observation.url and ( @@ -261,8 +261,8 @@ def _safe_plan( self, observation: PageObservation, report: ExplorationReport, - repeat_counter: Dict[str, int], - ) -> Optional[PlannedAction]: + repeat_counter: dict[str, int], + ) -> PlannedAction | None: try: # pylint: disable=assignment-from-no-return — Protocol body is `...`. action = self.planner.plan(observation) diff --git a/je_web_runner/utils/extension_harness/harness.py b/je_web_runner/utils/extension_harness/harness.py index e0dfb69a..f824b19b 100644 --- a/je_web_runner/utils/extension_harness/harness.py +++ b/je_web_runner/utils/extension_harness/harness.py @@ -18,7 +18,7 @@ import json from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Union +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -32,13 +32,13 @@ class ExtensionInfo: name: str version: str manifest_version: int - popup: Optional[str] = None - background_script: Optional[str] = None - permissions: Optional[List[str]] = None - extension_dir: Optional[str] = None + popup: str | None = None + background_script: str | None = None + permissions: list[str] | None = None + extension_dir: str | None = None -def parse_manifest(manifest: Union[str, Path, Dict[str, Any]]) -> ExtensionInfo: +def parse_manifest(manifest: str | Path | dict[str, Any]) -> ExtensionInfo: """Parse a manifest dict / file path into :class:`ExtensionInfo`.""" if isinstance(manifest, (str, Path)): path = Path(manifest) @@ -75,14 +75,14 @@ def parse_manifest(manifest: Union[str, Path, Dict[str, Any]]) -> ExtensionInfo: ) -def _popup_path(data: Dict[str, Any], manifest_version: int) -> Optional[str]: +def _popup_path(data: dict[str, Any], manifest_version: int) -> str | None: action_key = "action" if manifest_version == 3 else "browser_action" action = data.get(action_key) or {} popup = action.get("default_popup") return popup if isinstance(popup, str) else None -def _background_script(data: Dict[str, Any], manifest_version: int) -> Optional[str]: +def _background_script(data: dict[str, Any], manifest_version: int) -> str | None: background = data.get("background") or {} if manifest_version == 3: worker = background.get("service_worker") @@ -94,14 +94,14 @@ def _background_script(data: Dict[str, Any], manifest_version: int) -> Optional[ return page if isinstance(page, str) else None -def extension_info(directory: Union[str, Path]) -> ExtensionInfo: +def extension_info(directory: str | Path) -> ExtensionInfo: """Convenience: parse manifest under ``directory`` and stamp ``extension_dir``.""" info = parse_manifest(directory) info.extension_dir = str(Path(directory).resolve()) return info -def apply_to_chrome_options(options: Any, extensions: Iterable[Union[str, Path]]) -> Any: +def apply_to_chrome_options(options: Any, extensions: Iterable[str | Path]) -> Any: """ 給 Selenium ``ChromeOptions`` 加上 ``--load-extension``。 Add ``--load-extension`` flags for each unpacked extension directory. @@ -119,10 +119,10 @@ def apply_to_chrome_options(options: Any, extensions: Iterable[Union[str, Path]] def playwright_persistent_context_args( - extensions: Iterable[Union[str, Path]], - user_data_dir: Union[str, Path], + extensions: Iterable[str | Path], + user_data_dir: str | Path, headless: bool = False, -) -> Dict[str, Any]: +) -> dict[str, Any]: """ Return kwargs for Playwright's ``launch_persistent_context``. @@ -134,7 +134,7 @@ def playwright_persistent_context_args( for path in paths: if not Path(path).is_dir(): raise ExtensionHarnessError(f"extension directory missing: {path!r}") - args: List[str] = [] + args: list[str] = [] if paths: args.extend([ f"--disable-extensions-except={','.join(paths)}", diff --git a/je_web_runner/utils/extensions/extension_loader.py b/je_web_runner/utils/extensions/extension_loader.py index ac6ed6ba..0ad69f0f 100644 --- a/je_web_runner/utils/extensions/extension_loader.py +++ b/je_web_runner/utils/extensions/extension_loader.py @@ -7,7 +7,6 @@ from __future__ import annotations from pathlib import Path -from typing import List, Optional from selenium.webdriver.chrome.options import Options as ChromeOptions @@ -30,7 +29,7 @@ def _check_path(path: str, expect_dir: bool = False) -> Path: def selenium_chrome_options_with_extension( crx_or_dir: str, - options: Optional[ChromeOptions] = None, + options: ChromeOptions | None = None, ) -> ChromeOptions: """ 回傳已掛上擴充功能的 ChromeOptions @@ -47,7 +46,7 @@ def selenium_chrome_options_with_extension( return opts -def playwright_extension_launch_args(extension_dir: str) -> List[str]: +def playwright_extension_launch_args(extension_dir: str) -> list[str]: """ 回傳給 ``pw_launch(args=...)`` 用的旗標清單(Chromium only) Build the ``args=[...]`` list to pass to ``pw_launch`` so the persistent diff --git a/je_web_runner/utils/factories/factory.py b/je_web_runner/utils/factories/factory.py index 8cd29435..cb54263a 100644 --- a/je_web_runner/utils/factories/factory.py +++ b/je_web_runner/utils/factories/factory.py @@ -8,7 +8,7 @@ import itertools import time -from typing import Any, Callable, Dict, List +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -25,23 +25,23 @@ class Factory: invoked for every ``build()`` so faker-style providers stay fresh. """ - def __init__(self, defaults: Dict[str, Any]): + def __init__(self, defaults: dict[str, Any]): if not isinstance(defaults, dict): raise FactoryError("Factory defaults must be a dict") - self._defaults: Dict[str, Any] = dict(defaults) + self._defaults: dict[str, Any] = dict(defaults) - def build(self, **overrides: Any) -> Dict[str, Any]: + def build(self, **overrides: Any) -> dict[str, Any]: web_runner_logger.info("Factory.build") - out: Dict[str, Any] = {} + out: dict[str, Any] = {} for key, value in self._defaults.items(): out[key] = value() if callable(value) else value out.update(overrides) return out - def build_batch(self, count: int, **overrides: Any) -> List[Dict[str, Any]]: + def build_batch(self, count: int, **overrides: Any) -> list[dict[str, Any]]: return [self.build(**overrides) for _ in range(int(count))] - def extend(self, **extra_defaults: Any) -> "Factory": + def extend(self, **extra_defaults: Any) -> Factory: """Return a new Factory with additional / overriding defaults.""" merged = {**self._defaults, **extra_defaults} return Factory(merged) @@ -63,7 +63,7 @@ def _value(): try: from je_web_runner.utils.test_data.faker_integration import fake_value return fake_value(method) - except Exception: # noqa: BLE001 — faker not installed or provider missing + except Exception: return fallback() return _value diff --git a/je_web_runner/utils/failure_auto_tag/tag.py b/je_web_runner/utils/failure_auto_tag/tag.py index c58887bd..b20c5b04 100644 --- a/je_web_runner/utils/failure_auto_tag/tag.py +++ b/je_web_runner/utils/failure_auto_tag/tag.py @@ -11,7 +11,7 @@ import re from dataclasses import asdict, dataclass, field -from typing import Any, Callable, Dict, List, Optional, Sequence +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -26,8 +26,8 @@ class FailureBundle: exception_text: str = "" last_action: str = "" - console_errors: List[str] = field(default_factory=list) - network_errors: List[Dict[str, Any]] = field(default_factory=list) + console_errors: list[str] = field(default_factory=list) + network_errors: list[dict[str, Any]] = field(default_factory=list) def is_empty(self) -> bool: return not (self.exception_text or self.last_action @@ -40,13 +40,13 @@ class Tag: confidence: float = 1.0 reason: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) # pattern -> tag. Order matters: first hit wins per rule, but every rule # is evaluated so multiple tags can fire. -_PATTERN_TAGS: List[tuple] = [ +_PATTERN_TAGS: list[tuple] = [ (re.compile(r"NoSuchElement|element not found|locator did not match", re.IGNORECASE), "flaky-locator", "Selector did not resolve to an element."), @@ -66,7 +66,7 @@ def to_dict(self) -> Dict[str, Any]: ] -def _network_tag(bundle: FailureBundle) -> Optional[Tag]: +def _network_tag(bundle: FailureBundle) -> Tag | None: server_errors = [e for e in bundle.network_errors if isinstance(e, dict) and 500 <= int(e.get("status", 0)) < 600] if server_errors: @@ -81,7 +81,7 @@ def _network_tag(bundle: FailureBundle) -> Optional[Tag]: return None -def _console_tag(bundle: FailureBundle) -> Optional[Tag]: +def _console_tag(bundle: FailureBundle) -> Tag | None: if any("Uncaught" in c or "TypeError" in c or "ReferenceError" in c for c in bundle.console_errors): return Tag(name="js-error", confidence=0.9, @@ -89,13 +89,13 @@ def _console_tag(bundle: FailureBundle) -> Optional[Tag]: return None -def heuristic_tags(bundle: FailureBundle) -> List[Tag]: +def heuristic_tags(bundle: FailureBundle) -> list[Tag]: """Cheap, deterministic tag pass — no LLM required.""" if not isinstance(bundle, FailureBundle): raise FailureAutoTagError("bundle must be FailureBundle") if bundle.is_empty(): raise FailureAutoTagError("bundle has no signal to tag on") - tags: List[Tag] = [] + tags: list[Tag] = [] text = bundle.exception_text or "" for pattern, name, reason in _PATTERN_TAGS: if pattern.search(text): @@ -111,11 +111,23 @@ def heuristic_tags(bundle: FailureBundle) -> List[Tag]: # ---------------- optional LLM augmentation ---------------- -LlmTagger = Callable[[FailureBundle], Sequence[Dict[str, Any]]] +LlmTagger = Callable[[FailureBundle], Sequence[dict[str, Any]]] """Pluggable LLM hook returning ``[{'name', 'confidence', 'reason'}, ...]``.""" -def llm_tags(bundle: FailureBundle, tagger: LlmTagger) -> List[Tag]: +def _coerce_confidence(value: Any) -> float: + """Default to 0.5 only when confidence is absent; an explicit ``0`` is a + valid low score (a falsy-coalesce would wrongly promote it to 0.5 and + mis-rank the tag in :func:`merge_tags`). Non-numeric input falls back too.""" + if value is None: + return 0.5 + try: + return float(value) + except (TypeError, ValueError): + return 0.5 + + +def llm_tags(bundle: FailureBundle, tagger: LlmTagger) -> list[Tag]: if not callable(tagger): raise FailureAutoTagError("tagger must be callable") try: @@ -124,7 +136,7 @@ def llm_tags(bundle: FailureBundle, tagger: LlmTagger) -> List[Tag]: raise FailureAutoTagError(f"llm tagger failed: {error!r}") from error if not isinstance(raw, (list, tuple)): raise FailureAutoTagError("tagger must return a sequence of tag dicts") - out: List[Tag] = [] + out: list[Tag] = [] for item in raw: if not isinstance(item, dict): continue @@ -133,15 +145,15 @@ def llm_tags(bundle: FailureBundle, tagger: LlmTagger) -> List[Tag]: continue out.append(Tag( name=name, - confidence=float(item.get("confidence") or 0.5), + confidence=_coerce_confidence(item.get("confidence")), reason=str(item.get("reason") or ""), )) return out -def merge_tags(*streams: Sequence[Tag]) -> List[Tag]: +def merge_tags(*streams: Sequence[Tag]) -> list[Tag]: """De-duplicate by name, keeping the highest-confidence reason.""" - best: Dict[str, Tag] = {} + best: dict[str, Tag] = {} for stream in streams: for tag in stream: existing = best.get(tag.name) diff --git a/je_web_runner/utils/failure_bundle/bundle.py b/je_web_runner/utils/failure_bundle/bundle.py index 84e4cf8a..0becaebd 100644 --- a/je_web_runner/utils/failure_bundle/bundle.py +++ b/je_web_runner/utils/failure_bundle/bundle.py @@ -11,7 +11,7 @@ import zipfile from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional, Union +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -38,8 +38,8 @@ class FailureBundle: test_name: str error_repr: str captured_at: str = field(default_factory=_utc_now_iso) - metadata: Dict[str, Any] = field(default_factory=dict) - _entries: Dict[str, bytes] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + _entries: dict[str, bytes] = field(default_factory=dict) def add_screenshot(self, png_bytes: bytes, name: str = "screenshot.png") -> None: if not isinstance(png_bytes, (bytes, bytearray)): @@ -49,23 +49,23 @@ def add_screenshot(self, png_bytes: bytes, name: str = "screenshot.png") -> None def add_dom(self, html: str, name: str = "dom.html") -> None: self._entries[f"artifacts/{name}"] = html.encode("utf-8") - def add_console(self, messages: List[Dict[str, Any]]) -> None: + def add_console(self, messages: list[dict[str, Any]]) -> None: self._entries["artifacts/console.json"] = json.dumps( messages, ensure_ascii=False, indent=2 ).encode("utf-8") - def add_network(self, responses: List[Dict[str, Any]]) -> None: + def add_network(self, responses: list[dict[str, Any]]) -> None: self._entries["artifacts/network.json"] = json.dumps( responses, ensure_ascii=False, indent=2 ).encode("utf-8") - def add_trace(self, trace_path: Union[str, Path]) -> None: + def add_trace(self, trace_path: str | Path) -> None: path = Path(trace_path) if not path.is_file(): raise FailureBundleError(f"trace file not found: {trace_path!r}") self._entries[f"artifacts/{path.name}"] = path.read_bytes() - def add_file(self, source: Union[str, Path], inside_name: Optional[str] = None) -> None: + def add_file(self, source: str | Path, inside_name: str | None = None) -> None: path = Path(source) if not path.is_file(): raise FailureBundleError(f"file not found: {source!r}") @@ -75,7 +75,7 @@ def add_file(self, source: Union[str, Path], inside_name: Optional[str] = None) def add_text(self, name: str, text: str) -> None: self._entries[f"artifacts/{name}"] = text.encode("utf-8") - def _manifest(self) -> Dict[str, Any]: + def _manifest(self) -> dict[str, Any]: return { "test_name": self.test_name, "error_repr": self.error_repr, @@ -87,7 +87,7 @@ def _manifest(self) -> Dict[str, Any]: ], } - def write(self, output_path: Union[str, Path]) -> Path: + def write(self, output_path: str | Path) -> Path: path = Path(output_path) path.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zf: @@ -100,12 +100,12 @@ def write(self, output_path: Union[str, Path]) -> Path: return path -def extract_bundle(zip_path: Union[str, Path]) -> Dict[str, Any]: +def extract_bundle(zip_path: str | Path) -> dict[str, Any]: """Read a bundle and return ``{manifest, files: {name: bytes}}``.""" path = Path(zip_path) if not path.is_file(): raise FailureBundleError(f"bundle not found: {zip_path!r}") - files: Dict[str, bytes] = {} + files: dict[str, bytes] = {} with zipfile.ZipFile(path, "r") as zf: names = zf.namelist() if _MANIFEST_NAME not in names: diff --git a/je_web_runner/utils/failure_cluster/clustering.py b/je_web_runner/utils/failure_cluster/clustering.py index 5d81829f..8cf10c46 100644 --- a/je_web_runner/utils/failure_cluster/clustering.py +++ b/je_web_runner/utils/failure_cluster/clustering.py @@ -13,7 +13,7 @@ import re from dataclasses import dataclass, field -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -29,8 +29,8 @@ class FailureCluster: signature: str representative: str count: int = 0 - members: List[Dict[str, Any]] = field(default_factory=list) - files: List[str] = field(default_factory=list) + members: list[dict[str, Any]] = field(default_factory=list) + files: list[str] = field(default_factory=list) _HEX_ADDRESS_RE = re.compile(r"0x[0-9a-fA-F]+") @@ -77,9 +77,9 @@ def normalise_error(message: str) -> str: def cluster_failures( - failures: Iterable[Dict[str, Any]], - top_n: Optional[int] = None, -) -> List[FailureCluster]: + failures: Iterable[dict[str, Any]], + top_n: int | None = None, +) -> list[FailureCluster]: """ 把 ``[{function_name, exception, file_path?}, …]`` 分群並依 count 排序。 Group failures by normalised signature; clusters are sorted by count @@ -87,7 +87,7 @@ def cluster_failures( """ if failures is None: raise FailureClusterError("failures must be iterable") - buckets: Dict[str, FailureCluster] = {} + buckets: dict[str, FailureCluster] = {} for failure in failures: if not isinstance(failure, dict): raise FailureClusterError( @@ -115,7 +115,7 @@ def cluster_failures( return ordered -def cluster_summary(clusters: Iterable[FailureCluster]) -> List[Dict[str, Any]]: +def cluster_summary(clusters: Iterable[FailureCluster]) -> list[dict[str, Any]]: """Project clusters to ``{signature, count, files, representative}`` dicts.""" return [ { diff --git a/je_web_runner/utils/failure_cluster_dbscan/cluster.py b/je_web_runner/utils/failure_cluster_dbscan/cluster.py index 854aaaa0..eb2e3736 100644 --- a/je_web_runner/utils/failure_cluster_dbscan/cluster.py +++ b/je_web_runner/utils/failure_cluster_dbscan/cluster.py @@ -18,7 +18,7 @@ import re from collections import defaultdict from dataclasses import dataclass, field -from typing import Any, Dict, Iterable, List, Optional, Sequence, Set +from typing import Any, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -49,7 +49,7 @@ class FailureRecord: ) -def _tokenize(message: str) -> Set[str]: +def _tokenize(message: str) -> set[str]: if not isinstance(message, str): return set() cleaned = message @@ -58,7 +58,7 @@ def _tokenize(message: str) -> Set[str]: return {t.lower() for t in re.findall(r"\w{3,}", cleaned)} -def _jaccard_distance(a: Set[str], b: Set[str]) -> float: +def _jaccard_distance(a: set[str], b: set[str]) -> float: if not a and not b: return 0.0 union = a | b @@ -71,23 +71,23 @@ def _jaccard_distance(a: Set[str], b: Set[str]) -> float: @dataclass class Cluster: representative: str - members: List[str] = field(default_factory=list) + members: list[str] = field(default_factory=list) @property def size(self) -> int: return len(self.members) -def _neighbours_fn(tokens: List[Set[str]], eps: float): +def _neighbours_fn(tokens: list[set[str]], eps: float): n = len(tokens) - def find(i: int) -> List[int]: + def find(i: int) -> list[int]: return [j for j in range(n) if j != i and _jaccard_distance(tokens[i], tokens[j]) <= eps] return find def _expand_cluster( - seed: int, neighbours, labels: List[Optional[int]], + seed: int, neighbours, labels: list[int | None], cluster_id: int, min_samples: int, ) -> None: labels[seed] = cluster_id @@ -104,9 +104,9 @@ def _expand_cluster( def _assign_labels( - tokens: List[Set[str]], eps: float, min_samples: int, -) -> List[Optional[int]]: - labels: List[Optional[int]] = [None] * len(tokens) + tokens: list[set[str]], eps: float, min_samples: int, +) -> list[int | None]: + labels: list[int | None] = [None] * len(tokens) neighbours = _neighbours_fn(tokens, eps) cluster_id = 0 for i in range(len(tokens)): @@ -122,12 +122,12 @@ def _assign_labels( def _materialize_clusters( - records: Sequence[FailureRecord], labels: List[Optional[int]], -) -> List[Cluster]: - buckets: Dict[int, List[int]] = defaultdict(list) + records: Sequence[FailureRecord], labels: list[int | None], +) -> list[Cluster]: + buckets: dict[int, list[int]] = defaultdict(list) for i, label in enumerate(labels): buckets[label if label is not None else -1].append(i) - out: List[Cluster] = [] + out: list[Cluster] = [] for label, indexes in buckets.items(): if label == -1: for i in indexes: @@ -147,7 +147,7 @@ def _materialize_clusters( def cluster( records: Sequence[FailureRecord], *, eps: float = 0.3, min_samples: int = 2, -) -> List[Cluster]: +) -> list[Cluster]: """Tiny DBSCAN. Returns one ``Cluster`` per dense group. Noise points become singleton clusters.""" if not 0 < eps <= 1: @@ -162,7 +162,7 @@ def cluster( key=lambda c: -c.size) -def cluster_summary(clusters: Iterable[Cluster]) -> List[Dict[str, Any]]: +def cluster_summary(clusters: Iterable[Cluster]) -> list[dict[str, Any]]: return [{"representative": c.representative[:120], "size": c.size, "tests": c.members[:5]} for c in clusters] diff --git a/je_web_runner/utils/failure_narrator/narrator.py b/je_web_runner/utils/failure_narrator/narrator.py index 0135ef6a..67571a71 100644 --- a/je_web_runner/utils/failure_narrator/narrator.py +++ b/je_web_runner/utils/failure_narrator/narrator.py @@ -13,7 +13,7 @@ import json from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Dict, List, Protocol, Sequence, Union +from typing import Any, Protocol, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -36,18 +36,18 @@ class FailureBundle: error_class: str = "" last_url: str = "" last_dom_excerpt: str = "" - console_errors: List[str] = field(default_factory=list) - network_errors: List[str] = field(default_factory=list) + console_errors: list[str] = field(default_factory=list) + network_errors: list[str] = field(default_factory=list) failed_assertion: str = "" git_commit: str = "" flake_history: str = "" # e.g. "flaky in 3/10 recent runs" - extra_context: List[str] = field(default_factory=list) + extra_context: list[str] = field(default_factory=list) def __post_init__(self) -> None: if not isinstance(self.test_id, str) or not self.test_id: raise FailureNarratorError("test_id must be non-empty string") -def load_bundle_dir(path: Union[str, Path]) -> FailureBundle: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up PR +def load_bundle_dir(path: str | Path) -> FailureBundle: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up PR """Read a failure-bundle directory laid out as JSON + text files.""" bundle_dir = Path(path) if not bundle_dir.exists() or not bundle_dir.is_dir(): @@ -87,7 +87,7 @@ def _read_text(path: Path, *, limit: int) -> str: return text[:limit] -def _read_lines(path: Path) -> List[str]: +def _read_lines(path: Path) -> list[str]: if not path.exists(): return [] return [line.rstrip("\n") for line in path.read_text( @@ -179,7 +179,7 @@ class NarrationReport: confidence: str raw: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) def markdown(self) -> str: diff --git a/je_web_runner/utils/failure_triage/triage.py b/je_web_runner/utils/failure_triage/triage.py index 871c66ea..74861dfa 100644 --- a/je_web_runner/utils/failure_triage/triage.py +++ b/je_web_runner/utils/failure_triage/triage.py @@ -20,7 +20,7 @@ import re from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Union +from typing import Any, Sequence from je_web_runner.utils.ai_assist.llm_assist import LLMAssistError, _invoke from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -51,21 +51,21 @@ class TriageSignals: test_name: str error_repr: str error_signature: str - last_steps: List[Any] = field(default_factory=list) - console_tail: List[Dict[str, Any]] = field(default_factory=list) - network_tail: List[Dict[str, Any]] = field(default_factory=list) + last_steps: list[Any] = field(default_factory=list) + console_tail: list[dict[str, Any]] = field(default_factory=list) + network_tail: list[dict[str, Any]] = field(default_factory=list) dom_excerpt: str = "" - metadata: Dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) has_screenshot: bool = False -def _slice_tail(items: Sequence[Any], limit: int) -> List[Any]: +def _slice_tail(items: Sequence[Any], limit: int) -> list[Any]: if not items: return [] return list(items[-limit:]) -def _read_bundle_json(files: Dict[str, bytes], rel: str) -> Any: +def _read_bundle_json(files: dict[str, bytes], rel: str) -> Any: raw = files.get(rel) if raw is None: return None @@ -75,7 +75,7 @@ def _read_bundle_json(files: Dict[str, bytes], rel: str) -> Any: return None -def _read_bundle_text(files: Dict[str, bytes], rel: str) -> str: +def _read_bundle_text(files: dict[str, bytes], rel: str) -> str: raw = files.get(rel) if raw is None: return "" @@ -86,9 +86,9 @@ def _read_bundle_text(files: Dict[str, bytes], rel: str) -> str: def extract_signals_from_bundle( - bundle_path: Union[str, Path], + bundle_path: str | Path, *, - steps: Optional[Sequence[Any]] = None, + steps: Sequence[Any] | None = None, max_steps: int = _DEFAULT_MAX_STEPS, max_console: int = _DEFAULT_MAX_CONSOLE, max_network: int = _DEFAULT_MAX_NETWORK, @@ -160,14 +160,14 @@ class TriageReport: likely_cause: str category: str - evidence: List[str] - next_steps: List[str] + evidence: list[str] + next_steps: list[str] suggested_fix: str confidence: float test_name: str = "" error_signature: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -177,7 +177,7 @@ def to_dict(self) -> Dict[str, Any]: }) -def _parse_triage_payload(text: str) -> Dict[str, Any]: +def _parse_triage_payload(text: str) -> dict[str, Any]: match = _JSON_OBJECT_RE.search(text) if match is None: raise FailureTriageError("LLM did not return a JSON object") @@ -190,7 +190,7 @@ def _parse_triage_payload(text: str) -> Dict[str, Any]: return payload -def _coerce_str_list(value: Any) -> List[str]: +def _coerce_str_list(value: Any) -> list[str]: if isinstance(value, list): return [str(item) for item in value] if isinstance(value, str): @@ -258,9 +258,9 @@ def triage_failure(signals: TriageSignals) -> TriageReport: def triage_bundle( - bundle_path: Union[str, Path], + bundle_path: str | Path, *, - steps: Optional[Sequence[Any]] = None, + steps: Sequence[Any] | None = None, ) -> TriageReport: """One-shot helper: extract signals + run triage.""" signals = extract_signals_from_bundle(bundle_path, steps=steps) @@ -302,7 +302,7 @@ def render_markdown(report: TriageReport, *, heading_level: int = 2) -> str: return "\n".join(pieces).rstrip() + "\n" -def save_report(report: TriageReport, output_path: Union[str, Path]) -> Path: +def save_report(report: TriageReport, output_path: str | Path) -> Path: """Persist a report as JSON next to its bundle for later inspection.""" path = Path(output_path) path.parent.mkdir(parents=True, exist_ok=True) diff --git a/je_web_runner/utils/fanout/fanout.py b/je_web_runner/utils/fanout/fanout.py index 5a11a50e..94e5e1b2 100644 --- a/je_web_runner/utils/fanout/fanout.py +++ b/je_web_runner/utils/fanout/fanout.py @@ -13,7 +13,7 @@ import time from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Optional, Sequence +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -28,13 +28,13 @@ class _TaskOutcome: name: str duration_seconds: float result: Any = None - error: Optional[BaseException] = None + error: BaseException | None = None @property def succeeded(self) -> bool: return self.error is None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "name": self.name, "duration_seconds": round(self.duration_seconds, 4), @@ -54,17 +54,17 @@ def _safe_repr(value: Any) -> Any: @dataclass class FanOutResult: - outcomes: List[_TaskOutcome] = field(default_factory=list) + outcomes: list[_TaskOutcome] = field(default_factory=list) @property def succeeded(self) -> bool: return all(o.succeeded for o in self.outcomes) @property - def failures(self) -> List[_TaskOutcome]: + def failures(self) -> list[_TaskOutcome]: return [o for o in self.outcomes if not o.succeeded] - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "succeeded": self.succeeded, "outcomes": [o.to_dict() for o in self.outcomes], @@ -84,8 +84,8 @@ def raise_for_failures(self) -> None: def run_fan_out( tasks: Sequence[Any], - max_workers: Optional[int] = None, - timeout: Optional[float] = None, + max_workers: int | None = None, + timeout: float | None = None, fail_fast: bool = False, ) -> FanOutResult: """ @@ -111,8 +111,8 @@ def run_fan_out( return result -def _parse_tasks(tasks: Sequence[Any]) -> List[tuple]: - parsed: List[tuple] = [] +def _parse_tasks(tasks: Sequence[Any]) -> list[tuple]: + parsed: list[tuple] = [] for index, entry in enumerate(tasks): if callable(entry): parsed.append((f"task-{index}", entry)) @@ -123,8 +123,8 @@ def _parse_tasks(tasks: Sequence[Any]) -> List[tuple]: return parsed -def _collect_results(future_to_name: Dict[Any, str], result: FanOutResult, - timeout: Optional[float], fail_fast: bool) -> None: +def _collect_results(future_to_name: dict[Any, str], result: FanOutResult, + timeout: float | None, fail_fast: bool) -> None: try: for future in as_completed(future_to_name, timeout=timeout): outcome = future.result() diff --git a/je_web_runner/utils/file_process/get_dir_file_list.py b/je_web_runner/utils/file_process/get_dir_file_list.py index d8f041bb..6622082b 100644 --- a/je_web_runner/utils/file_process/get_dir_file_list.py +++ b/je_web_runner/utils/file_process/get_dir_file_list.py @@ -1,9 +1,8 @@ from os import getcwd, walk from os.path import abspath, join -from typing import List -def get_dir_files_as_list(dir_path: str = getcwd(), default_search_file_extension: str = ".json") -> List[str]: +def get_dir_files_as_list(dir_path: str = getcwd(), default_search_file_extension: str = ".json") -> list[str]: """ 取得指定資料夾下所有符合副檔名的檔案清單 Get all files in a directory that end with the given extension diff --git a/je_web_runner/utils/file_system_access/mock.py b/je_web_runner/utils/file_system_access/mock.py index 76c2e0ee..ef8c60d5 100644 --- a/je_web_runner/utils/file_system_access/mock.py +++ b/je_web_runner/utils/file_system_access/mock.py @@ -8,7 +8,7 @@ import json from dataclasses import asdict, dataclass -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -42,7 +42,7 @@ class WriteEvent: sequence: int data: str - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -117,7 +117,7 @@ def to_dict(self) -> Dict[str, Any]: def build_install_script( open_files: Sequence[MockFile] = (), *, - save_suggested_name: Optional[str] = None, + save_suggested_name: str | None = None, ) -> str: """Render the JS shim. Inject once per page via init-script.""" files_payload = [ @@ -135,13 +135,13 @@ def build_install_script( # ---------- harvest ----------------------------------------------------- -def parse_writes(payload: Any) -> List[WriteEvent]: +def parse_writes(payload: Any) -> list[WriteEvent]: """Convert the harvested array into typed :class:`WriteEvent` records.""" if not isinstance(payload, list): raise FileSystemAccessError( f"writes payload must be list, got {type(payload).__name__}" ) - out: List[WriteEvent] = [] + out: list[WriteEvent] = [] for raw in payload: if not isinstance(raw, dict): continue @@ -172,8 +172,8 @@ def assert_no_writes(writes: Sequence[WriteEvent]) -> None: def assert_wrote( writes: Sequence[WriteEvent], *, - file_name: Optional[str] = None, - contains: Optional[str] = None, + file_name: str | None = None, + contains: str | None = None, ) -> WriteEvent: """Assert at least one write matches name and/or substring.""" if file_name is None and contains is None: diff --git a/je_web_runner/utils/file_transfer/file_helpers.py b/je_web_runner/utils/file_transfer/file_helpers.py index 857ebd2b..7fb959f8 100644 --- a/je_web_runner/utils/file_transfer/file_helpers.py +++ b/je_web_runner/utils/file_transfer/file_helpers.py @@ -7,7 +7,6 @@ import time from pathlib import Path -from typing import List, Optional from selenium.webdriver.common.by import By @@ -55,7 +54,7 @@ def playwright_upload_file(input_selector: str, file_path: str) -> None: _PARTIAL_SUFFIXES = (".crdownload", ".part") -def _is_completed_match(name: str, seen: set, suffix_lower: Optional[str]) -> bool: +def _is_completed_match(name: str, seen: set, suffix_lower: str | None) -> bool: """Per-file predicate extracted to keep ``wait_for_download`` simple.""" if name in seen: return False @@ -69,7 +68,7 @@ def _is_completed_match(name: str, seen: set, suffix_lower: Optional[str]) -> bo def wait_for_download( directory: str, timeout: float = 60.0, - suffix: Optional[str] = None, + suffix: str | None = None, poll_seconds: float = 0.5, ) -> str: """ @@ -98,7 +97,7 @@ def wait_for_download( ) -def list_new_downloads(directory: str, before: List[str]) -> List[str]: +def list_new_downloads(directory: str, before: list[str]) -> list[str]: """ 回傳 ``directory`` 內目前存在但不在 ``before`` 清單中的檔案 Diff helper: list paths currently in ``directory`` that were not in the @@ -116,7 +115,7 @@ def list_new_downloads(directory: str, before: List[str]) -> List[str]: ] -def snapshot_directory(directory: str) -> List[str]: +def snapshot_directory(directory: str) -> list[str]: """Take a snapshot of resolved file paths under ``directory``.""" base = Path(directory) if not base.is_dir(): diff --git a/je_web_runner/utils/flag_matrix/matrix.py b/je_web_runner/utils/flag_matrix/matrix.py index 84361490..e7f6737c 100644 --- a/je_web_runner/utils/flag_matrix/matrix.py +++ b/je_web_runner/utils/flag_matrix/matrix.py @@ -18,7 +18,7 @@ import json import random from dataclasses import dataclass, field -from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple +from typing import Any, Callable, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -27,7 +27,7 @@ class FlagMatrixError(WebRunnerException): """Raised on bad flag definitions, impossible constraints, or sample size.""" -Combo = Dict[str, Any] +Combo = dict[str, Any] Constraint = Callable[[Combo], bool] @@ -53,12 +53,12 @@ def __post_init__(self) -> None: class FlagMatrix: """The materialised set of combos and metadata.""" - combos: List[Combo] = field(default_factory=list) + combos: list[Combo] = field(default_factory=list) total_possible: int = 0 pinned_count: int = 0 constrained_out: int = 0 sampled: bool = False - seed: Optional[int] = None + seed: int | None = None def __len__(self) -> int: return len(self.combos) @@ -74,8 +74,8 @@ def build_matrix( # NOSONAR S3776 — cohesive logic; planned refactor in follo *, constraints: Sequence[Constraint] = (), pinned: Sequence[Combo] = (), - sample_size: Optional[int] = None, - seed: Optional[int] = None, + sample_size: int | None = None, + seed: int | None = None, ) -> FlagMatrix: """ Materialise the combo list. ``constraints`` returning False drop a @@ -96,12 +96,12 @@ def build_matrix( # NOSONAR S3776 — cohesive logic; planned refactor in follo for variants in variant_lists: total_possible *= len(variants) - all_combos: List[Combo] = [] + all_combos: list[Combo] = [] for tup in itertools.product(*variant_lists): - combo = dict(zip(names, tup)) + combo = dict(zip(names, tup, strict=False)) all_combos.append(combo) - pinned_combos: List[Combo] = [] + pinned_combos: list[Combo] = [] pinned_keys = set() for combo in pinned: _validate_pinned(combo, names, variant_lists) @@ -111,7 +111,7 @@ def build_matrix( # NOSONAR S3776 — cohesive logic; planned refactor in follo pinned_keys.add(key) pinned_combos.append(combo) - filtered: List[Combo] = [] + filtered: list[Combo] = [] constrained_out = 0 for combo in all_combos: if _combo_key(combo) in pinned_keys: @@ -129,7 +129,7 @@ def build_matrix( # NOSONAR S3776 — cohesive logic; planned refactor in follo keep_count = max(0, sample_size - len(pinned_combos)) # S2245 ok: deterministic seeded sampling for reproducible test combos; # not used for any cryptographic / security decision. - filtered = rng.sample(filtered, keep_count) # noqa: S2245 + filtered = rng.sample(filtered, keep_count) # NOSONAR S2245 — non-crypto use (chaos/sampling), not security-sensitive sampled = True else: sampled = False @@ -156,7 +156,7 @@ def _validate_pinned( raise FlagMatrixError( f"pinned combo keys {sorted(combo.keys())} != flag names {sorted(names)}" ) - for name, variants in zip(names, variant_lists): + for name, variants in zip(names, variant_lists, strict=False): if combo[name] not in variants: raise FlagMatrixError( f"pinned combo value {combo[name]!r} for flag {name!r} " @@ -182,7 +182,7 @@ def _passes_all(combo: Combo, constraints: Sequence[Constraint]) -> bool: # ---------- constraint helpers ------------------------------------------ -def forbid(pair: Tuple[Tuple[str, Any], Tuple[str, Any]]) -> Constraint: +def forbid(pair: tuple[tuple[str, Any], tuple[str, Any]]) -> Constraint: """Block combos containing both ``(flag_a, val_a)`` AND ``(flag_b, val_b)``.""" (a_flag, a_val), (b_flag, b_val) = pair @@ -191,7 +191,7 @@ def _constraint(combo: Combo) -> bool: return _constraint -def require(pair: Tuple[Tuple[str, Any], Tuple[str, Any]]) -> Constraint: +def require(pair: tuple[tuple[str, Any], tuple[str, Any]]) -> Constraint: """If ``(flag_a, val_a)`` is set, ``(flag_b, val_b)`` must also be set.""" (a_flag, a_val), (b_flag, b_val) = pair @@ -211,7 +211,7 @@ class ComboResult: combo: Combo passed: bool duration_seconds: float = 0.0 - error: Optional[str] = None + error: str | None = None @dataclass @@ -221,7 +221,7 @@ class MatrixReport: total: int passed: int failed: int - failures: List[ComboResult] = field(default_factory=list) + failures: list[ComboResult] = field(default_factory=list) average_seconds: float = 0.0 @@ -229,7 +229,7 @@ def summarise_results(results: Iterable[ComboResult]) -> MatrixReport: """Compute counts and pull out the failures.""" total = 0 passed = 0 - failures: List[ComboResult] = [] + failures: list[ComboResult] = [] total_seconds = 0.0 for result in results: if not isinstance(result, ComboResult): @@ -252,7 +252,7 @@ def summarise_results(results: Iterable[ComboResult]) -> MatrixReport: ) -def smallest_failing_subset(failures: Sequence[ComboResult]) -> List[str]: +def smallest_failing_subset(failures: Sequence[ComboResult]) -> list[str]: """ Pick out the smallest set of flags that, alone, explain every failure. Greedy minimum-set-cover on ``{flag=value}`` strings. Useful for the @@ -262,11 +262,11 @@ def smallest_failing_subset(failures: Sequence[ComboResult]) -> List[str]: if not failures: return [] universe = set(range(len(failures))) - sets: Dict[str, set] = {} + sets: dict[str, set] = {} for index, failure in enumerate(failures): for flag, value in failure.combo.items(): sets.setdefault(f"{flag}={value!r}", set()).add(index) - chosen: List[str] = [] + chosen: list[str] = [] covered: set = set() while covered != universe: best_key = None diff --git a/je_web_runner/utils/flake_detector/detector.py b/je_web_runner/utils/flake_detector/detector.py index 43a10fa6..67fbd0bc 100644 --- a/je_web_runner/utils/flake_detector/detector.py +++ b/je_web_runner/utils/flake_detector/detector.py @@ -22,10 +22,11 @@ from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger +import builtins class FlakeDetectorError(WebRunnerException): @@ -48,14 +49,14 @@ class FlakeScore: fails: int pass_rate: float flake_score: float - last_run: Optional[str] = None + last_run: str | None = None is_flaky: bool = False - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) -def _load_runs(ledger_path: Union[str, Path]) -> List[Dict[str, Any]]: +def _load_runs(ledger_path: str | Path) -> list[dict[str, Any]]: path = Path(ledger_path) if not path.exists(): return [] @@ -95,13 +96,13 @@ def _decay_weight(age_seconds: float, half_life_days: float) -> float: def compute_flake_scores( # NOSONAR S3776 — cohesive logic; planned refactor in follow-up - ledger_path: Union[str, Path], + ledger_path: str | Path, *, half_life_days: float = _DEFAULT_HALF_LIFE_DAYS, min_runs: int = _DEFAULT_MIN_RUNS, threshold: float = _DEFAULT_FLAKE_THRESHOLD, - now_epoch: Optional[float] = None, -) -> Dict[str, FlakeScore]: + now_epoch: float | None = None, +) -> dict[str, FlakeScore]: """ 從 ledger 歷史計算每個 file 的 time-decayed flake score。 Produce per-file :class:`FlakeScore` records. A test is flagged ``is_flaky`` @@ -111,7 +112,7 @@ def compute_flake_scores( # NOSONAR S3776 — cohesive logic; planned refactor """ runs = _load_runs(ledger_path) now = now_epoch if now_epoch is not None else time.time() - buckets: Dict[str, Dict[str, Any]] = {} + buckets: dict[str, dict[str, Any]] = {} for run in runs: path = run.get("path") if not isinstance(path, str): @@ -137,7 +138,7 @@ def compute_flake_scores( # NOSONAR S3776 — cohesive logic; planned refactor if existing is None or last_run > existing: record["last_run"] = last_run - out: Dict[str, FlakeScore] = {} + out: dict[str, FlakeScore] = {} for path, rec in buckets.items(): runs_n = rec["runs"] passes = rec["passes"] @@ -161,12 +162,12 @@ def compute_flake_scores( # NOSONAR S3776 — cohesive logic; planned refactor def flaky_paths( - ledger_path: Union[str, Path], + ledger_path: str | Path, *, half_life_days: float = _DEFAULT_HALF_LIFE_DAYS, min_runs: int = _DEFAULT_MIN_RUNS, threshold: float = _DEFAULT_FLAKE_THRESHOLD, -) -> List[str]: +) -> list[str]: """Return paths whose decayed flake score is at or above ``threshold``.""" scores = compute_flake_scores( ledger_path, @@ -190,9 +191,9 @@ class QuarantineEntry: flake_score: float quarantined_at: str runs_when_added: int = 0 - triage_url: Optional[str] = None + triage_url: str | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -207,9 +208,9 @@ class QuarantineRegistry: alongside the ledger so the pytest plugin can read it on every run. """ - def __init__(self, registry_path: Union[str, Path]) -> None: + def __init__(self, registry_path: str | Path) -> None: self.registry_path = Path(registry_path) - self._entries: Dict[str, QuarantineEntry] = {} + self._entries: dict[str, QuarantineEntry] = {} self._load() def _load(self) -> None: @@ -251,7 +252,7 @@ def _save(self) -> None: def is_quarantined(self, test_id: str) -> bool: return test_id in self._entries - def get(self, test_id: str) -> Optional[QuarantineEntry]: + def get(self, test_id: str) -> QuarantineEntry | None: return self._entries.get(test_id) def add(self, entry: QuarantineEntry) -> None: @@ -270,7 +271,7 @@ def remove(self, test_id: str) -> bool: web_runner_logger.info(f"quarantine remove: {test_id}") return True - def list(self) -> List[QuarantineEntry]: + def list(self) -> builtins.list[QuarantineEntry]: return sorted( self._entries.values(), key=lambda e: (-e.flake_score, e.test_id), @@ -278,14 +279,14 @@ def list(self) -> List[QuarantineEntry]: def quarantine_flaky( - ledger_path: Union[str, Path], - registry_path: Union[str, Path], + ledger_path: str | Path, + registry_path: str | Path, *, half_life_days: float = _DEFAULT_HALF_LIFE_DAYS, min_runs: int = _DEFAULT_MIN_RUNS, threshold: float = _DEFAULT_FLAKE_THRESHOLD, reason_template: str = "auto: flake_score={score:.2f} after {runs} runs", -) -> List[str]: +) -> list[str]: """ 自動把 flake score ≥ threshold 的 test 加入 quarantine registry。 Walk the ledger, score each test, and write any newly-flaky tests into @@ -299,7 +300,7 @@ def quarantine_flaky( threshold=threshold, ) registry = QuarantineRegistry(registry_path) - newly_added: List[str] = [] + newly_added: list[str] = [] for score in scores.values(): if not score.is_flaky: continue @@ -318,13 +319,13 @@ def quarantine_flaky( def release_if_stable( - ledger_path: Union[str, Path], - registry_path: Union[str, Path], + ledger_path: str | Path, + registry_path: str | Path, *, half_life_days: float = _DEFAULT_HALF_LIFE_DAYS, release_threshold: float = 0.05, min_runs_since: int = 5, -) -> List[str]: +) -> list[str]: """ 放出 flake score 已穩定下降到 ``release_threshold`` 以下的 quarantine test。 Promote stable tests out of quarantine: each entry whose current score @@ -338,7 +339,7 @@ def release_if_stable( threshold=release_threshold + 1.0, # ensure is_flaky=False is meaningful ) registry = QuarantineRegistry(registry_path) - released: List[str] = [] + released: list[str] = [] for entry in registry.list(): current = scores.get(entry.test_id) if current is None: @@ -355,7 +356,7 @@ def release_if_stable( def flaky_quarantine( test_id: str, - registry_path: Union[str, Path], + registry_path: str | Path, *, skip_when_quarantined: bool = True, ) -> Callable: @@ -383,10 +384,10 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return fn(*args, **kwargs) try: import pytest # local import keeps decorator pytest-optional - except ImportError: + except ImportError as import_error: raise FlakeDetectorError( f"test {test_id!r} is quarantined: {entry.reason}" - ) + ) from import_error pytest.skip(f"flaky-quarantine: {entry.reason}") return None return wrapper diff --git a/je_web_runner/utils/flakiness_graveyard/graveyard.py b/je_web_runner/utils/flakiness_graveyard/graveyard.py index 50cd7835..3b781427 100644 --- a/je_web_runner/utils/flakiness_graveyard/graveyard.py +++ b/je_web_runner/utils/flakiness_graveyard/graveyard.py @@ -22,7 +22,7 @@ from dataclasses import asdict, dataclass from datetime import date, datetime, timedelta from enum import Enum -from typing import Dict, Iterable, List, Optional +from typing import Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -52,7 +52,7 @@ def __post_init__(self) -> None: _parse_date(self.quarantined_at, "quarantined_at") _parse_date(self.last_flake_date, "last_flake_date") - def to_dict(self) -> Dict[str, str]: + def to_dict(self) -> dict[str, str]: return {**asdict(self), "status": self.status.value} @@ -74,8 +74,8 @@ def _today() -> date: def register_flake( - registry: List[GraveEntry], test_name: str, *, owner: str = "", - ticket_url: str = "", today: Optional[date] = None, + registry: list[GraveEntry], test_name: str, *, owner: str = "", + ticket_url: str = "", today: date | None = None, ) -> GraveEntry: """Insert / update an entry. Returns the affected entry.""" if not isinstance(registry, list): @@ -100,7 +100,7 @@ def register_flake( return new_entry -def revive(registry: List[GraveEntry], test_name: str) -> GraveEntry: +def revive(registry: list[GraveEntry], test_name: str) -> GraveEntry: for entry in registry: if entry.test_name == test_name: if entry.status == Status.BURIED: @@ -114,13 +114,13 @@ def revive(registry: List[GraveEntry], test_name: str) -> GraveEntry: def due_for_burial( registry: Iterable[GraveEntry], - *, days: int = 30, today: Optional[date] = None, -) -> List[GraveEntry]: + *, days: int = 30, today: date | None = None, +) -> list[GraveEntry]: """Quarantined tests untouched for >= ``days`` days.""" if days < 1: raise FlakinessGraveyardError("days must be >= 1") today = today or _today() - out: List[GraveEntry] = [] + out: list[GraveEntry] = [] for entry in registry: if entry.status != Status.QUARANTINED: continue @@ -130,7 +130,7 @@ def due_for_burial( return out -def bury(registry: List[GraveEntry], test_name: str) -> GraveEntry: +def bury(registry: list[GraveEntry], test_name: str) -> GraveEntry: for entry in registry: if entry.test_name == test_name: if entry.status != Status.QUARANTINED: @@ -142,18 +142,18 @@ def bury(registry: List[GraveEntry], test_name: str) -> GraveEntry: raise FlakinessGraveyardError(f"unknown test {test_name!r}") -def load(path: str) -> List[GraveEntry]: +def load(path: str) -> list[GraveEntry]: if not isinstance(path, str) or not path: raise FlakinessGraveyardError("path must be non-empty string") if not os.path.exists(path): return [] - with open(path, "r", encoding="utf-8") as fh: + with open(path, encoding="utf-8") as fh: raw = json.load(fh) if not isinstance(raw, list): raise FlakinessGraveyardError( f"registry file {path!r} must contain a JSON array" ) - out: List[GraveEntry] = [] + out: list[GraveEntry] = [] for item in raw: if not isinstance(item, dict): continue diff --git a/je_web_runner/utils/font_loading_strategy/strategy.py b/je_web_runner/utils/font_loading_strategy/strategy.py index b901cbd2..3411bd1b 100644 --- a/je_web_runner/utils/font_loading_strategy/strategy.py +++ b/je_web_runner/utils/font_loading_strategy/strategy.py @@ -23,7 +23,7 @@ import re from dataclasses import dataclass from enum import Enum -from typing import Iterable, List +from typing import Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -59,10 +59,10 @@ class FontFace: _DECL_RE = re.compile(r"([\w-]+)\s*:\s*([^;]*)(?:;|$)") # NOSONAR python:S5852 -def parse_font_faces(css: str) -> List[FontFace]: +def parse_font_faces(css: str) -> list[FontFace]: if not isinstance(css, str): raise FontLoadingStrategyError("css must be a string") - out: List[FontFace] = [] + out: list[FontFace] = [] for block_match in _FONT_FACE_RE.finditer(css): decls = dict(_DECL_RE.findall(block_match.group(1))) family = (decls.get("font-family") or "").strip().strip("'\"") diff --git a/je_web_runner/utils/forced_colors_mode/modes.py b/je_web_runner/utils/forced_colors_mode/modes.py index c34469dd..720277b5 100644 --- a/je_web_runner/utils/forced_colors_mode/modes.py +++ b/je_web_runner/utils/forced_colors_mode/modes.py @@ -22,7 +22,7 @@ from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Callable, Dict, List, Optional, Sequence +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -64,7 +64,7 @@ class MediaProfile: forced_colors: ForcedColors = ForcedColors.NONE contrast: Contrast = Contrast.NO_PREFERENCE - def to_cdp_features(self) -> List[Dict[str, str]]: + def to_cdp_features(self) -> list[dict[str, str]]: """Render the ``features`` payload for ``Emulation.setEmulatedMedia``.""" return [ {"name": "prefers-color-scheme", "value": self.color_scheme.value}, @@ -90,7 +90,7 @@ def to_cdp_features(self) -> List[Dict[str, str]]: # ---------- CDP integration -------------------------------------------- -CdpEmulate = Callable[[List[Dict[str, str]]], Any] +CdpEmulate = Callable[[list[dict[str, str]]], Any] """Callable that pushes a features list to ``Emulation.setEmulatedMedia``.""" @@ -134,7 +134,7 @@ class ElementDiff: baseline_mode: str other_mode: str became_invisible: bool - changed_fields: Dict[str, Any] = field(default_factory=dict) + changed_fields: dict[str, Any] = field(default_factory=dict) def diff_snapshot( @@ -143,11 +143,11 @@ def diff_snapshot( other_mode: str, baseline: StyleSnapshot, other: StyleSnapshot, -) -> Optional[ElementDiff]: +) -> ElementDiff | None: """Return a :class:`ElementDiff` iff the snapshots meaningfully differ.""" if not isinstance(baseline, StyleSnapshot) or not isinstance(other, StyleSnapshot): raise ForcedColorsModeError("snapshots must be StyleSnapshot instances") - changed: Dict[str, Any] = {} + changed: dict[str, Any] = {} for field_name in asdict(baseline): a = getattr(baseline, field_name) b = getattr(other, field_name) @@ -171,8 +171,8 @@ def diff_snapshot( class ModeAuditReport: """Roll-up returned by :func:`audit_modes`.""" - diffs: List[ElementDiff] = field(default_factory=list) - invisible_in_modes: Dict[str, List[str]] = field(default_factory=dict) + diffs: list[ElementDiff] = field(default_factory=list) + invisible_in_modes: dict[str, list[str]] = field(default_factory=dict) def passed(self) -> bool: return not self.invisible_in_modes @@ -180,7 +180,7 @@ def passed(self) -> bool: def audit_modes( baseline_mode: str, - snapshots_by_mode: Dict[str, Dict[str, StyleSnapshot]], + snapshots_by_mode: dict[str, dict[str, StyleSnapshot]], ) -> ModeAuditReport: """ Given per-mode { selector → StyleSnapshot }, diff every non-baseline diff --git a/je_web_runner/utils/form_autofill/autofill.py b/je_web_runner/utils/form_autofill/autofill.py index 11b6416c..357d0cb5 100644 --- a/je_web_runner/utils/form_autofill/autofill.py +++ b/je_web_runner/utils/form_autofill/autofill.py @@ -16,7 +16,7 @@ import re from dataclasses import dataclass -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -27,7 +27,7 @@ class FormAutoFillError(WebRunnerException): @dataclass class FieldMatch: - field: Dict[str, Any] + field: dict[str, Any] fixture_key: str value: Any confidence: float @@ -37,7 +37,7 @@ class FieldMatch: _NORMALISE_RE = re.compile(r"[^a-z0-9]+") -_ALIAS_BUCKETS: Dict[str, List[str]] = { +_ALIAS_BUCKETS: dict[str, list[str]] = { "email": ["email", "e-mail", "emailaddress", "useremail"], "username": ["username", "user", "userid", "login", "account"], "password": ["password", "pass", "passwd", "pwd"], @@ -55,7 +55,7 @@ class FieldMatch: } -_CANONICAL_BY_TOKEN: Dict[str, str] = {} +_CANONICAL_BY_TOKEN: dict[str, str] = {} for canonical, aliases in _ALIAS_BUCKETS.items(): for alias in aliases: _CANONICAL_BY_TOKEN[alias] = canonical @@ -67,7 +67,7 @@ def _normalise(text: Any) -> str: return _NORMALISE_RE.sub("", text.lower()) -def classify_field(field: Dict[str, Any]) -> Optional[str]: +def classify_field(field: dict[str, Any]) -> str | None: """ 依 ``data-testid`` > ``id`` > ``name`` > ``placeholder`` > ``label`` > ``type`` Pick the first matching alias group; return the canonical key or None. @@ -102,13 +102,13 @@ def classify_field(field: Dict[str, Any]) -> Optional[str]: def match_fields( - fields: Iterable[Dict[str, Any]], - fixture: Dict[str, Any], -) -> List[FieldMatch]: + fields: Iterable[dict[str, Any]], + fixture: dict[str, Any], +) -> list[FieldMatch]: """Return a :class:`FieldMatch` for every field that maps to a fixture key.""" if not isinstance(fixture, dict): raise FormAutoFillError("fixture must be a dict") - matches: List[FieldMatch] = [] + matches: list[FieldMatch] = [] for field in fields: canonical = classify_field(field) if canonical is None: @@ -128,8 +128,8 @@ def match_fields( return matches -def _pick_fixture_value(field: Dict[str, Any], canonical: str, - fixture: Dict[str, Any]): +def _pick_fixture_value(field: dict[str, Any], canonical: str, + fixture: dict[str, Any]): raw_id = str(field.get("id") or field.get("name") or "").lower() if raw_id and raw_id in fixture: return raw_id, fixture[raw_id], "exact id/name match", 1.0 @@ -143,17 +143,17 @@ def _pick_fixture_value(field: Dict[str, Any], canonical: str, def plan_fill_actions( - fields: Iterable[Dict[str, Any]], - fixture: Dict[str, Any], - submit_locator: Optional[Dict[str, str]] = None, -) -> List[List[Any]]: + fields: Iterable[dict[str, Any]], + fixture: dict[str, Any], + submit_locator: dict[str, str] | None = None, +) -> list[list[Any]]: """ 把比對結果展開成 ``WR_save_test_object`` + ``WR_element_input`` 序列 Convert matches into an executable action list. ``submit_locator`` optional ``{strategy, value}`` adds a final click. """ matches = match_fields(fields, fixture) - actions: List[List[Any]] = [] + actions: list[list[Any]] = [] for match in matches: strategy, value = _locator_for(match.field) if strategy is None: @@ -176,7 +176,7 @@ def plan_fill_actions( return actions -def _locator_for(field: Dict[str, Any]): +def _locator_for(field: dict[str, Any]): if field.get("id"): return "ID", field["id"] if field.get("data-testid"): diff --git a/je_web_runner/utils/generate_report/generate_allure_report.py b/je_web_runner/utils/generate_report/generate_allure_report.py index b288cb67..0b0b443e 100644 --- a/je_web_runner/utils/generate_report/generate_allure_report.py +++ b/je_web_runner/utils/generate_report/generate_allure_report.py @@ -17,7 +17,7 @@ import uuid from pathlib import Path from threading import Lock -from typing import Any, Dict, List +from typing import Any from je_web_runner.utils.exception.exception_tags import cant_generate_json_report from je_web_runner.utils.exception.exceptions import WebRunnerGenerateJsonReportException @@ -45,7 +45,7 @@ def _parse_record_time(value: str) -> int: return int(timestamp * 1000 + micro_seconds // 1000) -def _step_from_record(record: Dict[str, Any]) -> Dict[str, Any]: +def _step_from_record(record: dict[str, Any]) -> dict[str, Any]: failed = record.get("program_exception", _NO_EXCEPTION_MARKER) != _NO_EXCEPTION_MARKER start = _parse_record_time(record.get("time", "")) return { @@ -63,7 +63,7 @@ def _step_from_record(record: Dict[str, Any]) -> Dict[str, Any]: } -def generate_allure() -> List[Dict[str, Any]]: +def generate_allure() -> list[dict[str, Any]]: """ 將目前的 record list 包成單一 Allure test case Wrap the current record list into a single Allure test case (with one @@ -95,7 +95,7 @@ def generate_allure() -> List[Dict[str, Any]]: return [case] -def generate_allure_report(output_dir: str = "allure-results") -> List[str]: +def generate_allure_report(output_dir: str = "allure-results") -> list[str]: """ 把 test cases 寫成 ``-result.json`` Write the generated test cases as ``-result.json`` files. @@ -107,7 +107,7 @@ def generate_allure_report(output_dir: str = "allure-results") -> List[str]: cases = generate_allure() target_dir = Path(output_dir) target_dir.mkdir(parents=True, exist_ok=True) - written: List[str] = [] + written: list[str] = [] for case in cases: out_path = target_dir / f"{case['uuid']}-result.json" try: diff --git a/je_web_runner/utils/generate_report/generate_html_report.py b/je_web_runner/utils/generate_report/generate_html_report.py index 1c4d052c..06602d3d 100644 --- a/je_web_runner/utils/generate_report/generate_html_report.py +++ b/je_web_runner/utils/generate_report/generate_html_report.py @@ -1,5 +1,4 @@ import html -import sys from threading import Lock from je_web_runner.utils.exception.exception_tags import html_generate_no_data_tag @@ -151,16 +150,15 @@ def generate_html() -> str: # 若沒有測試紀錄,拋出例外 # Raise exception if no test records raise WebRunnerHTMLException(html_generate_no_data_tag) - else: - event_str = "" - for record_data in test_record_instance.test_record_list: - # 根據是否有 exception 決定表格樣式 - # Choose table style based on exception presence - if record_data.get("program_exception") == "None": - event_str = make_html_table(event_str, record_data, "event_table_head") - else: - event_str = make_html_table(event_str, record_data, "failure_table_head") - new_html_string = _html_string.format(event_table=event_str) + event_str = "" + for record_data in test_record_instance.test_record_list: + # 根據是否有 exception 決定表格樣式 + # Choose table style based on exception presence + if record_data.get("program_exception") == "None": + event_str = make_html_table(event_str, record_data, "event_table_head") + else: + event_str = make_html_table(event_str, record_data, "failure_table_head") + new_html_string = _html_string.format(event_table=event_str) return new_html_string @@ -174,10 +172,8 @@ def generate_html_report(html_name: str = "default_name"): web_runner_logger.info(f"generate_html_report, html_name: {html_name}") new_html_string = generate_html() try: - _lock.acquire() # 確保多執行緒安全 / ensure thread safety - with open(html_name + ".html", "w+") as file_to_write: + # ``_lock`` serialises concurrent writers; ``with`` guarantees release. + with _lock, open(html_name + ".html", "w", encoding="utf-8") as file_to_write: # NOSONAR S8707 — developer-supplied path (own report/config file), not untrusted input file_to_write.write(new_html_string) - except Exception as error: - print(repr(error), file=sys.stderr) - finally: - _lock.release() \ No newline at end of file + except OSError as error: + web_runner_logger.error(f"generate_html_report write failed: {error!r}") \ No newline at end of file diff --git a/je_web_runner/utils/generate_report/generate_json_report.py b/je_web_runner/utils/generate_report/generate_json_report.py index b7f86eea..a8fcf80c 100644 --- a/je_web_runner/utils/generate_report/generate_json_report.py +++ b/je_web_runner/utils/generate_report/generate_json_report.py @@ -24,48 +24,47 @@ def generate_json(): # Raise exception if no test or error records exist if len(test_record_instance.test_record_list) == 0: raise WebRunnerGenerateJsonReportException(cant_generate_json_report) - else: - success_dict = {} - failure_dict = {} + success_dict = {} + failure_dict = {} - # 計數器與前綴字串 - # Counters and prefix strings - failure_count: int = 1 - failure_test_str: str = "Failure_Test" - success_count: int = 1 - success_test_str: str = "Success_Test" + # 計數器與前綴字串 + # Counters and prefix strings + failure_count: int = 1 + failure_test_str: str = "Failure_Test" + success_count: int = 1 + success_test_str: str = "Success_Test" - # 遍歷測試紀錄 - # Iterate through test records - for record_data in test_record_instance.test_record_list: - if record_data.get("program_exception", "None") == "None": - # 成功紀錄 - # Success record - success_dict.update( - { - success_test_str + str(success_count): { - "function_name": str(record_data.get("function_name")), - "param": str(record_data.get("local_param")), - "time": str(record_data.get("time")), - "exception": str(record_data.get("program_exception")), - } + # 遍歷測試紀錄 + # Iterate through test records + for record_data in test_record_instance.test_record_list: + if record_data.get("program_exception", "None") == "None": + # 成功紀錄 + # Success record + success_dict.update( + { + success_test_str + str(success_count): { + "function_name": str(record_data.get("function_name")), + "param": str(record_data.get("local_param")), + "time": str(record_data.get("time")), + "exception": str(record_data.get("program_exception")), } - ) - success_count += 1 - else: - # 失敗紀錄 - # Failure record - failure_dict.update( - { - failure_test_str + str(failure_count): { - "function_name": str(record_data.get("function_name")), - "param": str(record_data.get("local_param")), - "time": str(record_data.get("time")), - "exception": str(record_data.get("program_exception")), - } + } + ) + success_count += 1 + else: + # 失敗紀錄 + # Failure record + failure_dict.update( + { + failure_test_str + str(failure_count): { + "function_name": str(record_data.get("function_name")), + "param": str(record_data.get("local_param")), + "time": str(record_data.get("time")), + "exception": str(record_data.get("program_exception")), } - ) - failure_count += 1 + } + ) + failure_count += 1 return success_dict, failure_dict @@ -86,21 +85,15 @@ def generate_json_report(json_file_name: str = "default_name"): # 輸出成功紀錄 # Write success records try: - _lock.acquire() - with open(json_file_name + "_success.json", "w+") as file_to_write: + with _lock, open(json_file_name + "_success.json", "w", encoding="utf-8") as file_to_write: # NOSONAR S8707 — developer-supplied path (own report/config file), not untrusted input json.dump(dict(success_dict), file_to_write, indent=4) - except Exception as error: - web_runner_logger.error(f"generate_json_report, json_file_name: {json_file_name}, failed: {repr(error)}") - finally: - _lock.release() + except (OSError, TypeError, ValueError) as error: + web_runner_logger.error(f"generate_json_report, json_file_name: {json_file_name}, failed: {error!r}") # 輸出失敗紀錄 # Write failure records try: - _lock.acquire() - with open(json_file_name + "_failure.json", "w+") as file_to_write: + with _lock, open(json_file_name + "_failure.json", "w", encoding="utf-8") as file_to_write: # NOSONAR S8707 — developer-supplied path (own report/config file), not untrusted input json.dump(dict(failure_dict), file_to_write, indent=4) - except Exception as error: - web_runner_logger.error(f"generate_json_report, json_file_name: {json_file_name}, failed: {repr(error)}") - finally: - _lock.release() \ No newline at end of file + except (OSError, TypeError, ValueError) as error: + web_runner_logger.error(f"generate_json_report, json_file_name: {json_file_name}, failed: {error!r}") \ No newline at end of file diff --git a/je_web_runner/utils/generate_report/generate_junit_xml_report.py b/je_web_runner/utils/generate_report/generate_junit_xml_report.py index af4139fd..a5c06aca 100644 --- a/je_web_runner/utils/generate_report/generate_junit_xml_report.py +++ b/je_web_runner/utils/generate_report/generate_junit_xml_report.py @@ -93,13 +93,10 @@ def generate_junit_xml_report(junit_file_name: str = "default_name") -> None: junit_xml = generate_junit_xml() target = junit_file_name + "_junit.xml" try: - _lock.acquire() - with open(target, "w+", encoding="utf-8") as file_to_write: + with _lock, open(target, "w", encoding="utf-8") as file_to_write: # NOSONAR S8707 — developer-supplied path (own report/config file), not untrusted input file_to_write.write('\n') file_to_write.write(junit_xml) except OSError as error: web_runner_logger.error( - f"generate_junit_xml_report, junit_file_name: {junit_file_name}, failed: {repr(error)}" + f"generate_junit_xml_report, junit_file_name: {junit_file_name}, failed: {error!r}" ) - finally: - _lock.release() diff --git a/je_web_runner/utils/generate_report/generate_xml_report.py b/je_web_runner/utils/generate_report/generate_xml_report.py index 13546ba1..32f0d68f 100644 --- a/je_web_runner/utils/generate_report/generate_xml_report.py +++ b/je_web_runner/utils/generate_report/generate_xml_report.py @@ -59,21 +59,15 @@ def generate_xml_report(xml_file_name: str = "default_name"): # 輸出失敗報告 # Write failure report try: - _lock.acquire() - with open(xml_file_name + "_failure.xml", "w+") as file_to_write: + with _lock, open(xml_file_name + "_failure.xml", "w", encoding="utf-8") as file_to_write: # NOSONAR S8707 — developer-supplied path (own report/config file), not untrusted input file_to_write.write(failure_xml) - except Exception as error: - web_runner_logger.error(f"generate_xml_report, xml_file_name: {xml_file_name}, failed: {repr(error)}") - finally: - _lock.release() + except OSError as error: + web_runner_logger.error(f"generate_xml_report, xml_file_name: {xml_file_name}, failed: {error!r}") # 輸出成功報告 # Write success report try: - _lock.acquire() - with open(xml_file_name + "_success.xml", "w+") as file_to_write: + with _lock, open(xml_file_name + "_success.xml", "w", encoding="utf-8") as file_to_write: # NOSONAR S8707 — developer-supplied path (own report/config file), not untrusted input file_to_write.write(success_xml) - except Exception as error: - web_runner_logger.error(f"generate_xml_report, xml_file_name: {xml_file_name}, failed: {repr(error)}") - finally: - _lock.release() \ No newline at end of file + except OSError as error: + web_runner_logger.error(f"generate_xml_report, xml_file_name: {xml_file_name}, failed: {error!r}") \ No newline at end of file diff --git a/je_web_runner/utils/generate_report/report_manifest.py b/je_web_runner/utils/generate_report/report_manifest.py index 5440c1ac..1ea77ee8 100644 --- a/je_web_runner/utils/generate_report/report_manifest.py +++ b/je_web_runner/utils/generate_report/report_manifest.py @@ -18,7 +18,7 @@ import json from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.generate_report.generate_allure_report import generate_allure_report @@ -33,13 +33,13 @@ class ReportManifestError(WebRunnerException): """Raised when manifest generation cannot proceed.""" -def expected_paths(base_name: str, allure_dir: Optional[str] = None) -> Dict[str, List[str]]: +def expected_paths(base_name: str, allure_dir: str | None = None) -> dict[str, list[str]]: """ 回傳每個格式預期寫出的路徑(實際是否存在由 manifest 確認) Return the paths every generator is expected to produce. Whether each file actually got written is confirmed in :func:`generate_all_reports`. """ - paths: Dict[str, List[str]] = { + paths: dict[str, list[str]] = { "json": [f"{base_name}_success.json", f"{base_name}_failure.json"], "xml": [f"{base_name}_success.xml", f"{base_name}_failure.xml"], "html": [f"{base_name}.html"], @@ -50,15 +50,15 @@ def expected_paths(base_name: str, allure_dir: Optional[str] = None) -> Dict[str return paths -def _existing(paths: List[str]) -> List[str]: +def _existing(paths: list[str]) -> list[str]: return [path for path in paths if Path(path).exists()] def generate_all_reports( base_name: str, - allure_dir: Optional[str] = None, + allure_dir: str | None = None, write_manifest: bool = True, -) -> Dict[str, Any]: +) -> dict[str, Any]: """ 依預設慣例產出所有報告;回傳 ``{format: [paths produced]}`` 與 manifest 路徑 Run every report generator under a single base name and return a dict of @@ -75,7 +75,7 @@ def generate_all_reports( """ web_runner_logger.info(f"generate_all_reports base={base_name}") plan = expected_paths(base_name, allure_dir=allure_dir) - errors: Dict[str, str] = {} + errors: dict[str, str] = {} # Each generator may raise when there are no records; track but continue. for label, run in ( @@ -86,18 +86,18 @@ def generate_all_reports( ): try: run() - except Exception as error: # noqa: BLE001 — collect all generator errors + except Exception as error: errors[label] = repr(error) web_runner_logger.warning(f"generate_all_reports[{label}] failed: {error!r}") - allure_paths: List[str] = [] + allure_paths: list[str] = [] if allure_dir: try: allure_paths = generate_allure_report(allure_dir) or [] - except Exception as error: # noqa: BLE001 + except Exception as error: errors["allure"] = repr(error) - produced: Dict[str, List[str]] = { + produced: dict[str, list[str]] = { "json": _existing(plan["json"]), "xml": _existing(plan["xml"]), "html": _existing(plan["html"]), @@ -106,7 +106,7 @@ def generate_all_reports( if allure_dir: produced["allure"] = allure_paths or _existing(plan.get("allure", [])) - manifest_path: Optional[str] = None + manifest_path: str | None = None if write_manifest: manifest_path = f"{base_name}.manifest.json" try: diff --git a/je_web_runner/utils/geo_locale/geo_locale.py b/je_web_runner/utils/geo_locale/geo_locale.py index 7c7bfcb4..867a5879 100644 --- a/je_web_runner/utils/geo_locale/geo_locale.py +++ b/je_web_runner/utils/geo_locale/geo_locale.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -19,11 +19,11 @@ class GeoLocaleError(WebRunnerException): @dataclass(frozen=True) class GeoOverride: - latitude: Optional[float] = None - longitude: Optional[float] = None + latitude: float | None = None + longitude: float | None = None accuracy_meters: float = 50.0 - timezone: Optional[str] = None - locale: Optional[str] = None + timezone: str | None = None + locale: str | None = None def validate(self) -> None: if (self.latitude is None) ^ (self.longitude is None): @@ -40,10 +40,10 @@ def validate(self) -> None: raise GeoLocaleError(f"locale must be like 'en-US': {self.locale!r}") -def cdp_payloads(override: GeoOverride) -> List[Dict[str, Any]]: +def cdp_payloads(override: GeoOverride) -> list[dict[str, Any]]: """Return a list of ``(method, params)`` CDP commands.""" override.validate() - payloads: List[Dict[str, Any]] = [] + payloads: list[dict[str, Any]] = [] if override.latitude is not None and override.longitude is not None: payloads.append({ "method": "Emulation.setGeolocationOverride", @@ -66,10 +66,10 @@ def cdp_payloads(override: GeoOverride) -> List[Dict[str, Any]]: return payloads -def playwright_context_kwargs(override: GeoOverride) -> Dict[str, Any]: +def playwright_context_kwargs(override: GeoOverride) -> dict[str, Any]: """Return ``new_context`` kwargs for Playwright.""" override.validate() - kwargs: Dict[str, Any] = {} + kwargs: dict[str, Any] = {} if override.latitude is not None and override.longitude is not None: kwargs["geolocation"] = { "latitude": override.latitude, @@ -84,7 +84,7 @@ def playwright_context_kwargs(override: GeoOverride) -> Dict[str, Any]: return kwargs -def apply_overrides(driver: Any, override: GeoOverride) -> List[str]: +def apply_overrides(driver: Any, override: GeoOverride) -> list[str]: """ 對 Selenium driver 透過 ``execute_cdp_cmd`` 套用所有 override Issue every CDP command from :func:`cdp_payloads`. Returns the list of @@ -92,7 +92,7 @@ def apply_overrides(driver: Any, override: GeoOverride) -> List[str]: """ if not hasattr(driver, "execute_cdp_cmd"): raise GeoLocaleError("driver does not expose execute_cdp_cmd") - methods: List[str] = [] + methods: list[str] = [] for command in cdp_payloads(override): driver.execute_cdp_cmd(command["method"], command["params"]) methods.append(command["method"]) diff --git a/je_web_runner/utils/git_bisect_flake/bisect.py b/je_web_runner/utils/git_bisect_flake/bisect.py index dee5c83a..d5f8de05 100644 --- a/je_web_runner/utils/git_bisect_flake/bisect.py +++ b/je_web_runner/utils/git_bisect_flake/bisect.py @@ -18,7 +18,7 @@ import json from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Sequence, Union +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -46,7 +46,7 @@ def __post_init__(self) -> None: raise GitBisectFlakeError("LedgerEntry.test_id must be non-empty string") -def load_ledger(path: Union[str, Path]) -> List[LedgerEntry]: +def load_ledger(path: str | Path) -> list[LedgerEntry]: """Read the standard ledger JSON. Schema: ``{"runs": [{commit, path/test_id, passed}]}``.""" p = Path(path) if not p.exists(): @@ -60,7 +60,7 @@ def load_ledger(path: Union[str, Path]) -> List[LedgerEntry]: runs = data["runs"] if not isinstance(runs, list): raise GitBisectFlakeError("ledger 'runs' must be a list") - entries: List[LedgerEntry] = [] + entries: list[LedgerEntry] = [] for raw in runs: if not isinstance(raw, dict): continue @@ -84,13 +84,13 @@ class BisectResult: """Outcome of either bisect mode.""" test_id: str - last_good_commit: Optional[str] - first_bad_commit: Optional[str] + last_good_commit: str | None + first_bad_commit: str | None probes: int = 0 method: str = "ledger" # 'ledger' | 'probe' - history: List[Dict[str, Any]] = field(default_factory=list) + history: list[dict[str, Any]] = field(default_factory=list) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -110,16 +110,16 @@ def bisect_from_ledger( raise GitBisectFlakeError("commit_order must be a non-empty sequence") if not test_id: raise GitBisectFlakeError("test_id must be a non-empty string") - by_commit: Dict[str, LedgerEntry] = {} + by_commit: dict[str, LedgerEntry] = {} for entry in entries: if entry.test_id != test_id: continue by_commit[entry.commit] = entry if not by_commit: raise GitBisectFlakeError(f"no ledger rows for test_id {test_id!r}") - last_good: Optional[str] = None - first_bad: Optional[str] = None - history: List[Dict[str, Any]] = [] + last_good: str | None = None + first_bad: str | None = None + history: list[dict[str, Any]] = [] for commit in commit_order: entry = by_commit.get(commit) if entry is None: @@ -154,8 +154,8 @@ def bisect_with_probe( test_id: str, probe: CommitProbe, *, - known_good: Optional[str] = None, - known_bad: Optional[str] = None, + known_good: str | None = None, + known_bad: str | None = None, ) -> BisectResult: """ Classic bisect using ``probe``. ``known_good`` / ``known_bad`` clamp @@ -181,7 +181,7 @@ def bisect_with_probe( raise GitBisectFlakeError("known_good must come before known_bad in commit_order") probes = 0 - history: List[Dict[str, Any]] = [] + history: list[dict[str, Any]] = [] while high - low > 1: mid = (low + high) // 2 commit = commit_order[mid] diff --git a/je_web_runner/utils/graphql/client.py b/je_web_runner/utils/graphql/client.py index 732d70be..2b0203f9 100644 --- a/je_web_runner/utils/graphql/client.py +++ b/je_web_runner/utils/graphql/client.py @@ -13,7 +13,7 @@ import ssl import urllib.request from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -39,7 +39,7 @@ class GraphQLError(WebRunnerException): @dataclass class GraphQLClient: endpoint: str - headers: Optional[Dict[str, str]] = None + headers: dict[str, str] | None = None timeout: float = 10.0 def __post_init__(self) -> None: @@ -53,9 +53,9 @@ def __post_init__(self) -> None: def execute( self, query: str, - variables: Optional[Dict[str, Any]] = None, - operation_name: Optional[str] = None, - ) -> Dict[str, Any]: + variables: dict[str, Any] | None = None, + operation_name: str | None = None, + ) -> dict[str, Any]: request = self._build_request(query, variables, operation_name) payload = self._send(request) web_runner_logger.info( @@ -68,8 +68,8 @@ def execute( def _build_request( self, query: str, - variables: Optional[Dict[str, Any]], - operation_name: Optional[str], + variables: dict[str, Any] | None, + operation_name: str | None, ) -> urllib.request.Request: body = json.dumps( {"query": query, "variables": variables or {}, "operationName": operation_name}, @@ -82,7 +82,7 @@ def _build_request( request.add_header(name, value) return request - def _send(self, request: urllib.request.Request) -> Dict[str, Any]: + def _send(self, request: urllib.request.Request) -> dict[str, Any]: ssl_context = ssl.create_default_context() # NOSONAR — Py3.10+ default enforces TLS 1.2+ try: with urllib.request.urlopen( # nosec B310 — scheme already validated @@ -92,11 +92,11 @@ def _send(self, request: urllib.request.Request) -> Dict[str, Any]: except (OSError, ValueError) as error: raise GraphQLError(f"GraphQL transport failed: {error!r}") from error - def introspect(self) -> Dict[str, Any]: + def introspect(self) -> dict[str, Any]: return self.execute(_INTROSPECTION_QUERY) -def extract_field(payload: Dict[str, Any], path: str) -> Any: +def extract_field(payload: dict[str, Any], path: str) -> Any: """ 用 ``a.b.c[0].d`` 形式的路徑從 GraphQL 回應中取值 Pluck a value out of ``payload['data']`` using a dotted path with optional @@ -139,7 +139,7 @@ def _get_index(cursor: Any, index: int, path: str) -> Any: return cursor[index] -def introspect_types(payload: Dict[str, Any]) -> List[str]: +def introspect_types(payload: dict[str, Any]) -> list[str]: """Return the list of type names from an introspection payload.""" schema = payload.get("data", {}).get("__schema", {}) return [t.get("name") for t in schema.get("types", []) if t.get("name")] diff --git a/je_web_runner/utils/graphql_n_plus_1/detect.py b/je_web_runner/utils/graphql_n_plus_1/detect.py index d85d7f5a..c2b641fc 100644 --- a/je_web_runner/utils/graphql_n_plus_1/detect.py +++ b/je_web_runner/utils/graphql_n_plus_1/detect.py @@ -16,7 +16,7 @@ from collections import Counter, defaultdict from dataclasses import asdict, dataclass from enum import Enum -from typing import Any, Dict, Iterable, List, Sequence +from typing import Any, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -57,14 +57,14 @@ class Finding: template: str note: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "severity": self.severity.value} -def parse_rows(payload: Any) -> List[QueryRow]: +def parse_rows(payload: Any) -> list[QueryRow]: if not isinstance(payload, list): raise GraphqlNPlus1Error("payload must be a list of dicts") - out: List[QueryRow] = [] + out: list[QueryRow] = [] for raw in payload: if not isinstance(raw, dict): continue @@ -77,14 +77,14 @@ def parse_rows(payload: Any) -> List[QueryRow]: return out -def detect(rows: Sequence[QueryRow], threshold: int = 5) -> List[Finding]: +def detect(rows: Sequence[QueryRow], threshold: int = 5) -> list[Finding]: """Find SQL templates repeated >= ``threshold`` times under one field.""" if threshold < 2: raise GraphqlNPlus1Error("threshold must be >= 2") - per_field: Dict[str, Counter] = defaultdict(Counter) + per_field: dict[str, Counter] = defaultdict(Counter) for row in rows: per_field[row.parent_field][row.sql_template] += 1 - findings: List[Finding] = [] + findings: list[Finding] = [] for field_name, counter in per_field.items(): for template, count in counter.items(): if count >= threshold: @@ -102,12 +102,12 @@ def detect(rows: Sequence[QueryRow], threshold: int = 5) -> List[Finding]: return findings -def detect_cartesian(rows: Sequence[QueryRow]) -> List[Finding]: +def detect_cartesian(rows: Sequence[QueryRow]) -> list[Finding]: """Flag fields whose total queries > parent_field's queries * 10.""" per_field: Counter = Counter() for row in rows: per_field[row.parent_field] += 1 - findings: List[Finding] = [] + findings: list[Finding] = [] if not per_field: return findings parent_count = min(per_field.values()) diff --git a/je_web_runner/utils/grpc_streaming_assert/assertions.py b/je_web_runner/utils/grpc_streaming_assert/assertions.py index c47a12d8..83964873 100644 --- a/je_web_runner/utils/grpc_streaming_assert/assertions.py +++ b/je_web_runner/utils/grpc_streaming_assert/assertions.py @@ -16,7 +16,7 @@ from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -45,7 +45,7 @@ class StatusCode(str, Enum): @dataclass class StreamFrame: payload_size: int = 0 - body: Dict[str, Any] = field(default_factory=dict) + body: dict[str, Any] = field(default_factory=dict) ts_ms: float = 0 direction: str = "in" # "in" (server → client) | "out" @@ -54,20 +54,20 @@ class StreamFrame: class StreamRecord: method: str mode: Mode - frames: List[StreamFrame] = field(default_factory=list) + frames: list[StreamFrame] = field(default_factory=list) status: StatusCode = StatusCode.OK - half_closed_ts_ms: Optional[float] = None + half_closed_ts_ms: float | None = None duration_ms: float = 0 @property - def inbound(self) -> List[StreamFrame]: + def inbound(self) -> list[StreamFrame]: return [f for f in self.frames if f.direction == "in"] @property - def outbound(self) -> List[StreamFrame]: + def outbound(self) -> list[StreamFrame]: return [f for f in self.frames if f.direction == "out"] - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { **asdict(self), "mode": self.mode.value, diff --git a/je_web_runner/utils/grpc_tester/client.py b/je_web_runner/utils/grpc_tester/client.py index dde3302b..fde0760f 100644 --- a/je_web_runner/utils/grpc_tester/client.py +++ b/je_web_runner/utils/grpc_tester/client.py @@ -18,7 +18,7 @@ import time from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -52,10 +52,10 @@ class GrpcCall: response: Any status: GrpcStatus duration_ms: float - metadata: Dict[str, str] = field(default_factory=dict) - error: Optional[str] = None + metadata: dict[str, str] = field(default_factory=dict) + error: str | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "status": self.status.name} @@ -65,7 +65,7 @@ class GrpcCallRecorder: """In-memory recorder of calls.""" def __init__(self) -> None: - self._calls: List[GrpcCall] = [] + self._calls: list[GrpcCall] = [] def __len__(self) -> int: return len(self._calls) @@ -83,10 +83,10 @@ def clear(self) -> None: def calls( self, *, - method: Optional[str] = None, - status: Optional[GrpcStatus] = None, - ) -> List[GrpcCall]: - out: List[GrpcCall] = [] + method: str | None = None, + status: GrpcStatus | None = None, + ) -> list[GrpcCall]: + out: list[GrpcCall] = [] for c in self._calls: if method is not None and c.method != method: continue @@ -103,9 +103,9 @@ def call( stub_method: Callable[..., Any], request: Any, *, - recorder: Optional[GrpcCallRecorder] = None, - metadata: Optional[Sequence[Tuple[str, str]]] = None, - timeout: Optional[float] = None, + recorder: GrpcCallRecorder | None = None, + metadata: Sequence[tuple[str, str]] | None = None, + timeout: float | None = None, ) -> GrpcCall: """ Call a generated gRPC stub method, capturing response / status. @@ -119,9 +119,9 @@ def call( started = time.monotonic() status = GrpcStatus.OK response = None - error: Optional[str] = None + error: str | None = None try: - kwargs: Dict[str, Any] = {} + kwargs: dict[str, Any] = {} if metadata: kwargs["metadata"] = metadata if timeout is not None: @@ -174,11 +174,11 @@ def encode_grpc_web_message(payload: bytes) -> bytes: return b"\x00" + struct.pack(">I", len(payload)) + bytes(payload) -def decode_grpc_web_message(framed: bytes) -> List[Tuple[int, bytes]]: +def decode_grpc_web_message(framed: bytes) -> list[tuple[int, bytes]]: """Decode a (possibly multi-message) framed gRPC-Web body.""" if not isinstance(framed, (bytes, bytearray)): raise GrpcTesterError("framed must be bytes") - out: List[Tuple[int, bytes]] = [] + out: list[tuple[int, bytes]] = [] pos = 0 buf = bytes(framed) while pos < len(buf): @@ -194,12 +194,12 @@ def decode_grpc_web_message(framed: bytes) -> List[Tuple[int, bytes]]: return out -def parse_trailer(trailer_bytes: bytes) -> Dict[str, str]: +def parse_trailer(trailer_bytes: bytes) -> dict[str, str]: """Parse a ``grpc-status`` / ``grpc-message`` trailer payload.""" if not isinstance(trailer_bytes, (bytes, bytearray)): raise GrpcTesterError("trailer_bytes must be bytes") text = bytes(trailer_bytes).decode("utf-8", errors="replace") - out: Dict[str, str] = {} + out: dict[str, str] = {} for line in text.split("\r\n"): line = line.strip() if not line or ":" not in line: diff --git a/je_web_runner/utils/hallucination_probe/probe.py b/je_web_runner/utils/hallucination_probe/probe.py index f9069c15..52f70d84 100644 --- a/je_web_runner/utils/hallucination_probe/probe.py +++ b/je_web_runner/utils/hallucination_probe/probe.py @@ -16,7 +16,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field -from typing import Any, Callable, Dict, List, Sequence +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -29,8 +29,8 @@ class HallucinationProbeError(WebRunnerException): class Probe: name: str prompt: str - expected_substrings: List[str] = field(default_factory=list) - forbidden_substrings: List[str] = field(default_factory=list) + expected_substrings: list[str] = field(default_factory=list) + forbidden_substrings: list[str] = field(default_factory=list) expect_refusal: bool = False # model should say "I don't know" def __post_init__(self) -> None: @@ -64,13 +64,13 @@ class ProbeResult: passed: bool reason: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @dataclass class ProbeReport: - results: List[ProbeResult] = field(default_factory=list) + results: list[ProbeResult] = field(default_factory=list) @property def hallucination_rate(self) -> float: diff --git a/je_web_runner/utils/har_diff/har_diff.py b/je_web_runner/utils/har_diff/har_diff.py index eb689f01..860f2935 100644 --- a/je_web_runner/utils/har_diff/har_diff.py +++ b/je_web_runner/utils/har_diff/har_diff.py @@ -7,7 +7,7 @@ import json from pathlib import Path -from typing import Any, Dict, List, Tuple +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -17,10 +17,10 @@ class HarDiffError(WebRunnerException): """Raised when a HAR document cannot be parsed.""" -_Entry = Dict[str, Any] +_Entry = dict[str, Any] -def _load_har(har: Any) -> Dict[str, Any]: +def _load_har(har: Any) -> dict[str, Any]: if isinstance(har, dict): return har if isinstance(har, str): @@ -31,7 +31,7 @@ def _load_har(har: Any) -> Dict[str, Any]: raise HarDiffError(f"unsupported HAR type: {type(har).__name__}") -def _entries(har: Dict[str, Any]) -> List[_Entry]: +def _entries(har: dict[str, Any]) -> list[_Entry]: log = har.get("log") if not isinstance(log, dict): raise HarDiffError("HAR missing 'log' block") @@ -41,9 +41,9 @@ def _entries(har: Dict[str, Any]) -> List[_Entry]: return entries -def _index(entries: List[_Entry]) -> Dict[Tuple[str, str], _Entry]: +def _index(entries: list[_Entry]) -> dict[tuple[str, str], _Entry]: """Index entries by (METHOD, url); later entries with the same key win.""" - index: Dict[Tuple[str, str], _Entry] = {} + index: dict[tuple[str, str], _Entry] = {} for entry in entries: request = entry.get("request") or {} method = (request.get("method") or "").upper() @@ -59,7 +59,7 @@ def _status(entry: _Entry) -> int: return int(status) if isinstance(status, int) else 0 -def diff_har(left: Any, right: Any) -> Dict[str, List[Dict[str, Any]]]: +def diff_har(left: Any, right: Any) -> dict[str, list[dict[str, Any]]]: """ 比對兩份 HAR;回傳 ``{added, removed, changed}`` Diff two HAR documents; returns ``{added, removed, changed}`` lists. @@ -68,9 +68,9 @@ def diff_har(left: Any, right: Any) -> Dict[str, List[Dict[str, Any]]]: left_index = _index(_entries(_load_har(left))) right_index = _index(_entries(_load_har(right))) - added: List[Dict[str, Any]] = [] - removed: List[Dict[str, Any]] = [] - changed: List[Dict[str, Any]] = [] + added: list[dict[str, Any]] = [] + removed: list[dict[str, Any]] = [] + changed: list[dict[str, Any]] = [] for key, entry in right_index.items(): if key not in left_index: @@ -97,7 +97,7 @@ def diff_har(left: Any, right: Any) -> Dict[str, List[Dict[str, Any]]]: return {"added": added, "removed": removed, "changed": changed} -def diff_har_files(left_path: str, right_path: str) -> Dict[str, List[Dict[str, Any]]]: +def diff_har_files(left_path: str, right_path: str) -> dict[str, list[dict[str, Any]]]: """讀取兩個 HAR 檔並比對 / Read two HAR files from disk and diff them.""" left_path_obj = Path(left_path) right_path_obj = Path(right_path) diff --git a/je_web_runner/utils/har_replay/server.py b/je_web_runner/utils/har_replay/server.py index 620a9bfd..ed40a310 100644 --- a/je_web_runner/utils/har_replay/server.py +++ b/je_web_runner/utils/har_replay/server.py @@ -16,7 +16,7 @@ from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable from urllib.parse import urlparse from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -32,12 +32,12 @@ class HarEntry: method: str path: str status: int - headers: Dict[str, str] = field(default_factory=dict) + headers: dict[str, str] = field(default_factory=dict) body: str = "" body_is_base64: bool = False -def load_har(source: Union[str, Path]) -> List[HarEntry]: +def load_har(source: str | Path) -> list[HarEntry]: """Read a HAR file and return its ``entries`` projected to :class:`HarEntry`.""" path = Path(source) if not path.is_file(): @@ -49,7 +49,7 @@ def load_har(source: Union[str, Path]) -> List[HarEntry]: entries = (document.get("log") or {}).get("entries") if not isinstance(entries, list): raise HarReplayError("HAR missing log.entries") - parsed: List[HarEntry] = [] + parsed: list[HarEntry] = [] for index, entry in enumerate(entries): try: parsed.append(_entry_from_har(entry)) @@ -58,7 +58,7 @@ def load_har(source: Union[str, Path]) -> List[HarEntry]: return parsed -def _entry_from_har(entry: Dict[str, Any]) -> HarEntry: +def _entry_from_har(entry: dict[str, Any]) -> HarEntry: request = entry["request"] response = entry["response"] parsed = urlparse(request["url"]) @@ -100,7 +100,7 @@ def _build_matcher(pattern: str) -> _PathMatcher: class _Bucket: matcher: _PathMatcher pattern: str - entries: List[HarEntry] + entries: list[HarEntry] cursor: int = 0 @@ -109,7 +109,7 @@ class HarReplayServer: def __init__( self, - entries: List[HarEntry], + entries: list[HarEntry], host: str = "127.0.0.1", port: int = 0, not_found_status: int = 404, @@ -120,14 +120,14 @@ def __init__( self.host = host self.port = port self.not_found_status = not_found_status - self._buckets: Dict[str, List[_Bucket]] = defaultdict(list) + self._buckets: dict[str, list[_Bucket]] = defaultdict(list) self._build_buckets() - self._server: Optional[HTTPServer] = None - self._thread: Optional[threading.Thread] = None - self.calls: List[Tuple[str, str]] = [] + self._server: HTTPServer | None = None + self._thread: threading.Thread | None = None + self.calls: list[tuple[str, str]] = [] def _build_buckets(self) -> None: - grouped: Dict[Tuple[str, str], List[HarEntry]] = defaultdict(list) + grouped: dict[tuple[str, str], list[HarEntry]] = defaultdict(list) for entry in self.entries: grouped[(entry.method, entry.path)].append(entry) for (method, path), group in grouped.items(): @@ -138,7 +138,7 @@ def _build_buckets(self) -> None: ) self._buckets[method].append(bucket) - def find(self, method: str, path: str) -> Optional[HarEntry]: + def find(self, method: str, path: str) -> HarEntry | None: method_upper = method.upper() self.calls.append((method_upper, path)) candidates = self._buckets.get(method_upper) or [] @@ -216,20 +216,20 @@ def _serve(self) -> None: self.end_headers() self.wfile.write(body_bytes) - def do_GET(self): # noqa: N802 + def do_GET(self): self._serve() - def do_DELETE(self): # noqa: N802 + def do_DELETE(self): self._serve() - def do_POST(self): # noqa: N802 + def do_POST(self): self._drain_body() self._serve() # do_PUT and do_PATCH share POST's body-drain semantics; alias to # avoid SonarCloud S4144 duplicate-method-body findings. - do_PUT = do_POST # noqa: N815 - do_PATCH = do_POST # noqa: N815 + do_PUT = do_POST + do_PATCH = do_POST return _ReplayHandler diff --git a/je_web_runner/utils/har_to_openapi/converter.py b/je_web_runner/utils/har_to_openapi/converter.py index cbb75b20..bee00d44 100644 --- a/je_web_runner/utils/har_to_openapi/converter.py +++ b/je_web_runner/utils/har_to_openapi/converter.py @@ -16,7 +16,7 @@ import json import re from collections import defaultdict -from typing import Any, Dict, List, Mapping, Optional +from typing import Any, Mapping from urllib.parse import urlparse, parse_qsl from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -33,7 +33,7 @@ class HarToOpenapiError(WebRunnerException): ) -def _classify_segment(seg: str) -> Optional[str]: +def _classify_segment(seg: str) -> str | None: if _NUMERIC_RE.match(seg): return "{id}" if _UUID_RE.match(seg): @@ -43,7 +43,7 @@ def _classify_segment(seg: str) -> Optional[str]: def _path_template(path: str) -> str: parts = path.split("/") - out: List[str] = [] + out: list[str] = [] for seg in parts: if not seg: out.append(seg) @@ -69,7 +69,7 @@ def _js_type(value: Any) -> str: return "null" -def _schema_from_value(value: Any) -> Dict[str, Any]: +def _schema_from_value(value: Any) -> dict[str, Any]: if isinstance(value, dict): return { "type": "object", @@ -92,7 +92,7 @@ def _parse_body(content: Any) -> Any: return None -def _merge_query_params(op: Dict[str, Any], query: str) -> None: +def _merge_query_params(op: dict[str, Any], query: str) -> None: existing = {p["name"] for p in op["parameters"]} for q_name, _ in parse_qsl(query): if q_name not in existing: @@ -103,7 +103,7 @@ def _merge_query_params(op: Dict[str, Any], query: str) -> None: existing.add(q_name) -def _merge_response(op: Dict[str, Any], status: str, body: Any) -> None: +def _merge_response(op: dict[str, Any], status: str, body: Any) -> None: if body is None: op["responses"].setdefault(status, {"description": "auto-generated"}) return @@ -114,7 +114,7 @@ def _merge_response(op: Dict[str, Any], status: str, body: Any) -> None: def _register_entry( - paths: Dict[str, Dict[str, Any]], entry: Dict[str, Any], + paths: dict[str, dict[str, Any]], entry: dict[str, Any], ) -> None: req = entry.get("request") or {} res = entry.get("response") or {} @@ -134,14 +134,14 @@ def _register_entry( _parse_body((res.get("content") or {}).get("text"))) -def convert(har: Mapping[str, Any]) -> Dict[str, Any]: +def convert(har: Mapping[str, Any]) -> dict[str, Any]: if not isinstance(har, Mapping): raise HarToOpenapiError("har must be a mapping") log = har.get("log") entries = log.get("entries") if isinstance(log, Mapping) else None if not isinstance(entries, list): raise HarToOpenapiError("har.log.entries must be a list") - paths: Dict[str, Dict[str, Any]] = defaultdict(dict) + paths: dict[str, dict[str, Any]] = defaultdict(dict) for entry in entries: if isinstance(entry, dict): _register_entry(paths, entry) diff --git a/je_web_runner/utils/header_tampering/tamper.py b/je_web_runner/utils/header_tampering/tamper.py index a251d72d..3bfdd90d 100644 --- a/je_web_runner/utils/header_tampering/tamper.py +++ b/je_web_runner/utils/header_tampering/tamper.py @@ -11,7 +11,7 @@ import re from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Pattern +from typing import Any, Pattern from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -28,8 +28,8 @@ class HeaderRule: name: str # diagnostic label header: str action: str # "set" | "remove" | "append" - value: Optional[str] = None - url_match: Optional[Pattern] = None + value: str | None = None + url_match: Pattern | None = None def __post_init__(self) -> None: if not self.header: @@ -51,10 +51,10 @@ def _matches(rule: HeaderRule, url: str) -> bool: def apply_to_request_headers( - headers: Dict[str, str], + headers: dict[str, str], url: str, - rules: List[HeaderRule], -) -> Dict[str, str]: + rules: list[HeaderRule], +) -> dict[str, str]: """Return a new headers dict with all matching rules applied.""" next_headers = dict(headers) for rule in rules: @@ -75,11 +75,11 @@ def apply_to_request_headers( class HeaderTampering: """Track a list of rules and attach to a Playwright page.""" - rules: List[HeaderRule] = field(default_factory=list) + rules: list[HeaderRule] = field(default_factory=list) def set_header(self, header: str, value: str, - url_substring: Optional[str] = None, - name: Optional[str] = None) -> HeaderRule: + url_substring: str | None = None, + name: str | None = None) -> HeaderRule: rule = HeaderRule( name=name or f"set:{header}", header=header, @@ -91,7 +91,7 @@ def set_header(self, header: str, value: str, return rule def remove_header(self, header: str, - url_substring: Optional[str] = None) -> HeaderRule: + url_substring: str | None = None) -> HeaderRule: rule = HeaderRule( name=f"remove:{header}", header=header, diff --git a/je_web_runner/utils/hydration_check/check.py b/je_web_runner/utils/hydration_check/check.py index 615e30db..83cd3805 100644 --- a/je_web_runner/utils/hydration_check/check.py +++ b/je_web_runner/utils/hydration_check/check.py @@ -19,7 +19,7 @@ import re from dataclasses import dataclass, field -from typing import Dict, Iterable, List, Optional +from typing import Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -51,9 +51,9 @@ class HydrationFinding: # ---------- console scan ---------------------------------------------- -def scan_console(messages: Iterable[str]) -> List[HydrationFinding]: +def scan_console(messages: Iterable[str]) -> list[HydrationFinding]: """Pull hydration-related lines out of console messages.""" - findings: List[HydrationFinding] = [] + findings: list[HydrationFinding] = [] for line in messages: if not isinstance(line, str): continue @@ -71,7 +71,7 @@ def scan_console(messages: Iterable[str]) -> List[HydrationFinding]: _WS = re.compile(r"\s+") # NOSONAR python:S5852 — input is a finite SSR HTML snapshot, not attacker text -_WS_AROUND_TAGS = re.compile(r"\s*(<[^>]+>)\s*") # noqa: S5852 +_WS_AROUND_TAGS = re.compile(r"\s*(<[^>]+>)\s*") _COMMENT = re.compile(r"", re.DOTALL) _FRAMEWORK_ATTRS = re.compile( r"\s+(?:data-reactroot|data-reactid|data-react-helmet|data-n-head|" @@ -90,7 +90,7 @@ def _normalise_html(html: str) -> str: return text -def diff_dom(server_html: str, client_html: str) -> List[HydrationFinding]: +def diff_dom(server_html: str, client_html: str) -> list[HydrationFinding]: """ Compare server-rendered HTML to post-hydration HTML. Returns findings only if the *normalised* representations differ. @@ -122,13 +122,13 @@ def diff_dom(server_html: str, client_html: str) -> List[HydrationFinding]: class HydrationReport: """Combined console + DOM-diff finding set.""" - findings: List[HydrationFinding] = field(default_factory=list) + findings: list[HydrationFinding] = field(default_factory=list) def passed(self) -> bool: return not self.findings - def by_kind(self) -> Dict[str, int]: - out: Dict[str, int] = {} + def by_kind(self) -> dict[str, int]: + out: dict[str, int] = {} for f in self.findings: out[f.kind] = out.get(f.kind, 0) + 1 return out @@ -136,12 +136,12 @@ def by_kind(self) -> Dict[str, int]: def audit( *, - server_html: Optional[str] = None, - client_html: Optional[str] = None, - console_messages: Optional[Iterable[str]] = None, + server_html: str | None = None, + client_html: str | None = None, + console_messages: Iterable[str] | None = None, ) -> HydrationReport: """Run all available checks. Either pair of inputs may be ``None``.""" - findings: List[HydrationFinding] = [] + findings: list[HydrationFinding] = [] if server_html is not None and client_html is not None: findings.extend(diff_dom(server_html, client_html)) if console_messages is not None: diff --git a/je_web_runner/utils/hydration_streaming/timing.py b/je_web_runner/utils/hydration_streaming/timing.py index 0ad82597..dd2d8291 100644 --- a/je_web_runner/utils/hydration_streaming/timing.py +++ b/je_web_runner/utils/hydration_streaming/timing.py @@ -12,7 +12,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -74,16 +74,16 @@ class BoundaryTiming: """Per-Suspense / per-island timing snapshot.""" id: str - placeholder_ms: Optional[float] = None - arrived_ms: Optional[float] = None - interactive_ms: Optional[float] = None + placeholder_ms: float | None = None + arrived_ms: float | None = None + interactive_ms: float | None = None - def time_to_arrival(self) -> Optional[float]: + def time_to_arrival(self) -> float | None: if self.placeholder_ms is None or self.arrived_ms is None: return None return self.arrived_ms - self.placeholder_ms - def time_to_interactive(self) -> Optional[float]: + def time_to_interactive(self) -> float | None: if self.arrived_ms is None or self.interactive_ms is None: return None return self.interactive_ms - self.arrived_ms @@ -91,9 +91,9 @@ def time_to_interactive(self) -> Optional[float]: @dataclass class StreamingReport: - boundaries: List[BoundaryTiming] = field(default_factory=list) + boundaries: list[BoundaryTiming] = field(default_factory=list) - def by_id(self) -> Dict[str, BoundaryTiming]: + def by_id(self) -> dict[str, BoundaryTiming]: return {b.id: b for b in self.boundaries} @@ -105,7 +105,7 @@ def parse_log(payload: Any) -> StreamingReport: raw_boundaries = payload.get("boundaries") or {} if not isinstance(raw_boundaries, dict): raise HydrationStreamingError("boundaries must be a dict") - out: List[BoundaryTiming] = [] + out: list[BoundaryTiming] = [] for bid, phases in raw_boundaries.items(): if not isinstance(phases, dict): continue @@ -118,7 +118,7 @@ def parse_log(payload: Any) -> StreamingReport: return StreamingReport(boundaries=out) -def _to_float(value: Any) -> Optional[float]: +def _to_float(value: Any) -> float | None: if value is None: return None try: diff --git a/je_web_runner/utils/idempotency_check/check.py b/je_web_runner/utils/idempotency_check/check.py index f8c3ede3..6cc17d2a 100644 --- a/je_web_runner/utils/idempotency_check/check.py +++ b/je_web_runner/utils/idempotency_check/check.py @@ -13,7 +13,7 @@ import hashlib import json from dataclasses import asdict, dataclass, field -from typing import Any, Callable, Dict, List, Optional, Sequence +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -46,15 +46,15 @@ class IdempotencyReport: first: IdemResponse second: IdemResponse - state_before_first: Optional[Any] = None - state_after_first: Optional[Any] = None - state_after_second: Optional[Any] = None - violations: List[str] = field(default_factory=list) + state_before_first: Any | None = None + state_after_first: Any | None = None + state_after_second: Any | None = None + violations: list[str] = field(default_factory=list) def passed(self) -> bool: return not self.violations - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "first": asdict(self.first), "second": asdict(self.second), @@ -75,8 +75,8 @@ def to_dict(self) -> Dict[str, Any]: def check( request_runner: RequestRunner, *, - state_probe: Optional[StateProbe] = None, - allow_status_change_to: Optional[Sequence[int]] = None, + state_probe: StateProbe | None = None, + allow_status_change_to: Sequence[int] | None = None, ignore_body_keys: Sequence[str] = (), ) -> IdempotencyReport: """ @@ -98,7 +98,7 @@ def check( second = _safe_call(request_runner, "second") state_after_second = state_probe() if state_probe else None - violations: List[str] = [] + violations: list[str] = [] if ( first.status_code != second.status_code and second.status_code not in allowed diff --git a/je_web_runner/utils/impact_analysis/indexer.py b/je_web_runner/utils/impact_analysis/indexer.py index 0f839151..e9526743 100644 --- a/je_web_runner/utils/impact_analysis/indexer.py +++ b/je_web_runner/utils/impact_analysis/indexer.py @@ -12,7 +12,7 @@ from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Set, Union +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -26,31 +26,31 @@ class ImpactAnalysisError(WebRunnerException): class ImpactIndex: """Reverse index ``{kind: {token: {file_paths}}}``.""" - by_locator: Dict[str, Set[str]] = field(default_factory=lambda: defaultdict(set)) - by_url: Dict[str, Set[str]] = field(default_factory=lambda: defaultdict(set)) - by_template: Dict[str, Set[str]] = field(default_factory=lambda: defaultdict(set)) - by_command: Dict[str, Set[str]] = field(default_factory=lambda: defaultdict(set)) + by_locator: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set)) + by_url: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set)) + by_template: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set)) + by_command: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set)) - def files_for_locator(self, name: str) -> List[str]: + def files_for_locator(self, name: str) -> list[str]: return sorted(self.by_locator.get(name, set())) - def files_for_url(self, fragment: str) -> List[str]: + def files_for_url(self, fragment: str) -> list[str]: return sorted({ file for url, files in self.by_url.items() for file in files if fragment in url }) - def files_for_template(self, name: str) -> List[str]: + def files_for_template(self, name: str) -> list[str]: return sorted(self.by_template.get(name, set())) - def files_for_command(self, command: str) -> List[str]: + def files_for_command(self, command: str) -> list[str]: return sorted(self.by_command.get(command, set())) _ACTIONS_GLOB = "**/*.json" -def build_index(directory: Union[str, Path], glob: str = _ACTIONS_GLOB) -> ImpactIndex: +def build_index(directory: str | Path, glob: str = _ACTIONS_GLOB) -> ImpactIndex: """ 走訪 ``directory`` 下所有 action JSON 檔,建立反查表 Walk ``directory`` for ``*.json`` files and project each one's locators, @@ -74,7 +74,7 @@ def build_index(directory: Union[str, Path], glob: str = _ACTIONS_GLOB) -> Impac return index -def _index_actions(index: ImpactIndex, file_path: str, actions: List[Any]) -> None: +def _index_actions(index: ImpactIndex, file_path: str, actions: list[Any]) -> None: for action in actions: if not isinstance(action, list) or not action: continue @@ -92,7 +92,7 @@ def _index_actions(index: ImpactIndex, file_path: str, actions: List[Any]) -> No index.by_template[value].add(file_path) -def _extract_kwargs(action: List[Any]) -> Dict[str, Any]: +def _extract_kwargs(action: list[Any]) -> dict[str, Any]: if len(action) >= 3 and isinstance(action[2], dict): return action[2] if len(action) >= 2 and isinstance(action[1], dict): @@ -102,16 +102,16 @@ def _extract_kwargs(action: List[Any]) -> Dict[str, Any]: def affected_action_files( index: ImpactIndex, - locators: Optional[Iterable[str]] = None, - urls: Optional[Iterable[str]] = None, - templates: Optional[Iterable[str]] = None, - commands: Optional[Iterable[str]] = None, -) -> List[str]: + locators: Iterable[str] | None = None, + urls: Iterable[str] | None = None, + templates: Iterable[str] | None = None, + commands: Iterable[str] | None = None, +) -> list[str]: """ Given changed locator/URL/template/command names, return every action JSON file that touches at least one of them. """ - affected: Set[str] = set() + affected: set[str] = set() for name in locators or []: affected.update(index.files_for_locator(name)) for fragment in urls or []: diff --git a/je_web_runner/utils/inbox_render_outlook/render.py b/je_web_runner/utils/inbox_render_outlook/render.py index db0b490e..e2e029e6 100644 --- a/je_web_runner/utils/inbox_render_outlook/render.py +++ b/je_web_runner/utils/inbox_render_outlook/render.py @@ -14,7 +14,7 @@ import re from dataclasses import asdict, dataclass from enum import Enum -from typing import Any, Dict, Iterable, List +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -36,7 +36,7 @@ class RenderFinding: message: str snippet: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "severity": self.severity.value} @@ -60,10 +60,10 @@ def to_dict(self) -> Dict[str, Any]: _HTML_TYPE_ERROR = "html must be a string" -def audit_outlook(html: str) -> List[RenderFinding]: +def audit_outlook(html: str) -> list[RenderFinding]: if not isinstance(html, str): raise InboxRenderOutlookError(_HTML_TYPE_ERROR) - findings: List[RenderFinding] = [] + findings: list[RenderFinding] = [] for pattern in _OUTLOOK_BAD_CSS: for match in pattern.finditer(html): findings.append(RenderFinding( @@ -85,10 +85,10 @@ def audit_outlook(html: str) -> List[RenderFinding]: return findings -def audit_gmail(html: str) -> List[RenderFinding]: +def audit_gmail(html: str) -> list[RenderFinding]: if not isinstance(html, str): raise InboxRenderOutlookError(_HTML_TYPE_ERROR) - findings: List[RenderFinding] = [] + findings: list[RenderFinding] = [] for pattern in _GMAIL_RULES: if pattern.search(html): findings.append(RenderFinding( @@ -107,10 +107,10 @@ def audit_gmail(html: str) -> List[RenderFinding]: return findings -def audit_apple_mail(html: str) -> List[RenderFinding]: +def audit_apple_mail(html: str) -> list[RenderFinding]: if not isinstance(html, str): raise InboxRenderOutlookError(_HTML_TYPE_ERROR) - findings: List[RenderFinding] = [] + findings: list[RenderFinding] = [] if "@media (prefers-color-scheme: dark)" not in html.lower(): findings.append(RenderFinding( rule="apple-mail-dark-mode", severity=Severity.INFO, @@ -120,7 +120,7 @@ def audit_apple_mail(html: str) -> List[RenderFinding]: return findings -def audit_all(html: str) -> List[RenderFinding]: +def audit_all(html: str) -> list[RenderFinding]: return (audit_outlook(html) + audit_gmail(html) + audit_apple_mail(html)) diff --git a/je_web_runner/utils/indexed_db_explorer/explorer.py b/je_web_runner/utils/indexed_db_explorer/explorer.py index bca6d946..1bd37818 100644 --- a/je_web_runner/utils/indexed_db_explorer/explorer.py +++ b/je_web_runner/utils/indexed_db_explorer/explorer.py @@ -14,7 +14,7 @@ import json from dataclasses import asdict, dataclass, field -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -86,11 +86,11 @@ class StoreSnapshot: name: str key_path: Any = None auto_increment: bool = False - index_names: List[str] = field(default_factory=list) - records: List[Any] = field(default_factory=list) - keys: List[Any] = field(default_factory=list) + index_names: list[str] = field(default_factory=list) + records: list[Any] = field(default_factory=list) + keys: list[Any] = field(default_factory=list) - def find_one(self, predicate: Callable[[Any], bool]) -> Optional[Any]: + def find_one(self, predicate: Callable[[Any], bool]) -> Any | None: for r in self.records: try: if predicate(r): @@ -106,18 +106,18 @@ class IdbSnapshot: name: str exists: bool - version: Optional[int] = None - stores: Dict[str, StoreSnapshot] = field(default_factory=dict) + version: int | None = None + stores: dict[str, StoreSnapshot] = field(default_factory=dict) @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "IdbSnapshot": + def from_dict(cls, data: dict[str, Any]) -> IdbSnapshot: if not isinstance(data, dict): raise IndexedDbExplorerError( f"snapshot must be dict, got {type(data).__name__}" ) if "stores" in data and not isinstance(data["stores"], dict): raise IndexedDbExplorerError("snapshot.stores must be a dict") - stores: Dict[str, StoreSnapshot] = {} + stores: dict[str, StoreSnapshot] = {} for name, raw in (data.get("stores") or {}).items(): if not isinstance(raw, dict): continue @@ -136,7 +136,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "IdbSnapshot": stores=stores, ) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "name": self.name, "exists": self.exists, @@ -171,7 +171,7 @@ def assert_record_count( store_name: str, *, minimum: int = 0, - maximum: Optional[int] = None, + maximum: int | None = None, ) -> int: """Assert ``minimum <= len(records) <= maximum``.""" if minimum < 0: @@ -230,9 +230,9 @@ def assert_index_present( class SnapshotDiff: """High-level diff between two snapshots.""" - added_stores: List[str] = field(default_factory=list) - removed_stores: List[str] = field(default_factory=list) - record_count_changes: Dict[str, Dict[str, int]] = field(default_factory=dict) + added_stores: list[str] = field(default_factory=list) + removed_stores: list[str] = field(default_factory=list) + record_count_changes: dict[str, dict[str, int]] = field(default_factory=dict) def diff_snapshots(before: IdbSnapshot, after: IdbSnapshot) -> SnapshotDiff: diff --git a/je_web_runner/utils/inp_tracker/tracker.py b/je_web_runner/utils/inp_tracker/tracker.py index 725d767d..23bfb3c8 100644 --- a/je_web_runner/utils/inp_tracker/tracker.py +++ b/je_web_runner/utils/inp_tracker/tracker.py @@ -15,7 +15,7 @@ from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -82,7 +82,7 @@ class InteractionEvent: name: str interaction_id: int duration_ms: float - target_tag: Optional[str] = None + target_tag: str | None = None start_time: float = 0.0 processing_start: float = 0.0 processing_end: float = 0.0 @@ -90,7 +90,7 @@ class InteractionEvent: def rating(self) -> InpRating: return _rate(self.duration_ms) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "rating": self.rating().value} @@ -102,13 +102,13 @@ def _rate(duration_ms: float) -> InpRating: return InpRating.POOR -def parse_log(payload: Any) -> List[InteractionEvent]: +def parse_log(payload: Any) -> list[InteractionEvent]: """Convert the harvested ``__wr_inp_log__`` array into typed events.""" if not isinstance(payload, list): raise InpTrackerError( f"payload must be list, got {type(payload).__name__}" ) - out: List[InteractionEvent] = [] + out: list[InteractionEvent] = [] for raw in payload: if not isinstance(raw, dict): continue @@ -136,13 +136,13 @@ def parse_log(payload: Any) -> List[InteractionEvent]: class InpReport: """Rolled-up view of the events captured in a page session.""" - events: List[InteractionEvent] = field(default_factory=list) + events: list[InteractionEvent] = field(default_factory=list) - def filtered(self) -> List[InteractionEvent]: + def filtered(self) -> list[InteractionEvent]: """Discard zero-id non-interaction entries (mouse-move, raw events).""" return [e for e in self.events if e.interaction_id > 0] - def inp(self) -> Optional[float]: + def inp(self) -> float | None: """ Returns Google's INP: 98th percentile if 50+ interactions, else worst. ``None`` if no interactions observed. @@ -161,7 +161,7 @@ def rating(self) -> InpRating: return InpRating.GOOD return _rate(value) - def percentile(self, pct: float) -> Optional[float]: + def percentile(self, pct: float) -> float | None: """Arbitrary percentile (0..100) over interaction durations.""" if not 0 <= pct <= 100: raise InpTrackerError("pct must be in [0, 100]") diff --git a/je_web_runner/utils/json/json_file/json_file.py b/je_web_runner/utils/json/json_file/json_file.py index 14aac1f3..24e93218 100644 --- a/je_web_runner/utils/json/json_file/json_file.py +++ b/je_web_runner/utils/json/json_file/json_file.py @@ -18,20 +18,13 @@ def read_action_json(json_file_path: str) -> list: :param json_file_path: JSON 檔案路徑 / path to the JSON file :return: JSON 內容 (list) / JSON content as list """ - try: - lock.acquire() - file_path = Path(json_file_path) - if file_path.exists() and file_path.is_file(): - with open(json_file_path, encoding="utf-8") as read_file: - return json.load(read_file) - else: - # 如果檔案不存在或不是檔案,拋出例外 - # Raise exception if file does not exist or is not a file - raise WebRunnerJsonException(cant_find_json_error) - except WebRunnerJsonException: + file_path = Path(json_file_path) + if not (file_path.exists() and file_path.is_file()): + # 檔案不存在或不是檔案 + # File does not exist or is not a file raise WebRunnerJsonException(cant_find_json_error) - finally: - lock.release() + with lock, open(json_file_path, encoding="utf-8") as read_file: # NOSONAR S8707 — developer-supplied path (own report/config file), not untrusted input + return json.load(read_file) def write_action_json(json_save_path: str, action_json: list): @@ -43,10 +36,9 @@ def write_action_json(json_save_path: str, action_json: list): :param action_json: 要寫入的 JSON 資料 (list) / JSON data to write (list) """ try: - lock.acquire() - with open(json_save_path, "w+", encoding="utf-8") as file_to_write: + with lock, open(json_save_path, "w", encoding="utf-8") as file_to_write: file_to_write.write(json.dumps(action_json, indent=4, ensure_ascii=False)) - except WebRunnerJsonException: - raise WebRunnerJsonException(cant_save_json_error) - finally: - lock.release() \ No newline at end of file + except (OSError, TypeError, ValueError) as error: + # 先前 except 只攔 WebRunnerJsonException(本體不會丟),等於沒處理 — + # 寫檔/序列化失敗會以原生 OSError 外漏。改為包成 cant_save_json_error。 + raise WebRunnerJsonException(cant_save_json_error) from error \ No newline at end of file diff --git a/je_web_runner/utils/json/json_format/json_process.py b/je_web_runner/utils/json/json_format/json_process.py index 3db7b60f..7d36ee28 100644 --- a/je_web_runner/utils/json/json_format/json_process.py +++ b/je_web_runner/utils/json/json_format/json_process.py @@ -35,7 +35,7 @@ def __process_json(json_string: str, **kwargs) -> str: except TypeError: # 如果仍然失敗,拋出自訂例外 # If still fails, raise custom exception - raise WebRunnerJsonException(wrong_json_data_error) + raise WebRunnerJsonException(wrong_json_data_error) from None def reformat_json(json_string: str, **kwargs) -> str: @@ -55,4 +55,4 @@ def reformat_json(json_string: str, **kwargs) -> str: except WebRunnerJsonException: # 如果內部處理失敗,統一拋出「無法重新格式化 JSON」的錯誤 # If processing fails, raise unified "cannot reformat JSON" exception - raise WebRunnerJsonException(cant_reformat_json_error) \ No newline at end of file + raise WebRunnerJsonException(cant_reformat_json_error) from None \ No newline at end of file diff --git a/je_web_runner/utils/json/json_validator.py b/je_web_runner/utils/json/json_validator.py index 7f29fe09..af577a9f 100644 --- a/je_web_runner/utils/json/json_validator.py +++ b/je_web_runner/utils/json/json_validator.py @@ -13,7 +13,7 @@ """ from __future__ import annotations -from typing import Iterable, Union +from typing import Iterable from je_web_runner.utils.exception.exception_tags import executor_data_error, executor_list_error from je_web_runner.utils.exception.exceptions import WebRunnerExecuteException @@ -23,7 +23,7 @@ ACTION_LIST_KEY = "webdriver_wrapper" -def _extract_action_list(data: Union[list, dict]) -> list: +def _extract_action_list(data: list | dict) -> list: """Pull the action list out of a top-level dict, or accept a list directly.""" if isinstance(data, dict): action_list = data.get(ACTION_LIST_KEY) @@ -73,7 +73,7 @@ def _validate_single_action(action: object, index: int) -> None: ) -def validate_action_json(data: Union[list, dict]) -> bool: +def validate_action_json(data: list | dict) -> bool: """ 驗證動作 JSON 是否符合執行器格式 Validate that ``data`` matches the executor action format. diff --git a/je_web_runner/utils/k8s_runner/manifest.py b/je_web_runner/utils/k8s_runner/manifest.py index 83d5828f..647558e8 100644 --- a/je_web_runner/utils/k8s_runner/manifest.py +++ b/je_web_runner/utils/k8s_runner/manifest.py @@ -11,7 +11,7 @@ import re from dataclasses import dataclass, field -from typing import Any, Dict, List, Sequence +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -33,15 +33,15 @@ class ShardJobConfig: actions_dir: str namespace: str = "default" command: Sequence[str] = field(default_factory=lambda: ["python", "-m", "je_web_runner"]) - env: Dict[str, str] = field(default_factory=dict) - resources: Dict[str, Dict[str, str]] = field(default_factory=lambda: { + env: dict[str, str] = field(default_factory=dict) + resources: dict[str, dict[str, str]] = field(default_factory=lambda: { "requests": {"cpu": "500m", "memory": "1Gi"}, "limits": {"cpu": "1", "memory": "2Gi"}, }) backoff_limit: int = 1 parallelism: int = 1 completions: int = 1 - labels: Dict[str, str] = field(default_factory=dict) + labels: dict[str, str] = field(default_factory=dict) extra_args: Sequence[str] = field(default_factory=tuple) @@ -58,10 +58,10 @@ def _validate(config: ShardJobConfig) -> None: raise K8sRunnerError("actions_dir must be non-empty") -def render_job_manifests(config: ShardJobConfig) -> List[Dict[str, Any]]: +def render_job_manifests(config: ShardJobConfig) -> list[dict[str, Any]]: """Produce one ``batch/v1`` Job dict per shard.""" _validate(config) - manifests: List[Dict[str, Any]] = [] + manifests: list[dict[str, Any]] = [] base_labels = { "app.kubernetes.io/name": "webrunner", "app.kubernetes.io/component": "shard", @@ -80,8 +80,8 @@ def _render_one( config: ShardJobConfig, shard_index: int, job_name: str, - labels: Dict[str, str], -) -> Dict[str, Any]: + labels: dict[str, str], +) -> dict[str, Any]: args = [ "--execute_dir", config.actions_dir, "--shard", f"{shard_index}/{config.total_shards}", @@ -120,9 +120,9 @@ def _render_one( } -def render_yaml_documents(manifests: List[Dict[str, Any]]) -> str: +def render_yaml_documents(manifests: list[dict[str, Any]]) -> str: """Render to a multi-doc YAML string (basic dumper, no PyYAML dependency).""" - pieces: List[str] = [] + pieces: list[str] = [] for index, manifest in enumerate(manifests): if index > 0: pieces.append("---") @@ -139,7 +139,7 @@ def _dump_yaml(value: Any, indent: int = 0) -> str: return f"{pad}{_scalar(value)}" -def _dump_dict(value: Dict[str, Any], pad: str, indent: int) -> str: +def _dump_dict(value: dict[str, Any], pad: str, indent: int) -> str: if not value: return f"{pad}{{}}" lines = [] @@ -152,7 +152,7 @@ def _dump_dict(value: Dict[str, Any], pad: str, indent: int) -> str: return "\n".join(lines) -def _dump_list(value: List[Any], pad: str, indent: int) -> str: +def _dump_list(value: list[Any], pad: str, indent: int) -> str: if not value: return f"{pad}[]" lines = [] diff --git a/je_web_runner/utils/lcp_image_audit/audit.py b/je_web_runner/utils/lcp_image_audit/audit.py index 4a591676..7ec17a30 100644 --- a/je_web_runner/utils/lcp_image_audit/audit.py +++ b/je_web_runner/utils/lcp_image_audit/audit.py @@ -16,7 +16,7 @@ import re from dataclasses import dataclass -from typing import Any, List, Sequence +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -62,7 +62,7 @@ def parse_candidate(payload: Any) -> LcpCandidate: _HTML_TYPE_ERROR = "html must be a string" -def _extract_preloaded_image_urls(html: str) -> List[str]: +def _extract_preloaded_image_urls(html: str) -> list[str]: if not isinstance(html, str): raise LcpImageAuditError(_HTML_TYPE_ERROR) matches = _PRELOAD_RE.findall(html) + _PRELOAD_RE_REVERSE.findall(html) diff --git a/je_web_runner/utils/license_scanner/scanner.py b/je_web_runner/utils/license_scanner/scanner.py index c75e8f86..b520ef18 100644 --- a/je_web_runner/utils/license_scanner/scanner.py +++ b/je_web_runner/utils/license_scanner/scanner.py @@ -9,7 +9,7 @@ import re from collections import Counter from dataclasses import dataclass -from typing import Iterable, List, Optional, Sequence +from typing import Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -40,14 +40,14 @@ class LicenseFinding: } -def scan_text(text: str) -> List[LicenseFinding]: +def scan_text(text: str) -> list[LicenseFinding]: """ 從文字內容找出 SPDX/已知授權字樣 Find every SPDX identifier and known license phrase in ``text``. """ if not isinstance(text, str): raise LicenseScannerError("text must be str") - findings: List[LicenseFinding] = [] + findings: list[LicenseFinding] = [] for index, line in enumerate(text.splitlines(), start=1): match = _SPDX_PATTERN.search(line) if match: @@ -77,7 +77,7 @@ def summarise(findings: Iterable[LicenseFinding]) -> Counter: def assert_allowed_licenses( findings: Iterable[LicenseFinding], allow: Sequence[str], - deny: Optional[Sequence[str]] = None, + deny: Sequence[str] | None = None, ) -> None: """ 斷言所有偵測到的授權都在 ``allow``、且不在 ``deny`` 名單中 @@ -85,7 +85,7 @@ def assert_allowed_licenses( """ allow_set = {a.strip() for a in allow} deny_set = {d.strip() for d in (deny or [])} - bad: List[LicenseFinding] = [] + bad: list[LicenseFinding] = [] for finding in findings: if finding.license_id in deny_set or finding.license_id not in allow_set: bad.append(finding) diff --git a/je_web_runner/utils/lighthouse/lighthouse_runner.py b/je_web_runner/utils/lighthouse/lighthouse_runner.py index 94c7f6c3..6177aa5f 100644 --- a/je_web_runner/utils/lighthouse/lighthouse_runner.py +++ b/je_web_runner/utils/lighthouse/lighthouse_runner.py @@ -13,7 +13,7 @@ import subprocess # nosec B404 — controlled args, no shell import tempfile from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -27,7 +27,7 @@ class LighthouseError(WebRunnerException): def _check_url(url: str) -> str: - if not isinstance(url, str) or not (url.startswith("http://") or url.startswith("https://")): # NOSONAR — scheme allow-list, not an outbound HTTP call + if not isinstance(url, str) or not (url.startswith(("http://", "https://"))): # NOSONAR — scheme allow-list, not an outbound HTTP call raise LighthouseError(f"URL must be http(s): {url!r}") return url @@ -36,10 +36,10 @@ def _build_command( url: str, output_path: str, lighthouse_path: str, - chrome_flags: Optional[List[str]], - extra_args: Optional[List[str]], -) -> List[str]: - cmd: List[str] = [ + chrome_flags: list[str] | None, + extra_args: list[str] | None, +) -> list[str]: + cmd: list[str] = [ lighthouse_path, url, "--output=json", @@ -54,10 +54,10 @@ def _build_command( return cmd -def _summarise(report: Dict[str, Any]) -> Dict[str, Any]: +def _summarise(report: dict[str, Any]) -> dict[str, Any]: categories = report.get("categories") or {} - def _score(key: str) -> Optional[float]: + def _score(key: str) -> float | None: bucket = categories.get(key) if isinstance(bucket, dict): score = bucket.get("score") @@ -75,12 +75,12 @@ def _score(key: str) -> Optional[float]: def run_lighthouse( url: str, - output_path: Optional[str] = None, + output_path: str | None = None, lighthouse_path: str = "lighthouse", - chrome_flags: Optional[List[str]] = None, - extra_args: Optional[List[str]] = None, + chrome_flags: list[str] | None = None, + extra_args: list[str] | None = None, timeout: int = _DEFAULT_TIMEOUT, -) -> Dict[str, Any]: +) -> dict[str, Any]: """ 執行 Lighthouse 並回傳 ``{scores, report_path, raw}`` Run Lighthouse against ``url`` and return scores plus the raw report. @@ -139,7 +139,7 @@ def run_lighthouse( return summary -def assert_scores(result: Dict[str, Any], thresholds: Dict[str, float]) -> None: +def assert_scores(result: dict[str, Any], thresholds: dict[str, float]) -> None: """ 斷言所有指定分數皆達門檻 Assert each requested category score is at or above its threshold (0–1). @@ -147,7 +147,7 @@ def assert_scores(result: Dict[str, Any], thresholds: Dict[str, float]) -> None: scores = result.get("scores") if isinstance(result, dict) else None if not isinstance(scores, dict): raise LighthouseError("lighthouse result missing 'scores' dict") - breaches: List[Dict[str, Any]] = [] + breaches: list[dict[str, Any]] = [] for category, minimum in thresholds.items(): value = scores.get(category) if value is None: diff --git a/je_web_runner/utils/lighthouse_regression/regression.py b/je_web_runner/utils/lighthouse_regression/regression.py index 36a471ea..592bc169 100644 --- a/je_web_runner/utils/lighthouse_regression/regression.py +++ b/je_web_runner/utils/lighthouse_regression/regression.py @@ -14,7 +14,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field -from typing import Optional, Any, Dict, List, Mapping +from typing import Any, Mapping from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -32,14 +32,14 @@ class LighthouseRegressionError(WebRunnerException): @dataclass class LighthouseSnapshot: - scores: Dict[str, float] = field(default_factory=dict) # 0..100 - metrics: Dict[str, float] = field(default_factory=dict) # numeric + scores: dict[str, float] = field(default_factory=dict) # 0..100 + metrics: dict[str, float] = field(default_factory=dict) # numeric - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) -def _coerce_score(key: str, raw: Any) -> Optional[float]: +def _coerce_score(key: str, raw: Any) -> float | None: if raw is None: return None try: @@ -50,7 +50,7 @@ def _coerce_score(key: str, raw: Any) -> Optional[float]: ) from exc -def _coerce_metric(key: str, raw: Any) -> Optional[float]: +def _coerce_metric(key: str, raw: Any) -> float | None: if raw is None: return None try: @@ -61,8 +61,8 @@ def _coerce_metric(key: str, raw: Any) -> Optional[float]: ) from exc -def _collect_scores(categories: Mapping[str, Any]) -> Dict[str, float]: - scores: Dict[str, float] = {} +def _collect_scores(categories: Mapping[str, Any]) -> dict[str, float]: + scores: dict[str, float] = {} for key in _CATEGORY_KEYS: entry = categories.get(key) if isinstance(entry, Mapping) and "score" in entry: @@ -72,8 +72,8 @@ def _collect_scores(categories: Mapping[str, Any]) -> Dict[str, float]: return scores -def _collect_metrics(audits: Mapping[str, Any]) -> Dict[str, float]: - metrics: Dict[str, float] = {} +def _collect_metrics(audits: Mapping[str, Any]) -> dict[str, float]: + metrics: dict[str, float] = {} for key in _METRIC_KEYS: entry = audits.get(key) if isinstance(entry, Mapping): @@ -109,8 +109,8 @@ def delta(self) -> float: @dataclass class RegressionReport: - score_changes: List[ScoreDelta] = field(default_factory=list) - metric_changes: List[ScoreDelta] = field(default_factory=list) + score_changes: list[ScoreDelta] = field(default_factory=list) + metric_changes: list[ScoreDelta] = field(default_factory=list) def diff(baseline: LighthouseSnapshot, head: LighthouseSnapshot) -> RegressionReport: diff --git a/je_web_runner/utils/linter/action_linter.py b/je_web_runner/utils/linter/action_linter.py index 33adea21..3d12bac7 100644 --- a/je_web_runner/utils/linter/action_linter.py +++ b/je_web_runner/utils/linter/action_linter.py @@ -9,7 +9,7 @@ import json import re from pathlib import Path -from typing import Any, Dict, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -20,7 +20,7 @@ class ActionLinterError(WebRunnerException): # Map legacy → suggested replacement (mirrors the aliases added in fix #3-#11). -_LEGACY_NAMES: Dict[str, str] = { +_LEGACY_NAMES: dict[str, str] = { "WR_get_webdriver_manager": "WR_new_driver", "WR_quit": "WR_quit_all", "WR_single_quit": "WR_quit_current", @@ -49,7 +49,7 @@ class ActionLinterError(WebRunnerException): _HARDCODED_URL_RE = re.compile(r"https?://[^\s${}]+") -def _walk_args(value: Any, location: str, findings: List[Dict[str, Any]]) -> None: +def _walk_args(value: Any, location: str, findings: list[dict[str, Any]]) -> None: """Recursively scan args for hard-coded URLs.""" if isinstance(value, str): if _HARDCODED_URL_RE.search(value) and "${ENV." not in value and "${ROW." not in value: @@ -69,7 +69,7 @@ def _walk_args(value: Any, location: str, findings: List[Dict[str, Any]]) -> Non _walk_args(sub, f"{location}[{index}]", findings) -def _check_action(action: Any, index: int, findings: List[Dict[str, Any]]) -> None: +def _check_action(action: Any, index: int, findings: list[dict[str, Any]]) -> None: location = f"action[{index}]" if not isinstance(action, list) or not action or not isinstance(action[0], str): # The validator covers structural problems; the linter only adds @@ -105,7 +105,7 @@ def _check_action(action: Any, index: int, findings: List[Dict[str, Any]]) -> No _walk_args(action[slot_index], f"{location}.args[{slot_index - 1}]", findings) -def _check_duplicates(action_list: List[Any], findings: List[Dict[str, Any]]) -> None: +def _check_duplicates(action_list: list[Any], findings: list[dict[str, Any]]) -> None: """Flag two identical consecutive actions.""" for index in range(1, len(action_list)): if action_list[index] == action_list[index - 1]: @@ -117,9 +117,9 @@ def _check_duplicates(action_list: List[Any], findings: List[Dict[str, Any]]) -> }) -def lint_action(data: Any) -> List[Dict[str, Any]]: +def lint_action(data: Any) -> list[dict[str, Any]]: """Walk an action structure and return the findings list.""" - findings: List[Dict[str, Any]] = [] + findings: list[dict[str, Any]] = [] if isinstance(data, dict): action_list = data.get("webdriver_wrapper") meta = data.get("meta") or {} @@ -140,7 +140,7 @@ def lint_action(data: Any) -> List[Dict[str, Any]]: return findings -def lint_action_file(path: str) -> List[Dict[str, Any]]: +def lint_action_file(path: str) -> list[dict[str, Any]]: """Read ``path`` (UTF-8 JSON) and lint the contents.""" file_path = Path(path) if not file_path.exists(): @@ -154,9 +154,9 @@ def lint_action_file(path: str) -> List[Dict[str, Any]]: return lint_action(data) -def severity_counts(findings: List[Dict[str, Any]]) -> Dict[str, int]: +def severity_counts(findings: list[dict[str, Any]]) -> dict[str, int]: """Aggregate ``{warning: N, info: M}`` for reporting.""" - counts: Dict[str, int] = {"warning": 0, "info": 0} + counts: dict[str, int] = {"warning": 0, "info": 0} for finding in findings: sev = finding.get("severity", "info") counts[sev] = counts.get(sev, 0) + 1 diff --git a/je_web_runner/utils/linter/locator_strength.py b/je_web_runner/utils/linter/locator_strength.py index 0ce8c068..9e2d755e 100644 --- a/je_web_runner/utils/linter/locator_strength.py +++ b/je_web_runner/utils/linter/locator_strength.py @@ -7,7 +7,7 @@ import re from dataclasses import dataclass -from typing import Any, Dict, Iterable, List, Optional, Tuple +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -21,7 +21,7 @@ class LocatorScore: strategy: str value: str score: int - reasons: List[str] + reasons: list[str] _TEST_ID_HINTS = ("data-testid", "data-test", "data-qa", "data-cy") @@ -29,7 +29,7 @@ class LocatorScore: _DYNAMIC_CLASS_PATTERN = re.compile(r"\b[a-z]+-[A-Za-z0-9]{6,}\b") -def _score_id(value: str) -> Tuple[int, List[str]]: +def _score_id(value: str) -> tuple[int, list[str]]: if not value: return 0, ["empty id"] if any(ch.isdigit() for ch in value) and len(value) >= 12: @@ -37,14 +37,14 @@ def _score_id(value: str) -> Tuple[int, List[str]]: return 95, [] -def _score_test_id_attr(value: str) -> Tuple[int, List[str]]: +def _score_test_id_attr(value: str) -> tuple[int, list[str]]: if any(hint in value for hint in _TEST_ID_HINTS): return 90, [] return 75, ["no data-test* attribute"] -def _score_css(value: str) -> Tuple[int, List[str]]: - reasons: List[str] = [] +def _score_css(value: str) -> tuple[int, list[str]]: + reasons: list[str] = [] score = 75 depth = value.count(">") + value.count(" ") if depth >= 4: @@ -61,8 +61,8 @@ def _score_css(value: str) -> Tuple[int, List[str]]: return max(20, score), reasons -def _score_xpath(value: str) -> Tuple[int, List[str]]: - reasons: List[str] = [] +def _score_xpath(value: str) -> tuple[int, list[str]]: + reasons: list[str] = [] score = 55 depth = value.count("/") if depth >= 5: @@ -80,7 +80,7 @@ def _score_xpath(value: str) -> Tuple[int, List[str]]: return max(15, min(score, 90)), reasons -def _score_text(value: str) -> Tuple[int, List[str]]: +def _score_text(value: str) -> tuple[int, list[str]]: score = 35 if value else 0 reasons = ["text-only locator (locale-fragile)"] return score, reasons @@ -124,12 +124,12 @@ def score_locator(strategy: str, value: str) -> LocatorScore: return LocatorScore(strategy=strategy, value=value, score=score, reasons=reasons) -def score_action_locators(actions: Iterable[Any]) -> List[Dict[str, Any]]: +def score_action_locators(actions: Iterable[Any]) -> list[dict[str, Any]]: """ 從 action JSON 中抽取 ``{test_object_name, object_type}`` 評分 Walk an action list and score every locator definition encountered. """ - findings: List[Dict[str, Any]] = [] + findings: list[dict[str, Any]] = [] for index, action in enumerate(actions): if not isinstance(action, list) or not action: continue @@ -153,7 +153,7 @@ def score_action_locators(actions: Iterable[Any]) -> List[Dict[str, Any]]: return findings -def _extract_kwargs(action: List[Any]) -> Dict[str, Any]: +def _extract_kwargs(action: list[Any]) -> dict[str, Any]: if len(action) >= 3 and isinstance(action[2], dict): return action[2] if len(action) >= 2 and isinstance(action[1], dict): @@ -161,13 +161,13 @@ def _extract_kwargs(action: List[Any]) -> Dict[str, Any]: return {} -def weakest(findings: Iterable[Dict[str, Any]], threshold: int = 50) -> List[Dict[str, Any]]: +def weakest(findings: Iterable[dict[str, Any]], threshold: int = 50) -> list[dict[str, Any]]: """Return entries scoring at or below ``threshold``.""" return [f for f in findings if isinstance(f.get("score"), int) and f["score"] <= threshold] -def assert_strength(findings: Iterable[Dict[str, Any]], minimum: int = 50, - raise_on_fail: bool = True) -> Optional[List[Dict[str, Any]]]: +def assert_strength(findings: Iterable[dict[str, Any]], minimum: int = 50, + raise_on_fail: bool = True) -> list[dict[str, Any]] | None: """Raise (or return) entries below ``minimum``.""" bad = weakest(findings, threshold=minimum - 1) if bad and raise_on_fail: diff --git a/je_web_runner/utils/linter/migration.py b/je_web_runner/utils/linter/migration.py index 0b242e3a..9af79cd1 100644 --- a/je_web_runner/utils/linter/migration.py +++ b/je_web_runner/utils/linter/migration.py @@ -7,7 +7,7 @@ import json from pathlib import Path -from typing import Any, Dict, List, Tuple +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -17,7 +17,7 @@ class MigrationError(WebRunnerException): """Raised when a file cannot be read or written.""" -_LEGACY_TO_NEW: Dict[str, str] = { +_LEGACY_TO_NEW: dict[str, str] = { "WR_get_webdriver_manager": "WR_new_driver", "WR_quit": "WR_quit_all", "WR_single_quit": "WR_quit_current", @@ -32,9 +32,9 @@ class MigrationError(WebRunnerException): } -def _migrate_action_list(action_list: List[Any]) -> Tuple[List[Any], List[Dict[str, Any]]]: - rewritten: List[Any] = [] - changes: List[Dict[str, Any]] = [] +def _migrate_action_list(action_list: list[Any]) -> tuple[list[Any], list[dict[str, Any]]]: + rewritten: list[Any] = [] + changes: list[dict[str, Any]] = [] for index, action in enumerate(action_list): if isinstance(action, list) and action and isinstance(action[0], str): new_name = _LEGACY_TO_NEW.get(action[0]) @@ -50,7 +50,7 @@ def _migrate_action_list(action_list: List[Any]) -> Tuple[List[Any], List[Dict[s return rewritten, changes -def migrate_action(data: Any) -> Tuple[Any, List[Dict[str, Any]]]: +def migrate_action(data: Any) -> tuple[Any, list[dict[str, Any]]]: """ 將 action 結構內所有舊命令名改寫為新名 Rewrite legacy command names to their preferred aliases. Returns @@ -70,7 +70,7 @@ def migrate_action(data: Any) -> Tuple[Any, List[Dict[str, Any]]]: return data, [] -def migrate_action_file(path: str, dry_run: bool = True) -> Dict[str, Any]: +def migrate_action_file(path: str, dry_run: bool = True) -> dict[str, Any]: """ Read ``path``, rewrite legacy names, optionally write back when ``dry_run`` is False. Returns ``{path, changes, written}``. @@ -80,20 +80,20 @@ def migrate_action_file(path: str, dry_run: bool = True) -> Dict[str, Any]: raise MigrationError(f"action file not found: {path}") web_runner_logger.info(f"migrate_action_file: {path} dry_run={dry_run}") try: - with open(file_path, encoding="utf-8") as action_file: + with open(file_path, encoding="utf-8") as action_file: # NOSONAR S8707 — developer-supplied path (own report/config file), not untrusted input data = json.load(action_file) except ValueError as error: raise MigrationError(f"action file not valid JSON: {path}") from error new_data, changes = migrate_action(data) written = False if changes and not dry_run: - with open(file_path, "w", encoding="utf-8") as action_file: + with open(file_path, "w", encoding="utf-8") as action_file: # NOSONAR S8707 — developer-supplied path (own report/config file), not untrusted input json.dump(new_data, action_file, indent=2, ensure_ascii=False) written = True return {"path": str(file_path), "changes": changes, "written": written} -def migrate_directory(directory: str, dry_run: bool = True) -> List[Dict[str, Any]]: +def migrate_directory(directory: str, dry_run: bool = True) -> list[dict[str, Any]]: """ 遍歷目錄內所有 ``.json`` 檔做遷移 Walk a directory, migrate every ``.json`` file, return a list of per-file diff --git a/je_web_runner/utils/live_dashboard/server.py b/je_web_runner/utils/live_dashboard/server.py index d6b9d756..e8e9df66 100644 --- a/je_web_runner/utils/live_dashboard/server.py +++ b/je_web_runner/utils/live_dashboard/server.py @@ -30,7 +30,7 @@ from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, Type, Union +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.flake_detector.detector import ( @@ -52,11 +52,11 @@ class DashboardConfig: """ 指定每個資料來源檔案路徑。任何一個 None 就會在 UI 上顯示成空白。 """ - ledger_path: Optional[Union[str, Path]] = None - quarantine_path: Optional[Union[str, Path]] = None - locator_findings_path: Optional[Union[str, Path]] = None - schedule_path: Optional[Union[str, Path]] = None - triage_report_path: Optional[Union[str, Path]] = None + ledger_path: str | Path | None = None + quarantine_path: str | Path | None = None + locator_findings_path: str | Path | None = None + schedule_path: str | Path | None = None + triage_report_path: str | Path | None = None bind_host: str = "127.0.0.1" bind_port: int = 0 @@ -72,7 +72,7 @@ def __post_init__(self) -> None: # ---------- data loaders ------------------------------------------------- -def _load_runs(ledger_path: Optional[Path], limit: int = 50) -> List[Dict[str, Any]]: +def _load_runs(ledger_path: Path | None, limit: int = 50) -> list[dict[str, Any]]: if ledger_path is None or not ledger_path.exists(): return [] try: @@ -87,7 +87,7 @@ def _load_runs(ledger_path: Optional[Path], limit: int = 50) -> List[Dict[str, A return [r for r in runs[-limit:][::-1] if isinstance(r, dict)] -def _load_flake_scores(ledger_path: Optional[Path]) -> List[Dict[str, Any]]: +def _load_flake_scores(ledger_path: Path | None) -> list[dict[str, Any]]: if ledger_path is None or not ledger_path.exists(): return [] try: @@ -100,7 +100,7 @@ def _load_flake_scores(ledger_path: Optional[Path]) -> List[Dict[str, Any]]: return entries -def _load_quarantine(quarantine_path: Optional[Path]) -> List[Dict[str, Any]]: +def _load_quarantine(quarantine_path: Path | None) -> list[dict[str, Any]]: if quarantine_path is None or not quarantine_path.exists(): return [] try: @@ -111,7 +111,7 @@ def _load_quarantine(quarantine_path: Optional[Path]) -> List[Dict[str, Any]]: return [e.to_dict() for e in registry.list()] -def _load_locator_report(path: Optional[Path]) -> Dict[str, Any]: +def _load_locator_report(path: Path | None) -> dict[str, Any]: if path is None or not path.exists(): return {} try: @@ -122,7 +122,7 @@ def _load_locator_report(path: Optional[Path]) -> Dict[str, Any]: return {} -def _load_schedule(path: Optional[Path]) -> Dict[str, Any]: +def _load_schedule(path: Path | None) -> dict[str, Any]: if path is None or not path.exists(): return {} try: @@ -133,7 +133,7 @@ def _load_schedule(path: Optional[Path]) -> Dict[str, Any]: return {} -def _load_triage(path: Optional[Path]) -> Dict[str, Any]: +def _load_triage(path: Path | None) -> dict[str, Any]: if path is None or not path.exists(): return {} try: @@ -146,7 +146,7 @@ def _load_triage(path: Optional[Path]) -> Dict[str, Any]: # ---------- summary ------------------------------------------------------ -def build_summary(config: DashboardConfig) -> Dict[str, Any]: +def build_summary(config: DashboardConfig) -> dict[str, Any]: """One-shot snapshot used by ``/`` and ``/api/summary``.""" runs = _load_runs(config.ledger_path, limit=10_000) total = len(runs) @@ -227,7 +227,7 @@ def _layout(title: str, body: str) -> str: ) -def _render_overview(summary: Dict[str, Any]) -> str: +def _render_overview(summary: dict[str, Any]) -> str: pass_rate_pct = f"{summary['pass_rate'] * 100:.1f}%" cards = [ ("Total runs", summary["total_runs"]), @@ -251,7 +251,7 @@ def _render_overview(summary: Dict[str, Any]) -> str: return _layout("Overview", body) -def _render_runs(runs: List[Dict[str, Any]]) -> str: +def _render_runs(runs: list[dict[str, Any]]) -> str: if not runs: return _layout("Runs", "

Runs

No runs recorded yet.
") rows = [] @@ -271,7 +271,7 @@ def _render_runs(runs: List[Dict[str, Any]]) -> str: return _layout("Runs", body) -def _render_flake(entries: List[Dict[str, Any]]) -> str: +def _render_flake(entries: list[dict[str, Any]]) -> str: flaky_only = [e for e in entries if e.get("is_flaky")] if not flaky_only: return _layout("Flake", "

Flake leaderboard

No flaky tests detected.
") @@ -293,7 +293,7 @@ def _render_flake(entries: List[Dict[str, Any]]) -> str: return _layout("Flake", body) -def _render_quarantine(entries: List[Dict[str, Any]]) -> str: +def _render_quarantine(entries: list[dict[str, Any]]) -> str: if not entries: return _layout("Quarantine", "

Quarantine

Registry is empty.
") rows = [] @@ -313,7 +313,7 @@ def _render_quarantine(entries: List[Dict[str, Any]]) -> str: return _layout("Quarantine", body) -def _render_locators(report: Dict[str, Any]) -> str: +def _render_locators(report: dict[str, Any]) -> str: if not report: return _layout("Locators", "

Locators

No locator report loaded.
") summary_cards = [ @@ -358,13 +358,13 @@ def _render_locators(report: Dict[str, Any]) -> str: # ---------- request handler --------------------------------------------- -def _make_handler(config: DashboardConfig) -> Type[BaseHTTPRequestHandler]: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up +def _make_handler(config: DashboardConfig) -> type[BaseHTTPRequestHandler]: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up """Bind ``config`` into a fresh handler class so each server is isolated.""" class DashboardHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" - def log_message(self, format: str, *args: Any) -> None: # noqa: A003 # pylint: disable=redefined-builtin — match BaseHTTPRequestHandler signature + def log_message(self, format: str, *args: Any) -> None: # pylint: disable=redefined-builtin — match BaseHTTPRequestHandler signature web_runner_logger.info(f"dashboard: {format % args}") def _send(self, status: int, content_type: str, body: bytes) -> None: @@ -396,7 +396,7 @@ def _query_limit(self, parsed) -> int: value = 50 return max(1, min(value, 5000)) - def do_GET(self) -> None: # noqa: N802 — http.server requires camelCase + def do_GET(self) -> None: parsed = urllib.parse.urlparse(self.path) path = parsed.path try: @@ -449,11 +449,11 @@ class DashboardServer: 所以可以從測試 / shell 直接用。 """ - def __init__(self, config: Optional[DashboardConfig] = None) -> None: + def __init__(self, config: DashboardConfig | None = None) -> None: self.config = config or DashboardConfig() - self._httpd: Optional[ThreadingHTTPServer] = None - self._thread: Optional[threading.Thread] = None - self._bound: Optional[Tuple[str, int]] = None + self._httpd: ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None + self._bound: tuple[str, int] | None = None def start(self) -> str: """Bind + spawn a daemon thread serving requests. Returns the URL.""" @@ -498,13 +498,13 @@ def url(self) -> str: if self._bound is None: raise LiveDashboardError("server not started") host, port = self._bound - if host in {"0.0.0.0", "::"}: # noqa: S5332 # nosec B104 — string compare detecting "bind all"; rewritten to 127.0.0.1 in the URL below + if host in {"0.0.0.0", "::"}: # nosec B104 — string compare detecting "bind all"; rewritten to 127.0.0.1 in the URL below host = "127.0.0.1" # S5332 ok: dashboard binds to loopback by default; intentionally HTTP # so the user can open it in a browser without a self-signed cert. - return f"http://{host}:{port}" # noqa: S5332 + return f"http://{host}:{port}" # NOSONAR S5332 — intentional plain HTTP (localhost/dev-configured endpoint), not a security-sensitive transport - def __enter__(self) -> "DashboardServer": + def __enter__(self) -> DashboardServer: self.start() return self diff --git a/je_web_runner/utils/llm_token_cost_tracker/tracker.py b/je_web_runner/utils/llm_token_cost_tracker/tracker.py index 841948dc..4bf441b8 100644 --- a/je_web_runner/utils/llm_token_cost_tracker/tracker.py +++ b/je_web_runner/utils/llm_token_cost_tracker/tracker.py @@ -17,7 +17,7 @@ from collections import defaultdict from dataclasses import asdict, dataclass -from typing import Any, Dict, Iterable, List, Mapping, Optional +from typing import Any, Iterable, Mapping from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -27,7 +27,7 @@ class LlmTokenCostError(WebRunnerException): # USD per 1K tokens (input, output). Conservative late-2025 numbers. -DEFAULT_RATE_CARD: Dict[str, Dict[str, float]] = { +DEFAULT_RATE_CARD: dict[str, dict[str, float]] = { "claude-opus-4-7": {"input": 0.015, "output": 0.075}, "claude-sonnet-4-6": {"input": 0.003, "output": 0.015}, "claude-haiku-4-5": {"input": 0.001, "output": 0.005}, @@ -59,7 +59,7 @@ class Tally: cost_usd: float = 0.0 calls: int = 0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -80,7 +80,7 @@ def _resolve_price( def compute_cost( record: CallRecord, *, - rate_card_override: Optional[Mapping[str, Mapping[str, float]]] = None, + rate_card_override: Mapping[str, Mapping[str, float]] | None = None, ) -> float: rates = dict(DEFAULT_RATE_CARD) if rate_card_override: @@ -92,7 +92,7 @@ def compute_cost( def tally( records: Iterable[CallRecord], *, - rate_card_override: Optional[Mapping[str, Mapping[str, float]]] = None, + rate_card_override: Mapping[str, Mapping[str, float]] | None = None, ) -> Tally: out = Tally() for r in records: @@ -108,9 +108,9 @@ def tally( def tally_by_test( records: Iterable[CallRecord], - *, rate_card_override: Optional[Mapping[str, Mapping[str, float]]] = None, -) -> Dict[str, Tally]: - buckets: Dict[str, List[CallRecord]] = defaultdict(list) + *, rate_card_override: Mapping[str, Mapping[str, float]] | None = None, +) -> dict[str, Tally]: + buckets: dict[str, list[CallRecord]] = defaultdict(list) for r in records: buckets[r.test_name or "(unknown)"].append(r) return {k: tally(v, rate_card_override=rate_card_override) @@ -130,7 +130,7 @@ def assert_under_budget( def top_spenders( by_test: Mapping[str, Tally], *, top_n: int = 5, -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: if top_n < 1: raise LlmTokenCostError("top_n must be >= 1") items = sorted(by_test.items(), key=lambda kv: -kv[1].cost_usd) diff --git a/je_web_runner/utils/load_test/locust_wrapper.py b/je_web_runner/utils/load_test/locust_wrapper.py index e0148827..573e0dc6 100644 --- a/je_web_runner/utils/load_test/locust_wrapper.py +++ b/je_web_runner/utils/load_test/locust_wrapper.py @@ -9,7 +9,7 @@ from __future__ import annotations import time -from typing import Any, Callable, Dict, List +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -30,7 +30,7 @@ def _require_locust(): ) from error -def _build_task(action: Dict[str, Any]) -> Callable: +def _build_task(action: dict[str, Any]) -> Callable: """Turn an action dict into a Locust task method.""" method = (action.get("method") or "GET").upper() path = action.get("path") or "/" @@ -54,7 +54,7 @@ def run_task(self): def build_http_user_class( - actions: List[Dict[str, Any]], + actions: list[dict[str, Any]], wait_min: float = 1.0, wait_max: float = 3.0, ) -> type: @@ -66,7 +66,7 @@ def build_http_user_class( ``json_body``, ``headers``, ``params``. """ http_user_cls, between, task, _environment_cls = _require_locust() - attrs: Dict[str, Any] = {"wait_time": between(wait_min, wait_max)} + attrs: dict[str, Any] = {"wait_time": between(wait_min, wait_max)} for index, action in enumerate(actions): weight = int(action.get("weight", 1)) attrs[f"task_{index}"] = task(weight)(_build_task(action)) @@ -77,13 +77,13 @@ def build_http_user_class( def run_locust( host: str, - actions: List[Dict[str, Any]], + actions: list[dict[str, Any]], num_users: int = 10, spawn_rate: float = 2.0, run_seconds: float = 60.0, wait_min: float = 1.0, wait_max: float = 3.0, -) -> Dict[str, Any]: +) -> dict[str, Any]: """ 無頭模式跑 Locust 並回傳統計 Run Locust headlessly against ``host`` with the given actions and return @@ -92,7 +92,7 @@ def run_locust( :param run_seconds: 總時長 / total run time in seconds :return: dict with ``total`` and ``per_endpoint`` """ - if not isinstance(host, str) or not (host.startswith("http://") or host.startswith("https://")): # NOSONAR — scheme allow-list, not an outbound HTTP call + if not isinstance(host, str) or not (host.startswith(("http://", "https://"))): # NOSONAR — scheme allow-list, not an outbound HTTP call raise LoadTestError(f"host must be http(s): {host!r}") web_runner_logger.info(f"run_locust host={host} users={num_users} run_seconds={run_seconds}") _http_user_cls, _between, _task, environment_cls = _require_locust() @@ -110,9 +110,9 @@ def run_locust( return _summarise_stats(env.stats) -def _summarise_stats(stats: Any) -> Dict[str, Any]: +def _summarise_stats(stats: Any) -> dict[str, Any]: """Pull a small dict summary out of Locust's stats object.""" - per_endpoint: List[Dict[str, Any]] = [] + per_endpoint: list[dict[str, Any]] = [] for entry in getattr(stats, "entries", {}).values(): per_endpoint.append({ "name": entry.name, diff --git a/je_web_runner/utils/locator_hardener/hardener.py b/je_web_runner/utils/locator_hardener/hardener.py index 86458b37..eb3f5952 100644 --- a/je_web_runner/utils/locator_hardener/hardener.py +++ b/je_web_runner/utils/locator_hardener/hardener.py @@ -22,7 +22,7 @@ import re from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, List, Protocol +from typing import Any, Protocol from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -74,7 +74,7 @@ def __post_init__(self) -> None: _NTH_PATTERN = re.compile(r":nth-(?:of-type|child)\(\d+\)", re.IGNORECASE) # NOSONAR python:S5852 — input is a CSS selector (bounded, internal), not user text -_DEEP_DESCENDANT = re.compile(r"\s+\S+\s+\S+\s+\S+") # noqa: S5852 +_DEEP_DESCENDANT = re.compile(r"\s+\S+\s+\S+\s+\S+") _HASHED_CLASS = re.compile(r"[._][A-Za-z][\w-]*?-_?\w{4,}\b") _TEXT_XPATH = re.compile(r"text\s*\(\s*\)", re.IGNORECASE) @@ -84,14 +84,14 @@ class FragilityScore: """Heuristic locator-fragility score (0..1).""" score: float - reasons: List[str] = field(default_factory=list) + reasons: list[str] = field(default_factory=list) def score_fragility(locator: FragileLocator) -> FragilityScore: """Quick non-LLM check. Anything ``score >= 0.5`` is worth hardening.""" if not isinstance(locator, FragileLocator): raise LocatorHardenerError("expects FragileLocator") - reasons: List[str] = [] + reasons: list[str] = [] score = 0.0 if locator.strategy == LocatorStrategy.XPATH: score += 0.2 @@ -178,11 +178,11 @@ class LocatorSuggestion: value: str rationale: str - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {"strategy": self.strategy.value, "value": self.value, "rationale": self.rationale} -def parse_suggestions(raw: str) -> List[LocatorSuggestion]: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up PR +def parse_suggestions(raw: str) -> list[LocatorSuggestion]: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up PR """Decode the LLM's JSON array; reject malformed entries.""" if not isinstance(raw, str) or not raw.strip(): raise LocatorHardenerError("LLM returned empty response") @@ -198,8 +198,8 @@ def parse_suggestions(raw: str) -> List[LocatorSuggestion]: # NOSONAR S3776 — ) from error if not isinstance(obj, list): raise LocatorHardenerError("suggestions must be a list") - out: List[LocatorSuggestion] = [] - for index, raw_item in enumerate(obj): + out: list[LocatorSuggestion] = [] + for raw_item in obj: if not isinstance(raw_item, dict): continue strategy_str = raw_item.get("strategy") or "" @@ -236,7 +236,7 @@ def harden( client: HardenerClient, *, min_fragility: float = 0.5, -) -> List[LocatorSuggestion]: +) -> list[LocatorSuggestion]: """Score → maybe-skip → ask LLM → parse → return.""" if not 0.0 <= min_fragility <= 1.0: raise LocatorHardenerError("min_fragility must be in [0, 1]") diff --git a/je_web_runner/utils/locator_health/health_report.py b/je_web_runner/utils/locator_health/health_report.py index 478191e8..f726ae63 100644 --- a/je_web_runner/utils/locator_health/health_report.py +++ b/je_web_runner/utils/locator_health/health_report.py @@ -17,7 +17,7 @@ import threading from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Union +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.linter.locator_strength import ( @@ -43,8 +43,8 @@ class FallbackHitTracker: def __init__(self) -> None: self._lock = threading.Lock() - self._hits: Dict[str, int] = {} - self._fallback_used: Dict[str, int] = {} + self._hits: dict[str, int] = {} + self._fallback_used: dict[str, int] = {} def track_primary(self, name: str) -> None: with self._lock: @@ -55,7 +55,7 @@ def track_fallback(self, name: str) -> None: self._hits[name] = self._hits.get(name, 0) + 1 self._fallback_used[name] = self._fallback_used.get(name, 0) + 1 - def stats(self) -> Dict[str, Dict[str, int]]: + def stats(self) -> dict[str, dict[str, int]]: with self._lock: return { name: { @@ -85,8 +85,8 @@ class LocatorFinding: strategy: str value: str score: int - reasons: List[str] = field(default_factory=list) - name: Optional[str] = None + reasons: list[str] = field(default_factory=list) + name: str | None = None hits: int = 0 fallback_used: int = 0 @@ -94,13 +94,13 @@ class LocatorFinding: def fallback_rate(self) -> float: return (self.fallback_used / self.hits) if self.hits else 0.0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: out = asdict(self) out["fallback_rate"] = round(self.fallback_rate, 4) return out -def _walk_actions(payload: Any) -> Iterable[List[Any]]: +def _walk_actions(payload: Any) -> Iterable[list[Any]]: """Yield every action list inside ``payload`` (top-level list or nested).""" if isinstance(payload, list): for item in payload: @@ -108,7 +108,7 @@ def _walk_actions(payload: Any) -> Iterable[List[Any]]: yield item -def _extract_locator(action: List[Any]) -> Optional[Dict[str, Any]]: +def _extract_locator(action: list[Any]) -> dict[str, Any] | None: if len(action) < 2: return None kwargs = None @@ -126,7 +126,7 @@ def _extract_locator(action: List[Any]) -> Optional[Dict[str, Any]]: return {"strategy": str(strategy), "value": str(value), "name": str(name) if name else None} -def scan_action_file(file_path: Union[str, Path]) -> List[LocatorFinding]: +def scan_action_file(file_path: str | Path) -> list[LocatorFinding]: """Score every locator inside one action JSON file.""" path = Path(file_path) if not path.is_file(): @@ -137,7 +137,7 @@ def scan_action_file(file_path: Union[str, Path]) -> List[LocatorFinding]: except (OSError, ValueError) as error: raise LocatorHealthError(f"cannot parse {path}: {error!r}") from error - findings: List[LocatorFinding] = [] + findings: list[LocatorFinding] = [] hit_stats = fallback_hit_tracker.stats() for index, action in enumerate(_walk_actions(payload)): locator = _extract_locator(action) @@ -167,9 +167,9 @@ def scan_action_file(file_path: Union[str, Path]) -> List[LocatorFinding]: def scan_project( - root: Union[str, Path], + root: str | Path, pattern: str = "**/*.json", -) -> List[LocatorFinding]: +) -> list[LocatorFinding]: """ 掃整個專案的 action JSON、收集所有 locator finding。 Walk ``root`` for files matching ``pattern`` and score every locator. @@ -179,7 +179,7 @@ def scan_project( root = Path(root) if not root.is_dir(): raise LocatorHealthError(f"project root is not a directory: {root}") - findings: List[LocatorFinding] = [] + findings: list[LocatorFinding] = [] for file_path in sorted(root.glob(pattern)): if not file_path.is_file(): continue @@ -200,12 +200,12 @@ class LocatorHealthReport: weak: int strong: int average_score: float - findings: List[LocatorFinding] = field(default_factory=list) - weakest: List[LocatorFinding] = field(default_factory=list) - fallback_offenders: List[LocatorFinding] = field(default_factory=list) + findings: list[LocatorFinding] = field(default_factory=list) + weakest: list[LocatorFinding] = field(default_factory=list) + fallback_offenders: list[LocatorFinding] = field(default_factory=list) threshold: int = 60 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "total": self.total, "weak": self.weak, @@ -274,11 +274,11 @@ class UpgradeSuggestion: to_value: str rationale: str - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) -_STRATEGY_PRIORITY: Dict[str, int] = { +_STRATEGY_PRIORITY: dict[str, int] = { "ID": 100, "id": 100, "NAME": 70, "name": 70, "CSS_SELECTOR": 75, "css selector": 75, "css": 75, @@ -290,7 +290,7 @@ def to_dict(self) -> Dict[str, Any]: } -def _suggest_for_xpath(finding: LocatorFinding) -> Optional[UpgradeSuggestion]: +def _suggest_for_xpath(finding: LocatorFinding) -> UpgradeSuggestion | None: """Heuristic: if an XPath anchors on ``@id='X'``, suggest using ID directly.""" value = finding.value # //*[@id='foo'] or //tag[@id="foo"] @@ -321,7 +321,7 @@ def _suggest_for_xpath(finding: LocatorFinding) -> Optional[UpgradeSuggestion]: return None -def _suggest_for_css(finding: LocatorFinding) -> Optional[UpgradeSuggestion]: +def _suggest_for_css(finding: LocatorFinding) -> UpgradeSuggestion | None: """Heuristic: if a CSS selector anchors on ``#id`` alone, suggest ID.""" value = finding.value.strip() if value.startswith("#") and " " not in value and ">" not in value: @@ -337,7 +337,7 @@ def _suggest_for_css(finding: LocatorFinding) -> Optional[UpgradeSuggestion]: return None -def suggest_upgrade(finding: LocatorFinding) -> Optional[UpgradeSuggestion]: +def suggest_upgrade(finding: LocatorFinding) -> UpgradeSuggestion | None: """ 回傳 finding 的一個升級建議;找不到合理建議回 None。 Look for a structural pattern that points at a better strategy. Returns @@ -354,15 +354,15 @@ def suggest_upgrade(finding: LocatorFinding) -> Optional[UpgradeSuggestion]: def suggest_upgrades( findings: Iterable[LocatorFinding], *, - only_below: Optional[int] = None, -) -> List[UpgradeSuggestion]: + only_below: int | None = None, +) -> list[UpgradeSuggestion]: """ 對一批 finding 收集所有可行的升級建議。 Walk a finding list and return every upgrade suggestion. Pass ``only_below`` to skip findings whose static score is already above a chosen threshold. """ - suggestions: List[UpgradeSuggestion] = [] + suggestions: list[UpgradeSuggestion] = [] for finding in findings: if only_below is not None and finding.score >= only_below: continue @@ -373,9 +373,9 @@ def suggest_upgrades( def apply_upgrades( # NOSONAR S3776 — cohesive logic; planned refactor in follow-up - actions: List[Any], + actions: list[Any], suggestions: Iterable[UpgradeSuggestion], -) -> List[Any]: +) -> list[Any]: """ 根據 suggestion 把 action list 內的 locator 改寫,回傳新的 list。 Non-mutating: returns a deep-copied action list with the chosen @@ -384,7 +384,7 @@ def apply_upgrades( # NOSONAR S3776 — cohesive logic; planned refactor in fol """ import copy as _copy new_actions = _copy.deepcopy(actions) - by_index: Dict[int, UpgradeSuggestion] = {} + by_index: dict[int, UpgradeSuggestion] = {} for s in suggestions: by_index[s.action_index] = s for index, action in enumerate(new_actions): @@ -451,7 +451,7 @@ def render_health_markdown(report: LocatorHealthReport) -> str: def save_health_report( report: LocatorHealthReport, - output_path: Union[str, Path], + output_path: str | Path, ) -> Path: """Persist the JSON form of the report next to a CI artifact.""" path = Path(output_path) diff --git a/je_web_runner/utils/long_animation_frame/frames.py b/je_web_runner/utils/long_animation_frame/frames.py index 9bf2799c..abf69984 100644 --- a/je_web_runner/utils/long_animation_frame/frames.py +++ b/je_web_runner/utils/long_animation_frame/frames.py @@ -14,7 +14,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field -from typing import Any, Dict, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -92,19 +92,19 @@ class LongFrame: render_start_ms: float style_layout_start_ms: float blocking_duration_ms: float - scripts: List[ScriptAttribution] = field(default_factory=list) + scripts: list[ScriptAttribution] = field(default_factory=list) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) -def parse_log(payload: Any) -> List[LongFrame]: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up +def parse_log(payload: Any) -> list[LongFrame]: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up """Convert the harvested ``__wr_loaf_log__`` array into typed frames.""" if not isinstance(payload, list): raise LongAnimationFrameError( f"payload must be list, got {type(payload).__name__}" ) - out: List[LongFrame] = [] + out: list[LongFrame] = [] for raw in payload: if not isinstance(raw, dict): continue @@ -145,7 +145,7 @@ def parse_log(payload: Any) -> List[LongFrame]: # NOSONAR S3776 — cohesive lo class LoafReport: """Rolled-up view across all frames.""" - frames: List[LongFrame] = field(default_factory=list) + frames: list[LongFrame] = field(default_factory=list) def worst_frame_ms(self) -> float: return max((f.duration_ms for f in self.frames), default=0.0) @@ -153,9 +153,9 @@ def worst_frame_ms(self) -> float: def total_blocking_ms(self) -> float: return sum(f.blocking_duration_ms for f in self.frames) - def top_scripts(self, *, n: int = 5) -> List[ScriptAttribution]: + def top_scripts(self, *, n: int = 5) -> list[ScriptAttribution]: """Top N scripts by aggregated duration across all frames.""" - bucket: Dict[str, ScriptAttribution] = {} + bucket: dict[str, ScriptAttribution] = {} for frame in self.frames: for s in frame.scripts: key = s.source_url or s.name or s.invoker diff --git a/je_web_runner/utils/md_authoring/markdown_to_actions.py b/je_web_runner/utils/md_authoring/markdown_to_actions.py index e66df0b9..a5781b82 100644 --- a/je_web_runner/utils/md_authoring/markdown_to_actions.py +++ b/je_web_runner/utils/md_authoring/markdown_to_actions.py @@ -21,7 +21,7 @@ import re from pathlib import Path -from typing import Any, List, Optional, Tuple, Union +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -35,7 +35,7 @@ class MdAuthoringError(WebRunnerException): _BULLET_RE = re.compile(r"^\s*[-*]\s*(.*)$") # NOSONAR S5852 — greedy ``.*`` anchored to ``$``, no backtracking -def _strategy_value_for(selector: str) -> Tuple[str, str]: +def _strategy_value_for(selector: str) -> tuple[str, str]: selector = selector.strip() if selector.startswith("#"): return "ID", selector[1:] @@ -52,7 +52,7 @@ def _strategy_value_for(selector: str) -> Tuple[str, str]: return "CSS_SELECTOR", selector -def _click_actions(selector: str) -> List[List[Any]]: +def _click_actions(selector: str) -> list[list[Any]]: strategy, value = _strategy_value_for(selector) return [ ["WR_save_test_object", {"test_object_name": value, "object_type": strategy}], @@ -61,7 +61,7 @@ def _click_actions(selector: str) -> List[List[Any]]: ] -def _type_actions(text: str, selector: str) -> List[List[Any]]: +def _type_actions(text: str, selector: str) -> list[list[Any]]: strategy, value = _strategy_value_for(selector) return [ ["WR_save_test_object", {"test_object_name": value, "object_type": strategy}], @@ -89,7 +89,7 @@ def _type_actions(text: str, selector: str) -> List[List[Any]]: _QUIT_RE = re.compile(r"^quit$", re.IGNORECASE) -def _parse_bullet(text: str) -> Optional[List[List[Any]]]: +def _parse_bullet(text: str) -> list[list[Any]] | None: match = _OPEN_RE.match(text) if match: return [["WR_to_url", {"url": match.group(1)}]] @@ -121,7 +121,7 @@ def _parse_bullet(text: str) -> Optional[List[List[Any]]]: return None -def parse_markdown(text: str) -> List[List[Any]]: +def parse_markdown(text: str) -> list[list[Any]]: """ 把 Markdown bullets 解析成 action list;無法辨識的條目保留為 ``WR__note``。 Parse a Markdown body and return a flat WR_* action list. Each bullet @@ -130,7 +130,7 @@ def parse_markdown(text: str) -> List[List[Any]]: """ if not isinstance(text, str): raise MdAuthoringError("input must be str") - actions: List[List[Any]] = [] + actions: list[list[Any]] = [] for raw_line in text.splitlines(): match = _BULLET_RE.match(raw_line) if match is None: @@ -149,9 +149,9 @@ def parse_markdown(text: str) -> List[List[Any]]: def transpile_file( - md_path: Union[str, Path], - output_path: Optional[Union[str, Path]] = None, -) -> List[List[Any]]: + md_path: str | Path, + output_path: str | Path | None = None, +) -> list[list[Any]]: """ 讀 ``md_path``,轉成 action list。``output_path`` 提供時會寫成格式化 JSON。 Read ``md_path``, transpile, and optionally write the formatted JSON @@ -167,7 +167,7 @@ def transpile_file( return actions -def supported_bullet_patterns() -> List[str]: +def supported_bullet_patterns() -> list[str]: """Return the list of bullet templates the parser recognises.""" return [ "open ", diff --git a/je_web_runner/utils/memory_leak/detector.py b/je_web_runner/utils/memory_leak/detector.py index 0565cd08..242faa2c 100644 --- a/je_web_runner/utils/memory_leak/detector.py +++ b/je_web_runner/utils/memory_leak/detector.py @@ -7,7 +7,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Callable, List, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -46,7 +46,7 @@ def sample_used_heap(driver: Any) -> int: return int(value) -def _slope(samples: List[MemorySample]) -> float: +def _slope(samples: list[MemorySample]) -> float: n = len(samples) if n < 2: return 0.0 @@ -65,8 +65,8 @@ def detect_growth( action: Callable[[], None], iterations: int = 5, warmup: int = 1, - growth_bytes_per_iter_budget: Optional[int] = None, - sampler: Optional[Callable[[Any], int]] = None, + growth_bytes_per_iter_budget: int | None = None, + sampler: Callable[[Any], int] | None = None, ) -> dict: """ 跑 ``action`` N 次,回傳每輪的 heap 樣本與線性斜率 @@ -82,7 +82,7 @@ def detect_growth( for _ in range(max(0, warmup)): action() used_sampler(driver) # discard - samples: List[MemorySample] = [] + samples: list[MemorySample] = [] for index in range(iterations): action() size = used_sampler(driver) diff --git a/je_web_runner/utils/memory_pressure_emulate/emulate.py b/je_web_runner/utils/memory_pressure_emulate/emulate.py index 37a406bb..36a97aa4 100644 --- a/je_web_runner/utils/memory_pressure_emulate/emulate.py +++ b/je_web_runner/utils/memory_pressure_emulate/emulate.py @@ -8,7 +8,7 @@ import logging from dataclasses import dataclass from enum import Enum -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -37,7 +37,7 @@ class EmulationProfile: hardware_concurrency: int = 2 pressure_level: PressureLevel = PressureLevel.FAIR cpu_throttle_rate: float = 1.0 # 1.0 = normal, 4.0 = 4x slower - js_heap_limit_bytes: Optional[int] = None + js_heap_limit_bytes: int | None = None def __post_init__(self) -> None: if self.hardware_concurrency <= 0: @@ -64,14 +64,14 @@ def __post_init__(self) -> None: # ---------- CDP commands ------------------------------------------------ -def cdp_payloads(profile: EmulationProfile) -> List[Dict[str, Any]]: +def cdp_payloads(profile: EmulationProfile) -> list[dict[str, Any]]: """ Render the CDP commands a user's CDP-send callable should execute. Each entry is ``{"method": ..., "params": ...}``. """ if not isinstance(profile, EmulationProfile): raise MemoryPressureError("profile must be EmulationProfile") - commands: List[Dict[str, Any]] = [ + commands: list[dict[str, Any]] = [ {"method": "Emulation.setHardwareConcurrencyOverride", "params": {"hardwareConcurrency": profile.hardware_concurrency}}, {"method": "Emulation.setCPUThrottlingRate", @@ -96,12 +96,12 @@ class PressureRunOutcome: profile: str passed: bool duration_seconds: float = 0.0 - error: Optional[str] = None + error: str | None = None def run_under_profile( profile: EmulationProfile, - cdp_send: Callable[[str, Dict[str, Any]], Any], + cdp_send: Callable[[str, dict[str, Any]], Any], test_callable: Callable[[], None], ) -> PressureRunOutcome: """ @@ -120,7 +120,7 @@ def run_under_profile( raise MemoryPressureError(f"CDP apply failed: {error!r}") from error started = time.monotonic() passed = True - error_msg: Optional[str] = None + error_msg: str | None = None try: test_callable() except Exception as exc: @@ -132,7 +132,7 @@ def run_under_profile( cdp_send("Emulation.setHardwareConcurrencyOverride", {"hardwareConcurrency": 0}) cdp_send("Emulation.setCPUThrottlingRate", {"rate": 1.0}) cdp_send("Memory.simulatePressureNotification", {"level": "nominal"}) - except Exception as restore_err: # noqa: BLE001 - best-effort cleanup + except Exception as restore_err: # Don't mask the test result by re-raising here; CDP restore failure # is logged-only so a successful run isn't downgraded to error. _LOGGER.warning("CDP pressure restore failed: %r", restore_err) diff --git a/je_web_runner/utils/mixed_content_audit/audit.py b/je_web_runner/utils/mixed_content_audit/audit.py index 0fb4133c..08862ef5 100644 --- a/je_web_runner/utils/mixed_content_audit/audit.py +++ b/je_web_runner/utils/mixed_content_audit/audit.py @@ -18,7 +18,7 @@ import re from dataclasses import asdict, dataclass from enum import Enum -from typing import Any, Dict, Iterable, List, Optional, Sequence, Union +from typing import Any, Iterable, Sequence from urllib.parse import urlparse from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -60,7 +60,7 @@ class MixedFinding: severity: Severity source_url: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "severity": self.severity.value} @@ -99,10 +99,10 @@ def _hostname(url: str) -> str: # ---------- scanners --------------------------------------------------- def scan_har( # NOSONAR S3776 — cohesive logic; planned refactor in follow-up - har: Union[str, Dict[str, Any]], + har: str | dict[str, Any], *, - page_url: Optional[str] = None, -) -> List[MixedFinding]: + page_url: str | None = None, +) -> list[MixedFinding]: """ Parse a HAR object/string, returning one finding per http request on an https page. When ``page_url`` is None, we assume the first entry's @@ -117,7 +117,7 @@ def scan_har( # NOSONAR S3776 — cohesive logic; planned refactor in follow-up if document_url and not _is_https_origin(document_url): return [] # no risk if the page itself is http - findings: List[MixedFinding] = [] + findings: list[MixedFinding] = [] for entry in entries: if not isinstance(entry, dict): continue @@ -142,7 +142,7 @@ def scan_har( # NOSONAR S3776 — cohesive logic; planned refactor in follow-up return findings -def _coerce_har(har: Union[str, Dict[str, Any]]) -> Dict[str, Any]: +def _coerce_har(har: str | dict[str, Any]) -> dict[str, Any]: if isinstance(har, str): try: parsed = json.loads(har) @@ -158,7 +158,7 @@ def _coerce_har(har: Union[str, Dict[str, Any]]) -> Dict[str, Any]: ) -def _first_page_url(har: Dict[str, Any]) -> Optional[str]: +def _first_page_url(har: dict[str, Any]) -> str | None: pages = ((har.get("log") or {}).get("pages")) or [] if isinstance(pages, list) and pages: first = pages[0] @@ -180,9 +180,9 @@ def scan_console_errors( messages: Iterable[str], *, page_url: str = "", -) -> List[MixedFinding]: +) -> list[MixedFinding]: """Heuristic scan over console errors for ``Mixed Content:`` lines.""" - out: List[MixedFinding] = [] + out: list[MixedFinding] = [] for line in messages: if not isinstance(line, str): continue @@ -224,9 +224,9 @@ def assert_clean(findings: Sequence[MixedFinding]) -> None: raise MixedContentAuditError(f"mixed content detected: {sample}{more}") -def summary(findings: Sequence[MixedFinding]) -> Dict[str, int]: +def summary(findings: Sequence[MixedFinding]) -> dict[str, int]: """Return ``{severity: count}`` summary.""" - out: Dict[str, int] = {} + out: dict[str, int] = {} for f in findings: out[f.severity.value] = out.get(f.severity.value, 0) + 1 return out diff --git a/je_web_runner/utils/mock_services/servers.py b/je_web_runner/utils/mock_services/servers.py index e3ddd412..2e58f816 100644 --- a/je_web_runner/utils/mock_services/servers.py +++ b/je_web_runner/utils/mock_services/servers.py @@ -15,7 +15,7 @@ import threading from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -30,7 +30,7 @@ class MockServiceError(WebRunnerException): class MockS3Storage: """In-memory key-value store mimicking S3 ``put_object`` / ``get_object``.""" - buckets: Dict[str, Dict[str, bytes]] = field(default_factory=dict) + buckets: dict[str, dict[str, bytes]] = field(default_factory=dict) def create_bucket(self, name: str) -> None: self.buckets.setdefault(name, {}) @@ -48,7 +48,7 @@ def get_object(self, bucket: str, key: str) -> bytes: except KeyError as error: raise MockServiceError(f"object {key!r} not found in {bucket!r}") from error - def list_objects(self, bucket: str) -> List[str]: + def list_objects(self, bucket: str) -> list[str]: return sorted(self.buckets.get(bucket, {}).keys()) @@ -62,7 +62,7 @@ class _SmtpHandler(socketserver.StreamRequestHandler): def handle(self) -> None: # pragma: no cover - exercised via integration paths server: Any = self.server # type: ignore[assignment] self.wfile.write(b"220 mock SMTP\r\n") - state: Dict[str, Any] = {"in_data": False, "lines": []} + state: dict[str, Any] = {"in_data": False, "lines": []} while True: line = self.rfile.readline() if not line: @@ -74,7 +74,7 @@ def handle(self) -> None: # pragma: no cover - exercised via integration paths break -def _handle_command(handler: Any, decoded: str, state: Dict[str, Any]) -> bool: +def _handle_command(handler: Any, decoded: str, state: dict[str, Any]) -> bool: """Process one command line; return False to close the connection.""" upper = decoded.upper().strip() if upper == "QUIT": @@ -88,7 +88,7 @@ def _handle_command(handler: Any, decoded: str, state: Dict[str, Any]) -> bool: return True -def _handle_data_line(handler: Any, server: Any, state: Dict[str, Any], decoded: str) -> None: +def _handle_data_line(handler: Any, server: Any, state: dict[str, Any], decoded: str) -> None: if decoded.strip() == ".": server.captured.append("".join(state["lines"])) state["lines"] = [] @@ -104,9 +104,9 @@ class MockSmtpServer: def __init__(self, host: str = "127.0.0.1", port: int = 0) -> None: self.host = host self.port = port - self.captured: List[str] = [] - self._server: Optional[socketserver.TCPServer] = None - self._thread: Optional[threading.Thread] = None + self.captured: list[str] = [] + self._server: socketserver.TCPServer | None = None + self._thread: threading.Thread | None = None def start(self) -> int: if self._server is not None: @@ -130,7 +130,7 @@ def stop(self) -> None: # ----- OAuth token issuer ---------------------------------------------------- -def _make_oauth_handler(server_state: Dict[str, Any]) -> Callable: +def _make_oauth_handler(server_state: dict[str, Any]) -> Callable: class _OAuthRequestHandler(BaseHTTPRequestHandler): @@ -139,7 +139,7 @@ def log_message(self, format, *args): # pylint: disable=redefined-builtin # silence stdlib-driven access logging. return - def _send(self, status: int, payload: Dict[str, Any]) -> None: + def _send(self, status: int, payload: dict[str, Any]) -> None: body = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") @@ -147,7 +147,7 @@ def _send(self, status: int, payload: Dict[str, Any]) -> None: self.end_headers() self.wfile.write(body) - def do_POST(self): # noqa: N802 - http.server convention + def do_POST(self): if self.path == "/token": token = secrets.token_hex(16) server_state["issued"].append(token) @@ -168,10 +168,10 @@ class MockOAuthServer: def __init__(self, host: str = "127.0.0.1", port: int = 0) -> None: self.host = host self.port = port - self.issued: List[str] = [] - self._state: Dict[str, Any] = {"issued": self.issued} - self._server: Optional[HTTPServer] = None - self._thread: Optional[threading.Thread] = None + self.issued: list[str] = [] + self._state: dict[str, Any] = {"issued": self.issued} + self._server: HTTPServer | None = None + self._thread: threading.Thread | None = None def start(self) -> str: if self._server is not None: diff --git a/je_web_runner/utils/mq_assert/assertions.py b/je_web_runner/utils/mq_assert/assertions.py index f5f7a291..9b95d75e 100644 --- a/je_web_runner/utils/mq_assert/assertions.py +++ b/je_web_runner/utils/mq_assert/assertions.py @@ -10,7 +10,7 @@ import json from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Protocol, Sequence +from typing import Any, Protocol, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -23,10 +23,10 @@ class MqAssertError(WebRunnerException): class Message: topic: str body: Any - key: Optional[str] = None - headers: Dict[str, str] = field(default_factory=dict) + key: str | None = None + headers: dict[str, str] = field(default_factory=dict) - def body_as_dict(self) -> Dict[str, Any]: + def body_as_dict(self) -> dict[str, Any]: if isinstance(self.body, dict): return self.body if isinstance(self.body, (bytes, str)): @@ -48,7 +48,7 @@ def drain(self, topic: str, *, timeout: float = 5.0) -> Sequence[Message]: ... def drain_topic( consumer: Consumer, topic: str, timeout: float = 5.0, -) -> List[Message]: +) -> list[Message]: if not topic: raise MqAssertError("topic must be non-empty") if not hasattr(consumer, "drain"): @@ -56,7 +56,7 @@ def drain_topic( raw = consumer.drain(topic, timeout=timeout) if not isinstance(raw, (list, tuple)): raise MqAssertError("consumer.drain must return a sequence") - out: List[Message] = [] + out: list[Message] = [] for m in raw: if isinstance(m, Message): out.append(m) @@ -74,11 +74,11 @@ def drain_topic( return out -def _headers_match(message: Message, header_equals: Dict[str, str]) -> bool: +def _headers_match(message: Message, header_equals: dict[str, str]) -> bool: return all(message.headers.get(k) == v for k, v in header_equals.items()) -def _body_matches(message: Message, body_contains: Dict[str, Any]) -> bool: +def _body_matches(message: Message, body_contains: dict[str, Any]) -> bool: try: body = message.body_as_dict() except MqAssertError: @@ -87,9 +87,9 @@ def _body_matches(message: Message, body_contains: Dict[str, Any]) -> bool: def _matches(message: Message, *, - body_contains: Optional[Dict[str, Any]] = None, - key_matches: Optional[str] = None, - header_equals: Optional[Dict[str, str]] = None) -> bool: + body_contains: dict[str, Any] | None = None, + key_matches: str | None = None, + header_equals: dict[str, str] | None = None) -> bool: if key_matches is not None and message.key != key_matches: return False if header_equals and not _headers_match(message, header_equals): @@ -102,9 +102,9 @@ def _matches(message: Message, *, def assert_message_published( messages: Sequence[Message], *, - body_contains: Optional[Dict[str, Any]] = None, - key_matches: Optional[str] = None, - header_equals: Optional[Dict[str, str]] = None, + body_contains: dict[str, Any] | None = None, + key_matches: str | None = None, + header_equals: dict[str, str] | None = None, ) -> Message: """Find one matching message or raise.""" if not isinstance(messages, (list, tuple)): @@ -123,8 +123,8 @@ def assert_message_published( def assert_no_message( messages: Sequence[Message], *, - topic: Optional[str] = None, - body_contains: Optional[Dict[str, Any]] = None, + topic: str | None = None, + body_contains: dict[str, Any] | None = None, ) -> None: """Useful for `should NOT have published anything sensitive`.""" for m in messages: diff --git a/je_web_runner/utils/multi_tab/choreographer.py b/je_web_runner/utils/multi_tab/choreographer.py index d340b105..7a94be21 100644 --- a/je_web_runner/utils/multi_tab/choreographer.py +++ b/je_web_runner/utils/multi_tab/choreographer.py @@ -7,7 +7,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -27,7 +27,7 @@ class TabHandle: class TabChoreographer: """Track and switch between named browser tabs.""" - tabs: Dict[str, TabHandle] = field(default_factory=dict) + tabs: dict[str, TabHandle] = field(default_factory=dict) def register_current(self, driver: Any, alias: str) -> TabHandle: if not hasattr(driver, "current_window_handle"): @@ -37,7 +37,7 @@ def register_current(self, driver: Any, alias: str) -> TabHandle: self.tabs[alias] = tab return tab - def open_new(self, driver: Any, alias: str, url: Optional[str] = None) -> TabHandle: + def open_new(self, driver: Any, alias: str, url: str | None = None) -> TabHandle: """Open a fresh blank tab and register it under ``alias``.""" if not hasattr(driver, "switch_to") or not hasattr(driver, "window_handles"): raise MultiTabError("driver does not expose window_handles / switch_to") @@ -69,7 +69,7 @@ def close(self, driver: Any, alias: str) -> None: driver.switch_to.window(tab.handle) driver.close() - def aliases(self) -> List[str]: + def aliases(self) -> list[str]: return sorted(self.tabs.keys()) def with_tab( diff --git a/je_web_runner/utils/multi_user/matrix.py b/je_web_runner/utils/multi_user/matrix.py index 518d7a91..97ddef55 100644 --- a/je_web_runner/utils/multi_user/matrix.py +++ b/je_web_runner/utils/multi_user/matrix.py @@ -6,7 +6,7 @@ """ from __future__ import annotations -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -20,11 +20,11 @@ class MultiUserError(WebRunnerException): _NO_EXCEPTION = "None" -def _step_status(record: Dict[str, Any]) -> str: +def _step_status(record: dict[str, Any]) -> str: return "failed" if record.get("program_exception", _NO_EXCEPTION) != _NO_EXCEPTION else "passed" -def _capture_records() -> List[Dict[str, Any]]: +def _capture_records() -> list[dict[str, Any]]: return [dict(record) for record in test_record_instance.test_record_list] @@ -35,9 +35,9 @@ def _default_runner(action_data): def run_for_users( action_data: Any, - user_setups: List[Tuple[str, Optional[Callable[[], Any]]]], - runner: Optional[Callable[[Any], Any]] = None, -) -> Dict[str, Any]: + user_setups: list[tuple[str, Callable[[], Any] | None]], + runner: Callable[[Any], Any] | None = None, +) -> dict[str, Any]: """ 對每位使用者執行一次 ``action_data``,回傳記錄與差異 Run ``action_data`` once per user context. ``user_setups`` is a list of @@ -51,7 +51,7 @@ def run_for_users( raise MultiUserError("user_setups must not be empty") actual_runner = runner if runner is not None else _default_runner - by_user: Dict[str, List[Dict[str, Any]]] = {} + by_user: dict[str, list[dict[str, Any]]] = {} for name, setup in user_setups: web_runner_logger.info(f"run_for_users user={name}") test_record_instance.clean_record() @@ -63,16 +63,16 @@ def run_for_users( return {"by_user": by_user, "diff": _build_diff(by_user)} -def _build_diff(by_user: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]: +def _build_diff(by_user: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]: """For every step index, list users whose status disagrees with the rest.""" if not by_user: return [] names = list(by_user.keys()) max_steps = max(len(records) for records in by_user.values()) - diffs: List[Dict[str, Any]] = [] + diffs: list[dict[str, Any]] = [] for step_index in range(max_steps): - per_user_status: Dict[str, Optional[str]] = {} - per_user_function: Dict[str, Optional[str]] = {} + per_user_status: dict[str, str | None] = {} + per_user_function: dict[str, str | None] = {} for name in names: records = by_user[name] if step_index < len(records): diff --git a/je_web_runner/utils/multimodal_qa/qa.py b/je_web_runner/utils/multimodal_qa/qa.py index 9259bddc..df116a2a 100644 --- a/je_web_runner/utils/multimodal_qa/qa.py +++ b/je_web_runner/utils/multimodal_qa/qa.py @@ -22,7 +22,7 @@ from dataclasses import asdict, dataclass, field from enum import Enum from pathlib import Path -from typing import Any, Dict, List, Protocol, Sequence, Union +from typing import Any, Protocol, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -49,7 +49,7 @@ class QaRequest: image_bytes: bytes question: str - rubric: List[str] = field(default_factory=list) + rubric: list[str] = field(default_factory=list) image_label: str = "" def __post_init__(self) -> None: @@ -73,13 +73,13 @@ class QaResponse: verdict: Verdict confidence: float rationale: str - issues: List[str] = field(default_factory=list) + issues: list[str] = field(default_factory=list) raw: str = "" def is_pass(self) -> bool: return self.verdict == Verdict.PASS - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "verdict": self.verdict.value} @@ -95,7 +95,7 @@ def ask(self, prompt: str, image_b64: str) -> str: ... def build_prompt(request: QaRequest) -> str: """Render a deterministic prompt the vision model will see.""" - parts: List[str] = [ + parts: list[str] = [ "You are reviewing a UI screenshot. Answer the question strictly.", "Respond with ONLY a JSON object on a single line with keys:", ' "verdict": one of "pass" | "fail" | "uncertain"', @@ -167,7 +167,7 @@ def ask( def ask_path( - path: Union[str, Path], + path: str | Path, question: str, client: VisionClient, *, diff --git a/je_web_runner/utils/mutation_testing/mutator.py b/je_web_runner/utils/mutation_testing/mutator.py index 0b23e7be..22f5b39d 100644 --- a/je_web_runner/utils/mutation_testing/mutator.py +++ b/je_web_runner/utils/mutation_testing/mutator.py @@ -20,7 +20,7 @@ from dataclasses import asdict, dataclass, field from enum import Enum from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Sequence, Union +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -58,7 +58,7 @@ class Mutation: original: Any = None mutated: Any = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: out = asdict(self) out["type"] = self.type.value return out @@ -70,9 +70,9 @@ class MutationResult: mutation: Mutation killed: bool - error: Optional[str] = None + error: str | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "mutation": self.mutation.to_dict(), "killed": self.killed, @@ -88,9 +88,9 @@ class MutationScore: killed: int survived: int score: float - results: List[MutationResult] = field(default_factory=list) + results: list[MutationResult] = field(default_factory=list) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "total": self.total, "killed": self.killed, @@ -102,7 +102,7 @@ def to_dict(self) -> Dict[str, Any]: # ---------- helpers ------------------------------------------------------ -def _kwargs_of(action: List[Any]) -> Optional[Dict[str, Any]]: +def _kwargs_of(action: list[Any]) -> dict[str, Any] | None: if not isinstance(action, list) or not action: return None if len(action) >= 3 and isinstance(action[2], dict): @@ -112,7 +112,7 @@ def _kwargs_of(action: List[Any]) -> Optional[Dict[str, Any]]: return None -def _action_command(action: List[Any]) -> str: +def _action_command(action: list[Any]) -> str: if isinstance(action, list) and action and isinstance(action[0], str): return action[0] return "" @@ -120,8 +120,8 @@ def _action_command(action: List[Any]) -> str: # ---------- mutation generators ----------------------------------------- -def _gen_locator_swap(actions: List[Any]) -> List[Mutation]: - mutations: List[Mutation] = [] +def _gen_locator_swap(actions: list[Any]) -> list[Mutation]: + mutations: list[Mutation] = [] for idx, action in enumerate(actions): kwargs = _kwargs_of(action) if not kwargs: @@ -139,8 +139,8 @@ def _gen_locator_swap(actions: List[Any]) -> List[Mutation]: return mutations -def _gen_timeout_shrink(actions: List[Any]) -> List[Mutation]: - mutations: List[Mutation] = [] +def _gen_timeout_shrink(actions: list[Any]) -> list[Mutation]: + mutations: list[Mutation] = [] for idx, action in enumerate(actions): kwargs = _kwargs_of(action) if not kwargs: @@ -158,8 +158,8 @@ def _gen_timeout_shrink(actions: List[Any]) -> List[Mutation]: return mutations -def _gen_url_change(actions: List[Any]) -> List[Mutation]: - mutations: List[Mutation] = [] +def _gen_url_change(actions: list[Any]) -> list[Mutation]: + mutations: list[Mutation] = [] for idx, action in enumerate(actions): kwargs = _kwargs_of(action) if not kwargs: @@ -189,8 +189,8 @@ def _flip_assertion_value(value: Any) -> Any: return f"__MUTATED__{value!r}" -def _gen_assertion_flip(actions: List[Any]) -> List[Mutation]: - mutations: List[Mutation] = [] +def _gen_assertion_flip(actions: list[Any]) -> list[Mutation]: + mutations: list[Mutation] = [] for idx, action in enumerate(actions): kwargs = _kwargs_of(action) if not kwargs: @@ -209,8 +209,8 @@ def _gen_assertion_flip(actions: List[Any]) -> List[Mutation]: return mutations -def _gen_action_removal(actions: List[Any]) -> List[Mutation]: - mutations: List[Mutation] = [] +def _gen_action_removal(actions: list[Any]) -> list[Mutation]: + mutations: list[Mutation] = [] for idx, action in enumerate(actions): command = _action_command(action) if not command: @@ -227,8 +227,8 @@ def _gen_action_removal(actions: List[Any]) -> List[Mutation]: return mutations -def _gen_adjacent_reorder(actions: List[Any]) -> List[Mutation]: - mutations: List[Mutation] = [] +def _gen_adjacent_reorder(actions: list[Any]) -> list[Mutation]: + mutations: list[Mutation] = [] for idx in range(len(actions) - 1): if not isinstance(actions[idx], list) or not isinstance(actions[idx + 1], list): continue @@ -250,7 +250,7 @@ def _gen_adjacent_reorder(actions: List[Any]) -> List[Mutation]: return mutations -_GENERATORS: Dict[MutationType, Callable[[List[Any]], List[Mutation]]] = { +_GENERATORS: dict[MutationType, Callable[[list[Any]], list[Mutation]]] = { MutationType.LOCATOR_SWAP: _gen_locator_swap, MutationType.TIMEOUT_SHRINK: _gen_timeout_shrink, MutationType.URL_CHANGE: _gen_url_change, @@ -261,12 +261,12 @@ def _gen_adjacent_reorder(actions: List[Any]) -> List[Mutation]: def generate_mutations( - actions: List[Any], + actions: list[Any], types: Sequence[MutationType] = _DEFAULT_MUTATION_TYPES, *, - seed: Optional[int] = None, - max_per_type: Optional[int] = None, -) -> List[Mutation]: + seed: int | None = None, + max_per_type: int | None = None, +) -> list[Mutation]: """ 依 mutation type 對 action list 生出可能的變異。 Run every configured generator and concatenate. ``max_per_type`` caps @@ -276,7 +276,7 @@ def generate_mutations( if not isinstance(actions, list): raise MutationTestingError(f"actions must be a list, got {type(actions).__name__}") rng = random.Random(seed) if seed is not None else random # nosec B311 — mutation sampling, not crypto - all_mutations: List[Mutation] = [] + all_mutations: list[Mutation] = [] for mt in types: generator = _GENERATORS.get(mt) if generator is None: @@ -290,7 +290,7 @@ def generate_mutations( # ---------- apply --------------------------------------------------------- -def apply_mutation(actions: List[Any], mutation: Mutation) -> List[Any]: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up +def apply_mutation(actions: list[Any], mutation: Mutation) -> list[Any]: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up """ 產生一份套了 mutation 的 actions(不修改原 list)。 Return a deep-copied action list with ``mutation`` applied. Mutations @@ -343,16 +343,16 @@ def apply_mutation(actions: List[Any], mutation: Mutation) -> List[Any]: # NOSO # ---------- runner ------------------------------------------------------- -ExecutorFn = Callable[[List[Any]], bool] +ExecutorFn = Callable[[list[Any]], bool] def run_mutation_testing( - actions: List[Any], + actions: list[Any], executor: ExecutorFn, *, types: Sequence[MutationType] = _DEFAULT_MUTATION_TYPES, - seed: Optional[int] = None, - max_per_type: Optional[int] = None, + seed: int | None = None, + max_per_type: int | None = None, stop_on_first_survivor: bool = False, ) -> MutationScore: """ @@ -363,12 +363,12 @@ def run_mutation_testing( executor are caught and treated as kills (failures). """ mutations = generate_mutations(actions, types, seed=seed, max_per_type=max_per_type) - results: List[MutationResult] = [] + results: list[MutationResult] = [] for mutation in mutations: mutated = apply_mutation(actions, mutation) try: passed = bool(executor(mutated)) - except Exception as error: # noqa: BLE001 — executor may raise + except Exception as error: results.append(MutationResult( mutation=mutation, killed=True, error=repr(error), )) @@ -392,7 +392,7 @@ def run_mutation_testing( def run_mutation_testing_on_file( - action_path: Union[str, Path], + action_path: str | Path, executor: ExecutorFn, **kwargs: Any, ) -> MutationScore: diff --git a/je_web_runner/utils/network_emulation/throttling.py b/je_web_runner/utils/network_emulation/throttling.py index 75340398..b48da18a 100644 --- a/je_web_runner/utils/network_emulation/throttling.py +++ b/je_web_runner/utils/network_emulation/throttling.py @@ -5,7 +5,7 @@ """ from __future__ import annotations -from typing import Any, Dict +from typing import Any from je_web_runner.utils.cdp.cdp_commands import playwright_cdp, selenium_cdp from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -18,7 +18,7 @@ class NetworkEmulationError(WebRunnerException): # Throughput is in bytes per second; latency is in ms. Numbers based on # Chrome DevTools' published throttling profiles. -_PRESETS: Dict[str, Dict[str, Any]] = { +_PRESETS: dict[str, dict[str, Any]] = { "offline": { "offline": True, "latency": 0, @@ -63,7 +63,7 @@ def list_presets() -> list: return sorted(_PRESETS.keys()) -def _params(preset: str) -> Dict[str, Any]: +def _params(preset: str) -> dict[str, Any]: if preset not in _PRESETS: raise NetworkEmulationError( f"unknown network preset {preset!r}; available: {list_presets()}" diff --git a/je_web_runner/utils/notifications_audit/audit.py b/je_web_runner/utils/notifications_audit/audit.py index 7289bd9a..2863a1a2 100644 --- a/je_web_runner/utils/notifications_audit/audit.py +++ b/je_web_runner/utils/notifications_audit/audit.py @@ -10,7 +10,7 @@ from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -36,7 +36,7 @@ class PermissionRequest: result: PermissionResult page_age_ms: float = 0.0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "result": self.result.value} @@ -47,7 +47,7 @@ class NotificationShown: timestamp_ms: float title: str body: str = "" - tag: Optional[str] = None + tag: str | None = None require_interaction: bool = False silent: bool = False @@ -56,8 +56,8 @@ class NotificationShown: class NotificationsLog: """Combined audit log.""" - permission_requests: List[PermissionRequest] = field(default_factory=list) - notifications: List[NotificationShown] = field(default_factory=list) + permission_requests: list[PermissionRequest] = field(default_factory=list) + notifications: list[NotificationShown] = field(default_factory=list) # ---------- script generation ------------------------------------------ @@ -133,7 +133,7 @@ def parse_log(payload: Any) -> NotificationsLog: # NOSONAR S3776 — cohesive l raise NotificationsAuditError( f"payload must be dict, got {type(payload).__name__}" ) - requests: List[PermissionRequest] = [] + requests: list[PermissionRequest] = [] for raw in payload.get("permission_requests") or []: if not isinstance(raw, dict): continue @@ -152,7 +152,7 @@ def parse_log(payload: Any) -> NotificationsLog: # NOSONAR S3776 — cohesive l raise NotificationsAuditError( f"bad permission_request entry {raw!r}: {error}" ) from error - notifications: List[NotificationShown] = [] + notifications: list[NotificationShown] = [] for raw in payload.get("notifications") or []: if not isinstance(raw, dict): continue @@ -204,7 +204,7 @@ def assert_no_prompt_before( def assert_no_spam_after_deny(log: NotificationsLog) -> None: """Assert no further prompts or notifications appear after a 'denied'.""" - deny_time: Optional[float] = None + deny_time: float | None = None for req in log.permission_requests: if req.result == PermissionResult.DENIED: deny_time = req.timestamp_ms @@ -225,9 +225,9 @@ def assert_no_spam_after_deny(log: NotificationsLog) -> None: def assert_notification_shown( log: NotificationsLog, *, - title_contains: Optional[str] = None, - body_contains: Optional[str] = None, - tag: Optional[str] = None, + title_contains: str | None = None, + body_contains: str | None = None, + tag: str | None = None, ) -> NotificationShown: """Assert at least one notification matches the given filters.""" if title_contains is None and body_contains is None and tag is None: @@ -250,7 +250,7 @@ def assert_notification_shown( def assert_unique_tags(log: NotificationsLog) -> None: """Assert no tag was reused (would silently replace earlier notification).""" - seen: Dict[str, int] = {} + seen: dict[str, int] = {} for notif in log.notifications: if notif.tag is None: continue diff --git a/je_web_runner/utils/notifier/webhook_notifier.py b/je_web_runner/utils/notifier/webhook_notifier.py index 55bc638d..d604a734 100644 --- a/je_web_runner/utils/notifier/webhook_notifier.py +++ b/je_web_runner/utils/notifier/webhook_notifier.py @@ -4,7 +4,7 @@ """ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any import requests @@ -24,12 +24,12 @@ class NotifierError(WebRunnerException): def _check_url(url: str) -> str: if not isinstance(url, str) or not url: raise NotifierError("webhook URL must be a non-empty string") - if not (url.startswith("http://") or url.startswith("https://")): # NOSONAR — scheme allow-list, not an outbound HTTP call + if not (url.startswith(("http://", "https://"))): # NOSONAR — scheme allow-list, not an outbound HTTP call raise NotifierError(f"webhook URL must be http(s): {url!r}") return url -def summarise_run() -> Dict[str, Any]: +def summarise_run() -> dict[str, Any]: """ 從 ``test_record_instance`` 產生 pass/fail 統計 Build a {total, passed, failed, failures} summary from the recorded @@ -52,9 +52,9 @@ def summarise_run() -> Dict[str, Any]: def notify_webhook( url: str, - payload: Dict[str, Any], + payload: dict[str, Any], timeout: int = _DEFAULT_TIMEOUT, - headers: Optional[Dict[str, str]] = None, + headers: dict[str, str] | None = None, ) -> int: """ POST 任意 JSON payload 到 webhook,回傳 HTTP 狀態碼 @@ -70,7 +70,7 @@ def notify_webhook( return response.status_code -def _slack_text(summary: Dict[str, Any], header: str) -> str: +def _slack_text(summary: dict[str, Any], header: str) -> str: lines = [ f"*{header}*", f"total: {summary['total']} passed: {summary['passed']} failed: {summary['failed']}", @@ -86,7 +86,7 @@ def _slack_text(summary: Dict[str, Any], header: str) -> str: def notify_slack( webhook_url: str, - summary: Optional[Dict[str, Any]] = None, + summary: dict[str, Any] | None = None, header: str = "WebRunner Run Summary", ) -> int: """ diff --git a/je_web_runner/utils/number_currency_locale/locale.py b/je_web_runner/utils/number_currency_locale/locale.py index 6871b5d3..7b2c1478 100644 --- a/je_web_runner/utils/number_currency_locale/locale.py +++ b/je_web_runner/utils/number_currency_locale/locale.py @@ -14,7 +14,6 @@ import re from dataclasses import dataclass -from typing import Dict, Tuple from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -27,7 +26,7 @@ class NumberCurrencyLocaleError(WebRunnerException): class NumberRules: decimal: str thousands: str - grouping: Tuple[int, ...] = (3,) + grouping: tuple[int, ...] = (3,) @dataclass(frozen=True) @@ -38,7 +37,7 @@ class CurrencyRules: # Curated minimal locale catalog — extend as you adopt new locales -NUMBER_RULES: Dict[str, NumberRules] = { +NUMBER_RULES: dict[str, NumberRules] = { "en-US": NumberRules(decimal=".", thousands=","), "en-GB": NumberRules(decimal=".", thousands=","), "de-DE": NumberRules(decimal=",", thousands="."), @@ -50,7 +49,7 @@ class CurrencyRules: "ar-EG": NumberRules(decimal="٫", thousands="٬"), # Arabic } -CURRENCY_RULES: Dict[str, CurrencyRules] = { +CURRENCY_RULES: dict[str, CurrencyRules] = { "en-US": CurrencyRules(symbol="$", code="USD"), "en-GB": CurrencyRules(symbol="£", code="GBP"), "de-DE": CurrencyRules(symbol="€", code="EUR", symbol_position="suffix"), @@ -80,7 +79,7 @@ def _detect_decimal(body: str): return None if tail_len == 3 else only -def _check_indian_grouping(body: str, rules: "NumberRules", rendered: str) -> None: +def _check_indian_grouping(body: str, rules: NumberRules, rendered: str) -> None: if rules.grouping != (3, 2) or rules.thousands not in body: return integer_part = body.split(rules.decimal, 1)[0] diff --git a/je_web_runner/utils/oauth_pkce_replay/replay.py b/je_web_runner/utils/oauth_pkce_replay/replay.py index c09f1736..595f33f7 100644 --- a/je_web_runner/utils/oauth_pkce_replay/replay.py +++ b/je_web_runner/utils/oauth_pkce_replay/replay.py @@ -15,7 +15,7 @@ import secrets from dataclasses import asdict, dataclass from enum import Enum -from typing import Any, Callable, Dict, List, Sequence +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -57,10 +57,10 @@ class TokenExchangeResponse: """What the probe callable must return.""" status_code: int - body: Dict[str, Any] + body: dict[str, Any] -ProbeFn = Callable[[Dict[str, Any]], TokenExchangeResponse] +ProbeFn = Callable[[dict[str, Any]], TokenExchangeResponse] """Callable that POSTs to the token endpoint with the given payload.""" @@ -69,7 +69,7 @@ class ReplayCase: """One attempt at re-using a previously-consumed value.""" name: str - payload: Dict[str, Any] + payload: dict[str, Any] expected: ReplayOutcome = ReplayOutcome.REJECTED @@ -80,7 +80,7 @@ class ReplayResult: status_code: int note: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "outcome": self.outcome.value} @@ -122,14 +122,18 @@ def replay(case: ReplayCase, probe: ProbeFn) -> ReplayResult: ) -def run_cases(cases: Sequence[ReplayCase], probe: ProbeFn) -> List[ReplayResult]: +def run_cases(cases: Sequence[ReplayCase], probe: ProbeFn) -> list[ReplayResult]: if not cases: raise OauthPkceReplayError("cases must be non-empty") return [replay(c, probe) for c in cases] def assert_all_rejected(results: Sequence[ReplayResult]) -> None: - """Raise if any result is ACCEPTED (the server reused something it shouldn't).""" + """Raise if any result is ACCEPTED (the server reused something it + shouldn't). Also raise on empty ``results``: a green here would be false + confidence for a security check that actually exercised nothing.""" + if not results: + raise OauthPkceReplayError("no replay results to check — nothing was tested") accepted = [r for r in results if r.outcome == ReplayOutcome.ACCEPTED] if accepted: names = [r.case for r in accepted] diff --git a/je_web_runner/utils/observability/event_capture.py b/je_web_runner/utils/observability/event_capture.py index c66c85e1..2d6a0295 100644 --- a/je_web_runner/utils/observability/event_capture.py +++ b/je_web_runner/utils/observability/event_capture.py @@ -5,7 +5,7 @@ """ from __future__ import annotations -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -20,10 +20,10 @@ class EventCapture: """Buffer console + response events from a Playwright page.""" def __init__(self) -> None: - self.console_messages: List[Dict[str, Any]] = [] - self.network_responses: List[Dict[str, Any]] = [] - self._page: Optional[Any] = None - self._handlers: Dict[str, Callable] = {} + self.console_messages: list[dict[str, Any]] = [] + self.network_responses: list[dict[str, Any]] = [] + self._page: Any | None = None + self._handlers: dict[str, Callable] = {} def attach(self, page: Any) -> None: """Hook ``console`` and ``response`` listeners on ``page``.""" @@ -60,7 +60,7 @@ def clear(self) -> None: def _on_console(self, message: Any) -> None: try: location = message.location - except Exception: # noqa: BLE001 — older Playwright shapes + except Exception: location = None self.console_messages.append({ "type": getattr(message, "type", None), @@ -110,11 +110,11 @@ def stop_event_capture() -> None: event_capture.detach() -def get_console_messages() -> List[Dict[str, Any]]: +def get_console_messages() -> list[dict[str, Any]]: return list(event_capture.console_messages) -def get_network_responses() -> List[Dict[str, Any]]: +def get_network_responses() -> list[dict[str, Any]]: return list(event_capture.network_responses) diff --git a/je_web_runner/utils/observability/otlp_exporter.py b/je_web_runner/utils/observability/otlp_exporter.py index 5517386a..e9339804 100644 --- a/je_web_runner/utils/observability/otlp_exporter.py +++ b/je_web_runner/utils/observability/otlp_exporter.py @@ -11,7 +11,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -26,7 +26,7 @@ class OtlpExportConfig: endpoint: str protocol: str = "grpc" # "grpc" | "http" - headers: Optional[Dict[str, str]] = None + headers: dict[str, str] | None = None timeout: float = 10.0 insecure: bool = False service_name: str = "webrunner" @@ -100,8 +100,8 @@ def build_exporter(config: OtlpExportConfig) -> Any: def configure_otlp_export( tracer_provider: Any, config: OtlpExportConfig, - processor_factory: Optional[Any] = None, - exporter_factory: Optional[Any] = None, + processor_factory: Any | None = None, + exporter_factory: Any | None = None, ) -> Any: """ Build the exporter + ``BatchSpanProcessor`` and register it with the diff --git a/je_web_runner/utils/observability/timeline.py b/je_web_runner/utils/observability/timeline.py index e3275c12..f2ff7812 100644 --- a/je_web_runner/utils/observability/timeline.py +++ b/je_web_runner/utils/observability/timeline.py @@ -7,7 +7,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -23,10 +23,10 @@ class TimelineEvent: timestamp_ms: float kind: str label: str - payload: Dict[str, Any] + payload: dict[str, Any] -def _coerce_timestamp(value: Any) -> Optional[float]: +def _coerce_timestamp(value: Any) -> float | None: if value is None: return None if isinstance(value, (int, float)): @@ -39,16 +39,16 @@ def _coerce_timestamp(value: Any) -> Optional[float]: return None -def _first_present(source: Dict[str, Any], *keys: str) -> Any: +def _first_present(source: dict[str, Any], *keys: str) -> Any: for key in keys: if key in source: return source[key] return None -def from_spans(spans: Iterable[Dict[str, Any]]) -> List[TimelineEvent]: +def from_spans(spans: Iterable[dict[str, Any]]) -> list[TimelineEvent]: """Convert OTel-shaped spans (``{name, start_ms, end_ms, attrs}``).""" - events: List[TimelineEvent] = [] + events: list[TimelineEvent] = [] for span in spans: start = _coerce_timestamp(_first_present(span, "start_ms", "start")) end = _coerce_timestamp(_first_present(span, "end_ms", "end")) @@ -72,8 +72,8 @@ def from_spans(spans: Iterable[Dict[str, Any]]) -> List[TimelineEvent]: return events -def from_console(messages: Iterable[Dict[str, Any]]) -> List[TimelineEvent]: - events: List[TimelineEvent] = [] +def from_console(messages: Iterable[dict[str, Any]]) -> list[TimelineEvent]: + events: list[TimelineEvent] = [] for index, message in enumerate(messages): ts = _coerce_timestamp(_first_present(message, "timestamp_ms", "ts")) if ts is None: @@ -87,8 +87,8 @@ def from_console(messages: Iterable[Dict[str, Any]]) -> List[TimelineEvent]: return events -def from_responses(responses: Iterable[Dict[str, Any]]) -> List[TimelineEvent]: - events: List[TimelineEvent] = [] +def from_responses(responses: Iterable[dict[str, Any]]) -> list[TimelineEvent]: + events: list[TimelineEvent] = [] for index, response in enumerate(responses): ts = _coerce_timestamp(_first_present(response, "timestamp_ms", "ts")) if ts is None: @@ -106,16 +106,16 @@ def from_responses(responses: Iterable[Dict[str, Any]]) -> List[TimelineEvent]: return events -def merge(*event_lists: Iterable[TimelineEvent]) -> List[TimelineEvent]: +def merge(*event_lists: Iterable[TimelineEvent]) -> list[TimelineEvent]: """Concatenate event lists and sort by timestamp ascending (stable).""" - merged: List[TimelineEvent] = [] + merged: list[TimelineEvent] = [] for events in event_lists: merged.extend(events) merged.sort(key=lambda evt: evt.timestamp_ms) return merged -def to_dicts(events: Iterable[TimelineEvent]) -> List[Dict[str, Any]]: +def to_dicts(events: Iterable[TimelineEvent]) -> list[dict[str, Any]]: return [ { "timestamp_ms": e.timestamp_ms, @@ -128,10 +128,10 @@ def to_dicts(events: Iterable[TimelineEvent]) -> List[Dict[str, Any]]: def build( - spans: Optional[Iterable[Dict[str, Any]]] = None, - console: Optional[Iterable[Dict[str, Any]]] = None, - responses: Optional[Iterable[Dict[str, Any]]] = None, -) -> List[Dict[str, Any]]: + spans: Iterable[dict[str, Any]] | None = None, + console: Iterable[dict[str, Any]] | None = None, + responses: Iterable[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: """Top-level helper: take three optional sources, return one ordered list.""" return to_dicts(merge( from_spans(spans or []), diff --git a/je_web_runner/utils/ocr_assert/ocr.py b/je_web_runner/utils/ocr_assert/ocr.py index ed55e6a2..30ae2447 100644 --- a/je_web_runner/utils/ocr_assert/ocr.py +++ b/je_web_runner/utils/ocr_assert/ocr.py @@ -15,7 +15,7 @@ import unicodedata from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable, List, Optional, Sequence, Union +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -77,7 +77,7 @@ def _require_pil() -> Any: ) from error -def _open_image(source: Union[bytes, str, Path, Any]) -> Any: +def _open_image(source: bytes | str | Path | Any) -> Any: image_cls = _require_pil() if isinstance(source, (bytes, bytearray)): from io import BytesIO @@ -113,9 +113,9 @@ def _extract(source: Any) -> str: def extract_text( - source: Union[bytes, str, Path, Any], + source: bytes | str | Path | Any, *, - backend: Optional[OcrBackend] = None, + backend: OcrBackend | None = None, ) -> str: """Run OCR on a screenshot / image and return the raw recognised text.""" runner = backend or tesseract_backend() @@ -138,7 +138,7 @@ class OcrMatchResult: needle: str haystack: str score: float = 0.0 - notes: List[str] = field(default_factory=list) + notes: list[str] = field(default_factory=list) def raise_if_failed(self) -> None: if not self.matched: @@ -150,10 +150,10 @@ def raise_if_failed(self) -> None: def assert_text_contains( - source: Union[bytes, str, Path, Any], + source: bytes | str | Path | Any, needle: str, *, - backend: Optional[OcrBackend] = None, + backend: OcrBackend | None = None, case_sensitive: bool = False, ) -> OcrMatchResult: """Assert that ``needle`` appears in the OCR output (whitespace-collapsed).""" @@ -182,11 +182,11 @@ def assert_text_contains( def assert_text_fuzzy( - source: Union[bytes, str, Path, Any], + source: bytes | str | Path | Any, expected: str, *, min_ratio: float = 0.8, - backend: Optional[OcrBackend] = None, + backend: OcrBackend | None = None, ) -> OcrMatchResult: """Assert that the OCR output is ``min_ratio``-similar to ``expected``.""" if not 0.0 < min_ratio <= 1.0: @@ -207,10 +207,10 @@ def assert_text_fuzzy( def assert_text_any( - source: Union[bytes, str, Path, Any], + source: bytes | str | Path | Any, candidates: Sequence[str], *, - backend: Optional[OcrBackend] = None, + backend: OcrBackend | None = None, ) -> OcrMatchResult: """Assert that at least one ``candidate`` appears in the OCR output.""" if not candidates: diff --git a/je_web_runner/utils/open_redirect_detector/detector.py b/je_web_runner/utils/open_redirect_detector/detector.py index 439ec800..2132972d 100644 --- a/je_web_runner/utils/open_redirect_detector/detector.py +++ b/je_web_runner/utils/open_redirect_detector/detector.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, Callable, Dict, List, Optional, Sequence +from typing import Any, Callable, Sequence from urllib.parse import urlparse from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -41,10 +41,10 @@ class RedirectPayload: label: str value: str - expected_off_origin_host: Optional[str] = None + expected_off_origin_host: str | None = None -def default_payloads(attacker_host: str = "evil.example") -> List[RedirectPayload]: +def default_payloads(attacker_host: str = "evil.example") -> list[RedirectPayload]: """Return a representative payload set for the given attacker host.""" if not isinstance(attacker_host, str) or "." not in attacker_host: raise OpenRedirectError("attacker_host must look like a domain") @@ -52,7 +52,7 @@ def default_payloads(attacker_host: str = "evil.example") -> List[RedirectPayloa # S5332 ok: these payloads INTENTIONALLY use http:// — the whole point # of an open-redirect probe is to see if the app redirects to them. RedirectPayload("absolute_http", - f"http://{attacker_host}/", # noqa: S5332 + f"http://{attacker_host}/", # NOSONAR S5332 — intentional plain HTTP (localhost/dev-configured endpoint), not a security-sensitive transport attacker_host), RedirectPayload("absolute_https", f"https://{attacker_host}/", @@ -85,12 +85,12 @@ class ProbeResult: """One payload → one outcome.""" payload: RedirectPayload - final_location: Optional[str] + final_location: str | None status_code: int outcome: ProbeOutcome note: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "payload_label": self.payload.label, "payload_value": self.payload.value, @@ -103,7 +103,7 @@ def to_dict(self) -> Dict[str, Any]: def classify_response( payload: RedirectPayload, - final_location: Optional[str], + final_location: str | None, status_code: int, *, legitimate_host: str, @@ -183,7 +183,7 @@ class ProbeResponse: """What the probe callable must return.""" status_code: int - location: Optional[str] + location: str | None @dataclass @@ -191,9 +191,9 @@ class ProbeReport: """Aggregate over all payloads.""" legitimate_host: str - results: List[ProbeResult] = field(default_factory=list) + results: list[ProbeResult] = field(default_factory=list) - def vulnerable(self) -> List[ProbeResult]: + def vulnerable(self) -> list[ProbeResult]: return [r for r in self.results if r.outcome == ProbeOutcome.ALLOWED] def passed(self) -> bool: diff --git a/je_web_runner/utils/openapi_drift/drift.py b/je_web_runner/utils/openapi_drift/drift.py index ffc4eec4..58ac41fc 100644 --- a/je_web_runner/utils/openapi_drift/drift.py +++ b/je_web_runner/utils/openapi_drift/drift.py @@ -14,7 +14,7 @@ from collections import defaultdict from dataclasses import dataclass, field -from typing import Optional, Any, Dict, Iterable, List, Mapping, Sequence, Set +from typing import Any, Iterable, Mapping, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -33,23 +33,23 @@ class ApiObservation: @dataclass class DriftReport: - undocumented: List[str] = field(default_factory=list) - zombie: List[str] = field(default_factory=list) - undocumented_methods: List[str] = field(default_factory=list) - undocumented_statuses: List[str] = field(default_factory=list) + undocumented: list[str] = field(default_factory=list) + zombie: list[str] = field(default_factory=list) + undocumented_methods: list[str] = field(default_factory=list) + undocumented_statuses: list[str] = field(default_factory=list) -def _collect_spec(spec: Mapping[str, Any]) -> Dict[str, Dict[str, Set[str]]]: +def _collect_spec(spec: Mapping[str, Any]) -> dict[str, dict[str, set[str]]]: if not isinstance(spec, Mapping): raise OpenapiDriftError("spec must be a mapping") paths = spec.get("paths") or {} if not isinstance(paths, Mapping): raise OpenapiDriftError("spec.paths must be a mapping") - out: Dict[str, Dict[str, Set[str]]] = {} + out: dict[str, dict[str, set[str]]] = {} for path, methods in paths.items(): if not isinstance(methods, Mapping): continue - method_map: Dict[str, Set[str]] = {} + method_map: dict[str, set[str]] = {} for method, op in methods.items(): method = method.upper() if method not in ("GET", "POST", "PUT", "PATCH", @@ -58,7 +58,7 @@ def _collect_spec(spec: Mapping[str, Any]) -> Dict[str, Dict[str, Set[str]]]: if not isinstance(op, Mapping): continue responses = op.get("responses") or {} - method_map[method] = {str(code) for code in responses.keys()} + method_map[method] = {str(code) for code in responses} out[path] = method_map return out @@ -72,7 +72,7 @@ def _normalize_path(path: str, spec_paths: Iterable[str]) -> str: if len(spec_parts) != len(parts): continue match = True - for s, p in zip(spec_parts, parts): + for s, p in zip(spec_parts, parts, strict=False): if s == p: continue if s.startswith("{") and s.endswith("}"): @@ -85,9 +85,9 @@ def _normalize_path(path: str, spec_paths: Iterable[str]) -> str: def _classify_observation( - obs: ApiObservation, spec_map: Dict[str, Dict[str, Set[str]]], - report: DriftReport, seen_methods: Dict[str, Set[str]], -) -> Optional[str]: + obs: ApiObservation, spec_map: dict[str, dict[str, set[str]]], + report: DriftReport, seen_methods: dict[str, set[str]], +) -> str | None: """Record drift for ``obs``; return the matched spec path if any.""" if not isinstance(obs, ApiObservation): raise OpenapiDriftError("observation must be ApiObservation") @@ -109,10 +109,10 @@ def _classify_observation( def _collect_zombies( - spec_map: Dict[str, Dict[str, Set[str]]], - seen_paths: Set[str], seen_methods: Dict[str, Set[str]], -) -> List[str]: - out: List[str] = [] + spec_map: dict[str, dict[str, set[str]]], + seen_paths: set[str], seen_methods: dict[str, set[str]], +) -> list[str]: + out: list[str] = [] for spec_path, methods in spec_map.items(): for method in methods: if (spec_path not in seen_paths @@ -126,8 +126,8 @@ def diff( ) -> DriftReport: spec_map = _collect_spec(spec) report = DriftReport() - seen_paths: Set[str] = set() - seen_methods: Dict[str, Set[str]] = defaultdict(set) + seen_paths: set[str] = set() + seen_methods: dict[str, set[str]] = defaultdict(set) for obs in observations: matched = _classify_observation(obs, spec_map, report, seen_methods) if matched is not None: diff --git a/je_web_runner/utils/openapi_to_e2e/generator.py b/je_web_runner/utils/openapi_to_e2e/generator.py index 21f67ff0..167f1d89 100644 --- a/je_web_runner/utils/openapi_to_e2e/generator.py +++ b/je_web_runner/utils/openapi_to_e2e/generator.py @@ -25,7 +25,7 @@ import re from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Tuple, Union +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -35,7 +35,7 @@ class OpenAPIGeneratorError(WebRunnerException): """Raised when the spec is unreadable or required fields are missing.""" -SUPPORTED_METHODS: Tuple[str, ...] = ( +SUPPORTED_METHODS: tuple[str, ...] = ( "get", "post", "put", "patch", "delete", "head", "options", ) @@ -48,10 +48,10 @@ class GeneratedTest: method: str path: str expected_status: int - actions: List[Any] + actions: list[Any] scenario: str # "happy" | "missing_body" | "bad_path_param" | etc. - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "name": self.name, "method": self.method, @@ -68,10 +68,10 @@ class GenerationResult: spec_title: str base_url: str - tests: List[GeneratedTest] = field(default_factory=list) - skipped: List[Dict[str, str]] = field(default_factory=list) + tests: list[GeneratedTest] = field(default_factory=list) + skipped: list[dict[str, str]] = field(default_factory=list) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "spec_title": self.spec_title, "base_url": self.base_url, @@ -82,7 +82,7 @@ def to_dict(self) -> Dict[str, Any]: # ---------- spec loading ------------------------------------------------ -def load_spec(spec_path: Union[str, Path]) -> Dict[str, Any]: +def load_spec(spec_path: str | Path) -> dict[str, Any]: """ 讀 JSON 或 YAML 格式的 OpenAPI spec。 YAML support is soft-dependency on ``PyYAML``; JSON specs work without. @@ -115,7 +115,7 @@ def load_spec(spec_path: Union[str, Path]) -> Dict[str, Any]: _REF_RE = re.compile(r"^#/(.+)$") -def _resolve_ref(spec: Dict[str, Any], ref: str) -> Any: +def _resolve_ref(spec: dict[str, Any], ref: str) -> Any: match = _REF_RE.match(ref or "") if not match: return None @@ -128,7 +128,7 @@ def _resolve_ref(spec: Dict[str, Any], ref: str) -> Any: return node -def _maybe_resolve(spec: Dict[str, Any], schema: Any, *, depth: int = 0) -> Any: +def _maybe_resolve(spec: dict[str, Any], schema: Any, *, depth: int = 0) -> Any: if depth > 6 or not isinstance(schema, dict): return schema if "$ref" in schema: @@ -141,7 +141,7 @@ def _maybe_resolve(spec: Dict[str, Any], schema: Any, *, depth: int = 0) -> Any: # ---------- example synthesis ------------------------------------------ -_TYPE_DEFAULTS: Dict[str, Any] = { +_TYPE_DEFAULTS: dict[str, Any] = { "string": "sample", "integer": 1, "number": 1.0, @@ -152,7 +152,7 @@ def _maybe_resolve(spec: Dict[str, Any], schema: Any, *, depth: int = 0) -> Any: def synthesize_example( # NOSONAR S3776 — cohesive logic; planned refactor in follow-up - spec: Dict[str, Any], + spec: dict[str, Any], schema: Any, *, depth: int = 0, @@ -183,7 +183,7 @@ def synthesize_example( # NOSONAR S3776 — cohesive logic; planned refactor in return copy.deepcopy(schema["default"]) schema_type = schema.get("type") if schema_type == "object" or "properties" in schema: - out: Dict[str, Any] = {} + out: dict[str, Any] = {} properties = schema.get("properties") or {} required = set(schema.get("required") or []) for key, prop in properties.items(): @@ -205,7 +205,7 @@ def synthesize_example( # NOSONAR S3776 — cohesive logic; planned refactor in # ---------- url assembly ------------------------------------------------ -def _base_url(spec: Dict[str, Any]) -> str: +def _base_url(spec: dict[str, Any]) -> str: """Honour OpenAPI 3 ``servers`` first, then Swagger 2 ``host`` + ``basePath``.""" servers = spec.get("servers") if isinstance(servers, list) and servers: @@ -231,14 +231,14 @@ def _base_url(spec: Dict[str, Any]) -> str: def _expand_path( # NOSONAR S3776 — cohesive logic; planned refactor in follow-up template: str, - parameters: List[Dict[str, Any]], - spec: Dict[str, Any], + parameters: list[dict[str, Any]], + spec: dict[str, Any], *, - invalid_param: Optional[str] = None, -) -> Tuple[str, Dict[str, Any]]: + invalid_param: str | None = None, +) -> tuple[str, dict[str, Any]]: """Returns ``(expanded_path, query_params)``.""" resolved = template - query: Dict[str, Any] = {} + query: dict[str, Any] = {} for raw_param in parameters: param = _maybe_resolve(spec, raw_param) if not isinstance(param, dict): @@ -267,13 +267,13 @@ def _action_command(method: str) -> str: # ---------- auth heuristics -------------------------------------------- -def _auth_headers(spec: Dict[str, Any]) -> Dict[str, str]: +def _auth_headers(spec: dict[str, Any]) -> dict[str, str]: """ 粗略偵測 Bearer / API-key,塞 ``${TOKEN}`` placeholder 讓 env_loader 補。 """ components = spec.get("components") or {} security_schemes = components.get("securitySchemes") or spec.get("securityDefinitions") or {} - headers: Dict[str, str] = {} + headers: dict[str, str] = {} if not isinstance(security_schemes, dict): return headers for scheme in security_schemes.values(): @@ -296,11 +296,11 @@ def _build_action( base_url: str, *, body: Any = None, - query: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, + query: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, timeout: int = 15, -) -> List[Any]: - kwargs: Dict[str, Any] = {"url": f"{base_url}{path}", "timeout": timeout} +) -> list[Any]: + kwargs: dict[str, Any] = {"url": f"{base_url}{path}", "timeout": timeout} if query: kwargs["params"] = query if headers: @@ -310,7 +310,7 @@ def _build_action( return [_action_command(method), kwargs] -def _request_body_example(spec: Dict[str, Any], operation: Dict[str, Any]) -> Any: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up +def _request_body_example(spec: dict[str, Any], operation: dict[str, Any]) -> Any: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up body = operation.get("requestBody") if isinstance(body, dict): body = _maybe_resolve(spec, body) @@ -334,7 +334,7 @@ def _request_body_example(spec: Dict[str, Any], operation: Dict[str, Any]) -> An return None -def _success_status(operation: Dict[str, Any]) -> int: +def _success_status(operation: dict[str, Any]) -> int: responses = operation.get("responses") or {} if not isinstance(responses, dict): return 200 @@ -347,7 +347,7 @@ def _success_status(operation: Dict[str, Any]) -> int: return 200 -def _operation_name(method: str, path: str, operation: Dict[str, Any]) -> str: +def _operation_name(method: str, path: str, operation: dict[str, Any]) -> str: op_id = operation.get("operationId") if isinstance(op_id, str) and op_id: return op_id @@ -356,12 +356,12 @@ def _operation_name(method: str, path: str, operation: Dict[str, Any]) -> str: def _build_happy_test( - spec: Dict[str, Any], + spec: dict[str, Any], base_url: str, method: str, path: str, - operation: Dict[str, Any], - extra_headers: Dict[str, str], + operation: dict[str, Any], + extra_headers: dict[str, str], ) -> GeneratedTest: parameters = list(operation.get("parameters") or []) expanded_path, query = _expand_path(path, parameters, spec) @@ -386,13 +386,13 @@ def _build_happy_test( def _build_missing_body_test( - spec: Dict[str, Any], + spec: dict[str, Any], base_url: str, method: str, path: str, - operation: Dict[str, Any], - extra_headers: Dict[str, str], -) -> Optional[GeneratedTest]: + operation: dict[str, Any], + extra_headers: dict[str, str], +) -> GeneratedTest | None: if method.lower() not in {"post", "put", "patch"}: return None if not operation.get("requestBody") and not any( @@ -421,13 +421,13 @@ def _build_missing_body_test( def _build_bad_path_param_test( - spec: Dict[str, Any], + spec: dict[str, Any], base_url: str, method: str, path: str, - operation: Dict[str, Any], - extra_headers: Dict[str, str], -) -> Optional[GeneratedTest]: + operation: dict[str, Any], + extra_headers: dict[str, str], +) -> GeneratedTest | None: path_params = _PATH_PARAM_RE.findall(path) if not path_params: return None @@ -455,7 +455,7 @@ def _build_bad_path_param_test( # ---------- public entry points ---------------------------------------- -def _validate_spec_shape(spec: Any) -> Dict[str, Any]: +def _validate_spec_shape(spec: Any) -> dict[str, Any]: if not isinstance(spec, dict): raise OpenAPIGeneratorError("spec must be a dict") paths = spec.get("paths") @@ -464,16 +464,16 @@ def _validate_spec_shape(spec: Any) -> Dict[str, Any]: return paths -def _spec_title(spec: Dict[str, Any]) -> str: +def _spec_title(spec: dict[str, Any]) -> str: info = spec.get("info") return str(((info or {}).get("title") or "") if isinstance(info, dict) else "") def _build_negative_tests( - spec: Dict[str, Any], base_url: str, method: str, path: str, - operation: Dict[str, Any], extra_headers: Dict[str, str], -) -> List[GeneratedTest]: - out: List[GeneratedTest] = [] + spec: dict[str, Any], base_url: str, method: str, path: str, + operation: dict[str, Any], extra_headers: dict[str, str], +) -> list[GeneratedTest]: + out: list[GeneratedTest] = [] missing = _build_missing_body_test(spec, base_url, method, path, operation, extra_headers) if missing: out.append(missing) @@ -484,14 +484,14 @@ def _build_negative_tests( def _expand_operation( - spec: Dict[str, Any], base_url: str, path: str, method: str, operation: Any, - extra_headers: Dict[str, str], include_negative: bool, - skipped: List[Dict[str, str]], -) -> List[GeneratedTest]: + spec: dict[str, Any], base_url: str, path: str, method: str, operation: Any, + extra_headers: dict[str, str], include_negative: bool, + skipped: list[dict[str, str]], +) -> list[GeneratedTest]: if not isinstance(operation, dict): skipped.append({"path": path, "method": method, "reason": "operation not a dict"}) return [] - out: List[GeneratedTest] = [ + out: list[GeneratedTest] = [ _build_happy_test(spec, base_url, method, path, operation, extra_headers), ] if include_negative: @@ -510,11 +510,11 @@ def _select_method( def generate_tests_from_spec( - spec: Dict[str, Any], + spec: dict[str, Any], *, include_negative: bool = True, - method_filter: Optional[Iterable[str]] = None, - path_prefix_filter: Optional[str] = None, + method_filter: Iterable[str] | None = None, + path_prefix_filter: str | None = None, ) -> GenerationResult: """ 從已 load 的 spec 直接產出 GenerationResult。 @@ -528,8 +528,8 @@ def generate_tests_from_spec( methods_lower = ( {m.lower() for m in method_filter} if method_filter else set(SUPPORTED_METHODS) ) - tests: List[GeneratedTest] = [] - skipped: List[Dict[str, str]] = [] + tests: list[GeneratedTest] = [] + skipped: list[dict[str, str]] = [] for path, operations in paths.items(): if not isinstance(path, str) or not isinstance(operations, dict): continue @@ -555,11 +555,11 @@ def generate_tests_from_spec( def generate_tests_from_file( - spec_path: Union[str, Path], + spec_path: str | Path, *, include_negative: bool = True, - method_filter: Optional[Iterable[str]] = None, - path_prefix_filter: Optional[str] = None, + method_filter: Iterable[str] | None = None, + path_prefix_filter: str | None = None, ) -> GenerationResult: """Convenience: load + generate in one shot.""" spec = load_spec(spec_path) @@ -573,12 +573,12 @@ def generate_tests_from_file( def write_tests_to_dir( result: GenerationResult, - output_dir: Union[str, Path], -) -> List[Path]: + output_dir: str | Path, +) -> list[Path]: """One JSON file per generated test (slug-named, sorted by name).""" target = Path(output_dir) target.mkdir(parents=True, exist_ok=True) - written: List[Path] = [] + written: list[Path] = [] for test in result.tests: slug = re.sub(r"[^A-Za-z0-9_-]+", "_", test.name).strip("_") path = target / f"{slug}.json" diff --git a/je_web_runner/utils/otel_bridge/trace_bridge.py b/je_web_runner/utils/otel_bridge/trace_bridge.py index 412caaea..6d268a8e 100644 --- a/je_web_runner/utils/otel_bridge/trace_bridge.py +++ b/je_web_runner/utils/otel_bridge/trace_bridge.py @@ -18,7 +18,7 @@ import secrets from contextlib import contextmanager from dataclasses import dataclass -from typing import Any, Dict, Iterator, Optional +from typing import Any, Iterator from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -38,13 +38,13 @@ class TraceContext: span_id: str # 16 hex chars sampled: bool = True version: str = "00" - tracestate: Optional[str] = None + tracestate: str | None = None def to_traceparent(self) -> str: flags = "01" if self.sampled else "00" return f"{self.version}-{self.trace_id}-{self.span_id}-{flags}" - def as_headers(self) -> Dict[str, str]: + def as_headers(self) -> dict[str, str]: headers = {"traceparent": self.to_traceparent()} if self.tracestate: headers["tracestate"] = self.tracestate @@ -98,7 +98,7 @@ def parse_traceparent(header: str) -> TraceContext: # ---------- pull context from active OpenTelemetry span ------------------ -def current_otel_context() -> Optional[TraceContext]: +def current_otel_context() -> TraceContext | None: """ 若有 active OTel span 就把它包成 TraceContext;沒 OTel 或無 active span 回 None。 Return the active OpenTelemetry span's context as a :class:`TraceContext` @@ -148,7 +148,7 @@ def inject_headers_selenium(driver: Any, context: TraceContext) -> None: try: cdp("Network.enable", {}) cdp("Network.setExtraHTTPHeaders", {"headers": headers}) - except Exception as error: # noqa: BLE001 — CDP errors are driver-specific + except Exception as error: raise TraceBridgeError(f"CDP header injection failed: {error!r}") from error web_runner_logger.info( f"inject_headers_selenium: trace_id={context.trace_id} span_id={context.span_id}" @@ -164,7 +164,7 @@ def clear_headers_selenium(driver: Any) -> None: return try: cdp("Network.setExtraHTTPHeaders", {"headers": {}}) - except Exception as error: # noqa: BLE001 + except Exception as error: web_runner_logger.warning(f"clear_headers_selenium failed: {error!r}") @@ -181,7 +181,7 @@ def inject_headers_playwright(page: Any, context: TraceContext) -> None: raise TraceBridgeError("page has no set_extra_http_headers method") try: setter(context.as_headers()) - except Exception as error: # noqa: BLE001 + except Exception as error: raise TraceBridgeError(f"Playwright header injection failed: {error!r}") from error web_runner_logger.info( f"inject_headers_playwright: trace_id={context.trace_id}" @@ -197,7 +197,7 @@ def clear_headers_playwright(page: Any) -> None: return try: setter({}) - except Exception as error: # noqa: BLE001 + except Exception as error: web_runner_logger.warning(f"clear_headers_playwright failed: {error!r}") @@ -208,7 +208,7 @@ def bridged_span_selenium( driver: Any, span_name: str, *, - fallback_context: Optional[TraceContext] = None, + fallback_context: TraceContext | None = None, ) -> Iterator[TraceContext]: """ 用 OTel span 包住一段 selenium 動作,並把 traceparent 注入瀏覽器。 @@ -217,7 +217,7 @@ def bridged_span_selenium( If OTel isn't installed, ``fallback_context`` is used (or a fresh synthetic context if both are missing). """ - span_ctx: Optional[Any] = None + span_ctx: Any | None = None try: from opentelemetry import trace # type: ignore[import-not-found] tracer = trace.get_tracer("je_web_runner.otel_bridge") @@ -234,7 +234,7 @@ def bridged_span_selenium( if span_ctx is not None: try: span_ctx.__exit__(None, None, None) - except Exception as error: # noqa: BLE001 + except Exception as error: web_runner_logger.warning(f"span exit failed: {error!r}") @@ -243,10 +243,10 @@ def bridged_span_playwright( page: Any, span_name: str, *, - fallback_context: Optional[TraceContext] = None, + fallback_context: TraceContext | None = None, ) -> Iterator[TraceContext]: """Playwright twin of :func:`bridged_span_selenium`.""" - span_ctx: Optional[Any] = None + span_ctx: Any | None = None try: from opentelemetry import trace # type: ignore[import-not-found] tracer = trace.get_tracer("je_web_runner.otel_bridge") @@ -263,7 +263,7 @@ def bridged_span_playwright( if span_ctx is not None: try: span_ctx.__exit__(None, None, None) - except Exception as error: # noqa: BLE001 + except Exception as error: web_runner_logger.warning(f"span exit failed: {error!r}") @@ -272,9 +272,9 @@ def bridged_span_playwright( def trace_link( context: TraceContext, *, - jaeger_base: Optional[str] = None, - tempo_base: Optional[str] = None, -) -> Optional[str]: + jaeger_base: str | None = None, + tempo_base: str | None = None, +) -> str | None: """ 給定 trace context 與後端 base URL,回傳可點擊的 trace 連結。 Build a direct UI link to the trace in Jaeger / Tempo. Returns the diff --git a/je_web_runner/utils/otp_interceptor/interceptor.py b/je_web_runner/utils/otp_interceptor/interceptor.py index 61787836..178033aa 100644 --- a/je_web_runner/utils/otp_interceptor/interceptor.py +++ b/je_web_runner/utils/otp_interceptor/interceptor.py @@ -26,7 +26,7 @@ import urllib.request from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Optional, Pattern, Union +from typing import Any, Callable, Pattern from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -48,7 +48,7 @@ class InterceptedMessage: subject: str body: str received_at: float - headers: Dict[str, str] = field(default_factory=dict) + headers: dict[str, str] = field(default_factory=dict) # ---------- abstract provider -------------------------------------------- @@ -59,11 +59,11 @@ class OtpProvider(ABC): @abstractmethod def fetch_messages( self, - recipient: Optional[str] = None, + recipient: str | None = None, *, - since: Optional[float] = None, + since: float | None = None, limit: int = 25, - ) -> List[InterceptedMessage]: + ) -> list[InterceptedMessage]: """Return messages newest-first, optionally filtered by recipient/since.""" @@ -72,11 +72,11 @@ def fetch_messages( def _http_get_json(url: str, timeout: float = 10.0) -> Any: # S5332 ok: MailHog / Mailpit are local-only services that expose plain # HTTP REST APIs by design; the caller passes a localhost URL. - if not url.startswith(("http://", "https://")): # noqa: S5332 + if not url.startswith(("http://", "https://")): # NOSONAR S5332 — intentional plain HTTP (localhost/dev-configured endpoint), not a security-sensitive transport raise OtpInterceptError(f"refusing non-http URL: {url!r}") req = urllib.request.Request(url, method="GET") req.add_header("Accept", "application/json") - if url.startswith("http://"): # noqa: S5332 + if url.startswith("http://"): # NOSONAR S5332 — intentional plain HTTP (localhost/dev-configured endpoint), not a security-sensitive transport context = None # plain HTTP for local MailHog / Mailpit else: context = ssl.create_default_context() @@ -101,17 +101,17 @@ def _http_get_json(url: str, timeout: float = 10.0) -> Any: class MailHogProvider(OtpProvider): """Talks to a MailHog ``/api/v2/messages`` endpoint.""" - def __init__(self, base_url: str, *, http_fetcher: Optional[Callable[[str], Any]] = None) -> None: + def __init__(self, base_url: str, *, http_fetcher: Callable[[str], Any] | None = None) -> None: self.base_url = base_url.rstrip("/") self._fetch = http_fetcher or _http_get_json def fetch_messages( self, - recipient: Optional[str] = None, + recipient: str | None = None, *, - since: Optional[float] = None, + since: float | None = None, limit: int = 25, - ) -> List[InterceptedMessage]: + ) -> list[InterceptedMessage]: url = f"{self.base_url}/api/v2/messages?limit={limit}" payload = self._fetch(url) if not isinstance(payload, dict): @@ -119,7 +119,7 @@ def fetch_messages( items = payload.get("items") if not isinstance(items, list): return [] - out: List[InterceptedMessage] = [] + out: list[InterceptedMessage] = [] for raw in items: msg = _mailhog_to_message(raw) if msg is None: @@ -133,7 +133,7 @@ def fetch_messages( return out -def _mailhog_to_message(raw: Any) -> Optional[InterceptedMessage]: +def _mailhog_to_message(raw: Any) -> InterceptedMessage | None: if not isinstance(raw, dict): return None content = raw.get("Content") or {} @@ -160,17 +160,17 @@ def _mailhog_to_message(raw: Any) -> Optional[InterceptedMessage]: class MailpitProvider(OtpProvider): """Talks to a Mailpit ``/api/v1/messages`` endpoint.""" - def __init__(self, base_url: str, *, http_fetcher: Optional[Callable[[str], Any]] = None) -> None: + def __init__(self, base_url: str, *, http_fetcher: Callable[[str], Any] | None = None) -> None: self.base_url = base_url.rstrip("/") self._fetch = http_fetcher or _http_get_json def fetch_messages( self, - recipient: Optional[str] = None, + recipient: str | None = None, *, - since: Optional[float] = None, + since: float | None = None, limit: int = 25, - ) -> List[InterceptedMessage]: + ) -> list[InterceptedMessage]: url = f"{self.base_url}/api/v1/messages?limit={limit}" payload = self._fetch(url) if not isinstance(payload, dict): @@ -178,7 +178,7 @@ def fetch_messages( items = payload.get("messages") or payload.get("Messages") or [] if not isinstance(items, list): return [] - out: List[InterceptedMessage] = [] + out: list[InterceptedMessage] = [] for raw in items: msg = _mailpit_to_message(raw) if msg is None: @@ -192,7 +192,7 @@ def fetch_messages( return out -def _mailpit_to_message(raw: Any) -> Optional[InterceptedMessage]: +def _mailpit_to_message(raw: Any) -> InterceptedMessage | None: if not isinstance(raw, dict): return None to_list = raw.get("To") or [] @@ -221,7 +221,7 @@ def __init__( password: str, mailbox: str = "INBOX", use_ssl: bool = True, - connector: Optional[Callable[..., Any]] = None, + connector: Callable[..., Any] | None = None, ) -> None: if not host or not username or not password: raise OtpInterceptError("IMAP host/username/password are all required") @@ -239,7 +239,7 @@ def _connect(self): # NOSONAR S3776 — cohesive logic; planned refactor in fol import imaplib # local import — IMAP is rarely needed return (imaplib.IMAP4_SSL if self.use_ssl else imaplib.IMAP4)(self.host, self.port) - def _fetch_one(self, conn: Any, raw_id: bytes, since: Optional[float]) -> Optional[InterceptedMessage]: + def _fetch_one(self, conn: Any, raw_id: bytes, since: float | None) -> InterceptedMessage | None: _typ, msg_data = conn.fetch(raw_id, "(RFC822)") if not msg_data or not msg_data[0]: return None @@ -256,16 +256,16 @@ def _close_quietly(self, conn: Any) -> None: for method_name in ("close", "logout"): try: getattr(conn, method_name)() - except Exception: # noqa: BLE001 # nosec B110 — best-effort cleanup + except Exception: # nosec B110 — best-effort cleanup pass def fetch_messages( self, - recipient: Optional[str] = None, + recipient: str | None = None, *, - since: Optional[float] = None, + since: float | None = None, limit: int = 25, - ) -> List[InterceptedMessage]: + ) -> list[InterceptedMessage]: conn = self._connect() try: conn.login(self.username, self.password) @@ -273,7 +273,7 @@ def fetch_messages( criteria = "ALL" if not recipient else f'(TO "{recipient}")' _typ, ids_data = conn.search(None, criteria) ids = (ids_data[0].split() if ids_data and ids_data[0] else [])[-limit:] - messages: List[InterceptedMessage] = [] + messages: list[InterceptedMessage] = [] for raw_id in reversed(ids): msg = self._fetch_one(conn, raw_id, since) if msg is not None: @@ -283,13 +283,13 @@ def fetch_messages( self._close_quietly(conn) -def _imap_bytes_to_message(message_id: str, raw_bytes: bytes) -> Optional[InterceptedMessage]: +def _imap_bytes_to_message(message_id: str, raw_bytes: bytes) -> InterceptedMessage | None: import email from email import policy try: msg = email.message_from_bytes(raw_bytes, policy=policy.default) - except Exception: # noqa: BLE001 + except Exception: return None body = "" if msg.is_multipart(): @@ -300,7 +300,7 @@ def _imap_bytes_to_message(message_id: str, raw_bytes: bytes) -> Optional[Interc else: try: body = msg.get_content() - except Exception: # noqa: BLE001 + except Exception: body = "" return InterceptedMessage( message_id=message_id, @@ -327,7 +327,7 @@ def __init__( base_url: str, *, endpoint: str = "/messages", - http_fetcher: Optional[Callable[[str], Any]] = None, + http_fetcher: Callable[[str], Any] | None = None, ) -> None: self.base_url = base_url.rstrip("/") self.endpoint = "/" + endpoint.lstrip("/") @@ -335,11 +335,11 @@ def __init__( def fetch_messages( self, - recipient: Optional[str] = None, + recipient: str | None = None, *, - since: Optional[float] = None, + since: float | None = None, limit: int = 25, - ) -> List[InterceptedMessage]: + ) -> list[InterceptedMessage]: query = f"limit={limit}" if recipient: query += "&to=" + urllib.parse.quote(recipient, safe="") @@ -347,7 +347,7 @@ def fetch_messages( payload = self._fetch(url) if not isinstance(payload, list): raise OtpInterceptError("SMS webhook payload must be a JSON list") - out: List[InterceptedMessage] = [] + out: list[InterceptedMessage] = [] for raw in payload: if not isinstance(raw, dict): continue @@ -372,7 +372,7 @@ class InMemoryProvider(OtpProvider): """Tests and dry-runs: hand it a list of messages.""" def __init__(self) -> None: - self.messages: List[InterceptedMessage] = [] + self.messages: list[InterceptedMessage] = [] def push(self, message: InterceptedMessage) -> None: self.messages.append(message) @@ -382,11 +382,11 @@ def clear(self) -> None: def fetch_messages( self, - recipient: Optional[str] = None, + recipient: str | None = None, *, - since: Optional[float] = None, + since: float | None = None, limit: int = 25, - ) -> List[InterceptedMessage]: + ) -> list[InterceptedMessage]: out = list(self.messages) if recipient: out = [m for m in out if m.recipient.lower() == recipient.lower()] @@ -427,8 +427,8 @@ def _parse_time(value: Any) -> float: def extract_otp_from_text( text: str, - pattern: Union[str, Pattern[str], None] = None, -) -> Optional[str]: + pattern: str | Pattern[str] | None = None, +) -> str | None: """ 從文字中抽出 OTP code。預設 4–8 位數字。 Apply ``pattern`` (defaults to 4–8 digits) and return the first match. @@ -453,9 +453,9 @@ def extract_otp_from_text( def _otp_match( msg: InterceptedMessage, *, - subject_contains: Optional[str], - pattern: Union[str, Pattern[str], None], -) -> Optional[str]: + subject_contains: str | None, + pattern: str | Pattern[str] | None, +) -> str | None: if subject_contains and subject_contains.lower() not in msg.subject.lower(): return None return ( @@ -481,11 +481,11 @@ def wait_for_otp( provider: OtpProvider, recipient: str, *, - pattern: Union[str, Pattern[str], None] = None, + pattern: str | Pattern[str] | None = None, timeout: float = 30.0, poll_interval: float = 1.0, - since: Optional[float] = None, - subject_contains: Optional[str] = None, + since: float | None = None, + subject_contains: str | None = None, sleep_fn: Callable[[float], None] = time.sleep, time_fn: Callable[[], float] = time.time, ) -> str: diff --git a/je_web_runner/utils/package_manager/package_manager_class.py b/je_web_runner/utils/package_manager/package_manager_class.py index 489c9996..1e1990b8 100644 --- a/je_web_runner/utils/package_manager/package_manager_class.py +++ b/je_web_runner/utils/package_manager/package_manager_class.py @@ -9,7 +9,7 @@ _VALID_MODULE_NAME = re.compile(r"^[A-Za-z_]\w*(\.[A-Za-z_]\w*)*$", re.ASCII) -class PackageManager(object): +class PackageManager: def __init__(self): # 已安裝套件快取,避免重複載入 diff --git a/je_web_runner/utils/pagination_audit/audit.py b/je_web_runner/utils/pagination_audit/audit.py index 19a8277b..5d15c9c4 100644 --- a/je_web_runner/utils/pagination_audit/audit.py +++ b/je_web_runner/utils/pagination_audit/audit.py @@ -15,7 +15,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Callable, Dict, Hashable, List, Optional, Protocol +from typing import Any, Callable, Hashable, Protocol from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -30,8 +30,8 @@ class PaginationAuditError(WebRunnerException): class Page: """One fetched page.""" - items: List[Any] - next_cursor: Optional[Any] = None + items: list[Any] + next_cursor: Any | None = None def __post_init__(self) -> None: if not isinstance(self.items, list): @@ -41,7 +41,7 @@ def __post_init__(self) -> None: class PageFetcher(Protocol): """Caller-supplied fetcher.""" - def __call__(self, cursor: Optional[Any]) -> Page: ... + def __call__(self, cursor: Any | None) -> Page: ... KeyFn = Callable[[Any], Hashable] @@ -57,12 +57,12 @@ class PaginationFindings: page_count: int = 0 total_items: int = 0 unique_items: int = 0 - duplicates: List[Hashable] = field(default_factory=list) - duplicate_pages: Dict[Hashable, List[int]] = field(default_factory=dict) - empty_pages: List[int] = field(default_factory=list) + duplicates: list[Hashable] = field(default_factory=list) + duplicate_pages: dict[Hashable, list[int]] = field(default_factory=dict) + empty_pages: list[int] = field(default_factory=list) cursor_loop: bool = False hit_max_pages: bool = False - item_keys_by_page: List[List[Hashable]] = field(default_factory=list) + item_keys_by_page: list[list[Hashable]] = field(default_factory=list) def passed(self) -> bool: return not self.duplicates and not self.cursor_loop and not self.hit_max_pages @@ -73,7 +73,7 @@ def walk_all_pages( # NOSONAR S3776 — cohesive logic; planned refactor in fol key_fn: KeyFn, *, max_pages: int = 1_000, - initial_cursor: Optional[Any] = None, + initial_cursor: Any | None = None, ) -> PaginationFindings: """ Iterate pages until ``next_cursor`` is None (or ``max_pages`` reached), @@ -88,8 +88,8 @@ def walk_all_pages( # NOSONAR S3776 — cohesive logic; planned refactor in fol findings = PaginationFindings() seen_cursors: set = set() - seen_items: Dict[Hashable, List[int]] = {} - cursor: Optional[Any] = initial_cursor + seen_items: dict[Hashable, list[int]] = {} + cursor: Any | None = initial_cursor page_index = 0 while page_index < max_pages: try: @@ -103,7 +103,7 @@ def walk_all_pages( # NOSONAR S3776 — cohesive logic; planned refactor in fol f"fetcher must return Page, got {type(page).__name__}" ) findings.page_count = page_index + 1 - page_keys: List[Hashable] = [] + page_keys: list[Hashable] = [] for item in page.items: try: key = key_fn(item) @@ -209,9 +209,16 @@ def assert_sorted_by( """ if not callable(items_by_page_key): raise PaginationAuditError("items_by_page_key must be callable") - flattened: List[Hashable] = [ - key for page_keys in findings.item_keys_by_page for key in page_keys - ] + try: + flattened: list[Hashable] = [ + items_by_page_key(key) + for page_keys in findings.item_keys_by_page + for key in page_keys + ] + except Exception as error: + raise PaginationAuditError( + f"items_by_page_key failed: {error!r}" + ) from error if not flattened: return last = flattened[0] diff --git a/je_web_runner/utils/payment_request_assert/payment.py b/je_web_runner/utils/payment_request_assert/payment.py index 382fe2ea..1b7ef7c0 100644 --- a/je_web_runner/utils/payment_request_assert/payment.py +++ b/je_web_runner/utils/payment_request_assert/payment.py @@ -18,7 +18,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -70,9 +70,9 @@ class PaymentRequestAssertError(WebRunnerException): @dataclass class ConstructedPaymentRequest: - method_data: List[Dict[str, Any]] = field(default_factory=list) - details: Dict[str, Any] = field(default_factory=dict) - options: Dict[str, Any] = field(default_factory=dict) + method_data: list[dict[str, Any]] = field(default_factory=list) + details: dict[str, Any] = field(default_factory=dict) + options: dict[str, Any] = field(default_factory=dict) def supports_method(self, method: str) -> bool: return any((m.get("supportedMethods") or "") == method @@ -86,14 +86,14 @@ class CompletedPayment: @dataclass class PaymentLog: - constructed: List[ConstructedPaymentRequest] = field(default_factory=list) - completed: List[CompletedPayment] = field(default_factory=list) + constructed: list[ConstructedPaymentRequest] = field(default_factory=list) + completed: list[CompletedPayment] = field(default_factory=list) def parse_log(payload: Any) -> PaymentLog: if not isinstance(payload, dict): raise PaymentRequestAssertError("payload must be dict") - constructed: List[ConstructedPaymentRequest] = [] + constructed: list[ConstructedPaymentRequest] = [] for raw in payload.get("constructed") or []: if not isinstance(raw, dict): continue @@ -102,7 +102,7 @@ def parse_log(payload: Any) -> PaymentLog: details=dict(raw.get("details") or {}), options=dict(raw.get("options") or {}), )) - completed: List[CompletedPayment] = [] + completed: list[CompletedPayment] = [] for raw in payload.get("completed") or []: if not isinstance(raw, dict): continue @@ -128,6 +128,8 @@ def assert_supports(log: PaymentLog, *, method: str) -> None: def assert_total_currency(log: PaymentLog, *, currency: str) -> None: if not currency: raise PaymentRequestAssertError("currency must be non-empty") + if not log.constructed: + raise PaymentRequestAssertError("page never constructed a PaymentRequest") for c in log.constructed: total = c.details.get("total") or {} amount = total.get("amount") or {} if isinstance(total, dict) else {} diff --git a/je_web_runner/utils/perf_drift/drift.py b/je_web_runner/utils/perf_drift/drift.py index 61341b71..4d87f930 100644 --- a/je_web_runner/utils/perf_drift/drift.py +++ b/je_web_runner/utils/perf_drift/drift.py @@ -12,7 +12,7 @@ import math from dataclasses import dataclass, field -from typing import Dict, Iterable, List, Optional, Sequence +from typing import Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -34,10 +34,10 @@ class _MetricResult: @dataclass class DriftReport: - metrics: List[_MetricResult] = field(default_factory=list) + metrics: list[_MetricResult] = field(default_factory=list) @property - def regressions(self) -> List[_MetricResult]: + def regressions(self) -> list[_MetricResult]: return [m for m in self.metrics if m.drifted and m.direction == "regressed"] @property @@ -119,12 +119,12 @@ def compute_drift( def detect_drift( - metrics: Dict[str, Sequence[float]], + metrics: dict[str, Sequence[float]], *, baseline_window: int = 20, recent_window: int = 5, tolerance: float = 0.1, - higher_is_better: Optional[Iterable[str]] = None, + higher_is_better: Iterable[str] | None = None, pct: float = 95.0, ) -> DriftReport: """ @@ -149,7 +149,7 @@ def detect_drift( def assert_no_regression(report: DriftReport, - allow_metrics: Optional[Iterable[str]] = None) -> None: + allow_metrics: Iterable[str] | None = None) -> None: """Raise if any drifted+regressed metric remains.""" allow = set(allow_metrics or []) bad = [m for m in report.regressions if m.metric not in allow] diff --git a/je_web_runner/utils/perf_metrics/budgets.py b/je_web_runner/utils/perf_metrics/budgets.py index 47bef8f1..24e141dd 100644 --- a/je_web_runner/utils/perf_metrics/budgets.py +++ b/je_web_runner/utils/perf_metrics/budgets.py @@ -20,7 +20,7 @@ import json from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Union +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -33,9 +33,9 @@ class PerfBudgetError(WebRunnerException): class RouteBudget: """Single budget entry.""" - path: Optional[str] = None - path_glob: Optional[str] = None - metrics: Dict[str, float] = field(default_factory=dict) + path: str | None = None + path_glob: str | None = None + metrics: dict[str, float] = field(default_factory=dict) def matches(self, route_path: str) -> bool: if self.path is not None and self.path == route_path: @@ -45,7 +45,7 @@ def matches(self, route_path: str) -> bool: return False -def load_budgets(source: Union[str, Path, list]) -> List[RouteBudget]: +def load_budgets(source: str | Path | list) -> list[RouteBudget]: """Load budgets from a path, JSON string, or in-memory list.""" raw = _coerce_to_list(source) if not isinstance(raw, list): @@ -53,7 +53,7 @@ def load_budgets(source: Union[str, Path, list]) -> List[RouteBudget]: return [_build_route_budget(index, entry) for index, entry in enumerate(raw)] -def _coerce_to_list(source: Union[str, Path, list]) -> Any: +def _coerce_to_list(source: str | Path | list) -> Any: if isinstance(source, list): return source if not isinstance(source, (str, Path)): @@ -67,7 +67,7 @@ def _coerce_to_list(source: Union[str, Path, list]) -> Any: raise PerfBudgetError(f"{label} is not JSON: {error}") from error -def _build_route_budget(index: int, entry: Any) -> "RouteBudget": +def _build_route_budget(index: int, entry: Any) -> RouteBudget: if not isinstance(entry, dict): raise PerfBudgetError(f"budget[{index}] must be an object") if "path" not in entry and "path_glob" not in entry: @@ -85,8 +85,8 @@ def _build_route_budget(index: int, entry: Any) -> "RouteBudget": @dataclass class BudgetCheckResult: route: str - matched_rule: Optional[RouteBudget] - breaches: List[Dict[str, Any]] = field(default_factory=list) + matched_rule: RouteBudget | None + breaches: list[dict[str, Any]] = field(default_factory=list) @property def passed(self) -> bool: @@ -95,7 +95,7 @@ def passed(self) -> bool: def evaluate_metrics( route_path: str, - metrics: Dict[str, float], + metrics: dict[str, float], budgets: Sequence[RouteBudget], ) -> BudgetCheckResult: """Find the first matching rule and check every configured metric.""" diff --git a/je_web_runner/utils/perf_metrics/page_metrics.py b/je_web_runner/utils/perf_metrics/page_metrics.py index 9393f79e..6d012be1 100644 --- a/je_web_runner/utils/perf_metrics/page_metrics.py +++ b/je_web_runner/utils/perf_metrics/page_metrics.py @@ -9,7 +9,7 @@ """ from __future__ import annotations -from typing import Any, Dict +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -68,7 +68,7 @@ class PerfMetricsError(WebRunnerException): """ -def selenium_collect_metrics(observe_ms: int = 1000) -> Dict[str, Any]: +def selenium_collect_metrics(observe_ms: int = 1000) -> dict[str, Any]: """ 透過 ``execute_async_script`` 抓取效能指標 Collect performance metrics via Selenium ``execute_async_script``. @@ -84,7 +84,7 @@ def selenium_collect_metrics(observe_ms: int = 1000) -> Dict[str, Any]: return driver.execute_async_script(script) or {} -def playwright_collect_metrics(observe_ms: int = 1000) -> Dict[str, Any]: +def playwright_collect_metrics(observe_ms: int = 1000) -> dict[str, Any]: """ 透過 ``page.evaluate`` 抓取效能指標 Collect performance metrics via Playwright's ``page.evaluate``. @@ -101,7 +101,7 @@ def playwright_collect_metrics(observe_ms: int = 1000) -> Dict[str, Any]: return page.evaluate(expression, observe_ms) or {} -def assert_metrics_within(metrics: Dict[str, Any], thresholds: Dict[str, float]) -> None: +def assert_metrics_within(metrics: dict[str, Any], thresholds: dict[str, float]) -> None: """ 斷言所有指標都不超過上限 Assert each measured metric is at or below its threshold (ms / score). diff --git a/je_web_runner/utils/persona_runner/runner.py b/je_web_runner/utils/persona_runner/runner.py index ef3bd9be..2897e76b 100644 --- a/je_web_runner/utils/persona_runner/runner.py +++ b/je_web_runner/utils/persona_runner/runner.py @@ -19,7 +19,7 @@ import time from dataclasses import dataclass, field -from typing import Any, Dict, Iterable, List, Optional, Protocol, Sequence +from typing import Any, Iterable, Protocol, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -36,9 +36,9 @@ class Persona: """One identity under test.""" name: str - auth_state: Dict[str, Any] = field(default_factory=dict) - flags: Dict[str, Any] = field(default_factory=dict) - tags: List[str] = field(default_factory=list) + auth_state: dict[str, Any] = field(default_factory=dict) + flags: dict[str, Any] = field(default_factory=dict) + tags: list[str] = field(default_factory=list) def __post_init__(self) -> None: if not self.name or not isinstance(self.name, str): @@ -55,8 +55,8 @@ class PersonaCaseResult: action_file: str passed: bool duration_seconds: float = 0.0 - error: Optional[str] = None - notes: List[str] = field(default_factory=list) + error: str | None = None + notes: list[str] = field(default_factory=list) @dataclass @@ -66,9 +66,9 @@ class MatrixSummary: total: int passed: int failed: int - by_persona: Dict[str, Dict[str, int]] = field(default_factory=dict) - persona_only_failures: List[str] = field(default_factory=list) - file_only_failures: List[str] = field(default_factory=list) + by_persona: dict[str, dict[str, int]] = field(default_factory=dict) + persona_only_failures: list[str] = field(default_factory=list) + file_only_failures: list[str] = field(default_factory=list) # ---------- runner protocol -------------------------------------------- @@ -101,12 +101,12 @@ def __post_init__(self) -> None: if len(set(self.action_files)) != len(self.action_files): raise PersonaRunnerError("duplicate action_files in matrix") - def run(self) -> List[PersonaCaseResult]: - results: List[PersonaCaseResult] = [] + def run(self) -> list[PersonaCaseResult]: + results: list[PersonaCaseResult] = [] for persona in self.personas: for action_file in self.action_files: started = time.monotonic() - error: Optional[str] = None + error: str | None = None try: self.case_runner(persona, action_file) passed = True @@ -137,9 +137,9 @@ def summarise(results: Iterable[PersonaCaseResult]) -> MatrixSummary: """Build a :class:`MatrixSummary` from a result iterable.""" total = 0 passed_count = 0 - by_persona: Dict[str, Dict[str, int]] = {} - failures_by_persona: Dict[str, List[str]] = {} - failures_by_file: Dict[str, List[str]] = {} + by_persona: dict[str, dict[str, int]] = {} + failures_by_persona: dict[str, list[str]] = {} + failures_by_file: dict[str, list[str]] = {} seen_personas: set = set() seen_files: set = set() for result in results: @@ -158,7 +158,7 @@ def summarise(results: Iterable[PersonaCaseResult]) -> MatrixSummary: bucket["failed"] += 1 failures_by_persona.setdefault(result.persona, []).append(result.action_file) failures_by_file.setdefault(result.action_file, []).append(result.persona) - persona_only: List[str] = [] + persona_only: list[str] = [] for persona, failed_files in failures_by_persona.items(): # A persona "only" fails if every other persona passes the same files if all( @@ -167,7 +167,7 @@ def summarise(results: Iterable[PersonaCaseResult]) -> MatrixSummary: for file in failed_files ): persona_only.append(persona) - file_only: List[str] = [] + file_only: list[str] = [] for file, failing_personas in failures_by_file.items(): if len(set(failing_personas)) >= len(seen_personas): file_only.append(file) diff --git a/je_web_runner/utils/pii_in_screenshot/scanner.py b/je_web_runner/utils/pii_in_screenshot/scanner.py index 74ee0a8d..a177b963 100644 --- a/je_web_runner/utils/pii_in_screenshot/scanner.py +++ b/je_web_runner/utils/pii_in_screenshot/scanner.py @@ -15,7 +15,7 @@ import re from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Pattern, Sequence, Union +from typing import Any, Callable, Pattern, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.ocr_assert.ocr import OcrBackend, extract_text, normalise_text @@ -35,7 +35,7 @@ class PiiRule: pattern: Pattern[str] severity: str = "high" # If validator returns False, the match is discarded (e.g. Luhn check). - validator: Optional[Callable[[str], bool]] = None + validator: Callable[[str], bool] | None = None def _luhn(card: str) -> bool: @@ -112,7 +112,7 @@ class PiiFinding: image: str = "" raw_excerpt: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -126,12 +126,12 @@ def _redact_match(value: str) -> str: # ---------- scan -------------------------------------------------------- def scan_image( - source: Union[bytes, str, Path, Any], + source: bytes | str | Path | Any, *, - backend: Optional[OcrBackend] = None, + backend: OcrBackend | None = None, rules: Sequence[PiiRule] = DEFAULT_RULES, image_label: str = "", -) -> List[PiiFinding]: +) -> list[PiiFinding]: """OCR the image and return one :class:`PiiFinding` per (rule, match).""" try: raw_text = extract_text(source, backend=backend) @@ -145,7 +145,7 @@ def scan_text_only( *, rules: Sequence[PiiRule] = DEFAULT_RULES, image_label: str = "", -) -> List[PiiFinding]: +) -> list[PiiFinding]: """Variant for callers that already have OCR'd text in hand.""" if not isinstance(text, str): raise PiiInScreenshotError( @@ -159,8 +159,8 @@ def _scan_text( *, rules: Sequence[PiiRule], image_label: str, -) -> List[PiiFinding]: - findings: List[PiiFinding] = [] +) -> list[PiiFinding]: + findings: list[PiiFinding] = [] seen: set = set() normalised = normalise_text(text, lowercase=False, strip_accents=False) for rule in rules: @@ -200,17 +200,17 @@ class ScanReport: """Aggregate over many screenshots.""" scanned: int = 0 - findings: List[PiiFinding] = field(default_factory=list) - by_severity: Dict[str, int] = field(default_factory=dict) + findings: list[PiiFinding] = field(default_factory=list) + by_severity: dict[str, int] = field(default_factory=dict) def passed(self) -> bool: return not self.findings def scan_screenshots( - sources: Sequence[Union[bytes, str, Path, Any]], + sources: Sequence[bytes | str | Path | Any], *, - backend: Optional[OcrBackend] = None, + backend: OcrBackend | None = None, rules: Sequence[PiiRule] = DEFAULT_RULES, ) -> ScanReport: """Scan a batch of screenshots and return a :class:`ScanReport`.""" diff --git a/je_web_runner/utils/pii_scanner/scanner.py b/je_web_runner/utils/pii_scanner/scanner.py index c292b2f1..5ff34e7d 100644 --- a/je_web_runner/utils/pii_scanner/scanner.py +++ b/je_web_runner/utils/pii_scanner/scanner.py @@ -20,7 +20,7 @@ import re from collections import Counter from dataclasses import dataclass -from typing import Iterable, List, Optional, Sequence +from typing import Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -79,7 +79,7 @@ def _taiwan_id_check(value: str) -> bool: head = _TAIWAN_LETTER_VALUES[value[0]] digits = [head // 10, head % 10] + [int(c) for c in value[1:]] weights = [1, 9, 8, 7, 6, 5, 4, 3, 2, 1, 1] - total = sum(d * w for d, w in zip(digits, weights)) + total = sum(d * w for d, w in zip(digits, weights, strict=False)) return total % 10 == 0 @@ -89,7 +89,7 @@ def _redact(value: str) -> str: return value[:2] + "*" * (len(value) - 4) + value[-2:] -def scan_text(text: str, categories: Optional[Sequence[str]] = None) -> List[PiiFinding]: +def scan_text(text: str, categories: Sequence[str] | None = None) -> list[PiiFinding]: """ 對 ``text`` 跑全部或指定的 PII 偵測類別 Run every (or a filtered subset of) PII detector against ``text``. @@ -97,7 +97,7 @@ def scan_text(text: str, categories: Optional[Sequence[str]] = None) -> List[Pii if not isinstance(text, str): raise PiiScannerError("text must be str") allowed = set(categories) if categories else None - findings: List[PiiFinding] = [] + findings: list[PiiFinding] = [] for category, regex, validator in _DETECTORS: if allowed is not None and category not in allowed: continue @@ -130,8 +130,8 @@ def summarise(findings: Iterable[PiiFinding]) -> Counter: return Counter(f.category for f in findings) -def assert_no_pii(text: str, categories: Optional[Sequence[str]] = None, - allow_categories: Optional[Sequence[str]] = None) -> None: +def assert_no_pii(text: str, categories: Sequence[str] | None = None, + allow_categories: Sequence[str] | None = None) -> None: """ 斷言文本中沒有指定類別的 PII;``allow_categories`` 可白名單跳過。 Raise :class:`PiiScannerError` when any non-allowed category is found. @@ -148,12 +148,12 @@ def assert_no_pii(text: str, categories: Optional[Sequence[str]] = None, def redact_text(text: str, replacement: str = "[REDACTED]", - categories: Optional[Sequence[str]] = None) -> str: + categories: Sequence[str] | None = None) -> str: """Return ``text`` with each PII match replaced by ``replacement``.""" findings = scan_text(text, categories=categories) if not findings: return text - pieces: List[str] = [] + pieces: list[str] = [] cursor = 0 for finding in findings: if finding.start < cursor: diff --git a/je_web_runner/utils/pip_assert/pip.py b/je_web_runner/utils/pip_assert/pip.py index e1030d5c..029290b1 100644 --- a/je_web_runner/utils/pip_assert/pip.py +++ b/je_web_runner/utils/pip_assert/pip.py @@ -14,7 +14,7 @@ from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -71,22 +71,22 @@ class PipEvent: kind: str # "enter" | "exit" mode: Mode ts_ms: int = 0 - width: Optional[int] = None - height: Optional[int] = None + width: int | None = None + height: int | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "mode": self.mode.value} @dataclass class PipLog: - events: List[PipEvent] = field(default_factory=list) + events: list[PipEvent] = field(default_factory=list) def parse_log(payload: Any) -> PipLog: if not isinstance(payload, list): raise PipAssertError("payload must be a list") - out: List[PipEvent] = [] + out: list[PipEvent] = [] for raw in payload: if not isinstance(raw, dict): continue diff --git a/je_web_runner/utils/pipeline/pipeline.py b/je_web_runner/utils/pipeline/pipeline.py index 11f68df7..890d2c70 100644 --- a/je_web_runner/utils/pipeline/pipeline.py +++ b/je_web_runner/utils/pipeline/pipeline.py @@ -24,7 +24,7 @@ import json from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Union +from typing import Any, Callable, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -36,8 +36,8 @@ class PipelineError(WebRunnerException): @dataclass class PipelineStage: name: str - files: List[str] - required_status: List[str] = field(default_factory=lambda: ["passed"]) + files: list[str] + required_status: list[str] = field(default_factory=lambda: ["passed"]) continue_on_failure: bool = False @@ -45,15 +45,15 @@ class PipelineStage: class PipelineResult: stage_name: str status: str # "passed" / "failed" / "skipped" - file_results: List[Dict[str, Any]] = field(default_factory=list) - error: Optional[str] = None + file_results: list[dict[str, Any]] = field(default_factory=list) + error: str | None = None @dataclass class Pipeline: - stages: List[PipelineStage] = field(default_factory=list) + stages: list[PipelineStage] = field(default_factory=list) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {"stages": [ { "name": stage.name, @@ -65,20 +65,20 @@ def to_dict(self) -> Dict[str, Any]: ]} -def load_pipeline(source: Union[str, Path, Dict[str, Any]]) -> Pipeline: +def load_pipeline(source: str | Path | dict[str, Any]) -> Pipeline: """Load a pipeline definition from a path / JSON string / dict.""" document = _coerce_pipeline_document(source) raw_stages = document.get("stages") if not isinstance(raw_stages, list) or not raw_stages: raise PipelineError("'stages' must be a non-empty list") - stages: List[PipelineStage] = [] + stages: list[PipelineStage] = [] seen: set = set() for index, entry in enumerate(raw_stages): stages.append(_parse_stage(index, entry, seen)) return Pipeline(stages=stages) -def _coerce_pipeline_document(source: Union[str, Path, Dict[str, Any]]) -> Dict[str, Any]: +def _coerce_pipeline_document(source: str | Path | dict[str, Any]) -> dict[str, Any]: if isinstance(source, dict): document = source elif isinstance(source, (str, Path)): @@ -90,7 +90,7 @@ def _coerce_pipeline_document(source: Union[str, Path, Dict[str, Any]]) -> Dict[ return document -def _load_pipeline_from_text(source: Union[str, Path]) -> Dict[str, Any]: +def _load_pipeline_from_text(source: str | Path) -> dict[str, Any]: path = Path(source) text = path.read_text(encoding="utf-8") if path.is_file() else str(source) try: @@ -125,14 +125,14 @@ def _parse_stage(index: int, entry: Any, seen: set) -> PipelineStage: ) -FileRunner = Callable[[str], Dict[str, Any]] +FileRunner = Callable[[str], dict[str, Any]] def run_pipeline( pipeline: Pipeline, runner: FileRunner, - file_resolver: Optional[Callable[[str], List[str]]] = None, -) -> List[PipelineResult]: + file_resolver: Callable[[str], list[str]] | None = None, +) -> list[PipelineResult]: """ 依宣告順序跑 pipeline。Stage 失敗時: - ``continue_on_failure=True`` → 收集失敗、進下一 stage @@ -143,8 +143,8 @@ def run_pipeline( if not callable(runner): raise PipelineError("runner must be callable") resolver = file_resolver or (lambda pattern: [pattern]) - results: List[PipelineResult] = [] - short_circuit_cause: Optional[PipelineResult] = None + results: list[PipelineResult] = [] + short_circuit_cause: PipelineResult | None = None for stage in pipeline.stages: if short_circuit_cause is not None: results.append(PipelineResult( @@ -162,7 +162,7 @@ def run_pipeline( def _run_stage(stage: PipelineStage, runner: FileRunner, - resolver: Callable[[str], List[str]]) -> PipelineResult: + resolver: Callable[[str], list[str]]) -> PipelineResult: try: files = _flatten_files(stage.files, resolver) except PipelineError as error: @@ -171,7 +171,7 @@ def _run_stage(stage: PipelineStage, runner: FileRunner, ) if not files: return PipelineResult(stage_name=stage.name, status="passed") - file_outcomes: List[Dict[str, Any]] = [] + file_outcomes: list[dict[str, Any]] = [] overall = "passed" for path in files: try: @@ -191,8 +191,8 @@ def _run_stage(stage: PipelineStage, runner: FileRunner, def _flatten_files(patterns: Sequence[str], - resolver: Callable[[str], List[str]]) -> List[str]: - files: List[str] = [] + resolver: Callable[[str], list[str]]) -> list[str]: + files: list[str] = [] seen: set = set() for pattern in patterns: resolved = resolver(pattern) diff --git a/je_web_runner/utils/pom_codegen/codegen.py b/je_web_runner/utils/pom_codegen/codegen.py index 9d621583..8f0a0697 100644 --- a/je_web_runner/utils/pom_codegen/codegen.py +++ b/je_web_runner/utils/pom_codegen/codegen.py @@ -15,7 +15,7 @@ import re from dataclasses import dataclass from html.parser import HTMLParser -from typing import Any, Dict, List, Sequence +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -56,10 +56,10 @@ class _ElementCollector(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) - self.elements: List[DiscoveredElement] = [] - self._used_names: Dict[str, int] = {} + self.elements: list[DiscoveredElement] = [] + self._used_names: dict[str, int] = {} - def handle_starttag(self, tag: str, attrs: List[Any]) -> None: + def handle_starttag(self, tag: str, attrs: list[Any]) -> None: attr_dict = {name: value for name, value in attrs if isinstance(name, str)} for source in _PRIORITY_KEYS: value = attr_dict.get(source) @@ -90,7 +90,7 @@ def _record(self, tag: str, source: str, value: str) -> None: )) -def discover_elements_from_html(html: str) -> List[DiscoveredElement]: +def discover_elements_from_html(html: str) -> list[DiscoveredElement]: """Collect every wrap-worthy element from the HTML snapshot.""" if not isinstance(html, str): raise PomCodegenError("html must be str") diff --git a/je_web_runner/utils/pom_generator/pom_generator.py b/je_web_runner/utils/pom_generator/pom_generator.py index 3f6da9df..c1ae39d2 100644 --- a/je_web_runner/utils/pom_generator/pom_generator.py +++ b/je_web_runner/utils/pom_generator/pom_generator.py @@ -9,7 +9,6 @@ class with locators + interaction methods. Output is a starting point that import re from html.parser import HTMLParser from pathlib import Path -from typing import Dict, List, Optional import requests @@ -31,15 +30,15 @@ class _InteractiveElementCollector(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) - self.elements: List[Dict[str, Optional[str]]] = [] - self._current_button_text: List[str] = [] + self.elements: list[dict[str, str | None]] = [] + self._current_button_text: list[str] = [] self._inside_button: bool = False def handle_starttag(self, tag: str, attrs): if tag not in _INTERACTIVE_TAGS: return attr_dict = dict(attrs) - record: Dict[str, Optional[str]] = { + record: dict[str, str | None] = { "tag": tag, "id": attr_dict.get("id"), "name": attr_dict.get("name"), @@ -67,7 +66,7 @@ def handle_data(self, data: str): self._current_button_text.append(data) -def extract_elements_from_html(html: str) -> List[Dict[str, Optional[str]]]: +def extract_elements_from_html(html: str) -> list[dict[str, str | None]]: """Parse ``html`` and return a list of interactive element dicts.""" parser = _InteractiveElementCollector() parser.feed(html) @@ -88,7 +87,7 @@ def _safe_method_name(prefix: str, candidate: str, used: set) -> str: return name -def _locator_for(element: Dict[str, Optional[str]]): +def _locator_for(element: dict[str, str | None]): """Return a (strategy, value) tuple suitable for TestObject.""" if element.get("id"): return ("ID", element["id"]) @@ -102,7 +101,7 @@ def _locator_for(element: Dict[str, Optional[str]]): return None -def _hint_for(element: Dict[str, Optional[str]]) -> str: +def _hint_for(element: dict[str, str | None]) -> str: return ( element.get("id") or element.get("name") @@ -115,7 +114,7 @@ def _hint_for(element: Dict[str, Optional[str]]) -> str: ) -def _action_kind(element: Dict[str, Optional[str]]) -> str: +def _action_kind(element: dict[str, str | None]) -> str: """Decide whether to scaffold a click or an input method.""" tag = element.get("tag") type_attr = (element.get("type") or "").lower() @@ -131,7 +130,7 @@ def _action_kind(element: Dict[str, Optional[str]]) -> str: _METHOD_STUB_BODY = " pass" -def _render_method(method_name: str, kind: str, locator_constant: str) -> List[str]: +def _render_method(method_name: str, kind: str, locator_constant: str) -> list[str]: register_todo = " # TODO: register self." + locator_constant + " as a TestObject before calling." if kind == "input": return [ @@ -166,15 +165,15 @@ def _prefix_for(kind: str) -> str: return "click" -def generate_pom_class(class_name: str, elements: List[Dict[str, Optional[str]]]) -> str: +def generate_pom_class(class_name: str, elements: list[dict[str, str | None]]) -> str: """ 產生 POM Python 類別原始碼 Render Python source for a POM class with one constant + method per element. """ used_constants: set = set() used_methods: set = set() - constants: List[str] = [] - methods: List[str] = [] + constants: list[str] = [] + methods: list[str] = [] for element in elements: locator = _locator_for(element) if locator is None: @@ -225,7 +224,7 @@ def generate_pom_from_url(url: str, class_name: str, timeout: int = 30) -> str: 從 URL 下載 HTML 並產生 POM 類別 Fetch the URL and emit a POM class. ``http`` / ``https`` schemes only. """ - if not isinstance(url, str) or not (url.startswith("http://") or url.startswith("https://")): # NOSONAR — scheme allow-list, not an outbound HTTP call + if not isinstance(url, str) or not (url.startswith(("http://", "https://"))): # NOSONAR — scheme allow-list, not an outbound HTTP call raise POMGeneratorError(f"URL must be http(s): {url!r}") web_runner_logger.info(f"generate_pom_from_url: {url}") response = requests.get(url, timeout=timeout) diff --git a/je_web_runner/utils/popover_assert/popover.py b/je_web_runner/utils/popover_assert/popover.py index b45ae19c..e1c63cd0 100644 --- a/je_web_runner/utils/popover_assert/popover.py +++ b/je_web_runner/utils/popover_assert/popover.py @@ -14,7 +14,7 @@ from dataclasses import asdict, dataclass from enum import Enum -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -73,23 +73,23 @@ class PopoverState: kind: PopoverKind open: bool - id: Optional[str] = None - role: Optional[str] = None + id: str | None = None + role: str | None = None modal: bool = False - invoker: Optional[str] = None - bounding_rect: Optional[Dict[str, float]] = None + invoker: str | None = None + bounding_rect: dict[str, float] | None = None - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "kind": self.kind.value} -def parse_snapshot(payload: Any) -> List[PopoverState]: +def parse_snapshot(payload: Any) -> list[PopoverState]: """Parse the harvested ``HARVEST_SCRIPT`` payload.""" if not isinstance(payload, list): raise PopoverAssertError( f"snapshot must be a list, got {type(payload).__name__}" ) - out: List[PopoverState] = [] + out: list[PopoverState] = [] for raw in payload: if not isinstance(raw, dict): continue diff --git a/je_web_runner/utils/pr_comment/poster.py b/je_web_runner/utils/pr_comment/poster.py index ce28dfc7..3f15a499 100644 --- a/je_web_runner/utils/pr_comment/poster.py +++ b/je_web_runner/utils/pr_comment/poster.py @@ -13,7 +13,7 @@ import ssl import urllib.request from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -32,12 +32,12 @@ class PrSummary: passed: int failed: int skipped: int = 0 - duration_seconds: Optional[float] = None + duration_seconds: float | None = None flaky: int = 0 - sections: List[Dict[str, Any]] = field(default_factory=list) + sections: list[dict[str, Any]] = field(default_factory=list) -def build_summary_markdown(summary: PrSummary, run_url: Optional[str] = None) -> str: +def build_summary_markdown(summary: PrSummary, run_url: str | None = None) -> str: pass_pct = (summary.passed / summary.total * 100) if summary.total else 0.0 pieces = [ _MARKER, @@ -70,10 +70,10 @@ def _request( method: str, url: str, token: str, - payload: Optional[Dict[str, Any]] = None, + payload: dict[str, Any] | None = None, timeout: float = 15.0, ) -> Any: - if not (url.startswith("https://api.github.com/") or url.startswith("http://api.github.com/")): # NOSONAR — allow-list + if not (url.startswith(("https://api.github.com/", "http://api.github.com/"))): # NOSONAR — allow-list raise PrCommentError(f"refusing to call non-GitHub URL: {url!r}") data = None if payload is None else json.dumps(payload).encode("utf-8") request = urllib.request.Request(url, data=data, method=method) @@ -98,7 +98,7 @@ def _request( raise PrCommentError(f"GitHub returned non-JSON: {error}") from error -def _list_comments(repo: str, pr_number: int, token: str) -> List[Dict[str, Any]]: +def _list_comments(repo: str, pr_number: int, token: str) -> list[dict[str, Any]]: url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments?per_page=100" payload = _request("GET", url, token=token) if not isinstance(payload, list): @@ -106,7 +106,7 @@ def _list_comments(repo: str, pr_number: int, token: str) -> List[Dict[str, Any] return payload -def _find_marker(comments: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: +def _find_marker(comments: list[dict[str, Any]]) -> dict[str, Any] | None: for entry in comments: if isinstance(entry, dict) and _MARKER in (entry.get("body") or ""): return entry @@ -117,8 +117,8 @@ def post_or_update_comment( repo: str, pr_number: int, body: str, - token: Optional[str] = None, -) -> Dict[str, Any]: + token: str | None = None, +) -> dict[str, Any]: """ 依 marker 判斷新增或覆寫 WebRunner summary 留言 Find the WebRunner-marker comment on this PR and PATCH it; create a new diff --git a/je_web_runner/utils/pr_risk_score/scorer.py b/je_web_runner/utils/pr_risk_score/scorer.py index 6e614e63..dd5d4442 100644 --- a/je_web_runner/utils/pr_risk_score/scorer.py +++ b/je_web_runner/utils/pr_risk_score/scorer.py @@ -13,7 +13,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -89,8 +89,8 @@ class RiskReport: score: float # 0..100 level: str # "low" | "medium" | "high" | "critical" - reasons: List[str] = field(default_factory=list) - contributions: Dict[str, float] = field(default_factory=dict) + reasons: list[str] = field(default_factory=list) + contributions: dict[str, float] = field(default_factory=dict) def is_blocking(self, block_at: float = 75.0) -> bool: return self.score >= block_at @@ -114,7 +114,7 @@ def _clip(value: float) -> float: ) -def _signal_components(signals: PrSignals) -> Dict[str, float]: +def _signal_components(signals: PrSignals) -> dict[str, float]: flake_touched_ratio = _ratio( signals.flaky_tests_touched, signals.total_tests_touched, ) @@ -140,7 +140,7 @@ def _signal_components(signals: PrSignals) -> Dict[str, float]: } -def _format_reason(name: str, component: float, weight: float) -> Optional[str]: +def _format_reason(name: str, component: float, weight: float) -> str | None: if component <= 0: return None pct = round(component * 100) @@ -159,7 +159,7 @@ def _level_for(score: float) -> str: def score_pr( signals: PrSignals, - weights: Optional[RiskWeights] = None, + weights: RiskWeights | None = None, ) -> RiskReport: """Combine ``signals`` × ``weights`` into a 0–100 :class:`RiskReport`.""" if not isinstance(signals, PrSignals): @@ -169,7 +169,7 @@ def score_pr( if weight_total <= 0: raise PrRiskScoreError("at least one weight must be > 0") components = _signal_components(signals) - contributions: Dict[str, float] = {} + contributions: dict[str, float] = {} weighted_sum = 0.0 for name in _SIGNAL_NAMES: weight = getattr(weights, name) @@ -212,20 +212,20 @@ def report_markdown(report: RiskReport) -> str: return "\n".join(lines) + "\n" -def aggregate_signals(per_file: Sequence[Dict[str, Any]]) -> PrSignals: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up +def aggregate_signals(per_file: Sequence[dict[str, Any]]) -> PrSignals: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up """ Reduce a per-file signal list (from upstream tools) into one :class:`PrSignals`. Unknown keys are ignored so callers can pass richer dicts without breaking. """ - totals: Dict[str, int] = dict.fromkeys(( + totals: dict[str, int] = dict.fromkeys(( "flaky_tests_touched", "total_tests_touched", "impacted_modules", "impacted_critical_paths", "fragile_locators_touched", "total_locators_touched", "lines_added", "lines_covered", "migration_files_changed", "security_files_changed", ), 0) - flake_scores: List[float] = [] + flake_scores: list[float] = [] repo_modules = 0 for entry in per_file: if not isinstance(entry, dict): diff --git a/je_web_runner/utils/pr_title_generator/generate.py b/je_web_runner/utils/pr_title_generator/generate.py index 54be1367..bf01ee5f 100644 --- a/je_web_runner/utils/pr_title_generator/generate.py +++ b/je_web_runner/utils/pr_title_generator/generate.py @@ -16,7 +16,7 @@ import re from collections import Counter from dataclasses import dataclass -from typing import Callable, List, Sequence +from typing import Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -46,7 +46,7 @@ class PrTitleGeneratorError(WebRunnerException): @dataclass class DiffStat: - files: List[str] + files: list[str] additions: int = 0 deletions: int = 0 diff --git a/je_web_runner/utils/pre_merge_gate_dsl/gate.py b/je_web_runner/utils/pre_merge_gate_dsl/gate.py index 61563504..c28058e9 100644 --- a/je_web_runner/utils/pre_merge_gate_dsl/gate.py +++ b/je_web_runner/utils/pre_merge_gate_dsl/gate.py @@ -19,7 +19,7 @@ import fnmatch import re from dataclasses import asdict, dataclass, field -from typing import Any, Callable, Dict, Iterable, List, Optional +from typing import Any, Callable, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -31,13 +31,13 @@ class PreMergeGateDslError(WebRunnerException): @dataclass class PrFacts: title: str = "" - files_changed: List[str] = field(default_factory=list) + files_changed: list[str] = field(default_factory=list) additions: int = 0 deletions: int = 0 review_approvals: int = 0 - failing_checks: List[str] = field(default_factory=list) + failing_checks: list[str] = field(default_factory=list) flake_score_delta: float = 0 - labels: List[str] = field(default_factory=list) + labels: list[str] = field(default_factory=list) @property def is_docs_only(self) -> bool: @@ -53,7 +53,7 @@ def has_path(self, glob: str) -> bool: @dataclass class Rule: when: str - require: List[str] + require: list[str] def __post_init__(self) -> None: if not isinstance(self.when, str) or not self.when: @@ -108,47 +108,47 @@ def _safe_eval_when(expr: str, facts: PrFacts) -> bool: # requirement name -> predicate (facts -> bool, "" or "reason string") -Predicate = Callable[[PrFacts], Optional[str]] +Predicate = Callable[[PrFacts], str | None] -def _pr_title_has_jira(facts: PrFacts) -> Optional[str]: +def _pr_title_has_jira(facts: PrFacts) -> str | None: if re.search(r"\b[A-Z]{2,}-\d+\b", facts.title): return None return "PR title missing JIRA key (e.g. ABC-123)" -def _two_reviewers(facts: PrFacts) -> Optional[str]: +def _two_reviewers(facts: PrFacts) -> str | None: if facts.review_approvals >= 2: return None return f"need 2 reviewers, have {facts.review_approvals}" -def _one_reviewer(facts: PrFacts) -> Optional[str]: +def _one_reviewer(facts: PrFacts) -> str | None: if facts.review_approvals >= 1: return None return "need at least 1 reviewer" -def _no_failing_checks(facts: PrFacts) -> Optional[str]: +def _no_failing_checks(facts: PrFacts) -> str | None: if not facts.failing_checks: return None return f"failing checks: {facts.failing_checks}" -def _no_flake_regression(facts: PrFacts) -> Optional[str]: +def _no_flake_regression(facts: PrFacts) -> str | None: if facts.flake_score_delta <= 0.05: return None return f"flake score regressed by {facts.flake_score_delta:.2f}" -def _small_pr(facts: PrFacts) -> Optional[str]: +def _small_pr(facts: PrFacts) -> str | None: total = facts.additions + facts.deletions if total <= 400: return None return f"PR too large ({total} LOC > 400)" -BUILTIN_PREDICATES: Dict[str, Predicate] = { +BUILTIN_PREDICATES: dict[str, Predicate] = { "pr_title_has_jira": _pr_title_has_jira, "two_reviewers": _two_reviewers, "one_reviewer": _one_reviewer, @@ -161,16 +161,16 @@ def _small_pr(facts: PrFacts) -> Optional[str]: @dataclass class GateResult: passed: bool - failures: List[str] = field(default_factory=list) + failures: list[str] = field(default_factory=list) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) -def parse_rules(raw: Any) -> List[Rule]: +def parse_rules(raw: Any) -> list[Rule]: if not isinstance(raw, list): raise PreMergeGateDslError("rules must be a list of dicts") - out: List[Rule] = [] + out: list[Rule] = [] for i, item in enumerate(raw): if not isinstance(item, dict): raise PreMergeGateDslError(f"rule #{i} must be a dict") @@ -182,14 +182,14 @@ def parse_rules(raw: Any) -> List[Rule]: def evaluate( rules: Iterable[Rule], facts: PrFacts, - predicates: Optional[Dict[str, Predicate]] = None, + predicates: dict[str, Predicate] | None = None, ) -> GateResult: if not isinstance(facts, PrFacts): raise PreMergeGateDslError("facts must be PrFacts") table = dict(BUILTIN_PREDICATES) if predicates: table.update(predicates) - failures: List[str] = [] + failures: list[str] = [] for rule in rules: if not _safe_eval_when(rule.when, facts): continue diff --git a/je_web_runner/utils/process_supervisor/supervisor.py b/je_web_runner/utils/process_supervisor/supervisor.py index 3f6bfaa0..3b154669 100644 --- a/je_web_runner/utils/process_supervisor/supervisor.py +++ b/je_web_runner/utils/process_supervisor/supervisor.py @@ -19,7 +19,7 @@ import subprocess # nosec B404 — argv-only invocation, no shell import threading from dataclasses import dataclass, field -from typing import Any, Callable, Dict, Iterable, List, Optional +from typing import Any, Callable, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -48,11 +48,11 @@ class OrphanFinding: command_line: str = "" -ProcessLister = Callable[[], List[OrphanFinding]] +ProcessLister = Callable[[], list[OrphanFinding]] ProcessKiller = Callable[[int], bool] -def _ps_unix_lister() -> List[OrphanFinding]: +def _ps_unix_lister() -> list[OrphanFinding]: try: # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit.dangerous-subprocess-use-audit out = subprocess.check_output( # nosec B603 B607 — explicit argv list @@ -62,7 +62,7 @@ def _ps_unix_lister() -> List[OrphanFinding]: ) except (FileNotFoundError, subprocess.CalledProcessError) as error: raise ProcessSupervisorError(f"ps failed: {error!r}") from error - findings: List[OrphanFinding] = [] + findings: list[OrphanFinding] = [] for line in out.splitlines(): parts = line.strip().split(None, 2) if len(parts) < 2: @@ -77,7 +77,7 @@ def _ps_unix_lister() -> List[OrphanFinding]: return findings -def _tasklist_windows_lister() -> List[OrphanFinding]: +def _tasklist_windows_lister() -> list[OrphanFinding]: try: # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit.dangerous-subprocess-use-audit out = subprocess.check_output( # nosec B603 B607 — explicit argv list @@ -87,7 +87,7 @@ def _tasklist_windows_lister() -> List[OrphanFinding]: ) except (FileNotFoundError, subprocess.CalledProcessError) as error: raise ProcessSupervisorError(f"tasklist failed: {error!r}") from error - findings: List[OrphanFinding] = [] + findings: list[OrphanFinding] = [] for line in out.splitlines(): # CSV with quoted fields: "Image","PID","Session","Session#","Mem" cleaned = [field.strip().strip('"') for field in line.split(",")] @@ -102,7 +102,7 @@ def _tasklist_windows_lister() -> List[OrphanFinding]: return findings -def default_lister() -> List[OrphanFinding]: +def default_lister() -> list[OrphanFinding]: if os.name == "nt": return _tasklist_windows_lister() return _ps_unix_lister() @@ -135,7 +135,7 @@ class ProcessSupervisor: lister: ProcessLister = field(default=default_lister) killer: ProcessKiller = field(default=default_killer) - def list_orphans(self, names: Iterable[str] = KNOWN_DRIVER_NAMES) -> List[OrphanFinding]: + def list_orphans(self, names: Iterable[str] = KNOWN_DRIVER_NAMES) -> list[OrphanFinding]: target_set = {name.lower() for name in names} all_processes = self.lister() if not isinstance(all_processes, list): @@ -149,10 +149,10 @@ def list_orphans(self, names: Iterable[str] = KNOWN_DRIVER_NAMES) -> List[Orphan def kill_orphans( self, names: Iterable[str] = KNOWN_DRIVER_NAMES, - protected_pids: Optional[Iterable[int]] = None, - ) -> Dict[int, bool]: + protected_pids: Iterable[int] | None = None, + ) -> dict[int, bool]: protected = set(protected_pids or []) - results: Dict[int, bool] = {} + results: dict[int, bool] = {} for finding in self.list_orphans(names): if finding.pid in protected: continue @@ -178,7 +178,7 @@ def with_watchdog( """ if timeout_seconds <= 0: raise ProcessSupervisorError("timeout_seconds must be > 0") - container: Dict[str, Any] = {} + container: dict[str, Any] = {} def runner() -> None: try: diff --git a/je_web_runner/utils/project/create_project_structure.py b/je_web_runner/utils/project/create_project_structure.py index e2a2f32c..ffea70da 100644 --- a/je_web_runner/utils/project/create_project_structure.py +++ b/je_web_runner/utils/project/create_project_structure.py @@ -32,7 +32,7 @@ def create_dir(dir_name: str) -> None: ) -def create_template(parent_name: str, project_path: str = None) -> None: +def create_template(parent_name: str, project_path: str | None = None) -> None: """ 在專案目錄下建立範本檔案 Create template files in the project directory @@ -61,21 +61,21 @@ def create_template(parent_name: str, project_path: str = None) -> None: if executor_dir_path.exists() and executor_dir_path.is_dir(): lock.acquire() try: - with open(project_root + EXECUTOR_DIR + "/executor_one_file.py", "w+") as file: + with open(project_root + EXECUTOR_DIR + "/executor_one_file.py", "w+", encoding="utf-8") as file: file.write( executor_template_1.replace( TEMP_PLACEHOLDER, project_root + KEYWORD_DIR + "/keyword1.json" ) ) - with open(project_root + EXECUTOR_DIR + "/executor_bad_file.py", "w+") as file: + with open(project_root + EXECUTOR_DIR + "/executor_bad_file.py", "w+", encoding="utf-8") as file: file.write( bad_executor_template_1.replace( TEMP_PLACEHOLDER, project_root + KEYWORD_DIR + "/bad_keyword_1.json" ) ) - with open(project_root + EXECUTOR_DIR + "/executor_folder.py", "w+") as file: + with open(project_root + EXECUTOR_DIR + "/executor_folder.py", "w+", encoding="utf-8") as file: file.write( executor_template_2.replace( TEMP_PLACEHOLDER, @@ -86,7 +86,7 @@ def create_template(parent_name: str, project_path: str = None) -> None: lock.release() -def create_project_dir(project_path: str = None, parent_name: str = "WebRunner") -> None: +def create_project_dir(project_path: str | None = None, parent_name: str = "WebRunner") -> None: """ 建立專案資料夾結構,並生成範本檔案 Create project directory structure and generate template files diff --git a/je_web_runner/utils/prompt_drift_monitor/monitor.py b/je_web_runner/utils/prompt_drift_monitor/monitor.py index 6f54a977..6571ccce 100644 --- a/je_web_runner/utils/prompt_drift_monitor/monitor.py +++ b/je_web_runner/utils/prompt_drift_monitor/monitor.py @@ -20,7 +20,7 @@ from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path -from typing import Any, Callable, Dict, List, Sequence, Union +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -43,12 +43,12 @@ class BaselineSample: prompt_id: str prompt: str answer: str - embedding: List[float] - must_include: List[str] = field(default_factory=list) - must_exclude: List[str] = field(default_factory=list) + embedding: list[float] + must_include: list[str] = field(default_factory=list) + must_exclude: list[str] = field(default_factory=list) captured_at: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -56,15 +56,15 @@ def to_dict(self) -> Dict[str, Any]: class Baseline: """Set of frozen reference samples, persisted as JSON.""" - samples: List[BaselineSample] = field(default_factory=list) + samples: list[BaselineSample] = field(default_factory=list) captured_at: str = "" - def by_id(self) -> Dict[str, BaselineSample]: + def by_id(self) -> dict[str, BaselineSample]: return {s.prompt_id: s for s in self.samples} def capture_baseline( - prompts: Sequence[Dict[str, Any]], + prompts: Sequence[dict[str, Any]], embedder: Embedder, answerer: Callable[[str], str], ) -> Baseline: @@ -74,7 +74,7 @@ def capture_baseline( """ if not prompts: raise PromptDriftError("prompts must be non-empty") - samples: List[BaselineSample] = [] + samples: list[BaselineSample] = [] now = datetime.now(tz=timezone.utc).isoformat(timespec="seconds") for raw in prompts: if not isinstance(raw, dict): @@ -102,7 +102,7 @@ def capture_baseline( return Baseline(samples=samples, captured_at=now) -def save_baseline(baseline: Baseline, path: Union[str, Path]) -> Path: +def save_baseline(baseline: Baseline, path: str | Path) -> Path: """Persist baseline to JSON.""" if not isinstance(baseline, Baseline): raise PromptDriftError("save_baseline expects Baseline") @@ -117,7 +117,7 @@ def save_baseline(baseline: Baseline, path: Union[str, Path]) -> Path: return p -def load_baseline(path: Union[str, Path]) -> Baseline: +def load_baseline(path: str | Path) -> Baseline: """Read baseline JSON back into a :class:`Baseline`.""" p = Path(path) if not p.exists(): @@ -128,7 +128,7 @@ def load_baseline(path: Union[str, Path]) -> Baseline: raise PromptDriftError(f"baseline file invalid: {error}") from error if not isinstance(data, dict) or not isinstance(data.get("samples"), list): raise PromptDriftError("baseline JSON missing 'samples' list") - samples: List[BaselineSample] = [] + samples: list[BaselineSample] = [] for raw in data["samples"]: if not isinstance(raw, dict): continue @@ -159,8 +159,8 @@ class DriftFinding: prompt_id: str similarity: float drifted: bool - missing_required: List[str] = field(default_factory=list) - forbidden_present: List[str] = field(default_factory=list) + missing_required: list[str] = field(default_factory=list) + forbidden_present: list[str] = field(default_factory=list) current_answer: str = "" @@ -169,9 +169,9 @@ class DriftReport: """Roll-up returned by :func:`check_drift`.""" threshold: float - findings: List[DriftFinding] = field(default_factory=list) + findings: list[DriftFinding] = field(default_factory=list) - def drifted_findings(self) -> List[DriftFinding]: + def drifted_findings(self) -> list[DriftFinding]: return [f for f in self.findings if f.drifted or f.missing_required or f.forbidden_present] @@ -261,7 +261,7 @@ def _embed_or_raise(embedder: Embedder, text: str, *, label: str) -> Sequence[fl def _cosine(a: Sequence[float], b: Sequence[float]) -> float: if len(a) != len(b) or not a: raise PromptDriftError("embeddings must be non-empty and equal-length") - dot = sum(float(x) * float(y) for x, y in zip(a, b)) + dot = sum(float(x) * float(y) for x, y in zip(a, b, strict=False)) norm_a = math.sqrt(sum(float(x) * float(x) for x in a)) norm_b = math.sqrt(sum(float(x) * float(x) for x in b)) if norm_a == 0 or norm_b == 0: diff --git a/je_web_runner/utils/prompt_injection_scanner/scanner.py b/je_web_runner/utils/prompt_injection_scanner/scanner.py index 343f7782..81f76835 100644 --- a/je_web_runner/utils/prompt_injection_scanner/scanner.py +++ b/je_web_runner/utils/prompt_injection_scanner/scanner.py @@ -16,7 +16,7 @@ from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Dict, List, Protocol, Sequence +from typing import Any, Protocol, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -114,18 +114,18 @@ class Finding: severity: Severity leaked: bool response_excerpt: str - matched_indicators: List[str] = field(default_factory=list) + matched_indicators: list[str] = field(default_factory=list) refused: bool = False - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "severity": self.severity.value} @dataclass class ScanReport: - findings: List[Finding] = field(default_factory=list) + findings: list[Finding] = field(default_factory=list) - def leaks(self) -> List[Finding]: + def leaks(self) -> list[Finding]: return [f for f in self.findings if f.leaked] def passed(self) -> bool: diff --git a/je_web_runner/utils/pseudo_localization/pseudo.py b/je_web_runner/utils/pseudo_localization/pseudo.py index 7d463e8c..d685e259 100644 --- a/je_web_runner/utils/pseudo_localization/pseudo.py +++ b/je_web_runner/utils/pseudo_localization/pseudo.py @@ -17,7 +17,7 @@ import re from dataclasses import dataclass, field -from typing import Dict, List, Mapping, Optional +from typing import Mapping from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -80,9 +80,9 @@ def _pad_to_ratio(text: str, ratio: float) -> str: return f"{_PADDING_CHAR * left} {text} {_PADDING_CHAR * right}" -def _split_around_placeholders(text: str) -> List[tuple]: +def _split_around_placeholders(text: str) -> list[tuple]: """Return list of (segment, is_placeholder) tuples preserving order.""" - parts: List[tuple] = [] + parts: list[tuple] = [] last = 0 for match in _PLACEHOLDER_RE.finditer(text): if match.start() > last: @@ -98,7 +98,7 @@ def _split_around_placeholders(text: str) -> List[tuple]: def pseudo_localize( # NOSONAR S3776 — cohesive logic; planned refactor in follow-up text: str, - config: Optional[PseudoConfig] = None, + config: PseudoConfig | None = None, ) -> str: """Return a pseudo-localised version of ``text``.""" if not isinstance(text, str): @@ -109,7 +109,7 @@ def pseudo_localize( # NOSONAR S3776 — cohesive logic; planned refactor in fo if not text: return text if cfg.preserve_placeholders: - chunks: List[str] = [] + chunks: list[str] = [] for segment, is_placeholder in _split_around_placeholders(text): if is_placeholder: chunks.append(segment) @@ -128,12 +128,12 @@ def pseudo_localize( # NOSONAR S3776 — cohesive logic; planned refactor in fo def pseudo_localize_dict( catalogue: Mapping[str, str], - config: Optional[PseudoConfig] = None, -) -> Dict[str, str]: + config: PseudoConfig | None = None, +) -> dict[str, str]: """Apply :func:`pseudo_localize` to every value in a {key: string} map.""" if not isinstance(catalogue, Mapping): raise PseudoLocalizationError("catalogue must be a mapping") - out: Dict[str, str] = {} + out: dict[str, str] = {} for key, value in catalogue.items(): if not isinstance(value, str): raise PseudoLocalizationError( @@ -158,7 +158,7 @@ class PseudoAuditReport: """Roll-up of :func:`scan_for_hardcoded`.""" rendered_chars: int = 0 - hits: List[HardcodedHit] = field(default_factory=list) + hits: list[HardcodedHit] = field(default_factory=list) def passed(self) -> bool: return not self.hits @@ -181,7 +181,7 @@ def scan_for_hardcoded( if min_length < 1: raise PseudoLocalizationError("min_length must be >= 1") report = PseudoAuditReport(rendered_chars=len(rendered_text)) - seen: Dict[str, int] = {} + seen: dict[str, int] = {} for value in catalogue.values(): if not isinstance(value, str) or len(value) < min_length: continue diff --git a/je_web_runner/utils/push_delivery/delivery.py b/je_web_runner/utils/push_delivery/delivery.py index ec5f3c69..09c3116b 100644 --- a/je_web_runner/utils/push_delivery/delivery.py +++ b/je_web_runner/utils/push_delivery/delivery.py @@ -16,7 +16,7 @@ import json import re from enum import Enum -from typing import Any, Dict +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -43,7 +43,7 @@ class Provider(str, Enum): ) -def assert_fcm_payload(payload: Dict[str, Any]) -> None: +def assert_fcm_payload(payload: dict[str, Any]) -> None: if not isinstance(payload, dict): raise PushDeliveryError("payload must be a dict") if "message" not in payload: @@ -61,7 +61,7 @@ def assert_fcm_payload(payload: Dict[str, Any]) -> None: _assert_ttl_string(message["android"].get("ttl"), FCM_MAX_TTL_SEC) -def assert_apns_payload(payload: Dict[str, Any]) -> None: +def assert_apns_payload(payload: dict[str, Any]) -> None: if not isinstance(payload, dict): raise PushDeliveryError("payload must be a dict") aps = payload.get("aps") @@ -79,7 +79,7 @@ def assert_apns_payload(payload: Dict[str, Any]) -> None: _assert_no_pii(alert) -def _assert_size(payload: Dict[str, Any], max_bytes: int) -> None: +def _assert_size(payload: dict[str, Any], max_bytes: int) -> None: serialized = json.dumps(payload, separators=(",", ":")) size = len(serialized.encode("utf-8")) if size > max_bytes: @@ -88,7 +88,7 @@ def _assert_size(payload: Dict[str, Any], max_bytes: int) -> None: ) -def _assert_no_pii(notification: Dict[str, Any]) -> None: +def _assert_no_pii(notification: dict[str, Any]) -> None: for field_name in ("title", "body"): value = notification.get(field_name) if not isinstance(value, str): @@ -120,7 +120,7 @@ def _assert_ttl_string(ttl: Any, max_seconds: int) -> None: ) -def assert_collapse_intent(payload: Dict[str, Any]) -> None: +def assert_collapse_intent(payload: dict[str, Any]) -> None: """If the message is *meant* to replace older notifications, a collapse key / thread identifier must be set.""" if isinstance(payload.get("aps"), dict): diff --git a/je_web_runner/utils/quarantine_age_report/report.py b/je_web_runner/utils/quarantine_age_report/report.py index 023835e7..81963147 100644 --- a/je_web_runner/utils/quarantine_age_report/report.py +++ b/je_web_runner/utils/quarantine_age_report/report.py @@ -13,7 +13,7 @@ from datetime import datetime, timezone from enum import Enum from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Sequence, Union +from typing import Any, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -48,10 +48,10 @@ class AgedEntry: quarantined_at: str age_days: float tier: EscalationTier - triage_url: Optional[str] = None + triage_url: str | None = None runs_when_added: int = 0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "tier": self.tier.value} @@ -60,9 +60,9 @@ class AgeReport: """Roll-up over a whole quarantine registry.""" total_entries: int = 0 - by_tier: Dict[str, int] = field(default_factory=dict) - entries: List[AgedEntry] = field(default_factory=list) - abandoned: List[str] = field(default_factory=list) + by_tier: dict[str, int] = field(default_factory=dict) + entries: list[AgedEntry] = field(default_factory=list) + abandoned: list[str] = field(default_factory=list) def _parse_iso(value: str) -> datetime: @@ -87,7 +87,7 @@ def _tier_for(age_days: float) -> EscalationTier: return EscalationTier.ABANDONED -def _load_registry(path: Union[str, Path]) -> List[Dict[str, Any]]: +def _load_registry(path: str | Path) -> list[dict[str, Any]]: p = Path(path) if not p.exists(): raise QuarantineAgeReportError(f"registry not found: {p}") @@ -104,15 +104,15 @@ def _load_registry(path: Union[str, Path]) -> List[Dict[str, Any]]: def age_entries( - entries: Sequence[Dict[str, Any]], + entries: Sequence[dict[str, Any]], *, - now: Optional[datetime] = None, -) -> List[AgedEntry]: + now: datetime | None = None, +) -> list[AgedEntry]: """Convert raw registry rows into typed entries with age + tier.""" moment = now if now is not None else datetime.now(tz=timezone.utc) if moment.tzinfo is None: raise QuarantineAgeReportError("now must be tz-aware") - out: List[AgedEntry] = [] + out: list[AgedEntry] = [] for raw in entries: if not isinstance(raw, dict): continue @@ -155,9 +155,9 @@ def build_report(entries: Iterable[AgedEntry]) -> AgeReport: def load_and_age( - registry_path: Union[str, Path], + registry_path: str | Path, *, - now: Optional[datetime] = None, + now: datetime | None = None, ) -> AgeReport: """One-shot: load JSON registry, age every row, build report.""" return build_report(age_entries(_load_registry(registry_path), now=now)) diff --git a/je_web_runner/utils/rag_grounding_assert/grounding.py b/je_web_runner/utils/rag_grounding_assert/grounding.py index 0934f21c..a7bd5b3a 100644 --- a/je_web_runner/utils/rag_grounding_assert/grounding.py +++ b/je_web_runner/utils/rag_grounding_assert/grounding.py @@ -15,7 +15,7 @@ import re from dataclasses import dataclass, field -from typing import List, Sequence, Set +from typing import Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -37,14 +37,14 @@ def __post_init__(self) -> None: @dataclass class RagAnswer: text: str - cited_chunk_ids: List[str] = field(default_factory=list) + cited_chunk_ids: list[str] = field(default_factory=list) def __post_init__(self) -> None: if not isinstance(self.text, str): raise RagGroundingError("text must be string") -def _tokens(text: str) -> Set[str]: +def _tokens(text: str) -> set[str]: return {t.lower() for t in re.findall(r"\w{3,}", text or "")} @@ -77,7 +77,7 @@ def lexical_overlap_score( answer_tokens = _tokens(answer.text) if not answer_tokens: return 0.0 - retrieved_tokens: Set[str] = set() + retrieved_tokens: set[str] = set() for c in retrieved: retrieved_tokens |= _tokens(c.text) return len(answer_tokens & retrieved_tokens) / len(answer_tokens) @@ -103,7 +103,7 @@ def find_unsupported_claims( retrieved: Sequence[Chunk], *, min_phrase_len: int = 4, -) -> List[str]: +) -> list[str]: """Return ``n``-token phrases in the answer that don't appear in any chunk.""" if min_phrase_len < 2: raise RagGroundingError("min_phrase_len must be >= 2") @@ -111,7 +111,7 @@ def find_unsupported_claims( if len(answer_words) < min_phrase_len: return [] haystack = " ".join((c.text or "").lower() for c in retrieved) - unsupported: List[str] = [] + unsupported: list[str] = [] for i in range(len(answer_words) - min_phrase_len + 1): phrase = " ".join(answer_words[i:i + min_phrase_len]).lower() if phrase not in haystack: diff --git a/je_web_runner/utils/rate_limit_assert/rate.py b/je_web_runner/utils/rate_limit_assert/rate.py index 1911aa09..f4bb4f99 100644 --- a/je_web_runner/utils/rate_limit_assert/rate.py +++ b/je_web_runner/utils/rate_limit_assert/rate.py @@ -13,7 +13,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, Optional, Sequence +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -25,7 +25,7 @@ class RateLimitAssertError(WebRunnerException): @dataclass class RateLimitResponse: status_code: int - headers: Dict[str, str] = field(default_factory=dict) + headers: dict[str, str] = field(default_factory=dict) body: Any = None @property @@ -33,7 +33,7 @@ def is_429(self) -> bool: return self.status_code == 429 @property - def retry_after_seconds(self) -> Optional[float]: + def retry_after_seconds(self) -> float | None: raw = self.headers.get("Retry-After") or self.headers.get("retry-after") if not raw: return None @@ -43,12 +43,12 @@ def retry_after_seconds(self) -> Optional[float]: return None @property - def limit(self) -> Optional[int]: + def limit(self) -> int | None: raw = self.headers.get("X-RateLimit-Limit") or self.headers.get("x-ratelimit-limit") return int(raw) if raw and raw.isdigit() else None @property - def remaining(self) -> Optional[int]: + def remaining(self) -> int | None: raw = self.headers.get("X-RateLimit-Remaining") or self.headers.get("x-ratelimit-remaining") return int(raw) if raw and raw.isdigit() else None @@ -90,7 +90,7 @@ def assert_remaining_monotonic( responses: Sequence[RateLimitResponse], ) -> None: """``X-RateLimit-Remaining`` must decrease (or stay flat) until 429.""" - last: Optional[int] = None + last: int | None = None for i, r in enumerate(responses): rem = r.remaining if rem is None: diff --git a/je_web_runner/utils/recorder/browser_recorder.py b/je_web_runner/utils/recorder/browser_recorder.py index dcd35586..b7607e29 100644 --- a/je_web_runner/utils/recorder/browser_recorder.py +++ b/je_web_runner/utils/recorder/browser_recorder.py @@ -14,7 +14,7 @@ import json import re from pathlib import Path -from typing import Iterable, List, Optional +from typing import Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.json.json_file.json_file import write_action_json @@ -102,13 +102,13 @@ def _looks_sensitive(selector: str, value: str) -> bool: return bool(_LONG_DIGIT_RE.match(digits)) -def mask_sensitive_events(events: Iterable[dict]) -> List[dict]: +def mask_sensitive_events(events: Iterable[dict]) -> list[dict]: """ 在 Python 端再做一次遮罩,保險起見 Defensive Python-side masking pass; mainly useful when consuming raw events that bypassed the JS-side masking (e.g. loaded from disk). """ - masked: List[dict] = [] + masked: list[dict] = [] for event in events: copy = dict(event) if copy.get("type") == "input" and not copy.get("masked"): @@ -139,7 +139,7 @@ def start_recording(driver_or_wrapper) -> None: driver.execute_script(_RECORDER_JS) -def pull_events(driver_or_wrapper) -> List[dict]: +def pull_events(driver_or_wrapper) -> list[dict]: """ 從瀏覽器拉回累積事件並清空緩衝 Drain accumulated events from the browser side and clear the buffer. @@ -162,7 +162,7 @@ def stop_recording(driver_or_wrapper) -> None: driver.execute_script(_RESET_JS) -def _click_to_actions(event: dict, name_index: int) -> List[list]: +def _click_to_actions(event: dict, name_index: int) -> list[list]: test_object_name = event["selector"] return [ [ @@ -174,7 +174,7 @@ def _click_to_actions(event: dict, name_index: int) -> List[list]: ] -def _input_to_actions(event: dict, name_index: int) -> List[list]: +def _input_to_actions(event: dict, name_index: int) -> list[list]: test_object_name = event["selector"] value = event.get("value", "") return [ @@ -187,7 +187,7 @@ def _input_to_actions(event: dict, name_index: int) -> List[list]: ] -def events_to_actions(events: Iterable[dict]) -> List[list]: +def events_to_actions(events: Iterable[dict]) -> list[list]: """ 將事件清單翻譯成 WR_* action 序列 Translate captured events into a WR_* action list. @@ -195,7 +195,7 @@ def events_to_actions(events: Iterable[dict]) -> List[list]: 未支援的事件類型會被忽略並寫入日誌。 Unsupported event types are skipped and logged. """ - actions: List[list] = [] + actions: list[list] = [] handlers = {"click": _click_to_actions, "input": _input_to_actions} for index, event in enumerate(events): event_type = event.get("type") @@ -210,7 +210,7 @@ def events_to_actions(events: Iterable[dict]) -> List[list]: def save_recording( driver_or_wrapper, output_path: str, - raw_events_path: Optional[str] = None, + raw_events_path: str | None = None, ) -> str: """ 便捷函式:拉回事件、轉成 actions、寫出 JSON diff --git a/je_web_runner/utils/replay_studio/replay_studio.py b/je_web_runner/utils/replay_studio/replay_studio.py index 4d25bed7..a2c69141 100644 --- a/je_web_runner/utils/replay_studio/replay_studio.py +++ b/je_web_runner/utils/replay_studio/replay_studio.py @@ -7,7 +7,7 @@ import html from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -21,7 +21,7 @@ class ReplayStudioError(WebRunnerException): _NO_EXCEPTION = "None" -def _matching_screenshot(name: str, screenshot_dir: Optional[Path]) -> Optional[Path]: +def _matching_screenshot(name: str, screenshot_dir: Path | None) -> Path | None: if screenshot_dir is None or not screenshot_dir.is_dir(): return None for png in sorted(screenshot_dir.glob(f"*_{name}.png")): @@ -29,12 +29,12 @@ def _matching_screenshot(name: str, screenshot_dir: Optional[Path]) -> Optional[ return None -def _row_html(record: Dict[str, Any], screenshot: Optional[Path]) -> str: +def _row_html(record: dict[str, Any], screenshot: Path | None) -> str: function_name = record.get("function_name", "(unknown)") failed = record.get("program_exception", _NO_EXCEPTION) != _NO_EXCEPTION status_class = "fail" if failed else "ok" status_label = "FAILED" if failed else "PASSED" - parts: List[str] = [ + parts: list[str] = [ f"", f"{html.escape(str(record.get('time', '')))}", f"{html.escape(str(function_name))}", @@ -55,8 +55,8 @@ def _row_html(record: Dict[str, Any], screenshot: Optional[Path]) -> str: def build_replay_html( - records: Optional[List[Dict[str, Any]]] = None, - screenshot_dir: Optional[str] = None, + records: list[dict[str, Any]] | None = None, + screenshot_dir: str | None = None, title: str = "WebRunner replay", ) -> str: """ @@ -81,8 +81,8 @@ def build_replay_html( def export_replay_studio( output_path: str, - records: Optional[List[Dict[str, Any]]] = None, - screenshot_dir: Optional[str] = None, + records: list[dict[str, Any]] | None = None, + screenshot_dir: str | None = None, title: str = "WebRunner replay", ) -> str: """Write the studio to ``output_path`` and return the resolved path.""" diff --git a/je_web_runner/utils/repro_minimizer/minimizer.py b/je_web_runner/utils/repro_minimizer/minimizer.py index 54525b6d..a8eec527 100644 --- a/je_web_runner/utils/repro_minimizer/minimizer.py +++ b/je_web_runner/utils/repro_minimizer/minimizer.py @@ -14,7 +14,7 @@ import time from dataclasses import dataclass -from typing import Any, Callable, List, Sequence +from typing import Any, Callable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -31,7 +31,7 @@ class MinimizationResult: """Outcome returned by :func:`minimize`.""" original_size: int - minimized_actions: List[Any] + minimized_actions: list[Any] minimized_size: int iterations: int = 0 eval_count: int = 0 @@ -48,7 +48,7 @@ def reduction_pct(self) -> float: # Runner returns True if the (sub)sequence still *passes* the test # (i.e. doesn't reproduce the failure), False if it still fails. -ActionRunner = Callable[[List[Any]], bool] +ActionRunner = Callable[[list[Any]], bool] # ---------- ddmin ------------------------------------------------------- @@ -77,7 +77,7 @@ def minimize( # NOSONAR S3776 — cohesive logic; planned refactor in follow-up counter = {"evals": 0} - def _evaluate(subset: List[Any]) -> bool: + def _evaluate(subset: list[Any]) -> bool: counter["evals"] += 1 try: return bool(runner(subset)) @@ -103,7 +103,7 @@ def _evaluate(subset: List[Any]) -> bool: for i in range(0, len(current), chunk_size)] # Try removing complement of each chunk first (granularity = n). reduced = False - for index, chunk in enumerate(chunks): + for index in range(len(chunks)): complement = [ a for j, c in enumerate(chunks) if j != index for a in c ] diff --git a/je_web_runner/utils/resource_hints_audit/hints.py b/je_web_runner/utils/resource_hints_audit/hints.py index 32cf9fea..e396afde 100644 --- a/je_web_runner/utils/resource_hints_audit/hints.py +++ b/je_web_runner/utils/resource_hints_audit/hints.py @@ -15,7 +15,7 @@ import re from dataclasses import asdict, dataclass from enum import Enum -from typing import Any, Dict, Iterable, List, Sequence +from typing import Any, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -39,7 +39,7 @@ class Hint: as_: str = "" # only for preload crossorigin: bool = False - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "kind": self.kind.value} @@ -48,18 +48,18 @@ def to_dict(self) -> Dict[str, Any]: re.IGNORECASE) -def _parse_attrs(tag: str) -> Dict[str, str]: - attrs: Dict[str, str] = {} +def _parse_attrs(tag: str) -> dict[str, str]: + attrs: dict[str, str] = {} for match in _ATTR_RE.finditer(tag): key = match.group(1).lower() attrs[key] = match.group(2) or match.group(3) or match.group(4) or "" return attrs -def parse_hints(html: str) -> List[Hint]: +def parse_hints(html: str) -> list[Hint]: if not isinstance(html, str): raise ResourceHintsAuditError("html must be a string") - out: List[Hint] = [] + out: list[Hint] = [] for tag in _LINK_RE.findall(html): attrs = _parse_attrs(tag) rel = (attrs.get("rel") or "").lower() @@ -88,7 +88,7 @@ def assert_preload_has_as(hints: Iterable[Hint]) -> None: def find_unused_hints( hints: Sequence[Hint], used_urls: Iterable[str], -) -> List[Hint]: +) -> list[Hint]: used = {u for u in used_urls if isinstance(u, str)} return [h for h in hints if h.kind in (HintKind.PRELOAD, HintKind.PREFETCH, diff --git a/je_web_runner/utils/rtl_layout_verify/verify.py b/je_web_runner/utils/rtl_layout_verify/verify.py index 98673490..a043d785 100644 --- a/je_web_runner/utils/rtl_layout_verify/verify.py +++ b/je_web_runner/utils/rtl_layout_verify/verify.py @@ -17,7 +17,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -69,16 +69,16 @@ class ElementBox: padding_left: str = "0px" padding_right: str = "0px" unicode_bidi: str = "normal" - raw: Dict[str, Any] = field(default_factory=dict) + raw: dict[str, Any] = field(default_factory=dict) @dataclass class Snapshot: document_dir: str - selectors: Dict[str, List[ElementBox]] = field(default_factory=dict) + selectors: dict[str, list[ElementBox]] = field(default_factory=dict) -def _parse_box(raw: Dict[str, Any]) -> ElementBox: +def _parse_box(raw: dict[str, Any]) -> ElementBox: return ElementBox( tag=str(raw.get("tag") or ""), text=str(raw.get("text") or ""), diff --git a/je_web_runner/utils/run_ledger/classifier.py b/je_web_runner/utils/run_ledger/classifier.py index cd627cef..fa40d514 100644 --- a/je_web_runner/utils/run_ledger/classifier.py +++ b/je_web_runner/utils/run_ledger/classifier.py @@ -5,7 +5,7 @@ from __future__ import annotations import re -from typing import Any, Dict, Iterable, Optional +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -42,7 +42,7 @@ class ClassifierError(WebRunnerException): ] -def classify_error(error_repr: str) -> Optional[str]: +def classify_error(error_repr: str) -> str | None: """ 依錯誤字串嘗試判定 transient / environment;無法判定回傳 None Try to bucket an error repr into ``transient`` / ``environment``; @@ -59,8 +59,8 @@ def classify_error(error_repr: str) -> Optional[str]: return None -def classify(error_repr: str, ledger_path: Optional[str] = None, - file_path: Optional[str] = None) -> str: +def classify(error_repr: str, ledger_path: str | None = None, + file_path: str | None = None) -> str: """ 綜合錯誤字串與 ledger 歷史回傳分類 Combine error text + ledger history to bucket a single failure into @@ -76,14 +76,14 @@ def classify(error_repr: str, ledger_path: Optional[str] = None, def classify_failures( - failures: Iterable[Dict[str, Any]], - ledger_path: Optional[str] = None, -) -> Dict[str, str]: + failures: Iterable[dict[str, Any]], + ledger_path: str | None = None, +) -> dict[str, str]: """ 對 ``[{function_name, exception, file_path?}, ...]`` 各分類 Map each failure entry to a category. """ - result: Dict[str, str] = {} + result: dict[str, str] = {} for failure in failures: key = str(failure.get("file_path") or failure.get("function_name") or len(result)) result[key] = classify( diff --git a/je_web_runner/utils/run_ledger/flaky.py b/je_web_runner/utils/run_ledger/flaky.py index dc6fb37e..7ce280ea 100644 --- a/je_web_runner/utils/run_ledger/flaky.py +++ b/je_web_runner/utils/run_ledger/flaky.py @@ -8,7 +8,6 @@ import json from collections import defaultdict from pathlib import Path -from typing import Dict, List from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -18,7 +17,7 @@ class FlakyDetectorError(WebRunnerException): """Raised when the ledger cannot be read.""" -def _load_runs(ledger_path: str) -> List[dict]: +def _load_runs(ledger_path: str) -> list[dict]: path = Path(ledger_path) if not path.exists(): return [] @@ -32,7 +31,7 @@ def _load_runs(ledger_path: str) -> List[dict]: return [run for run in data["runs"] if isinstance(run, dict)] -def flakiness_stats(ledger_path: str, min_runs: int = 3) -> Dict[str, Dict[str, int]]: +def flakiness_stats(ledger_path: str, min_runs: int = 3) -> dict[str, dict[str, int]]: """ 從 ledger 算出每個檔案的 ``{runs, passes, fails, flaky}`` 統計 Compute per-file run / pass / fail counts plus a ``flaky`` flag. @@ -40,7 +39,7 @@ def flakiness_stats(ledger_path: str, min_runs: int = 3) -> Dict[str, Dict[str, passes and fails in its history. """ runs = _load_runs(ledger_path) - counters: Dict[str, Dict[str, int]] = defaultdict(lambda: {"runs": 0, "passes": 0, "fails": 0}) + counters: dict[str, dict[str, int]] = defaultdict(lambda: {"runs": 0, "passes": 0, "fails": 0}) for run in runs: path = run.get("path") if not isinstance(path, str): @@ -51,7 +50,7 @@ def flakiness_stats(ledger_path: str, min_runs: int = 3) -> Dict[str, Dict[str, else: counters[path]["fails"] += 1 - result: Dict[str, Dict[str, int]] = {} + result: dict[str, dict[str, int]] = {} for path, stats in counters.items(): flaky = stats["runs"] >= min_runs and stats["passes"] > 0 and stats["fails"] > 0 stats_with_flag = dict(stats) @@ -64,7 +63,7 @@ def flaky_paths( ledger_path: str, min_runs: int = 3, min_fail_rate: float = 0.0, -) -> List[str]: +) -> list[str]: """ 回傳被判為 flaky 的檔案 Return the file paths that the heuristic considers flaky. @@ -76,7 +75,7 @@ def flaky_paths( f"flaky_paths min_runs={min_runs} min_fail_rate={min_fail_rate}" ) stats = flakiness_stats(ledger_path, min_runs=min_runs) - out: List[str] = [] + out: list[str] = [] for path, info in stats.items(): if not info["flaky"]: continue diff --git a/je_web_runner/utils/run_ledger/ledger.py b/je_web_runner/utils/run_ledger/ledger.py index 1a8ec1ce..6de4660a 100644 --- a/je_web_runner/utils/run_ledger/ledger.py +++ b/je_web_runner/utils/run_ledger/ledger.py @@ -9,7 +9,6 @@ import json from datetime import datetime from pathlib import Path -from typing import Dict, List from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -19,12 +18,12 @@ class LedgerError(WebRunnerException): """Raised on ledger I/O / format problems.""" -def _load(ledger_path: str) -> Dict[str, list]: +def _load(ledger_path: str) -> dict[str, list]: path = Path(ledger_path) if not path.exists(): return {"runs": []} try: - with open(path, encoding="utf-8") as ledger_file: + with open(path, encoding="utf-8") as ledger_file: # NOSONAR S8707 — developer-supplied path (own report/config file), not untrusted input data = json.load(ledger_file) except ValueError as error: raise LedgerError(f"ledger file is not valid JSON: {ledger_path}") from error @@ -33,7 +32,7 @@ def _load(ledger_path: str) -> Dict[str, list]: return data -def _save(ledger_path: str, data: Dict[str, list]) -> None: +def _save(ledger_path: str, data: dict[str, list]) -> None: path = Path(ledger_path) path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as ledger_file: @@ -52,13 +51,13 @@ def record_run(ledger_path: str, file_path: str, passed: bool) -> None: _save(ledger_path, data) -def latest_status(ledger_path: str) -> Dict[str, bool]: +def latest_status(ledger_path: str) -> dict[str, bool]: """ 取每個檔案最新的 pass/fail 狀態 Build a dict keyed by file path of the most recent passed flag. """ data = _load(ledger_path) - latest: Dict[str, bool] = {} + latest: dict[str, bool] = {} for run in data["runs"]: path = run.get("path") if isinstance(path, str): @@ -66,12 +65,12 @@ def latest_status(ledger_path: str) -> Dict[str, bool]: return latest -def failed_files(ledger_path: str) -> List[str]: +def failed_files(ledger_path: str) -> list[str]: """Return paths whose most recent run was a failure.""" return [path for path, passed in latest_status(ledger_path).items() if not passed] -def passed_files(ledger_path: str) -> List[str]: +def passed_files(ledger_path: str) -> list[str]: """Return paths whose most recent run was a pass.""" return [path for path, passed in latest_status(ledger_path).items() if passed] diff --git a/je_web_runner/utils/sbom_diff/diff.py b/je_web_runner/utils/sbom_diff/diff.py index ddaad5b9..e1a0e11c 100644 --- a/je_web_runner/utils/sbom_diff/diff.py +++ b/je_web_runner/utils/sbom_diff/diff.py @@ -14,7 +14,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field -from typing import Any, Dict, Iterable, List, Optional, Tuple +from typing import Any, Iterable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -28,7 +28,7 @@ class Component: name: str version: str = "" purl: str = "" - licenses: Tuple[str, ...] = () + licenses: tuple[str, ...] = () @property def key(self) -> str: @@ -41,18 +41,18 @@ class VersionChange: base_version: str head_version: str - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @dataclass class SbomReport: - added: List[Component] = field(default_factory=list) - removed: List[Component] = field(default_factory=list) - upgraded: List[VersionChange] = field(default_factory=list) - downgraded: List[VersionChange] = field(default_factory=list) - new_licenses: List[str] = field(default_factory=list) - new_vulnerable: List[str] = field(default_factory=list) + added: list[Component] = field(default_factory=list) + removed: list[Component] = field(default_factory=list) + upgraded: list[VersionChange] = field(default_factory=list) + downgraded: list[VersionChange] = field(default_factory=list) + new_licenses: list[str] = field(default_factory=list) + new_vulnerable: list[str] = field(default_factory=list) @property def has_changes(self) -> bool: @@ -62,8 +62,8 @@ def has_changes(self) -> bool: ) -def _extract_licenses(component: Dict[str, Any]) -> List[str]: - out: List[str] = [] +def _extract_licenses(component: dict[str, Any]) -> list[str]: + out: list[str] = [] for lic in component.get("licenses") or []: if not isinstance(lic, dict): continue @@ -74,7 +74,7 @@ def _extract_licenses(component: Dict[str, Any]) -> List[str]: return out -def _parse_component(c: Any) -> Optional[Component]: +def _parse_component(c: Any) -> Component | None: if not isinstance(c, dict): return None name = c.get("name") @@ -88,7 +88,7 @@ def _parse_component(c: Any) -> Optional[Component]: ) -def _parse_components(sbom: Dict[str, Any]) -> List[Component]: +def _parse_components(sbom: dict[str, Any]) -> list[Component]: if not isinstance(sbom, dict): raise SbomDiffError("sbom must be a dict") raw = sbom.get("components") @@ -100,7 +100,7 @@ def _parse_components(sbom: Dict[str, Any]) -> List[Component]: return [c for c in parsed if c is not None] -def _vulnerable_purls(sbom: Dict[str, Any]) -> set: +def _vulnerable_purls(sbom: dict[str, Any]) -> set: vulns = sbom.get("vulnerabilities") if not isinstance(vulns, list): return set() @@ -115,11 +115,11 @@ def _vulnerable_purls(sbom: Dict[str, Any]) -> set: return refs -def _index(components: Iterable[Component]) -> Dict[str, Component]: +def _index(components: Iterable[Component]) -> dict[str, Component]: return {c.key: c for c in components} -def _version_order(a: str, b: str) -> Optional[int]: +def _version_order(a: str, b: str) -> int | None: """Return -1/0/1 if version sort is decidable, None otherwise.""" if a == b: return 0 @@ -137,7 +137,7 @@ def _version_order(a: str, b: str) -> Optional[int]: return 0 -def diff_sboms(base: Dict[str, Any], head: Dict[str, Any]) -> SbomReport: +def diff_sboms(base: dict[str, Any], head: dict[str, Any]) -> SbomReport: """Compare two CycloneDX SBOMs and return a high-level report.""" base_comps = _parse_components(base) head_comps = _parse_components(head) @@ -180,8 +180,8 @@ def diff_sboms(base: Dict[str, Any], head: Dict[str, Any]) -> SbomReport: else: report.upgraded.append(change) # unknown order → treat as change - base_licenses = {l for c in base_comps for l in c.licenses} - head_licenses = {l for c in head_comps for l in c.licenses} + base_licenses = {lic for c in base_comps for lic in c.licenses} + head_licenses = {lic for c in head_comps for lic in c.licenses} report.new_licenses = sorted(head_licenses - base_licenses) head_vuln_purls = _vulnerable_purls(head) @@ -202,10 +202,10 @@ def assert_no_new_vulnerable(report: SbomReport) -> None: def assert_no_disallowed_licenses( report: SbomReport, disallowed: Iterable[str], ) -> None: - disallowed_set = {l.upper() for l in disallowed} + disallowed_set = {lic.upper() for lic in disallowed} if not disallowed_set: raise SbomDiffError("disallowed list must be non-empty") - bad = [l for l in report.new_licenses if l.upper() in disallowed_set] + bad = [lic for lic in report.new_licenses if lic.upper() in disallowed_set] if bad: raise SbomDiffError(f"PR introduces disallowed licenses: {bad}") @@ -237,7 +237,7 @@ def report_markdown(report: SbomReport) -> str: ) if report.new_licenses: lines.append("### New licenses") - lines.append(", ".join(f"`{l}`" for l in report.new_licenses)) + lines.append(", ".join(f"`{lic}`" for lic in report.new_licenses)) if report.new_vulnerable: lines.append("### New vulnerable components") lines.extend(f"- `{ref}`" for ref in report.new_vulnerable) diff --git a/je_web_runner/utils/scheduler/cron_runner.py b/je_web_runner/utils/scheduler/cron_runner.py index 7f9e2261..252b8167 100644 --- a/je_web_runner/utils/scheduler/cron_runner.py +++ b/je_web_runner/utils/scheduler/cron_runner.py @@ -9,7 +9,7 @@ import sched import threading import time -from typing import Any, Callable, Dict, List +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -24,9 +24,9 @@ class ScheduledRunner: def __init__(self) -> None: self._scheduler = sched.scheduler(time.monotonic, time.sleep) - self._jobs: List[Dict[str, Any]] = [] + self._jobs: list[dict[str, Any]] = [] self._stop_event = threading.Event() - self._counts: Dict[str, int] = {} + self._counts: dict[str, int] = {} def add(self, name: str, interval_seconds: float, callback: Callable[[], Any]) -> None: """Register a job; takes effect once ``run_for`` / ``run_forever`` is called.""" @@ -39,21 +39,21 @@ def add(self, name: str, interval_seconds: float, callback: Callable[[], Any]) - }) self._counts[name] = 0 - def counts(self) -> Dict[str, int]: + def counts(self) -> dict[str, int]: """Number of times each job has fired (since the last clear).""" return dict(self._counts) def stop(self) -> None: self._stop_event.set() - def _enqueue(self, job: Dict[str, Any]) -> None: + def _enqueue(self, job: dict[str, Any]) -> None: def _fire(): if self._stop_event.is_set(): return try: job["callback"]() self._counts[job["name"]] = self._counts.get(job["name"], 0) + 1 - except Exception as error: # noqa: BLE001 — schedulers must keep running + except Exception as error: web_runner_logger.error(f"scheduled job {job['name']!r} raised: {error!r}") self._enqueue(job) self._scheduler.enter(job["interval"], 1, _fire) @@ -115,7 +115,7 @@ def stop_scheduler() -> None: _runner.stop() -def scheduler_counts() -> Dict[str, int]: +def scheduler_counts() -> dict[str, int]: return _runner.counts() diff --git a/je_web_runner/utils/schema/action_schema.py b/je_web_runner/utils/schema/action_schema.py index 05c112e5..f5f723e7 100644 --- a/je_web_runner/utils/schema/action_schema.py +++ b/je_web_runner/utils/schema/action_schema.py @@ -8,7 +8,7 @@ import json from pathlib import Path -from typing import Any, Dict, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -18,14 +18,14 @@ class SchemaExportError(WebRunnerException): """Raised when a target path cannot be written.""" -def _command_names() -> List[str]: +def _command_names() -> list[str]: """Pull every registered command name from the executor singleton.""" # Imported lazily to avoid an import cycle with the executor module. from je_web_runner.utils.executor.action_executor import executor - return sorted(name for name in executor.event_dict.keys() if name.startswith("WR_")) + return sorted(name for name in executor.event_dict if name.startswith("WR_")) -def build_action_schema() -> Dict[str, Any]: +def build_action_schema() -> dict[str, Any]: """ 產生一份描述 action JSON 結構的 Draft 2020-12 Schema Build a Draft 2020-12 JSON Schema describing the action JSON format. @@ -36,7 +36,7 @@ def build_action_schema() -> Dict[str, Any]: """ web_runner_logger.info("build_action_schema") command_enum = _command_names() - action_schema: Dict[str, Any] = { + action_schema: dict[str, Any] = { "type": "array", "minItems": 1, "maxItems": 3, diff --git a/je_web_runner/utils/screen_reader_runner/reader.py b/je_web_runner/utils/screen_reader_runner/reader.py index 88d324ba..ebee3bcf 100644 --- a/je_web_runner/utils/screen_reader_runner/reader.py +++ b/je_web_runner/utils/screen_reader_runner/reader.py @@ -21,7 +21,7 @@ import re from dataclasses import asdict, dataclass, field from enum import Enum -from typing import Any, Dict, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -68,7 +68,7 @@ class Utterance: role: str node_index: int - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -81,7 +81,7 @@ class Violation: node_index: int detail: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "kind": self.kind.value} @@ -89,8 +89,8 @@ def to_dict(self) -> Dict[str, Any]: class ScreenReaderTranscript: """Result of :func:`walk_tree`.""" - utterances: List[Utterance] = field(default_factory=list) - violations: List[Violation] = field(default_factory=list) + utterances: list[Utterance] = field(default_factory=list) + violations: list[Violation] = field(default_factory=list) def speech(self) -> str: """Joined transcript as a single string.""" @@ -103,7 +103,7 @@ def passed(self) -> bool: # ---------- walker ------------------------------------------------------ def walk_tree( - root: Dict[str, Any], + root: dict[str, Any], *, include_decorative: bool = False, ) -> ScreenReaderTranscript: @@ -123,9 +123,9 @@ def walk_tree( def _walk_node( # NOSONAR S3776 — cohesive logic; planned refactor in follow-up - node: Dict[str, Any], + node: dict[str, Any], transcript: ScreenReaderTranscript, - state: Dict[str, int], + state: dict[str, int], *, include_decorative: bool, ) -> None: @@ -177,7 +177,7 @@ def _walk_node( # NOSONAR S3776 — cohesive logic; planned refactor in follow- def _emit_interactive( - _node: Dict[str, Any], + _node: dict[str, Any], role: str, name: str, index: int, @@ -211,7 +211,7 @@ def _is_generic_link(name: str) -> bool: return cleaned in _BANNED_LINK_TEXT -def _is_decorative(node: Dict[str, Any]) -> bool: +def _is_decorative(node: dict[str, Any]) -> bool: if node.get("decorative") is True: return True properties = node.get("properties") or {} @@ -226,7 +226,7 @@ def _is_decorative(node: Dict[str, Any]) -> bool: def _check_heading_level( level: int, index: int, - state: Dict[str, int], + state: dict[str, int], transcript: ScreenReaderTranscript, ) -> None: last = state["last_heading_level"] diff --git a/je_web_runner/utils/secrets_scanner/scanner.py b/je_web_runner/utils/secrets_scanner/scanner.py index a6e22831..48ce21df 100644 --- a/je_web_runner/utils/secrets_scanner/scanner.py +++ b/je_web_runner/utils/secrets_scanner/scanner.py @@ -12,7 +12,7 @@ import json import re from pathlib import Path -from typing import Any, Dict, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -27,7 +27,7 @@ class SecretsFound(WebRunnerException): re.IGNORECASE, ) -_PATTERNS: List[Dict[str, Any]] = [ +_PATTERNS: list[dict[str, Any]] = [ {"id": "aws_access_key", "regex": re.compile(r"\bAKIA[0-9A-Z]{16}\b")}, {"id": "github_token", "regex": re.compile(r"\bghp_[A-Za-z0-9]{36}\b")}, {"id": "github_oauth", "regex": re.compile(r"\bgho_[A-Za-z0-9]{36}\b")}, @@ -51,16 +51,16 @@ def _looks_high_entropy(value: str) -> bool: return distinct >= 12 -def _scan_string(value: str, path: str) -> List[Dict[str, Any]]: - findings: List[Dict[str, Any]] = [] +def _scan_string(value: str, path: str) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] for pattern in _PATTERNS: if pattern["regex"].search(value): findings.append({"path": path, "rule": pattern["id"], "preview": value[:60]}) return findings -def _scan_value_for_key(key: str, value: Any, path: str) -> List[Dict[str, Any]]: - findings: List[Dict[str, Any]] = [] +def _scan_value_for_key(key: str, value: Any, path: str) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] if not isinstance(value, str): return findings if _SUSPICIOUS_KEY_RE.search(key) and _looks_high_entropy(value): @@ -68,7 +68,7 @@ def _scan_value_for_key(key: str, value: Any, path: str) -> List[Dict[str, Any]] return findings -def _walk(data: Any, path: str, findings: List[Dict[str, Any]]) -> None: +def _walk(data: Any, path: str, findings: list[dict[str, Any]]) -> None: if isinstance(data, str): findings.extend(_scan_string(data, path)) return @@ -83,18 +83,18 @@ def _walk(data: Any, path: str, findings: List[Dict[str, Any]]) -> None: _walk(item, f"{path}[{index}]", findings) -def scan_action(data: Any) -> List[Dict[str, Any]]: +def scan_action(data: Any) -> list[dict[str, Any]]: """ 掃描 action 結構,回傳所有疑似秘密的位置 Scan an action structure and return a list of {path, rule, preview} findings. """ web_runner_logger.info("scan_action") - findings: List[Dict[str, Any]] = [] + findings: list[dict[str, Any]] = [] _walk(data, "", findings) return findings -def scan_action_file(path: str) -> List[Dict[str, Any]]: +def scan_action_file(path: str) -> list[dict[str, Any]]: """讀取 action JSON 檔並掃描 / Load an action JSON file and scan it.""" file_path = Path(path) if not file_path.exists(): diff --git a/je_web_runner/utils/security_headers/headers_audit.py b/je_web_runner/utils/security_headers/headers_audit.py index 29008b69..6fa39b50 100644 --- a/je_web_runner/utils/security_headers/headers_audit.py +++ b/je_web_runner/utils/security_headers/headers_audit.py @@ -6,7 +6,7 @@ """ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any import requests @@ -18,7 +18,7 @@ class SecurityHeadersError(WebRunnerException): """Raised on operational problems (e.g. fetch failure).""" -_DEFAULT_REQUIRED: List[Dict[str, Any]] = [ +_DEFAULT_REQUIRED: list[dict[str, Any]] = [ {"name": "Strict-Transport-Security", "rule": "presence_and_max_age"}, {"name": "Content-Security-Policy", "rule": "presence"}, {"name": "X-Frame-Options", "rule": "deny_or_sameorigin"}, @@ -28,7 +28,7 @@ class SecurityHeadersError(WebRunnerException): ] -def _lookup_header(headers: Dict[str, str], name: str) -> Optional[str]: +def _lookup_header(headers: dict[str, str], name: str) -> str | None: lower_name = name.lower() for header_name, value in headers.items(): if header_name.lower() == lower_name: @@ -36,7 +36,7 @@ def _lookup_header(headers: Dict[str, str], name: str) -> Optional[str]: return None -def _check_rule(rule: str, value: Optional[str]) -> Optional[str]: +def _check_rule(rule: str, value: str | None) -> str | None: """Return a problem string when the rule is violated; None if OK.""" if value is None: return "missing" @@ -59,9 +59,9 @@ def _check_rule(rule: str, value: Optional[str]) -> Optional[str]: def audit_headers( - headers: Dict[str, str], - required: Optional[List[Dict[str, Any]]] = None, -) -> List[Dict[str, Any]]: + headers: dict[str, str], + required: list[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: """ 對 headers dict 套用規則表,回傳所有違反項目 Apply the rule table against ``headers`` and return findings. @@ -70,7 +70,7 @@ def audit_headers( :return: list of {header, rule, problem, value} dicts """ rules = required if required is not None else _DEFAULT_REQUIRED - findings: List[Dict[str, Any]] = [] + findings: list[dict[str, Any]] = [] for rule in rules: header_name = rule["name"] value = _lookup_header(headers, header_name) @@ -89,13 +89,13 @@ def audit_headers( def audit_url( url: str, timeout: int = 30, - required: Optional[List[Dict[str, Any]]] = None, -) -> List[Dict[str, Any]]: + required: list[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: """ GET ``url`` 並稽核回應 headers Issue a GET request and audit the response headers. """ - if not isinstance(url, str) or not (url.startswith("http://") or url.startswith("https://")): # NOSONAR — scheme allow-list, not an outbound HTTP call + if not isinstance(url, str) or not (url.startswith(("http://", "https://"))): # NOSONAR — scheme allow-list, not an outbound HTTP call raise SecurityHeadersError(f"URL must be http(s): {url!r}") web_runner_logger.info(f"audit_url: {url}") response = requests.get(url, timeout=timeout, allow_redirects=True) diff --git a/je_web_runner/utils/sel_to_pw/translator.py b/je_web_runner/utils/sel_to_pw/translator.py index a09647dd..d1f88d41 100644 --- a/je_web_runner/utils/sel_to_pw/translator.py +++ b/je_web_runner/utils/sel_to_pw/translator.py @@ -15,7 +15,7 @@ import re from dataclasses import dataclass -from typing import Any, List, Tuple +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -32,7 +32,7 @@ class Translation: note: str = "" -_PYTHON_PATTERNS: List[Tuple[re.Pattern, str, str]] = [ +_PYTHON_PATTERNS: list[tuple[re.Pattern, str, str]] = [ (re.compile(r"driver\.find_element\(By\.ID,\s*['\"]([^'\"]+)['\"]\)"), "page.locator('#\\1')", "ID -> CSS id selector"), @@ -87,14 +87,14 @@ class Translation: ] -def translate_python_source(source: str) -> List[Translation]: +def translate_python_source(source: str) -> list[Translation]: """Translate Python source line-by-line, returning a Translation per hit.""" if not isinstance(source, str): raise SelToPwError("source must be str") - translations: List[Translation] = [] + translations: list[Translation] = [] for line_no, line in enumerate(source.splitlines(), start=1): translated = line - notes: List[str] = [] + notes: list[str] = [] for pattern, replacement, note in _PYTHON_PATTERNS: new_text = pattern.sub(replacement, translated) if new_text != translated: @@ -124,7 +124,7 @@ def translate_python_source(source: str) -> List[Translation]: } -def translate_action_list(actions: List[Any]) -> List[List[Any]]: +def translate_action_list(actions: list[Any]) -> list[list[Any]]: """ 把 ``WR_*`` action 清單翻譯成 Playwright 變體;無對應時保留原本的指令並加註。 Translate a WebRunner action list. ``WR_implicitly_wait`` is dropped @@ -133,7 +133,7 @@ def translate_action_list(actions: List[Any]) -> List[List[Any]]: """ if not isinstance(actions, list): raise SelToPwError("actions must be a list") - translated: List[List[Any]] = [] + translated: list[list[Any]] = [] for action in actions: if not isinstance(action, list) or not action: translated.append(action) @@ -154,9 +154,9 @@ def translate_action_list(actions: List[Any]) -> List[List[Any]]: return translated -def supported_python_patterns() -> List[str]: +def supported_python_patterns() -> list[str]: return [pat.pattern for pat, _replacement, _note in _PYTHON_PATTERNS] -def supported_action_commands() -> List[str]: +def supported_action_commands() -> list[str]: return sorted(_ACTION_COMMAND_MAP.keys()) diff --git a/je_web_runner/utils/selenium_utils_wrapper/desired_capabilities/desired_capabilities.py b/je_web_runner/utils/selenium_utils_wrapper/desired_capabilities/desired_capabilities.py index 7ca38bce..b4899706 100644 --- a/je_web_runner/utils/selenium_utils_wrapper/desired_capabilities/desired_capabilities.py +++ b/je_web_runner/utils/selenium_utils_wrapper/desired_capabilities/desired_capabilities.py @@ -1,4 +1,4 @@ -from typing import Any, Union +from typing import Any from selenium.webdriver import DesiredCapabilities @@ -15,7 +15,7 @@ } -def get_desired_capabilities_keys() -> Union[str, Any]: +def get_desired_capabilities_keys() -> str | Any: """ 取得所有可用的 WebDriver 名稱 Get all available WebDriver names diff --git a/je_web_runner/utils/selenium_utils_wrapper/keys/selenium_keys.py b/je_web_runner/utils/selenium_utils_wrapper/keys/selenium_keys.py index aa51f4f9..6f09570c 100644 --- a/je_web_runner/utils/selenium_utils_wrapper/keys/selenium_keys.py +++ b/je_web_runner/utils/selenium_utils_wrapper/keys/selenium_keys.py @@ -1,3 +1,4 @@ +"""Re-export Selenium's :class:`Keys` enum under the WebRunner namespace.""" from selenium.webdriver.common.keys import Keys -Keys = Keys +__all__ = ["Keys"] diff --git a/je_web_runner/utils/self_healing/healing_locator.py b/je_web_runner/utils/self_healing/healing_locator.py index 2f7518fd..16831d79 100644 --- a/je_web_runner/utils/self_healing/healing_locator.py +++ b/je_web_runner/utils/self_healing/healing_locator.py @@ -8,7 +8,7 @@ """ from __future__ import annotations -from typing import Any, Dict, List, Optional, Tuple +from typing import Any from selenium.webdriver.common.by import By @@ -33,9 +33,9 @@ class HealingRegistry: """In-memory store of fallback locators keyed by element name.""" def __init__(self) -> None: - self._fallbacks: Dict[str, List[Tuple[str, str]]] = {} + self._fallbacks: dict[str, list[tuple[str, str]]] = {} - def register(self, name: str, fallbacks: List[Tuple[str, str]]) -> None: + def register(self, name: str, fallbacks: list[tuple[str, str]]) -> None: """ 登錄某元素名稱的備援定位器 Register an ordered list of (strategy, value) fallbacks for ``name``. @@ -46,7 +46,7 @@ def append(self, name: str, strategy: str, value: str) -> None: """Append a single fallback entry.""" self._fallbacks.setdefault(name, []).append((strategy, value)) - def get(self, name: str) -> List[Tuple[str, str]]: + def get(self, name: str) -> list[tuple[str, str]]: return list(self._fallbacks.get(name, [])) def clear(self) -> None: @@ -56,10 +56,10 @@ def clear(self) -> None: healing_registry = HealingRegistry() -def _candidates(name: str) -> List[Tuple[str, str]]: +def _candidates(name: str) -> list[tuple[str, str]]: """Primary TestObject (if recorded) followed by registered fallbacks.""" - candidates: List[Tuple[str, str]] = [] - primary: Optional[TestObject] = test_object_record.test_object_record_dict.get(name) + candidates: list[tuple[str, str]] = [] + primary: TestObject | None = test_object_record.test_object_record_dict.get(name) if primary is not None: candidates.append((primary.test_object_type, primary.test_object_name)) candidates.extend(healing_registry.get(name)) @@ -85,7 +85,7 @@ def find_with_healing_selenium(name: str): candidates = _candidates(name) if not candidates: raise HealingError(f"no primary or fallback locator for {name!r}") - last_error: Optional[Exception] = None + last_error: Exception | None = None for strategy, value in candidates: try: element = webdriver_wrapper_instance.current_webdriver.find_element( @@ -96,7 +96,7 @@ def find_with_healing_selenium(name: str): ) web_element_wrapper.current_web_element = element return element - except Exception as error: # noqa: BLE001 — fall through to next candidate + except Exception as error: last_error = error raise HealingError( f"no candidate matched for {name!r}; last error: {last_error!r}" @@ -111,7 +111,7 @@ def find_with_healing_playwright(name: str): candidates = _candidates(name) if not candidates: raise HealingError(f"no primary or fallback locator for {name!r}") - last_error: Optional[Exception] = None + last_error: Exception | None = None for strategy, value in candidates: probe = TestObject.__new__(TestObject) probe.test_object_name = value @@ -127,7 +127,7 @@ def find_with_healing_playwright(name: str): ) playwright_element_wrapper.current_element = element return element - except Exception as error: # noqa: BLE001 — try the next candidate + except Exception as error: last_error = error raise HealingError( f"no Playwright candidate matched for {name!r}; last error: {last_error!r}" @@ -139,7 +139,7 @@ def register_fallback(name: str, strategy: str, value: str) -> None: healing_registry.append(name, strategy, value) -def register_fallbacks(name: str, fallbacks: List[Any]) -> None: +def register_fallbacks(name: str, fallbacks: list[Any]) -> None: """ Public helper for use as a WR_* command. diff --git a/je_web_runner/utils/service_worker/sw_control.py b/je_web_runner/utils/service_worker/sw_control.py index a055bf2b..21827667 100644 --- a/je_web_runner/utils/service_worker/sw_control.py +++ b/je_web_runner/utils/service_worker/sw_control.py @@ -5,7 +5,6 @@ """ from __future__ import annotations -from typing import List from je_web_runner.utils.cdp.cdp_commands import playwright_cdp, selenium_cdp from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -39,7 +38,7 @@ class ServiceWorkerError(WebRunnerException): ) -def selenium_unregister_service_workers() -> List[bool]: +def selenium_unregister_service_workers() -> list[bool]: """ 解除註冊當前頁面所有 Service Worker Unregister all service workers on the current page. @@ -51,7 +50,7 @@ def selenium_unregister_service_workers() -> List[bool]: return driver.execute_async_script(_UNREGISTER_JS) or [] -def selenium_clear_caches() -> List[str]: +def selenium_clear_caches() -> list[str]: """ 清空 Cache Storage Clear all entries from the browser's Cache Storage. @@ -71,7 +70,7 @@ def selenium_bypass_service_worker(bypass: bool = True) -> None: selenium_cdp("Network.setBypassServiceWorker", {"bypass": bool(bypass)}) -def playwright_unregister_service_workers() -> List[bool]: +def playwright_unregister_service_workers() -> list[bool]: web_runner_logger.info("playwright_unregister_service_workers") page = playwright_wrapper_instance.page return page.evaluate( @@ -83,7 +82,7 @@ def playwright_unregister_service_workers() -> List[bool]: ) or [] -def playwright_clear_caches() -> List[str]: +def playwright_clear_caches() -> list[str]: web_runner_logger.info("playwright_clear_caches") page = playwright_wrapper_instance.page return page.evaluate( diff --git a/je_web_runner/utils/session_to_test/converter.py b/je_web_runner/utils/session_to_test/converter.py index 38a4ec17..00d0f3fb 100644 --- a/je_web_runner/utils/session_to_test/converter.py +++ b/je_web_runner/utils/session_to_test/converter.py @@ -23,7 +23,7 @@ import json from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple, Union +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -63,7 +63,7 @@ class ConversionStats: actions_emitted: int = 0 skipped_events: int = 0 comment_actions: int = 0 - reasons: Dict[str, int] = field(default_factory=dict) + reasons: dict[str, int] = field(default_factory=dict) def note_skip(self, reason: str) -> None: self.skipped_events += 1 @@ -74,26 +74,24 @@ def note_skip(self, reason: str) -> None: class ConversionResult: """Output of :func:`convert_events`: actions plus stats.""" - actions: List[Dict[str, Any]] + actions: list[dict[str, Any]] stats: ConversionStats # ---------- entry points ------------------------------------------------ -def convert_rrweb_events(events: Sequence[Dict[str, Any]]) -> ConversionResult: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up PR +def convert_rrweb_events(events: Sequence[dict[str, Any]]) -> ConversionResult: # NOSONAR S3776 — cohesive logic; planned refactor in follow-up PR """Convert an rrweb event list into WR action JSON.""" if not isinstance(events, list): raise SessionToTestError("rrweb events must be a list") stats = ConversionStats(input_events=len(events)) - actions: List[Dict[str, Any]] = [] - last_ts: Optional[int] = None + actions: list[dict[str, Any]] = [] for event in events: if not isinstance(event, dict): stats.note_skip("non-dict event") continue kind = event.get("type") - timestamp = event.get("timestamp") if kind == _RRWEB_META and isinstance(event.get("data"), dict): url = event["data"].get("href") if isinstance(url, str) and url: @@ -101,22 +99,14 @@ def convert_rrweb_events(events: Sequence[Dict[str, Any]]) -> ConversionResult: stats.actions_emitted += 1 else: stats.note_skip("meta without href") - last_ts = timestamp continue if kind == _RRWEB_FULL_SNAPSHOT: stats.note_skip("full snapshot (no action)") - last_ts = timestamp continue if kind != _RRWEB_INCREMENTAL: stats.note_skip(f"unknown rrweb type {kind!r}") continue - # Track the latest timestamp so future iterations can compute deltas. - # We do not currently emit explicit waits for fast bursts; this hook is - # kept for future "WR_implicitly_wait" insertion logic. - if last_ts is not None and isinstance(timestamp, (int, float)): - last_ts = timestamp - emitted = _convert_rrweb_incremental(event, stats) if emitted is not None: actions.append(emitted) @@ -130,12 +120,12 @@ def convert_rrweb_events(events: Sequence[Dict[str, Any]]) -> ConversionResult: return ConversionResult(actions=actions, stats=stats) -def convert_generic_events(events: Sequence[Dict[str, Any]]) -> ConversionResult: +def convert_generic_events(events: Sequence[dict[str, Any]]) -> ConversionResult: """Convert a provider-agnostic event list into WR action JSON.""" if not isinstance(events, list): raise SessionToTestError("generic events must be a list") stats = ConversionStats(input_events=len(events)) - actions: List[Dict[str, Any]] = [] + actions: list[dict[str, Any]] = [] for event in events: if not isinstance(event, dict): stats.note_skip("non-dict event") @@ -151,7 +141,7 @@ def convert_generic_events(events: Sequence[Dict[str, Any]]) -> ConversionResult return ConversionResult(actions=actions, stats=stats) -def convert_events(payload: Union[str, Path, Sequence[Dict[str, Any]]]) -> ConversionResult: +def convert_events(payload: str | Path | Sequence[dict[str, Any]]) -> ConversionResult: """ Sniff the input: file → list / list → list. rrweb vs generic is detected by the presence of an integer ``type`` field on the events. @@ -164,7 +154,7 @@ def convert_events(payload: Union[str, Path, Sequence[Dict[str, Any]]]) -> Conve return convert_generic_events(events) -def _load_events(payload: Union[str, Path, Sequence[Dict[str, Any]]]) -> List[Dict[str, Any]]: +def _load_events(payload: str | Path | Sequence[dict[str, Any]]) -> list[dict[str, Any]]: if isinstance(payload, (list, tuple)): return list(payload) if isinstance(payload, (str, Path)): @@ -188,9 +178,9 @@ def _load_events(payload: Union[str, Path, Sequence[Dict[str, Any]]]) -> List[Di # ---------- rrweb mappings ---------------------------------------------- def _convert_rrweb_incremental( - event: Dict[str, Any], + event: dict[str, Any], stats: ConversionStats, -) -> Optional[Dict[str, Any]]: +) -> dict[str, Any] | None: data = event.get("data") if isinstance(event.get("data"), dict) else None if not data: stats.note_skip("incremental without data") @@ -223,7 +213,7 @@ def _convert_rrweb_incremental( return None -def _selector_for_node(node_id: Any) -> Optional[str]: +def _selector_for_node(node_id: Any) -> str | None: """ rrweb identifies nodes by integer id from its DOM mirror. Without the full snapshot we can't recover a stable CSS path, so we emit a custom @@ -236,7 +226,7 @@ def _selector_for_node(node_id: Any) -> Optional[str]: # ---------- generic mappings -------------------------------------------- -def _generic_navigate(event: Dict[str, Any], stats: ConversionStats) -> Optional[Dict[str, Any]]: +def _generic_navigate(event: dict[str, Any], stats: ConversionStats) -> dict[str, Any] | None: url = event.get("url") if isinstance(url, str) and url: return {"WR_to_url": [url]} @@ -244,14 +234,14 @@ def _generic_navigate(event: Dict[str, Any], stats: ConversionStats) -> Optional return None -def _generic_click(locator: Optional[Tuple[str, str]], stats: ConversionStats) -> Optional[Dict[str, Any]]: +def _generic_click(locator: tuple[str, str] | None, stats: ConversionStats) -> dict[str, Any] | None: if locator is None: stats.note_skip("click without target") return None return {"WR_click_element": list(locator)} -def _generic_input(event: Dict[str, Any], locator: Optional[Tuple[str, str]], stats: ConversionStats) -> Optional[Dict[str, Any]]: +def _generic_input(event: dict[str, Any], locator: tuple[str, str] | None, stats: ConversionStats) -> dict[str, Any] | None: if locator is None: stats.note_skip("input without target") return None @@ -259,13 +249,13 @@ def _generic_input(event: Dict[str, Any], locator: Optional[Tuple[str, str]], st return {"WR_input_to_element": [*locator, str(value)]} -def _generic_submit(locator: Optional[Tuple[str, str]]) -> Dict[str, Any]: +def _generic_submit(locator: tuple[str, str] | None) -> dict[str, Any]: if locator is None: return {"WR_comment": ["submit form (no target)"]} return {"WR_submit_element": list(locator)} -def _generic_wait(event: Dict[str, Any], stats: ConversionStats) -> Optional[Dict[str, Any]]: +def _generic_wait(event: dict[str, Any], stats: ConversionStats) -> dict[str, Any] | None: try: seconds = float(event.get("seconds", 0)) except (TypeError, ValueError): @@ -284,9 +274,9 @@ def _generic_wait(event: Dict[str, Any], stats: ConversionStats) -> Optional[Dic def _convert_generic_event( - event: Dict[str, Any], + event: dict[str, Any], stats: ConversionStats, -) -> Optional[Dict[str, Any]]: +) -> dict[str, Any] | None: kind = str(event.get("kind") or "").lower() handler = _GENERIC_DISPATCH.get(kind) if handler is None: @@ -295,7 +285,7 @@ def _convert_generic_event( return handler(event, _coerce_locator(event.get("target")), stats) -def _coerce_locator(target: Any) -> Optional[Tuple[str, str]]: +def _coerce_locator(target: Any) -> tuple[str, str] | None: if isinstance(target, dict): by = target.get("by") or _CSS_SELECTOR_BY value = target.get("value") @@ -311,7 +301,7 @@ def _coerce_locator(target: Any) -> Optional[Tuple[str, str]]: def write_actions_json( result: ConversionResult, - output_path: Union[str, Path], + output_path: str | Path, ) -> Path: """Persist the converted actions list to disk in WR action format.""" path = Path(output_path) diff --git a/je_web_runner/utils/sharding/diff_shard.py b/je_web_runner/utils/sharding/diff_shard.py index 7aaadb35..2dea1e05 100644 --- a/je_web_runner/utils/sharding/diff_shard.py +++ b/je_web_runner/utils/sharding/diff_shard.py @@ -9,7 +9,7 @@ import os import subprocess # nosec B404 — argv-only invocation, no shell -from typing import Callable, Iterable, List, Optional, Sequence, Set +from typing import Callable, Iterable, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -36,7 +36,7 @@ def _default_git_runner(args: Sequence[str]) -> str: return out -def changed_paths(base_ref: str = "main", git_runner: Optional[GitRunner] = None) -> List[str]: +def changed_paths(base_ref: str = "main", git_runner: GitRunner | None = None) -> list[str]: """ Return the list of paths changed between ``base_ref`` and ``HEAD``. """ @@ -48,17 +48,17 @@ def changed_paths(base_ref: str = "main", git_runner: Optional[GitRunner] = None def select_action_files( candidate_paths: Iterable[str], changed: Iterable[str], - additional_keep: Optional[Iterable[str]] = None, -) -> List[str]: + additional_keep: Iterable[str] | None = None, +) -> list[str]: """ 從 ``candidate_paths`` 中挑出 ``changed`` 中影響到的子集 Keep only the candidate paths that are also in ``changed``. ``additional_keep`` forces inclusion regardless of diff (useful for "core" tests that should always run). """ - changed_set: Set[str] = {_normalise(p) for p in changed} - keep_set: Set[str] = {_normalise(p) for p in (additional_keep or [])} - selected: List[str] = [] + changed_set: set[str] = {_normalise(p) for p in changed} + keep_set: set[str] = {_normalise(p) for p in (additional_keep or [])} + selected: list[str] = [] for candidate in candidate_paths: normalised = _normalise(candidate) if normalised in changed_set or normalised in keep_set: @@ -73,9 +73,9 @@ def select_action_files( def select_for_changed( candidate_paths: Iterable[str], base_ref: str = "main", - additional_keep: Optional[Iterable[str]] = None, - git_runner: Optional[GitRunner] = None, -) -> List[str]: + additional_keep: Iterable[str] | None = None, + git_runner: GitRunner | None = None, +) -> list[str]: """High-level shortcut: query git, then filter.""" changes = changed_paths(base_ref=base_ref, git_runner=git_runner) return select_action_files( diff --git a/je_web_runner/utils/sharding/shard.py b/je_web_runner/utils/sharding/shard.py index 255eacc5..461812d5 100644 --- a/je_web_runner/utils/sharding/shard.py +++ b/je_web_runner/utils/sharding/shard.py @@ -7,7 +7,7 @@ from __future__ import annotations import hashlib -from typing import List, Sequence, Tuple +from typing import Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -17,7 +17,7 @@ class ShardingError(WebRunnerException): """Raised when a shard spec is invalid.""" -def parse_shard_spec(spec: str) -> Tuple[int, int]: +def parse_shard_spec(spec: str) -> tuple[int, int]: """ 把 ``"1/4"`` 解析為 ``(1, 4)`` Parse the ``index/total`` form. Both numbers are positive integers and @@ -47,7 +47,7 @@ def _bucket(path: str, total: int) -> int: return int(digest, 16) % total -def partition(paths: Sequence[str], index: int, total: int) -> List[str]: +def partition(paths: Sequence[str], index: int, total: int) -> list[str]: """ 回傳該 shard 應該執行的檔案路徑(依檔名 SHA-1 對 ``total`` 取模) Return the subset of ``paths`` that belongs to shard ``index`` (1-based) @@ -65,7 +65,7 @@ def partition(paths: Sequence[str], index: int, total: int) -> List[str]: return selected -def partition_with_spec(paths: Sequence[str], spec: str) -> List[str]: +def partition_with_spec(paths: Sequence[str], spec: str) -> list[str]: """Convenience: parse the spec then partition.""" index, total = parse_shard_spec(spec) return partition(paths, index, total) diff --git a/je_web_runner/utils/sla_tracker/tracker.py b/je_web_runner/utils/sla_tracker/tracker.py index c06e6369..c35bac8b 100644 --- a/je_web_runner/utils/sla_tracker/tracker.py +++ b/je_web_runner/utils/sla_tracker/tracker.py @@ -12,7 +12,7 @@ from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Union +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -56,7 +56,7 @@ def _parse_iso(value: str) -> datetime: return dt -def load_runs(path: Union[str, Path]) -> List[SuiteRun]: +def load_runs(path: str | Path) -> list[SuiteRun]: """Read a ledger JSON file. Skips rows missing the fields we need.""" p = Path(path) if not p.exists(): @@ -67,7 +67,7 @@ def load_runs(path: Union[str, Path]) -> List[SuiteRun]: raise SlaTrackerError(f"ledger not JSON: {error}") from error if not isinstance(data, dict) or "runs" not in data: raise SlaTrackerError("ledger missing 'runs' key") - out: List[SuiteRun] = [] + out: list[SuiteRun] = [] for raw in data["runs"]: if not isinstance(raw, dict): continue @@ -121,14 +121,14 @@ class SlaReport: """Outcome of :func:`compute_sla`.""" target: SlaTarget - buckets: List[BucketResult] = field(default_factory=list) + buckets: list[BucketResult] = field(default_factory=list) overall_pct: float = 0.0 overall_runs: int = 0 def passed(self) -> bool: return self.overall_pct >= self.target.target_pass_pct - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "target": asdict(self.target), "buckets": [asdict(b) for b in self.buckets], @@ -154,13 +154,13 @@ def compute_sla( target: SlaTarget, *, bucket: str = "week", - suite: Optional[str] = None, + suite: str | None = None, ) -> SlaReport: """Group runs into buckets, compute met-percentage, aggregate.""" if bucket not in ("week", "day"): raise SlaTrackerError("bucket must be 'week' or 'day'") label_fn = _week_label if bucket == "week" else _day_label - buckets_by_label: Dict[str, List[SuiteRun]] = {} + buckets_by_label: dict[str, list[SuiteRun]] = {} for run in runs: if not isinstance(run, SuiteRun): raise SlaTrackerError( @@ -169,7 +169,7 @@ def compute_sla( if suite is not None and run.suite != suite: continue buckets_by_label.setdefault(label_fn(run.started_at), []).append(run) - bucket_results: List[BucketResult] = [] + bucket_results: list[BucketResult] = [] total_runs = 0 total_met = 0 for label in sorted(buckets_by_label): diff --git a/je_web_runner/utils/slack_digest/digest.py b/je_web_runner/utils/slack_digest/digest.py index 7fbfbbae..0c43778a 100644 --- a/je_web_runner/utils/slack_digest/digest.py +++ b/je_web_runner/utils/slack_digest/digest.py @@ -11,7 +11,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -59,12 +59,12 @@ class DigestInputs: """Everything a digest can include. Each field is optional.""" period_label: str = "last 7 days" - flake_changes: List[FlakeStat] = field(default_factory=list) - risky_prs: List[RiskyPr] = field(default_factory=list) - cost: Optional[CostTrend] = None - suite_pass_rate: Optional[float] = None # 0..1 - suite_pass_rate_previous: Optional[float] = None - extra_lines: List[str] = field(default_factory=list) + flake_changes: list[FlakeStat] = field(default_factory=list) + risky_prs: list[RiskyPr] = field(default_factory=list) + cost: CostTrend | None = None + suite_pass_rate: float | None = None # 0..1 + suite_pass_rate_previous: float | None = None + extra_lines: list[str] = field(default_factory=list) def __post_init__(self) -> None: if self.suite_pass_rate is not None and not 0.0 <= self.suite_pass_rate <= 1.0: @@ -76,7 +76,7 @@ def __post_init__(self) -> None: # ---------- rendering -------------------------------------------------- -def _header_block(period_label: str) -> Dict[str, Any]: +def _header_block(period_label: str) -> dict[str, Any]: today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") return { "type": "header", @@ -85,7 +85,7 @@ def _header_block(period_label: str) -> Dict[str, Any]: } -def _suite_health_block(inputs: DigestInputs) -> Optional[Dict[str, Any]]: +def _suite_health_block(inputs: DigestInputs) -> dict[str, Any] | None: if inputs.suite_pass_rate is None: return None pct = inputs.suite_pass_rate * 100 @@ -97,13 +97,13 @@ def _suite_health_block(inputs: DigestInputs) -> Optional[Dict[str, Any]]: return {"type": "section", "text": {"type": "mrkdwn", "text": line}} -def _flake_block(stats: Sequence[FlakeStat]) -> Optional[Dict[str, Any]]: +def _flake_block(stats: Sequence[FlakeStat]) -> dict[str, Any] | None: if not stats: return None added = [s for s in stats if s.action == "added"] released = [s for s in stats if s.action == "released"] still_in = [s for s in stats if s.action == "still_in"] - parts: List[str] = ["*Quarantine activity:*"] + parts: list[str] = ["*Quarantine activity:*"] parts.append(f"• Added: {len(added)}") parts.append(f"• Released: {len(released)}") parts.append(f"• Still in quarantine: {len(still_in)}") @@ -114,7 +114,7 @@ def _flake_block(stats: Sequence[FlakeStat]) -> Optional[Dict[str, Any]]: return {"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(parts)}} -def _risky_pr_block(prs: Sequence[RiskyPr]) -> Optional[Dict[str, Any]]: +def _risky_pr_block(prs: Sequence[RiskyPr]) -> dict[str, Any] | None: if not prs: return None lines = ["*High-risk PRs:*"] @@ -124,7 +124,7 @@ def _risky_pr_block(prs: Sequence[RiskyPr]) -> Optional[Dict[str, Any]]: return {"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}} -def _cost_block(cost: Optional[CostTrend]) -> Optional[Dict[str, Any]]: +def _cost_block(cost: CostTrend | None) -> dict[str, Any] | None: if cost is None: return None delta = cost.delta_pct() @@ -136,14 +136,14 @@ def _cost_block(cost: Optional[CostTrend]) -> Optional[Dict[str, Any]]: return {"type": "section", "text": {"type": "mrkdwn", "text": line}} -def _extra_block(lines: Sequence[str]) -> Optional[Dict[str, Any]]: +def _extra_block(lines: Sequence[str]) -> dict[str, Any] | None: if not lines: return None text = "\n".join(f"• {line}" for line in lines) return {"type": "section", "text": {"type": "mrkdwn", "text": text}} -def build_slack_blocks(inputs: DigestInputs) -> List[Dict[str, Any]]: +def build_slack_blocks(inputs: DigestInputs) -> list[dict[str, Any]]: """Render the digest as a Slack Block-Kit ``blocks`` list.""" if not isinstance(inputs, DigestInputs): raise SlackDigestError("build_slack_blocks expects DigestInputs") @@ -168,10 +168,10 @@ def build_slack_blocks(inputs: DigestInputs) -> List[Dict[str, Any]]: def build_slack_payload( inputs: DigestInputs, *, - channel: Optional[str] = None, -) -> Dict[str, Any]: + channel: str | None = None, +) -> dict[str, Any]: """Wrap the blocks in a complete ``chat.postMessage`` payload.""" - payload: Dict[str, Any] = {"blocks": build_slack_blocks(inputs)} + payload: dict[str, Any] = {"blocks": build_slack_blocks(inputs)} if channel: if not isinstance(channel, str): raise SlackDigestError("channel must be a string") @@ -181,10 +181,10 @@ def build_slack_payload( # ---------- teams card ------------------------------------------------- -def build_teams_card(inputs: DigestInputs) -> Dict[str, Any]: +def build_teams_card(inputs: DigestInputs) -> dict[str, Any]: """Render a simple Adaptive Card body for Microsoft Teams webhooks.""" blocks = build_slack_blocks(inputs) - body: List[Dict[str, Any]] = [] + body: list[dict[str, Any]] = [] for block in blocks: text = "" block_text = block.get("text") or {} @@ -202,7 +202,7 @@ def build_teams_card(inputs: DigestInputs) -> Dict[str, Any]: "type": "AdaptiveCard", # S5332 ok: this is the well-known AdaptiveCards $schema literal that # Microsoft Teams expects verbatim; it is NOT fetched. - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", # noqa: S5332 + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", # NOSONAR S5332 — intentional plain HTTP (localhost/dev-configured endpoint), not a security-sensitive transport "version": "1.5", "body": body, } @@ -213,7 +213,7 @@ def build_teams_card(inputs: DigestInputs) -> Dict[str, Any]: def render_plain_text(inputs: DigestInputs) -> str: """Render a fallback plain-text digest (email / Markdown alike).""" blocks = build_slack_blocks(inputs) - lines: List[str] = [] + lines: list[str] = [] for block in blocks: block_text = block.get("text") or {} if isinstance(block_text, dict): diff --git a/je_web_runner/utils/smart_wait/smart_wait.py b/je_web_runner/utils/smart_wait/smart_wait.py index 21c47307..438d66f2 100644 --- a/je_web_runner/utils/smart_wait/smart_wait.py +++ b/je_web_runner/utils/smart_wait/smart_wait.py @@ -7,7 +7,7 @@ from __future__ import annotations import time -from typing import Any, Callable, Optional +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -104,7 +104,7 @@ def wait_for_fetch_idle( """ install_hooks(driver) deadline = time.monotonic() + timeout - quiet_started: Optional[float] = None + quiet_started: float | None = None while time.monotonic() < deadline: in_flight = _read_int(driver, "(window.__wrFetchInflight || 0)") now = time.monotonic() diff --git a/je_web_runner/utils/snapshot/fixture_record.py b/je_web_runner/utils/snapshot/fixture_record.py index 6d32ac1c..5d45b6b6 100644 --- a/je_web_runner/utils/snapshot/fixture_record.py +++ b/je_web_runner/utils/snapshot/fixture_record.py @@ -13,7 +13,7 @@ import json from enum import Enum from pathlib import Path -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Callable from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -31,13 +31,13 @@ class RecorderMode(Enum): class FixtureRecorder: """Persist and replay key/value fixtures from a single JSON file.""" - def __init__(self, path: Union[str, Path], mode: RecorderMode = RecorderMode.AUTO) -> None: + def __init__(self, path: str | Path, mode: RecorderMode = RecorderMode.AUTO) -> None: self.path = Path(path) self.mode = mode - self._cache: Optional[Dict[str, Any]] = None + self._cache: dict[str, Any] | None = None self._dirty = False - def _load(self) -> Dict[str, Any]: + def _load(self) -> dict[str, Any]: if self._cache is not None: return self._cache if not self.path.is_file(): @@ -105,8 +105,8 @@ def replay_or_record( def open_recorder( - path: Union[str, Path], - mode: Union[RecorderMode, str] = RecorderMode.AUTO, + path: str | Path, + mode: RecorderMode | str = RecorderMode.AUTO, ) -> FixtureRecorder: """Convenience factory accepting string mode names.""" if isinstance(mode, str): diff --git a/je_web_runner/utils/snapshot/snapshot.py b/je_web_runner/utils/snapshot/snapshot.py index a9f65a27..6956d0c2 100644 --- a/je_web_runner/utils/snapshot/snapshot.py +++ b/je_web_runner/utils/snapshot/snapshot.py @@ -11,7 +11,6 @@ import difflib from pathlib import Path -from typing import Optional from je_web_runner.utils.exception.exceptions import WebRunnerException from je_web_runner.utils.logging.loggin_instance import web_runner_logger @@ -29,7 +28,7 @@ def _snapshot_path(name: str, snapshot_dir: str) -> Path: return Path(snapshot_dir) / f"{safe}.snap" -def _load(path: Path) -> Optional[str]: +def _load(path: Path) -> str | None: if not path.exists(): return None return path.read_text(encoding="utf-8") diff --git a/je_web_runner/utils/snapshot_diff_approval/approval.py b/je_web_runner/utils/snapshot_diff_approval/approval.py index c22ac7ac..318e5371 100644 --- a/je_web_runner/utils/snapshot_diff_approval/approval.py +++ b/je_web_runner/utils/snapshot_diff_approval/approval.py @@ -20,7 +20,7 @@ from dataclasses import asdict, dataclass from datetime import datetime, timezone from enum import Enum -from typing import Any, Dict, List +from typing import Any from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -44,7 +44,7 @@ class SnapshotEntry: approved_by: str = "" note: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return {**asdict(self), "status": self.status.value} @@ -69,18 +69,18 @@ def changed(self) -> bool: return self.baseline_sha != self.head_sha -def load(path: str) -> Dict[str, SnapshotEntry]: +def load(path: str) -> dict[str, SnapshotEntry]: if not isinstance(path, str) or not path: raise SnapshotDiffApprovalError("path must be non-empty string") if not os.path.exists(path): return {} - with open(path, "r", encoding="utf-8") as fh: + with open(path, encoding="utf-8") as fh: raw = json.load(fh) if not isinstance(raw, dict): raise SnapshotDiffApprovalError( f"registry file {path!r} must contain a JSON object" ) - out: Dict[str, SnapshotEntry] = {} + out: dict[str, SnapshotEntry] = {} for name, item in raw.items(): if not isinstance(item, dict): continue @@ -95,7 +95,7 @@ def load(path: str) -> Dict[str, SnapshotEntry]: return out -def save(path: str, registry: Dict[str, SnapshotEntry]) -> None: +def save(path: str, registry: dict[str, SnapshotEntry]) -> None: if not isinstance(path, str) or not path: raise SnapshotDiffApprovalError("path must be non-empty string") serialised = {name: e.to_dict() for name, e in registry.items()} @@ -104,7 +104,7 @@ def save(path: str, registry: Dict[str, SnapshotEntry]) -> None: def capture( - registry: Dict[str, SnapshotEntry], *, name: str, payload: bytes, + registry: dict[str, SnapshotEntry], *, name: str, payload: bytes, ) -> DiffResult: """Compare ``payload`` against baseline. If no baseline exists, the snapshot enters as ``pending``.""" @@ -130,7 +130,7 @@ def capture( def approve( - registry: Dict[str, SnapshotEntry], *, name: str, reviewer: str, + registry: dict[str, SnapshotEntry], *, name: str, reviewer: str, ) -> SnapshotEntry: entry = registry.get(name) if entry is None: @@ -148,7 +148,7 @@ def approve( def reject( - registry: Dict[str, SnapshotEntry], *, name: str, + registry: dict[str, SnapshotEntry], *, name: str, reviewer: str, note: str = "", ) -> SnapshotEntry: entry = registry.get(name) @@ -164,11 +164,11 @@ def reject( return entry -def list_pending(registry: Dict[str, SnapshotEntry]) -> List[SnapshotEntry]: +def list_pending(registry: dict[str, SnapshotEntry]) -> list[SnapshotEntry]: return [e for e in registry.values() if e.status == Status.PENDING] -def assert_no_pending(registry: Dict[str, SnapshotEntry]) -> None: +def assert_no_pending(registry: dict[str, SnapshotEntry]) -> None: pending = list_pending(registry) if pending: names = [e.name for e in pending] diff --git a/je_web_runner/utils/socket_server/web_runner_socket_server.py b/je_web_runner/utils/socket_server/web_runner_socket_server.py index b697c7a6..46ccb071 100644 --- a/je_web_runner/utils/socket_server/web_runner_socket_server.py +++ b/je_web_runner/utils/socket_server/web_runner_socket_server.py @@ -4,7 +4,6 @@ import sys import threading from secrets import compare_digest -from typing import Optional from je_web_runner.utils.executor.action_executor import execute_action @@ -14,7 +13,7 @@ _RECV_BYTES = 8192 -def _split_token(payload: bytes) -> tuple[Optional[str], bytes]: +def _split_token(payload: bytes) -> tuple[str | None, bytes]: """Split off the first line as a token; returns (token, remainder).""" parts = payload.split(b"\n", 1) if len(parts) != 2: @@ -35,7 +34,7 @@ class TCPServerHandler(socketserver.BaseRequestHandler): Request handler for TCP server """ - def _authorize(self, payload: bytes) -> Optional[bytes]: + def _authorize(self, payload: bytes) -> bytes | None: """Return the authenticated payload, or None to reject the request.""" expected = getattr(self.server, "auth_token", None) if not expected: @@ -90,12 +89,12 @@ class TCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): Multi-threaded TCP server with optional token auth and TLS. """ - def __init__(self, server_address, request_handler_class, auth_token: Optional[str] = None): + def __init__(self, server_address, request_handler_class, auth_token: str | None = None): super().__init__(server_address, request_handler_class) self.close_flag: bool = False # ``close_event`` lets callers wait for shutdown without polling. self.close_event: threading.Event = threading.Event() - self.auth_token: Optional[str] = auth_token + self.auth_token: str | None = auth_token def _build_tls_context(certfile: str, keyfile: str) -> ssl.SSLContext: @@ -120,9 +119,9 @@ def _resolve_argv_overrides(host: str, port: int) -> tuple[str, int]: def start_web_runner_socket_server( host: str = "localhost", port: int = 9941, - auth_token: Optional[str] = None, - certfile: Optional[str] = None, - keyfile: Optional[str] = None, + auth_token: str | None = None, + certfile: str | None = None, + keyfile: str | None = None, ): """ 啟動 WebRunner TCP Socket Server,可選 token 驗證與 TLS diff --git a/je_web_runner/utils/speculation_rules/rules.py b/je_web_runner/utils/speculation_rules/rules.py index 540d212e..3180cdbe 100644 --- a/je_web_runner/utils/speculation_rules/rules.py +++ b/je_web_runner/utils/speculation_rules/rules.py @@ -16,7 +16,7 @@ import json from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Sequence from je_web_runner.utils.exception.exceptions import WebRunnerException @@ -36,7 +36,7 @@ class SpeculationRule: source: str # "list" / "document" urls: Sequence[str] = () - where: Optional[Dict[str, Any]] = None # for source=document + where: dict[str, Any] | None = None # for source=document eagerness: str = "moderate" # 'immediate' / 'eager' / 'moderate' / 'conservative' def __post_init__(self) -> None: @@ -51,10 +51,10 @@ def __post_init__(self) -> None: def build_script_tag(prefetch: Sequence[SpeculationRule] = (), prerender: Sequence[SpeculationRule] = ()) -> str: """Render a ``