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 ``