1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-05-09 08:12:15 +02:00

Compare commits

...

26 Commits

Author SHA1 Message Date
semantic-release 603edede9c 3.7.2
Automatically generated by python-semantic-release
2026-04-29 13:13:24 +00:00
copilot-swe-agent[bot] 30ef25533a fix(workspace-actions): use try/finally and restore previous blocked state in refresh_profiles
Agent-Logs-Url: https://github.com/bec-project/bec_widgets/sessions/004cb4bc-5015-485e-a803-1e63876b7024

Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
2026-04-29 15:12:35 +02:00
wyzula_j 73b44cffb2 fix(dock-area): avoid switching profile when saving new profile 2026-04-29 15:12:35 +02:00
wakonig_k a614d662d6 test: fix assertions after updating ophyd devices templates
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:45:42 +02:00
wakonig_k 3f1aa80756 chore: update header comments in script files to indicate AI generation 2026-04-24 16:17:44 +02:00
wakonig_k 409c9e5bfa ci: increase threshold to 20 percent 2026-04-22 13:10:15 +02:00
wakonig_k 19b5c8f724 ci: fix benchmark upload 2026-04-22 13:10:15 +02:00
wyzula_j 5056ef8946 test: remove references to "scan_motors" in tests 2026-04-22 08:12:39 +02:00
wakonig_k 551d38d901 build: add pytest-benchmark dependency 2026-04-21 13:58:38 +02:00
wakonig_k 999b7a2321 ci: add benchmark workflow 2026-04-21 13:58:38 +02:00
semantic-release 5dc373bd8e 3.7.1
Automatically generated by python-semantic-release
2026-04-21 11:56:57 +00:00
appel_c 91afc775d5 test: fix exit status and status access in tests 2026-04-21 13:56:12 +02:00
appel_c 55694ff2b9 fix(heatmap): fix access to status from metadata 2026-04-21 13:56:12 +02:00
perl_d 5b68a51aaa tests: skip broken and mark with issue 2026-04-21 12:43:31 +02:00
semantic-release f13fa75e25 3.7.0
Automatically generated by python-semantic-release
2026-04-21 10:42:54 +00:00
wakonig_k 0cf84cd1d8 feat: move companion app to applications 2026-04-21 12:42:08 +02:00
wakonig_k 3e77f54034 refactor: cleanup of imports 2026-04-21 12:42:08 +02:00
semantic-release f7616102d8 3.6.0
Automatically generated by python-semantic-release
2026-04-21 06:39:15 +00:00
perl_d 5a497c3598 fix: small usability changes 2026-04-21 08:38:24 +02:00
perl_d 23e3644619 feat: add button/slot to pause/unpause logs 2026-04-21 08:38:24 +02:00
perl_d a5db2dc340 fix: change resize mode to interactive 2026-04-21 08:38:24 +02:00
perl_d 2e8f43fcac feat: add logpanel to menu 2026-04-21 08:38:24 +02:00
perl_d 09bb1121d8 feat: migrate logpanel to table model/view 2026-04-21 08:38:24 +02:00
semantic-release c9aaa77b3c 3.5.1
Automatically generated by python-semantic-release
2026-04-20 13:06:31 +00:00
perl_d f7a1ee49a4 fix: don't assume attr exists if we timed out waiting for it 2026-04-20 15:05:47 +02:00
perl_d 8e51c1adb6 refactor: don't import real widgets in client 2026-04-19 16:05:56 +02:00
90 changed files with 2131 additions and 826 deletions
+169
View File
@@ -0,0 +1,169 @@
##########################
### AI-generated file. ###
##########################
"""Aggregate and merge benchmark JSON files.
The workflow runs the same benchmark suite on multiple independent runners.
This script reads every JSON file produced by those attempts, normalizes the
contained benchmark values, and writes a compact mapping JSON where each value is
the median across attempts. It can also merge independent hyperfine JSON files
from one runner into a single hyperfine-style JSON file.
"""
from __future__ import annotations
import argparse
import json
import statistics
from pathlib import Path
from typing import Any
from compare_benchmarks import Benchmark, extract_benchmarks
def collect_benchmarks(paths: list[Path]) -> dict[str, list[Benchmark]]:
"""Collect benchmarks from multiple JSON files.
Args:
paths (list[Path]): Paths to hyperfine, pytest-benchmark, or compact
mapping JSON files.
Returns:
dict[str, list[Benchmark]]: Benchmarks grouped by benchmark name.
"""
collected: dict[str, list[Benchmark]] = {}
for path in paths:
for name, benchmark in extract_benchmarks(path).items():
collected.setdefault(name, []).append(benchmark)
return collected
def aggregate(collected: dict[str, list[Benchmark]]) -> dict[str, dict[str, object]]:
"""Aggregate grouped benchmarks using the median value.
Args:
collected (dict[str, list[Benchmark]]): Benchmarks grouped by benchmark
name.
Returns:
dict[str, dict[str, object]]: Compact mapping JSON data. Each benchmark
contains ``value``, ``unit``, ``metric``, ``attempts``, and
``attempt_values``.
"""
aggregated: dict[str, dict[str, object]] = {}
for name, benchmarks in sorted(collected.items()):
values = [benchmark.value for benchmark in benchmarks]
unit = next((benchmark.unit for benchmark in benchmarks if benchmark.unit), "")
metric = next((benchmark.metric for benchmark in benchmarks if benchmark.metric), "value")
aggregated[name] = {
"value": statistics.median(values),
"unit": unit,
"metric": f"median-of-attempt-{metric}",
"attempts": len(values),
"attempt_values": values,
}
return aggregated
def merge_hyperfine_results(paths: list[Path]) -> dict[str, Any]:
"""Merge hyperfine result files.
Args:
paths (list[Path]): Hyperfine JSON files to merge.
Returns:
dict[str, Any]: Hyperfine-style JSON object containing all result rows.
Raises:
ValueError: If any file has no hyperfine ``results`` list.
"""
merged: dict[str, Any] = {"results": []}
for path in paths:
data = json.loads(path.read_text(encoding="utf-8"))
results = data.get("results", []) if isinstance(data, dict) else None
if not isinstance(results, list):
raise ValueError(f"{path} has no hyperfine results list")
merged["results"].extend(results)
return merged
def main_from_paths(input_dir: Path, output: Path) -> int:
"""Aggregate all JSON files in a directory and write the result.
Args:
input_dir (Path): Directory containing benchmark JSON files.
output (Path): Path where the aggregate JSON should be written.
Returns:
int: Always ``0`` on success.
Raises:
ValueError: If no JSON files are found in ``input_dir``.
"""
paths = sorted(input_dir.rglob("*.json"))
if not paths:
raise ValueError(f"No benchmark JSON files found in {input_dir}")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(aggregate(collect_benchmarks(paths)), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
return 0
def merge_from_paths(input_dir: Path, output: Path) -> int:
"""Merge all hyperfine JSON files in a directory and write the result.
Args:
input_dir (Path): Directory containing hyperfine JSON files.
output (Path): Path where the merged JSON should be written.
Returns:
int: Always ``0`` on success.
Raises:
ValueError: If no JSON files are found in ``input_dir``.
"""
paths = sorted(input_dir.glob("*.json"))
if not paths:
raise ValueError(f"No hyperfine JSON files found in {input_dir}")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(merge_hyperfine_results(paths), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
return 0
def main() -> int:
"""Run the benchmark aggregation command line interface.
Returns:
int: Always ``0`` on success.
"""
parser = argparse.ArgumentParser()
parser.add_argument(
"--mode",
choices=("aggregate", "merge-hyperfine"),
default="aggregate",
help="Operation to perform.",
)
parser.add_argument("--input-dir", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
if args.mode == "merge-hyperfine":
return merge_from_paths(input_dir=args.input_dir, output=args.output)
return main_from_paths(input_dir=args.input_dir, output=args.output)
if __name__ == "__main__":
raise SystemExit(main())
+454
View File
@@ -0,0 +1,454 @@
##########################
### AI-generated file. ###
##########################
"""Compare benchmark JSON files and write a GitHub Actions summary.
The script supports JSON emitted by hyperfine, JSON emitted by pytest-benchmark,
and a compact mapping format generated by ``aggregate_benchmarks.py``. Timing
formats prefer median values and fall back to mean values when median values are
not present.
"""
from __future__ import annotations
import argparse
import json
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class Benchmark:
"""Normalized benchmark result.
Attributes:
name (str): Stable benchmark name used to match baseline and current results.
value (float): Numeric benchmark value used for comparison.
unit (str): Display unit for the value, for example ``"s"``.
metric (str): Source metric name, for example ``"median"`` or ``"mean"``.
"""
name: str
value: float
unit: str
metric: str = "value"
@dataclass(frozen=True)
class Comparison:
"""Comparison between one baseline benchmark and one current benchmark.
Attributes:
name (str): Benchmark name.
baseline (float): Baseline benchmark value.
current (float): Current benchmark value.
delta_percent (float): Percent change from baseline to current.
unit (str): Display unit for both values.
metric (str): Current result metric used for comparison.
regressed (bool): Whether the change exceeds the configured threshold in
the worse direction.
improved (bool): Whether the change exceeds the configured threshold in
the better direction.
"""
name: str
baseline: float
current: float
delta_percent: float
unit: str
metric: str
regressed: bool
improved: bool
def _read_json(path: Path) -> Any:
"""Read JSON data from a file.
Args:
path (Path): Path to the JSON file.
Returns:
Any: Parsed JSON value.
"""
with path.open("r", encoding="utf-8") as stream:
return json.load(stream)
def _as_float(value: Any) -> float | None:
"""Convert a value to a finite float.
Args:
value (Any): Value to convert.
Returns:
float | None: Converted finite float, or ``None`` if conversion fails.
"""
try:
result = float(value)
except (TypeError, ValueError):
return None
if math.isfinite(result):
return result
return None
def _extract_hyperfine(data: dict[str, Any]) -> dict[str, Benchmark]:
"""Extract normalized benchmarks from hyperfine JSON.
Args:
data (dict[str, Any]): Parsed hyperfine JSON object.
Returns:
dict[str, Benchmark]: Benchmarks keyed by command name.
"""
benchmarks: dict[str, Benchmark] = {}
for result in data.get("results", []):
if not isinstance(result, dict):
continue
name = str(result.get("command") or result.get("name") or "").strip()
metric = "median"
value = _as_float(result.get(metric))
if value is None:
metric = "mean"
value = _as_float(result.get(metric))
if name and value is not None:
benchmarks[name] = Benchmark(name=name, value=value, unit="s", metric=metric)
return benchmarks
def _extract_pytest_benchmark(data: dict[str, Any]) -> dict[str, Benchmark]:
"""Extract normalized benchmarks from pytest-benchmark JSON.
Args:
data (dict[str, Any]): Parsed pytest-benchmark JSON object.
Returns:
dict[str, Benchmark]: Benchmarks keyed by full benchmark name.
"""
benchmarks: dict[str, Benchmark] = {}
for benchmark in data.get("benchmarks", []):
if not isinstance(benchmark, dict):
continue
name = str(benchmark.get("fullname") or benchmark.get("name") or "").strip()
stats = benchmark.get("stats", {})
value = None
metric = "median"
if isinstance(stats, dict):
value = _as_float(stats.get(metric))
if value is None:
metric = "mean"
value = _as_float(stats.get(metric))
if name and value is not None:
benchmarks[name] = Benchmark(name=name, value=value, unit="s", metric=metric)
return benchmarks
def _extract_simple_mapping(data: dict[str, Any]) -> dict[str, Benchmark]:
"""Extract normalized benchmarks from a compact mapping JSON object.
Args:
data (dict[str, Any]): Parsed mapping where each benchmark is either a
raw number or an object containing ``value``, ``unit``, and ``metric``.
Returns:
dict[str, Benchmark]: Benchmarks keyed by mapping key.
"""
benchmarks: dict[str, Benchmark] = {}
for name, raw_value in data.items():
if name in {"version", "context", "commit", "timestamp"}:
continue
value = _as_float(raw_value)
unit = ""
metric = "value"
if value is None and isinstance(raw_value, dict):
value = _as_float(raw_value.get("value"))
unit = str(raw_value.get("unit") or "")
metric = str(raw_value.get("metric") or "value")
if value is not None:
benchmarks[str(name)] = Benchmark(name=str(name), value=value, unit=unit, metric=metric)
return benchmarks
def extract_benchmarks(path: Path) -> dict[str, Benchmark]:
"""Extract normalized benchmarks from a supported JSON file.
Args:
path (Path): Path to a hyperfine, pytest-benchmark, or compact mapping
JSON file.
Returns:
dict[str, Benchmark]: Normalized benchmarks keyed by name.
Raises:
ValueError: If the JSON root is not an object or no supported benchmark
entries can be extracted.
"""
data = _read_json(path)
if not isinstance(data, dict):
raise ValueError(f"{path} must contain a JSON object")
extractors = (_extract_hyperfine, _extract_pytest_benchmark, _extract_simple_mapping)
for extractor in extractors:
benchmarks = extractor(data)
if benchmarks:
return benchmarks
raise ValueError(f"No supported benchmark entries found in {path}")
def compare_benchmarks(
baseline: dict[str, Benchmark],
current: dict[str, Benchmark],
threshold_percent: float,
higher_is_better: bool,
) -> tuple[list[Comparison], list[str], list[str]]:
"""Compare baseline benchmarks with current benchmarks.
Args:
baseline (dict[str, Benchmark]): Baseline benchmarks keyed by name.
current (dict[str, Benchmark]): Current benchmarks keyed by name.
threshold_percent (float): Regression threshold in percent.
higher_is_better (bool): If ``True``, lower current values are treated as
regressions. If ``False``, higher current values are treated as
regressions.
Returns:
tuple[list[Comparison], list[str], list[str]]: Comparisons for common
benchmark names, names missing from current results, and names newly
present in current results.
"""
comparisons: list[Comparison] = []
missing_in_current: list[str] = []
new_in_current: list[str] = []
for name, baseline_benchmark in sorted(baseline.items()):
current_benchmark = current.get(name)
if current_benchmark is None:
missing_in_current.append(name)
continue
if baseline_benchmark.value == 0:
delta_percent = 0.0
else:
delta_percent = (
(current_benchmark.value - baseline_benchmark.value)
/ abs(baseline_benchmark.value)
* 100
)
if higher_is_better:
regressed = delta_percent <= -threshold_percent
improved = delta_percent >= threshold_percent
else:
regressed = delta_percent >= threshold_percent
improved = delta_percent <= -threshold_percent
comparisons.append(
Comparison(
name=name,
baseline=baseline_benchmark.value,
current=current_benchmark.value,
delta_percent=delta_percent,
unit=current_benchmark.unit or baseline_benchmark.unit,
metric=current_benchmark.metric,
regressed=regressed,
improved=improved,
)
)
for name in sorted(set(current) - set(baseline)):
new_in_current.append(name)
return comparisons, missing_in_current, new_in_current
def _format_value(value: float, unit: str) -> str:
"""Format a benchmark value for Markdown output.
Args:
value (float): Numeric benchmark value.
unit (str): Display unit.
Returns:
str: Formatted value with optional unit suffix.
"""
suffix = f" {unit}" if unit else ""
return f"{value:.6g}{suffix}"
def _format_status(comparison: Comparison) -> str:
"""Format a comparison status for Markdown output."""
if comparison.regressed:
return ":red_circle: regressed"
if comparison.improved:
return ":green_circle: improved"
return "ok"
def write_summary(
path: Path,
comparisons: list[Comparison],
missing_in_current: list[str],
new_in_current: list[str],
threshold_percent: float,
higher_is_better: bool,
) -> None:
"""Write a Markdown benchmark comparison summary.
Args:
path (Path): Path where the summary should be written.
comparisons (list[Comparison]): Comparison rows for matching benchmarks.
missing_in_current (list[str]): Baseline benchmark names missing from the
current result.
new_in_current (list[str]): Current benchmark names not present in the
baseline result.
threshold_percent (float): Regression threshold in percent.
higher_is_better (bool): Whether higher benchmark values are considered
better.
"""
regressions = [comparison for comparison in comparisons if comparison.regressed]
improvements = [comparison for comparison in comparisons if comparison.improved]
direction = "higher is better" if higher_is_better else "lower is better"
sorted_comparisons = sorted(comparisons, key=lambda comparison: comparison.name)
lines = [
"<!-- bw-benchmark-comment -->",
"## Benchmark comparison",
"",
f"Threshold: {threshold_percent:g}% ({direction}).",
f"Result: {len(regressions)} regression(s), {len(improvements)} improvement(s) beyond threshold.",
]
lines.append("")
if regressions:
lines.extend(
[
f"{len(regressions)} benchmark(s) regressed beyond the configured threshold.",
"",
"| Benchmark | Baseline | Current | Change |",
"| --- | ---: | ---: | ---: |",
]
)
for comparison in regressions:
lines.append(
"| "
f"{comparison.name} | "
f"{_format_value(comparison.baseline, comparison.unit)} | "
f"{_format_value(comparison.current, comparison.unit)} | "
f"{comparison.delta_percent:+.2f}% |"
)
else:
lines.append("No benchmark regression exceeded the configured threshold.")
lines.append("")
if improvements:
lines.extend(
[
f"{len(improvements)} benchmark(s) improved beyond the configured threshold.",
"",
"| Benchmark | Baseline | Current | Change |",
"| --- | ---: | ---: | ---: |",
]
)
for comparison in improvements:
lines.append(
"| "
f"{comparison.name} | "
f"{_format_value(comparison.baseline, comparison.unit)} | "
f"{_format_value(comparison.current, comparison.unit)} | "
f"{comparison.delta_percent:+.2f}% |"
)
else:
lines.append("No benchmark improvement exceeded the configured threshold.")
if sorted_comparisons:
lines.extend(
[
"",
"<details>",
"<summary>All benchmark results</summary>",
"",
"| Benchmark | Baseline | Current | Change | Status |",
"| --- | ---: | ---: | ---: | --- |",
]
)
for comparison in sorted_comparisons:
lines.append(
"| "
f"{comparison.name} | "
f"{_format_value(comparison.baseline, comparison.unit)} | "
f"{_format_value(comparison.current, comparison.unit)} | "
f"{comparison.delta_percent:+.2f}% | "
f"{_format_status(comparison)} |"
)
lines.extend(["", "</details>"])
if missing_in_current:
lines.extend(["", "Missing benchmarks in the current run:"])
lines.extend(f"- `{name}`" for name in missing_in_current)
if new_in_current:
lines.extend(["", "New benchmarks in the current run:"])
lines.extend(f"- `{name}`" for name in new_in_current)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> int:
"""Run the benchmark comparison command line interface.
Returns:
int: ``1`` when a regression exceeds the threshold, otherwise ``0``.
"""
parser = argparse.ArgumentParser()
parser.add_argument("--baseline", required=True, type=Path)
parser.add_argument("--current", required=True, type=Path)
parser.add_argument("--summary", required=True, type=Path)
parser.add_argument("--threshold-percent", required=True, type=float)
parser.add_argument("--higher-is-better", action="store_true")
args = parser.parse_args()
baseline = extract_benchmarks(args.baseline)
current = extract_benchmarks(args.current)
comparisons, missing_in_current, new_in_current = compare_benchmarks(
baseline=baseline,
current=current,
threshold_percent=args.threshold_percent,
higher_is_better=args.higher_is_better,
)
write_summary(
path=args.summary,
comparisons=comparisons,
missing_in_current=missing_in_current,
new_in_current=new_in_current,
threshold_percent=args.threshold_percent,
higher_is_better=args.higher_is_better,
)
return 1 if any(comparison.regressed for comparison in comparisons) else 0
if __name__ == "__main__":
raise SystemExit(main())
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
##########################
### AI-generated file. ###
##########################
set -euo pipefail
mkdir -p benchmark-results
benchmark_json="${BENCHMARK_JSON:-benchmark-results/current.json}"
benchmark_root="$(dirname "$benchmark_json")"
hyperfine_benchmark_dir="${BENCHMARK_HYPERFINE_DIR:-tests/benchmarks/hyperfine}"
pytest_benchmark_dirs="${BENCHMARK_PYTEST_DIRS:-${BENCHMARK_PYTEST_DIR:-}}"
benchmark_work_dir="$benchmark_root/raw-results"
hyperfine_json_dir="$benchmark_work_dir/hyperfine"
pytest_json="$benchmark_work_dir/pytest.json"
shopt -s nullglob
benchmark_scripts=()
benchmark_scripts=("$hyperfine_benchmark_dir"/benchmark_*.sh)
shopt -u nullglob
pytest_dirs=()
for pytest_benchmark_dir in $pytest_benchmark_dirs; do
if [ -d "$pytest_benchmark_dir" ]; then
pytest_dirs+=("$pytest_benchmark_dir")
else
echo "Pytest benchmark directory not found: $pytest_benchmark_dir" >&2
exit 1
fi
done
if [ "${#benchmark_scripts[@]}" -eq 0 ] && [ "${#pytest_dirs[@]}" -eq 0 ]; then
echo "No benchmark scripts or pytest benchmarks found" >&2
exit 1
fi
echo "Benchmark Python: $(command -v python)"
python -c 'import sys; print(sys.version)'
rm -rf "$benchmark_work_dir"
mkdir -p "$hyperfine_json_dir"
if [ "${#benchmark_scripts[@]}" -gt 0 ]; then
for benchmark_script in "${benchmark_scripts[@]}"; do
title="$(sed -n 's/^# BENCHMARK_TITLE:[[:space:]]*//p' "$benchmark_script" | head -n 1)"
if [ -z "$title" ]; then
title="$(basename "$benchmark_script" .sh)"
fi
benchmark_name="$(basename "$benchmark_script" .sh)"
benchmark_result_json="$hyperfine_json_dir/$benchmark_name.json"
echo "Preflight benchmark script: $benchmark_script"
bash "$benchmark_script"
hyperfine \
--show-output \
--warmup 1 \
--runs 5 \
--command-name "$title" \
--export-json "$benchmark_result_json" \
"bash $(printf "%q" "$benchmark_script")"
done
fi
if [ "${#pytest_dirs[@]}" -gt 0 ]; then
pytest \
-q "${pytest_dirs[@]}" \
--benchmark-only \
--benchmark-json "$pytest_json"
fi
python .github/scripts/aggregate_benchmarks.py \
--input-dir "$benchmark_work_dir" \
--output "$benchmark_json"
+125
View File
@@ -0,0 +1,125 @@
##########################
### AI-generated file. ###
##########################
"""Run a command with BEC e2e services available."""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
import bec_lib
from bec_ipython_client import BECIPythonClient
from bec_lib.redis_connector import RedisConnector
from bec_lib.service_config import ServiceConfig, ServiceConfigModel
from redis import Redis
def _wait_for_redis(host: str, port: int) -> None:
client = Redis(host=host, port=port)
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
try:
if client.ping():
return
except Exception:
time.sleep(0.1)
raise RuntimeError(f"Redis did not start on {host}:{port}")
def _start_redis(files_path: Path, host: str, port: int) -> subprocess.Popen:
redis_server = shutil.which("redis-server")
if redis_server is None:
raise RuntimeError("redis-server executable not found")
return subprocess.Popen(
[
redis_server,
"--bind",
host,
"--port",
str(port),
"--save",
"",
"--appendonly",
"no",
"--dir",
str(files_path),
]
)
def _write_configs(files_path: Path, host: str, port: int) -> Path:
test_config = files_path / "test_config.yaml"
services_config = files_path / "services_config.yaml"
bec_lib_path = Path(bec_lib.__file__).resolve().parent
shutil.copyfile(bec_lib_path / "tests" / "test_config.yaml", test_config)
service_config = ServiceConfigModel(
redis={"host": host, "port": port}, file_writer={"base_path": str(files_path)}
)
services_config.write_text(service_config.model_dump_json(indent=4), encoding="utf-8")
return services_config
def _load_demo_config(services_config: Path) -> None:
bec = BECIPythonClient(ServiceConfig(services_config), RedisConnector, forced=True)
bec.start()
try:
bec.config.load_demo_config()
finally:
bec.shutdown()
bec._client._reset_singleton()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
if args.command[:1] == ["--"]:
args.command = args.command[1:]
if not args.command:
raise ValueError("No command provided")
host = "127.0.0.1"
port = 6379
with tempfile.TemporaryDirectory(prefix="bec-benchmark-") as tmp:
files_path = Path(tmp)
services_config = _write_configs(files_path, host, port)
redis_process = _start_redis(files_path, host, port)
processes = None
service_handler = None
try:
_wait_for_redis(host, port)
from bec_server.bec_server_utils.service_handler import ServiceHandler
service_handler = ServiceHandler(
bec_path=files_path, config_path=services_config, interface="subprocess"
)
processes = service_handler.start()
_load_demo_config(services_config)
env = os.environ.copy()
return subprocess.run(args.command, env=env, check=False).returncode
finally:
if service_handler is not None and processes is not None:
service_handler.stop(processes)
redis_process.terminate()
try:
redis_process.wait(timeout=10)
except subprocess.TimeoutExpired:
redis_process.kill()
if __name__ == "__main__":
raise SystemExit(main())
+242
View File
@@ -0,0 +1,242 @@
name: BW Benchmarks
on: [ workflow_call ]
permissions:
contents: read
env:
BENCHMARK_JSON: benchmark-results/current.json
BENCHMARK_BASELINE_JSON: gh-pages-benchmark-data/benchmarks/latest.json
BENCHMARK_SUMMARY: benchmark-results/summary.md
BENCHMARK_COMMAND: "bash .github/scripts/run_benchmarks.sh"
BENCHMARK_THRESHOLD_PERCENT: 20
BENCHMARK_HIGHER_IS_BETTER: false
jobs:
benchmark_attempt:
runs-on: ubuntu-latest
continue-on-error: true
permissions:
contents: read
defaults:
run:
shell: bash -el {0}
strategy:
fail-fast: false
matrix:
attempt: [ 1, 2, 3 ]
env:
BENCHMARK_JSON: benchmark-results/current-${{ matrix.attempt }}.json
BEC_CORE_BRANCH: main
OPHYD_DEVICES_BRANCH: main
PLUGIN_REPO_BRANCH: main
BENCHMARK_PYTEST_DIRS: tests/unit_tests/benchmarks
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Set up Conda
uses: conda-incubator/setup-miniconda@v3
with:
auto-update-conda: true
auto-activate-base: true
python-version: "3.11"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
sudo apt-get -y install ttyd hyperfine redis-server
- name: Install full e2e environment
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch "$BEC_CORE_BRANCH" https://github.com/bec-project/bec.git
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
git clone --branch "$OPHYD_DEVICES_BRANCH" https://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
git clone --branch "$PLUGIN_REPO_BRANCH" https://github.com/bec-project/bec_testing_plugin.git
cd ./bec
conda create -q -n test-environment python=3.11
conda activate test-environment
source ./bin/install_bec_dev.sh -t
cd ../
python -m pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin pytest-benchmark
mkdir -p "$(dirname "$BENCHMARK_JSON")"
python .github/scripts/run_with_bec_servers.py -- bash -lc "$BENCHMARK_COMMAND"
test -s "$BENCHMARK_JSON"
- name: Upload benchmark artifact
uses: actions/upload-artifact@v4
with:
name: bw-benchmark-json-${{ matrix.attempt }}
path: ${{ env.BENCHMARK_JSON }}
benchmark:
needs: [ benchmark_attempt ]
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Download benchmark attempts
uses: actions/download-artifact@v4
with:
pattern: bw-benchmark-json-*
path: benchmark-results/attempts
merge-multiple: true
- name: Aggregate benchmark attempts
run: |
python .github/scripts/aggregate_benchmarks.py \
--input-dir benchmark-results/attempts \
--output "$BENCHMARK_JSON"
- name: Upload aggregate benchmark artifact
uses: actions/upload-artifact@v4
with:
name: bw-benchmark-json
path: ${{ env.BENCHMARK_JSON }}
- name: Fetch gh-pages benchmark data
run: |
if git ls-remote --exit-code --heads origin gh-pages; then
git clone --depth=1 --branch gh-pages "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" gh-pages-benchmark-data
else
mkdir -p gh-pages-benchmark-data
fi
- name: Compare with latest gh-pages benchmark
id: compare
continue-on-error: true
run: |
if [ ! -s "$BENCHMARK_BASELINE_JSON" ]; then
mkdir -p "$(dirname "$BENCHMARK_SUMMARY")"
{
echo "<!-- bw-benchmark-comment -->"
echo "## Benchmark comparison"
echo
echo "No benchmark baseline was found on gh-pages."
} > "$BENCHMARK_SUMMARY"
exit 0
fi
args=(
--baseline "$BENCHMARK_BASELINE_JSON"
--current "$BENCHMARK_JSON"
--summary "$BENCHMARK_SUMMARY"
--threshold-percent "$BENCHMARK_THRESHOLD_PERCENT"
)
if [ "$BENCHMARK_HIGHER_IS_BETTER" = "true" ]; then
args+=(--higher-is-better)
fi
set +e
python .github/scripts/compare_benchmarks.py "${args[@]}"
status=$?
set -e
if [ ! -s "$BENCHMARK_SUMMARY" ]; then
mkdir -p "$(dirname "$BENCHMARK_SUMMARY")"
{
echo "<!-- bw-benchmark-comment -->"
echo "## Benchmark comparison"
echo
echo "Benchmark comparison failed before writing a summary."
} > "$BENCHMARK_SUMMARY"
fi
exit "$status"
- name: Find existing benchmark PR comment
if: github.event_name == 'pull_request'
id: fc
uses: peter-evans/find-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: "<!-- bw-benchmark-comment -->"
- name: Create or update benchmark PR comment
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.fc.outputs.comment-id }}
body-path: ${{ env.BENCHMARK_SUMMARY }}
edit-mode: replace
- name: Fail on benchmark regression
if: github.event_name == 'pull_request' && steps.compare.outcome == 'failure'
run: exit 1
publish:
needs: [ benchmark ]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ github.sha }}
- name: Download aggregate benchmark artifact
uses: actions/download-artifact@v4
with:
name: bw-benchmark-json
path: benchmark-results
- name: Verify aggregate benchmark artifact
run: test -s "$BENCHMARK_JSON"
- name: Prepare gh-pages for publishing
run: |
# Clean up any existing worktree/directory
if [ -d gh-pages-benchmark-data ]; then
git worktree remove gh-pages-benchmark-data --force || rm -rf gh-pages-benchmark-data
fi
if git ls-remote --exit-code --heads origin gh-pages; then
git fetch --depth=1 origin gh-pages
git worktree add gh-pages-benchmark-data FETCH_HEAD
else
git worktree add --detach gh-pages-benchmark-data
git -C gh-pages-benchmark-data checkout --orphan gh-pages
git -C gh-pages-benchmark-data rm -rf .
fi
- name: Publish benchmark data to gh-pages
working-directory: gh-pages-benchmark-data
run: |
mkdir -p benchmarks/history
cp "../$BENCHMARK_JSON" benchmarks/latest.json
cp "../$BENCHMARK_JSON" "benchmarks/history/${GITHUB_SHA}.json"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add benchmarks/latest.json "benchmarks/history/${GITHUB_SHA}.json"
git commit -m "Update BW benchmark data for ${GITHUB_SHA}" || exit 0
git push origin HEAD:gh-pages
+17 -7
View File
@@ -1,19 +1,19 @@
name: Full CI name: Full CI
on: on:
push: push:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
BEC_WIDGETS_BRANCH: BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install' description: "Branch of BEC Widgets to install"
required: false required: false
type: string type: string
BEC_CORE_BRANCH: BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install' description: "Branch of BEC Core to install"
required: false required: false
type: string type: string
OPHYD_DEVICES_BRANCH: OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install' description: "Branch of Ophyd Devices to install"
required: false required: false
type: string type: string
@@ -23,6 +23,7 @@ concurrency:
permissions: permissions:
pull-requests: write pull-requests: write
contents: read
jobs: jobs:
check_pr_status: check_pr_status:
@@ -33,6 +34,15 @@ jobs:
if: needs.check_pr_status.outputs.branch-pr == '' if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/formatter.yml uses: ./.github/workflows/formatter.yml
benchmark:
needs: [check_pr_status]
if: needs.check_pr_status.outputs.branch-pr == ''
permissions:
contents: write
issues: write
pull-requests: write
uses: ./.github/workflows/benchmark.yml
unit-test: unit-test:
needs: [check_pr_status, formatter] needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == '' if: needs.check_pr_status.outputs.branch-pr == ''
@@ -69,9 +79,9 @@ jobs:
uses: ./.github/workflows/child_repos.yml uses: ./.github/workflows/child_repos.yml
with: with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }} BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}} OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }} BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
plugin_repos: plugin_repos:
needs: [check_pr_status, formatter] needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == '' if: needs.check_pr_status.outputs.branch-pr == ''
@@ -81,4 +91,4 @@ jobs:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }} BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
secrets: secrets:
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }} GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
+12 -13
View File
@@ -1,25 +1,25 @@
name: Run Pytest with different Python versions name: Run Pytest with different Python versions
on: on:
workflow_call: workflow_call:
inputs: inputs:
pr_number: pr_number:
description: 'Pull request number' description: "Pull request number"
required: false required: false
type: number type: number
BEC_CORE_BRANCH: BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install' description: "Branch of BEC Core to install"
required: false required: false
default: 'main' default: "main"
type: string type: string
OPHYD_DEVICES_BRANCH: OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install' description: "Branch of Ophyd Devices to install"
required: false required: false
default: 'main' default: "main"
type: string type: string
BEC_WIDGETS_BRANCH: BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install' description: "Branch of BEC Widgets to install"
required: false required: false
default: 'main' default: "main"
type: string type: string
jobs: jobs:
@@ -30,15 +30,14 @@ jobs:
python-version: ["3.11", "3.12", "3.13"] python-version: ["3.11", "3.12", "3.13"]
env: env:
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
BEC_CORE_BRANCH: main # Set the branch you want for bec BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }} PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1 QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen" QT_QPA_PLATFORM: "offscreen"
steps: steps:
- name: Checkout BEC Widgets - name: Checkout BEC Widgets
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
@@ -56,4 +55,4 @@ jobs:
- name: Run Pytest - name: Run Pytest
run: | run: |
pip install pytest pytest-random-order pip install pytest pytest-random-order
pytest -v --junitxml=report.xml --random-order ./tests/unit_tests pytest -v --junitxml=report.xml --random-order --ignore=tests/unit_tests/benchmarks ./tests/unit_tests
+10 -12
View File
@@ -1,32 +1,30 @@
name: Run Pytest with Coverage name: Run Pytest with Coverage
on: on:
workflow_call: workflow_call:
inputs: inputs:
pr_number: pr_number:
description: 'Pull request number' description: "Pull request number"
required: false required: false
type: number type: number
BEC_CORE_BRANCH: BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install' description: "Branch of BEC Core to install"
required: false required: false
default: 'main' default: "main"
type: string type: string
OPHYD_DEVICES_BRANCH: OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install' description: "Branch of Ophyd Devices to install"
required: false required: false
default: 'main' default: "main"
type: string type: string
BEC_WIDGETS_BRANCH: BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install' description: "Branch of BEC Widgets to install"
required: false required: false
default: 'main' default: "main"
type: string type: string
secrets: secrets:
CODECOV_TOKEN: CODECOV_TOKEN:
required: true required: true
permissions: permissions:
pull-requests: write pull-requests: write
@@ -55,7 +53,7 @@ jobs:
- name: Run Pytest with Coverage - name: Run Pytest with Coverage
id: coverage id: coverage
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/ run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail --ignore=tests/unit_tests/benchmarks tests/unit_tests/
- name: Upload test artifacts - name: Upload test artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -69,4 +67,4 @@ jobs:
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: bec-project/bec_widgets slug: bec-project/bec_widgets
+108
View File
@@ -1,6 +1,114 @@
# CHANGELOG # CHANGELOG
## v3.7.2 (2026-04-29)
### Bug Fixes
- **dock-area**: Avoid switching profile when saving new profile
([`73b44cf`](https://github.com/bec-project/bec_widgets/commit/73b44cffb219347cacb609f3b93068eda6701b42))
- **workspace-actions**: Use try/finally and restore previous blocked state in refresh_profiles
([`30ef255`](https://github.com/bec-project/bec_widgets/commit/30ef25533af9df5a9bd9e69808dc49fdf22f4318))
Agent-Logs-Url:
https://github.com/bec-project/bec_widgets/sessions/004cb4bc-5015-485e-a803-1e63876b7024
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
### Build System
- Add pytest-benchmark dependency
([`551d38d`](https://github.com/bec-project/bec_widgets/commit/551d38d90111361e64bfcf10a1409be71dd298bc))
### Chores
- Update header comments in script files to indicate AI generation
([`3f1aa80`](https://github.com/bec-project/bec_widgets/commit/3f1aa80756368454c3631cb6cf9d29db28af177a))
### Continuous Integration
- Add benchmark workflow
([`999b7a2`](https://github.com/bec-project/bec_widgets/commit/999b7a2321f2f222c04b056a2db4280f66de9c48))
- Fix benchmark upload
([`19b5c8f`](https://github.com/bec-project/bec_widgets/commit/19b5c8f724dbdb7421957c290f9213bf072392df))
- Increase threshold to 20 percent
([`409c9e5`](https://github.com/bec-project/bec_widgets/commit/409c9e5bfacdfc003f1bc8c9944f01798bd3818e))
### Testing
- Fix assertions after updating ophyd devices templates
([`a614d66`](https://github.com/bec-project/bec_widgets/commit/a614d662d6da716a49afb4ed3a3903f108210386))
Co-authored-by: Copilot <copilot@github.com>
- Remove references to "scan_motors" in tests
([`5056ef8`](https://github.com/bec-project/bec_widgets/commit/5056ef8946d03a20e802709d3fe81c84c195fe41))
## v3.7.1 (2026-04-21)
### Bug Fixes
- **heatmap**: Fix access to status from metadata
([`55694ff`](https://github.com/bec-project/bec_widgets/commit/55694ff2b96581e03c63c8b8e068e2db79bcf780))
### Testing
- Fix exit status and status access in tests
([`91afc77`](https://github.com/bec-project/bec_widgets/commit/91afc775d59b4ba31bed3585847a67c301acf9b0))
## v3.7.0 (2026-04-21)
### Features
- Move companion app to applications
([`0cf84cd`](https://github.com/bec-project/bec_widgets/commit/0cf84cd1d839ac4a39ffb5fb9ba57d432e04348a))
### Refactoring
- Cleanup of imports
([`3e77f54`](https://github.com/bec-project/bec_widgets/commit/3e77f540345f56b9f184a332fcdd50d4d4c8c621))
## v3.6.0 (2026-04-21)
### Bug Fixes
- Change resize mode to interactive
([`a5db2dc`](https://github.com/bec-project/bec_widgets/commit/a5db2dc340f3386e68b300fd4528a44f87cbbf97))
- Small usability changes
([`5a497c3`](https://github.com/bec-project/bec_widgets/commit/5a497c3598c2d8f27916d91d53c646d5d6d3a4a7))
### Features
- Add button/slot to pause/unpause logs
([`23e3644`](https://github.com/bec-project/bec_widgets/commit/23e3644619de958bcfdb8a0b2ee1f7c2ce05b235))
- Add logpanel to menu
([`2e8f43f`](https://github.com/bec-project/bec_widgets/commit/2e8f43fcac581cd1c227308198565d142a1bf276))
- Migrate logpanel to table model/view
([`09bb112`](https://github.com/bec-project/bec_widgets/commit/09bb1121d83bac1f6e4827daa476fbe7cd5b3a80))
## v3.5.1 (2026-04-20)
### Bug Fixes
- Don't assume attr exists if we timed out waiting for it
([`f7a1ee4`](https://github.com/bec-project/bec_widgets/commit/f7a1ee49a42c58ba315c8957b45a80d862ffe745))
### Refactoring
- Don't import real widgets in client
([`8e51c1a`](https://github.com/bec-project/bec_widgets/commit/8e51c1adb6a7658c54846794cf97b774cbac2193))
## v3.5.0 (2026-04-14) ## v3.5.0 (2026-04-14)
### Bug Fixes ### Bug Fixes
+12 -18
View File
@@ -1,19 +1,13 @@
import os
import sys
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
if sys.platform.startswith("linux"):
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
if qt_platform != "offscreen":
os.environ["QT_QPA_PLATFORM"] = "xcb"
# Default QtAds configuration
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
QtAds.CDockManager.setConfigFlag(
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
)
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"] __all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
def __getattr__(name):
if name == "BECWidget":
from bec_widgets.utils.bec_widget import BECWidget
return BECWidget
if name in {"SafeSlot", "SafeProperty"}:
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
return {"SafeSlot": SafeSlot, "SafeProperty": SafeProperty}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+15
View File
@@ -0,0 +1,15 @@
import os
import sys
import bec_widgets.widgets.containers.qt_ads as QtAds
if sys.platform.startswith("linux"):
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
if qt_platform != "offscreen":
os.environ["QT_QPA_PLATFORM"] = "xcb"
# Default QtAds configuration
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
QtAds.CDockManager.setConfigFlag(
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
)
@@ -19,8 +19,8 @@ from qtpy.QtWidgets import QApplication
import bec_widgets import bec_widgets
from bec_widgets.applications.launch_window import LaunchWindow from bec_widgets.applications.launch_window import LaunchWindow
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_dispatcher import BECDispatcher from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.rpc_register import RPCRegister
logger = bec_logger.logger logger = bec_logger.logger
+1 -1
View File
@@ -20,13 +20,13 @@ from qtpy.QtWidgets import (
) )
import bec_widgets import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app
from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader from bec_widgets.utils.ui_loader import UILoader
+1
View File
@@ -0,0 +1 @@
from bec_widgets.cli.rpc import rpc_base
+127 -28
View File
@@ -13,7 +13,7 @@ from typing import Literal, Optional
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module
logger = bec_logger.logger logger = bec_logger.logger
@@ -62,29 +62,19 @@ _Widgets = {
try: try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module() plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(
f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !"
)
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass): for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase: if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name not in _Widgets:
_Widgets[plugin_name] = plugin_name
if plugin_name in globals(): if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning( logger.warning(
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!" f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
) )
continue continue
if plugin_name not in _overlap: else:
globals()[plugin_name] = plugin_class globals()[plugin_name] = plugin_class
Widgets = _WidgetsEnumType("Widgets", _Widgets)
except ImportError as e: except ImportError as e:
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}") logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
@@ -92,6 +82,8 @@ except ImportError as e:
class AdminView(RPCBase): class AdminView(RPCBase):
"""A view for administrators to change the current active experiment, manage messaging""" """A view for administrators to change the current active experiment, manage messaging"""
_IMPORT_MODULE = "bec_widgets.applications.views.admin_view.admin_view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -100,6 +92,8 @@ class AdminView(RPCBase):
class AutoUpdates(RPCBase): class AutoUpdates(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.containers.auto_update.auto_updates"
@property @property
@rpc_call @rpc_call
def enabled(self) -> "bool": def enabled(self) -> "bool":
@@ -136,6 +130,8 @@ class AutoUpdates(RPCBase):
class AvailableDeviceResources(RPCBase): class AvailableDeviceResources(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -156,6 +152,8 @@ class AvailableDeviceResources(RPCBase):
class BECDockArea(RPCBase): class BECDockArea(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.dock_area"
@rpc_call @rpc_call
def new( def new(
self, self,
@@ -391,6 +389,8 @@ class BECDockArea(RPCBase):
class BECMainWindow(RPCBase): class BECMainWindow(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.containers.main_window.main_window"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -413,6 +413,8 @@ class BECMainWindow(RPCBase):
class BECProgressBar(RPCBase): class BECProgressBar(RPCBase):
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template.""" """A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
_IMPORT_MODULE = "bec_widgets.widgets.progress.bec_progressbar.bec_progressbar"
@rpc_call @rpc_call
def set_value(self, value): def set_value(self, value):
""" """
@@ -486,6 +488,8 @@ class BECProgressBar(RPCBase):
class BECQueue(RPCBase): class BECQueue(RPCBase):
"""Widget to display the BEC queue.""" """Widget to display the BEC queue."""
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_queue.bec_queue"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -508,6 +512,8 @@ class BECQueue(RPCBase):
class BECShell(RPCBase): class BECShell(RPCBase):
"""A BecConsole pre-configured to run the BEC shell.""" """A BecConsole pre-configured to run the BEC shell."""
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -530,6 +536,8 @@ class BECShell(RPCBase):
class BECStatusBox(RPCBase): class BECStatusBox(RPCBase):
"""An autonomous widget to display the status of BEC services.""" """An autonomous widget to display the status of BEC services."""
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_status_box.bec_status_box"
@rpc_call @rpc_call
def get_server_state(self) -> "str": def get_server_state(self) -> "str":
""" """
@@ -565,6 +573,8 @@ class BECStatusBox(RPCBase):
class BaseROI(RPCBase): class BaseROI(RPCBase):
"""Base class for all Region of Interest (ROI) implementations.""" """Base class for all Region of Interest (ROI) implementations."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property @property
@rpc_call @rpc_call
def label(self) -> "str": def label(self) -> "str":
@@ -694,6 +704,8 @@ class BaseROI(RPCBase):
class BecConsole(RPCBase): class BecConsole(RPCBase):
"""A console widget with access to a shared registry of terminals, such that instances can be moved around.""" """A console widget with access to a shared registry of terminals, such that instances can be moved around."""
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -716,6 +728,8 @@ class BecConsole(RPCBase):
class CircularROI(RPCBase): class CircularROI(RPCBase):
"""Circular Region of Interest with center/diameter tracking and auto-labeling.""" """Circular Region of Interest with center/diameter tracking and auto-labeling."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property @property
@rpc_call @rpc_call
def label(self) -> "str": def label(self) -> "str":
@@ -843,6 +857,8 @@ class CircularROI(RPCBase):
class Curve(RPCBase): class Curve(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.curve"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -1009,6 +1025,8 @@ class Curve(RPCBase):
class DapComboBox(RPCBase): class DapComboBox(RPCBase):
"""Editable combobox listing the available DAP models.""" """Editable combobox listing the available DAP models."""
_IMPORT_MODULE = "bec_widgets.widgets.dap.dap_combo_box.dap_combo_box"
@rpc_call @rpc_call
def select_y_axis(self, y_axis: str): def select_y_axis(self, y_axis: str):
""" """
@@ -1040,6 +1058,8 @@ class DapComboBox(RPCBase):
class DeveloperView(RPCBase): class DeveloperView(RPCBase):
"""A view for users to write scripts and macros and execute them within the application.""" """A view for users to write scripts and macros and execute them within the application."""
_IMPORT_MODULE = "bec_widgets.applications.views.developer_view.developer_view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -1050,6 +1070,8 @@ class DeveloperView(RPCBase):
class DeviceBrowser(RPCBase): class DeviceBrowser(RPCBase):
"""DeviceBrowser is a widget that displays all available devices in the current BEC session.""" """DeviceBrowser is a widget that displays all available devices in the current BEC session."""
_IMPORT_MODULE = "bec_widgets.widgets.services.device_browser.device_browser"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -1072,6 +1094,8 @@ class DeviceBrowser(RPCBase):
class DeviceInitializationProgressBar(RPCBase): class DeviceInitializationProgressBar(RPCBase):
"""A progress bar that displays the progress of device initialization.""" """A progress bar that displays the progress of device initialization."""
_IMPORT_MODULE = "bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -1094,6 +1118,8 @@ class DeviceInitializationProgressBar(RPCBase):
class DeviceInputBase(RPCBase): class DeviceInputBase(RPCBase):
"""Mixin base class for device input widgets.""" """Mixin base class for device input widgets."""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_input.base_classes.device_input_base"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -1116,6 +1142,8 @@ class DeviceInputBase(RPCBase):
class DeviceManagerView(RPCBase): class DeviceManagerView(RPCBase):
"""A view for users to manage devices within the application.""" """A view for users to manage devices within the application."""
_IMPORT_MODULE = "bec_widgets.applications.views.device_manager_view.device_manager_view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -1126,6 +1154,8 @@ class DeviceManagerView(RPCBase):
class DockAreaView(RPCBase): class DockAreaView(RPCBase):
"""Modular dock area view for arranging and managing multiple dockable widgets.""" """Modular dock area view for arranging and managing multiple dockable widgets."""
_IMPORT_MODULE = "bec_widgets.applications.views.dock_area_view.dock_area_view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -1369,6 +1399,8 @@ class DockAreaView(RPCBase):
class DockAreaWidget(RPCBase): class DockAreaWidget(RPCBase):
"""Lightweight dock area that exposes the core Qt ADS docking helpers without any""" """Lightweight dock area that exposes the core Qt ADS docking helpers without any"""
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.basic_dock_area"
@rpc_call @rpc_call
def new( def new(
self, self,
@@ -1553,6 +1585,8 @@ class DockAreaWidget(RPCBase):
class EllipticalROI(RPCBase): class EllipticalROI(RPCBase):
"""Elliptical Region of Interest with centre/width/height tracking and auto-labelling.""" """Elliptical Region of Interest with centre/width/height tracking and auto-labelling."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property @property
@rpc_call @rpc_call
def label(self) -> "str": def label(self) -> "str":
@@ -1675,6 +1709,8 @@ class EllipticalROI(RPCBase):
class Heatmap(RPCBase): class Heatmap(RPCBase):
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis.""" """Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.heatmap.heatmap"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -2373,6 +2409,8 @@ class Heatmap(RPCBase):
class Image(RPCBase): class Image(RPCBase):
"""Image widget for displaying 2D data.""" """Image widget for displaying 2D data."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -2984,6 +3022,8 @@ class Image(RPCBase):
class ImageItem(RPCBase): class ImageItem(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image_item"
@property @property
@rpc_call @rpc_call
def color_map(self) -> "str": def color_map(self) -> "str":
@@ -3134,6 +3174,8 @@ class ImageItem(RPCBase):
class LaunchWindow(RPCBase): class LaunchWindow(RPCBase):
_IMPORT_MODULE = "bec_widgets.applications.launch_window"
@rpc_call @rpc_call
def show_launcher(self): def show_launcher(self):
""" """
@@ -3148,33 +3190,38 @@ class LaunchWindow(RPCBase):
class LogPanel(RPCBase): class LogPanel(RPCBase):
"""Displays a log panel""" """Live display of the BEC logs in a table view."""
_IMPORT_MODULE = "bec_widgets.widgets.utility.logpanel.logpanel"
@rpc_call @rpc_call
def set_plain_text(self, text: str) -> None: def remove(self):
""" """
Set the plain text of the widget. Cleanup the BECConnector
Args:
text (str): The text to set.
""" """
@rpc_call @rpc_call
def set_html_text(self, text: str) -> None: def attach(self):
"""
None
""" """
Set the HTML text of the widget.
Args: @rpc_call
text (str): The text to set. def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
""" """
class Minesweeper(RPCBase): ... class Minesweeper(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.games.minesweeper"
class MonacoDock(RPCBase): class MonacoDock(RPCBase):
"""MonacoDock is a dock widget that contains Monaco editor instances.""" """MonacoDock is a dock widget that contains Monaco editor instances."""
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_dock"
@rpc_call @rpc_call
def new( def new(
self, self,
@@ -3359,6 +3406,8 @@ class MonacoDock(RPCBase):
class MonacoWidget(RPCBase): class MonacoWidget(RPCBase):
"""A simple Monaco editor widget""" """A simple Monaco editor widget"""
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_widget"
@rpc_call @rpc_call
def set_text( def set_text(
self, text: "str", file_name: "str | None" = None, reset: "bool" = False self, text: "str", file_name: "str | None" = None, reset: "bool" = False
@@ -3533,6 +3582,8 @@ class MonacoWidget(RPCBase):
class MotorMap(RPCBase): class MotorMap(RPCBase):
"""Motor map widget for plotting motor positions in 2D including a trace of the last points.""" """Motor map widget for plotting motor positions in 2D including a trace of the last points."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.motor_map.motor_map"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -4003,6 +4054,8 @@ class MotorMap(RPCBase):
class MultiWaveform(RPCBase): class MultiWaveform(RPCBase):
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal.""" """MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.multi_waveform.multi_waveform"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -4462,6 +4515,8 @@ class MultiWaveform(RPCBase):
class PdfViewerWidget(RPCBase): class PdfViewerWidget(RPCBase):
"""A widget to display PDF documents with toolbar controls.""" """A widget to display PDF documents with toolbar controls."""
_IMPORT_MODULE = "bec_widgets.widgets.utility.pdf_viewer.pdf_viewer"
@rpc_call @rpc_call
def load_pdf(self, file_path: str): def load_pdf(self, file_path: str):
""" """
@@ -4593,6 +4648,10 @@ class PdfViewerWidget(RPCBase):
class PositionIndicator(RPCBase): class PositionIndicator(RPCBase):
"""Display a position within a defined range, e.g. motor limits.""" """Display a position within a defined range, e.g. motor limits."""
_IMPORT_MODULE = (
"bec_widgets.widgets.control.device_control.position_indicator.position_indicator"
)
@rpc_call @rpc_call
def set_value(self, position: float): def set_value(self, position: float):
""" """
@@ -4658,6 +4717,10 @@ class PositionIndicator(RPCBase):
class PositionerBox(RPCBase): class PositionerBox(RPCBase):
"""Simple Widget to control a positioner in box form""" """Simple Widget to control a positioner in box form"""
_IMPORT_MODULE = (
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box"
)
@rpc_call @rpc_call
def set_positioner(self, positioner: "str | Positioner"): def set_positioner(self, positioner: "str | Positioner"):
""" """
@@ -4690,6 +4753,8 @@ class PositionerBox(RPCBase):
class PositionerBox2D(RPCBase): class PositionerBox2D(RPCBase):
"""Simple Widget to control two positioners in box form""" """Simple Widget to control two positioners in box form"""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d"
@rpc_call @rpc_call
def set_positioner_hor(self, positioner: "str | Positioner"): def set_positioner_hor(self, positioner: "str | Positioner"):
""" """
@@ -4759,6 +4824,8 @@ class PositionerBox2D(RPCBase):
class PositionerControlLine(RPCBase): class PositionerControlLine(RPCBase):
"""A widget that controls a single device.""" """A widget that controls a single device."""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line"
@rpc_call @rpc_call
def set_positioner(self, positioner: "str | Positioner"): def set_positioner(self, positioner: "str | Positioner"):
""" """
@@ -4791,6 +4858,8 @@ class PositionerControlLine(RPCBase):
class PositionerGroup(RPCBase): class PositionerGroup(RPCBase):
"""Simple Widget to control a positioner in box form""" """Simple Widget to control a positioner in box form"""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_group.positioner_group"
@rpc_call @rpc_call
def set_positioners(self, device_names: "str"): def set_positioners(self, device_names: "str"):
""" """
@@ -4822,6 +4891,8 @@ class PositionerGroup(RPCBase):
class RectangularROI(RPCBase): class RectangularROI(RPCBase):
"""Defines a rectangular Region of Interest (ROI) with additional functionality.""" """Defines a rectangular Region of Interest (ROI) with additional functionality."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property @property
@rpc_call @rpc_call
def label(self) -> "str": def label(self) -> "str":
@@ -4951,6 +5022,8 @@ class RectangularROI(RPCBase):
class ResumeButton(RPCBase): class ResumeButton(RPCBase):
"""A button that continue scan queue.""" """A button that continue scan queue."""
_IMPORT_MODULE = "bec_widgets.widgets.control.buttons.button_resume.button_resume"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -4971,6 +5044,8 @@ class ResumeButton(RPCBase):
class Ring(RPCBase): class Ring(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring"
@rpc_call @rpc_call
def set_value(self, value: "int | float"): def set_value(self, value: "int | float"):
""" """
@@ -5064,6 +5139,8 @@ class Ring(RPCBase):
class RingProgressBar(RPCBase): class RingProgressBar(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -5143,12 +5220,14 @@ class RingProgressBar(RPCBase):
class SBBMonitor(RPCBase): class SBBMonitor(RPCBase):
"""A widget to display the SBB monitor website.""" """A widget to display the SBB monitor website."""
... _IMPORT_MODULE = "bec_widgets.widgets.editors.sbb_monitor.sbb_monitor"
class ScanControl(RPCBase): class ScanControl(RPCBase):
"""Widget to submit new scans to the queue.""" """Widget to submit new scans to the queue."""
_IMPORT_MODULE = "bec_widgets.widgets.control.scan_control.scan_control"
@rpc_call @rpc_call
def attach(self): def attach(self):
""" """
@@ -5172,6 +5251,8 @@ class ScanControl(RPCBase):
class ScanProgressBar(RPCBase): class ScanProgressBar(RPCBase):
"""Widget to display a progress bar that is hooked up to the scan progress of a scan.""" """Widget to display a progress bar that is hooked up to the scan progress of a scan."""
_IMPORT_MODULE = "bec_widgets.widgets.progress.scan_progressbar.scan_progressbar"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -5194,6 +5275,8 @@ class ScanProgressBar(RPCBase):
class ScatterCurve(RPCBase): class ScatterCurve(RPCBase):
"""Scatter curve item for the scatter waveform widget.""" """Scatter curve item for the scatter waveform widget."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_curve"
@property @property
@rpc_call @rpc_call
def color_map(self) -> "str": def color_map(self) -> "str":
@@ -5203,6 +5286,8 @@ class ScatterCurve(RPCBase):
class ScatterWaveform(RPCBase): class ScatterWaveform(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_waveform"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -5670,6 +5755,8 @@ class ScatterWaveform(RPCBase):
class SignalLabel(RPCBase): class SignalLabel(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.utility.signal_label.signal_label"
@property @property
@rpc_call @rpc_call
def custom_label(self) -> "str": def custom_label(self) -> "str":
@@ -5814,6 +5901,8 @@ class SignalLabel(RPCBase):
class TextBox(RPCBase): class TextBox(RPCBase):
"""A widget that displays text in plain and HTML format""" """A widget that displays text in plain and HTML format"""
_IMPORT_MODULE = "bec_widgets.widgets.editors.text_box.text_box"
@rpc_call @rpc_call
def set_plain_text(self, text: str) -> None: def set_plain_text(self, text: str) -> None:
""" """
@@ -5836,6 +5925,8 @@ class TextBox(RPCBase):
class ViewBase(RPCBase): class ViewBase(RPCBase):
"""Wrapper for a content widget used inside the main app's stacked view.""" """Wrapper for a content widget used inside the main app's stacked view."""
_IMPORT_MODULE = "bec_widgets.applications.views.view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -5846,6 +5937,8 @@ class ViewBase(RPCBase):
class Waveform(RPCBase): class Waveform(RPCBase):
"""Widget for plotting waveforms.""" """Widget for plotting waveforms."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.waveform"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -6424,6 +6517,8 @@ class Waveform(RPCBase):
class WaveformViewInline(RPCBase): class WaveformViewInline(RPCBase):
_IMPORT_MODULE = "bec_widgets.applications.views.view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -6432,6 +6527,8 @@ class WaveformViewInline(RPCBase):
class WaveformViewPopup(RPCBase): class WaveformViewPopup(RPCBase):
_IMPORT_MODULE = "bec_widgets.applications.views.view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -6442,6 +6539,8 @@ class WaveformViewPopup(RPCBase):
class WebsiteWidget(RPCBase): class WebsiteWidget(RPCBase):
"""A simple widget to display a website""" """A simple widget to display a website"""
_IMPORT_MODULE = "bec_widgets.widgets.editors.website.website"
@rpc_call @rpc_call
def set_url(self, url: str) -> None: def set_url(self, url: str) -> None:
""" """
@@ -25,8 +25,8 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.rpc_widget_handler import widget_handler
from bec_widgets.utils.widget_io import WidgetHierarchy as wh from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
-12
View File
@@ -1,13 +1 @@
from qtpy.QtWebEngineWidgets import QWebEngineView
from .bec_connector import BECConnector, ConnectionConfig
from .bec_dispatcher import BECDispatcher
from .bec_table import BECTable
from .colors import Colors
from .container_utils import WidgetContainerUtils
from .crosshair import Crosshair
from .entry_validator import EntryValidator
from .layout_manager import GridLayoutManager
from .rpc_decorator import register_rpc_methods, rpc_public
from .ui_loader import UILoader
from .validator_delegate import DoubleValidationDelegate
+1 -1
View File
@@ -15,9 +15,9 @@ from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
from bec_widgets.utils.name_utils import sanitize_namespace from bec_widgets.utils.name_utils import sanitize_namespace
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
+1 -1
View File
@@ -10,11 +10,11 @@ from qtpy.QtGui import QFont, QPixmap
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
import bec_widgets.widgets.containers.qt_ads as QtAds import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.busy_loader import install_busy_loader from bec_widgets.utils.busy_loader import install_busy_loader
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
@@ -7,6 +7,7 @@ import inspect
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import get_overloads
import black import black
import isort import isort
@@ -18,20 +19,6 @@ from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
logger = bec_logger.logger logger = bec_logger.logger
if sys.version_info >= (3, 11):
from typing import get_overloads
else:
print(
"Python version is less than 3.11, using dummy function for get_overloads. "
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
)
def get_overloads(_obj):
"""
Dummy function for Python versions before 3.11.
"""
return []
class ClientGenerator: class ClientGenerator:
def __init__(self, base=False): def __init__(self, base=False):
@@ -54,7 +41,7 @@ from __future__ import annotations
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""} {"from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger logger = bec_logger.logger
@@ -111,27 +98,19 @@ _Widgets = {
self.content += """ self.content += """
try: try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module() plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass): for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase: if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name not in _Widgets:
_Widgets[plugin_name] = plugin_name
if plugin_name in globals(): if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning( logger.warning(
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!" f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
) )
continue continue
if plugin_name not in _overlap: else:
globals()[plugin_name] = plugin_class globals()[plugin_name] = plugin_class
Widgets = _WidgetsEnumType("Widgets", _Widgets)
except ImportError as e: except ImportError as e:
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}") logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
""" """
@@ -146,12 +125,8 @@ except ImportError as e:
class_name = cls.__name__ class_name = cls.__name__
if class_name == "BECDockArea": self.content += f"""
self.content += f""" class {class_name}(RPCBase):\n"""
class {class_name}(RPCBase):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
if cls.__doc__: if cls.__doc__:
# We only want the first line of the docstring # We only want the first line of the docstring
@@ -162,13 +137,9 @@ class {class_name}(RPCBase):"""
else: else:
class_docs = cls.__doc__.split("\n")[1] class_docs = cls.__doc__.split("\n")[1]
self.content += f""" self.content += f"""
\"\"\"{class_docs}\"\"\" \"\"\"{class_docs}\"\"\"\n"""
"""
user_access_entries = self._get_user_access_entries(cls) user_access_entries = self._get_user_access_entries(cls)
if not user_access_entries: self.content += f' _IMPORT_MODULE="{cls.__module__}"\n'
self.content += """...
"""
for method_entry in user_access_entries: for method_entry in user_access_entries:
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry) method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
if obj is None: if obj is None:
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Iterable
from bec_lib.plugin_helper import _get_available_plugins from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from bec_widgets.utils import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
+2 -2
View File
@@ -14,11 +14,11 @@ from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from redis.exceptions import RedisError from redis.exceptions import RedisError
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import ErrorPopupUtility from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.screen_utils import apply_window_geometry from bec_widgets.utils.screen_utils import apply_window_geometry
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
+6 -6
View File
@@ -26,7 +26,7 @@ from qtpy.QtWidgets import (
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils import BECConnector from bec_widgets.utils.bec_connector import BECConnector
logger = bec_logger.logger logger = bec_logger.logger
@@ -418,7 +418,7 @@ class WidgetHierarchy:
only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget. only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget.
show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget. show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget.
""" """
from bec_widgets.utils import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.widgets.plots.waveform.waveform import Waveform from bec_widgets.widgets.plots.waveform.waveform import Waveform
for node in WidgetHierarchy.iter_widget_tree( for node in WidgetHierarchy.iter_widget_tree(
@@ -468,7 +468,7 @@ class WidgetHierarchy:
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
from bec_widgets.utils import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.widgets.plots.plot_base import PlotBase from bec_widgets.widgets.plots.plot_base import PlotBase
# 1) Gather ALL QWidget-based BECConnector objects # 1) Gather ALL QWidget-based BECConnector objects
@@ -534,7 +534,7 @@ class WidgetHierarchy:
Returns: Returns:
The nearest ancestor that is a BECConnector, or None if not found. The nearest ancestor that is a BECConnector, or None if not found.
""" """
from bec_widgets.utils import BECConnector from bec_widgets.utils.bec_connector import BECConnector
# Guard against deleted/invalid Qt wrappers # Guard against deleted/invalid Qt wrappers
if not shb.isValid(widget): if not shb.isValid(widget):
@@ -636,7 +636,7 @@ class WidgetHierarchy:
Return all BECConnector instances whose closest BECConnector ancestor is the given widget, Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
including the widget itself if it is a BECConnector. including the widget itself if it is a BECConnector.
""" """
from bec_widgets.utils import BECConnector from bec_widgets.utils.bec_connector import BECConnector
connectors: list[BECConnector] = [] connectors: list[BECConnector] = []
if isinstance(widget, BECConnector): if isinstance(widget, BECConnector):
@@ -664,7 +664,7 @@ class WidgetHierarchy:
return None return None
try: try:
from bec_widgets.utils import BECConnector # local import to avoid cycles from bec_widgets.utils.bec_connector import BECConnector # local import to avoid cycles
is_bec_target = False is_bec_target = False
if isinstance(ancestor_class, str): if isinstance(ancestor_class, str):
@@ -13,9 +13,9 @@ from shiboken6 import isValid
import bec_widgets.widgets.containers.qt_ads as QtAds import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets import BECWidget, SafeSlot from bec_widgets import BECWidget, SafeSlot
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.property_editor import PropertyEditor from bec_widgets.utils.property_editor import PropertyEditor
from bec_widgets.utils.rpc_widget_handler import widget_handler
from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.qt_ads import ( from bec_widgets.widgets.containers.qt_ads import (
CDockAreaWidget, CDockAreaWidget,
@@ -20,10 +20,10 @@ from qtpy.QtWidgets import (
import bec_widgets.widgets.containers.qt_ads as QtAds import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets import BECWidget, SafeProperty, SafeSlot from bec_widgets import BECWidget, SafeProperty, SafeSlot
from bec_widgets.applications.views.view import ViewTourSteps from bec_widgets.applications.views.view import ViewTourSteps
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.rpc_widget_handler import widget_handler
from bec_widgets.utils.toolbars.actions import ( from bec_widgets.utils.toolbars.actions import (
ExpandableMenuAction, ExpandableMenuAction,
MaterialIconAction, MaterialIconAction,
@@ -79,7 +79,7 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel import LogPanel from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger logger = bec_logger.logger
@@ -235,11 +235,8 @@ class BECDockArea(DockAreaWidget):
def _load_initial_profile(self, name: str) -> None: def _load_initial_profile(self, name: str) -> None:
"""Load the initial profile.""" """Load the initial profile."""
self.load_profile(name) self.load_profile(name)
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.blockSignals(True)
if not self._empty_profile_active: if not self._empty_profile_active:
combo.setCurrentText(name) self._set_workspace_combo_text_silent(name)
combo.blockSignals(False)
def _start_empty_workspace(self) -> None: def _start_empty_workspace(self) -> None:
""" """
@@ -376,6 +373,7 @@ class BECDockArea(DockAreaWidget):
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"), "bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"), "log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"), "sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel", "LogPanel"),
} }
# Create expandable menu actions (original behavior) # Create expandable menu actions (original behavior)
@@ -487,9 +485,7 @@ class BECDockArea(DockAreaWidget):
# first two items not needed for this part # first two items not needed for this part
for key, (_, _, widget_type) in mapping.items(): for key, (_, _, widget_type) in mapping.items():
act = menu.actions[key].action act = menu.actions[key].action
if widget_type == "LogPanel": if key == "terminal":
act.setEnabled(False) # keep disabled per issue #644
elif key == "terminal":
act.triggered.connect( act.triggered.connect(
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None) lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
) )
@@ -510,10 +506,7 @@ class BECDockArea(DockAreaWidget):
for action_id, (_, _, widget_type) in mapping.items(): for action_id, (_, _, widget_type) in mapping.items():
flat_action_id = f"flat_{action_id}" flat_action_id = f"flat_{action_id}"
flat_action = self.toolbar.components.get_action(flat_action_id).action flat_action = self.toolbar.components.get_action(flat_action_id).action
if widget_type == "LogPanel": flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
flat_action.setEnabled(False) # keep disabled per issue #644
else:
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"]) _connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"]) _connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
@@ -673,6 +666,14 @@ class BECDockArea(DockAreaWidget):
combo = self.toolbar.components.get_action("workspace_combo").widget combo = self.toolbar.components.get_action("workspace_combo").widget
combo.refresh_profiles(active_profile=name) combo.refresh_profiles(active_profile=name)
def _set_workspace_combo_text_silent(self, text: str) -> None:
combo = self.toolbar.components.get_action("workspace_combo").widget
was_blocked = combo.blockSignals(True)
try:
combo.setCurrentText(text)
finally:
combo.blockSignals(was_blocked)
def _enter_empty_profile_state(self) -> None: def _enter_empty_profile_state(self) -> None:
""" """
Switch to the transient empty workspace state. Switch to the transient empty workspace state.
@@ -800,7 +801,6 @@ class BECDockArea(DockAreaWidget):
self._pending_autosave_skip = (current_profile, name) self._pending_autosave_skip = (current_profile, name)
else: else:
self._pending_autosave_skip = None self._pending_autosave_skip = None
workspace_combo.setCurrentText(name)
self._finalize_profile_change(name, namespace) self._finalize_profile_change(name, namespace)
@SafeSlot() @SafeSlot()
@@ -24,19 +24,9 @@ class ProfileComboBox(QComboBox):
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None: def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
self._quick_provider = provider self._quick_provider = provider
def refresh_profiles( def _refresh_profiles(
self, active_profile: str | None = None, show_empty_profile: bool = False self, current_text: str, active_profile: str | None = None, show_empty_profile: bool = False
) -> None: ) -> None:
"""
Refresh the profile list and ensure the active profile is visible.
Args:
active_profile(str | None): The currently active profile name.
show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry.
"""
current_text = active_profile or self.currentText()
self.blockSignals(True)
self.clear() self.clear()
quick_profiles = self._quick_provider() quick_profiles = self._quick_provider()
@@ -103,7 +93,6 @@ class ProfileComboBox(QComboBox):
if index >= 0: if index >= 0:
self.setCurrentIndex(index) self.setCurrentIndex(index)
self.blockSignals(False)
if active_profile and self.currentText() != active_profile: if active_profile and self.currentText() != active_profile:
idx = self.findText(active_profile) idx = self.findText(active_profile)
if idx >= 0: if idx >= 0:
@@ -115,6 +104,24 @@ class ProfileComboBox(QComboBox):
else: else:
self.setToolTip("") self.setToolTip("")
def refresh_profiles(
self, active_profile: str | None = None, show_empty_profile: bool = False
) -> None:
"""
Refresh the profile list and ensure the active profile is visible.
Args:
active_profile(str | None): The currently active profile name.
show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry.
"""
current_text = active_profile or self.currentText()
was_blocked = self.blockSignals(True)
try:
self._refresh_profiles(current_text, active_profile, show_empty_profile)
finally:
self.blockSignals(was_blocked)
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle: def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
""" """
@@ -122,6 +129,7 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
Args: Args:
components (ToolbarComponents): The components to be added to the bundle. components (ToolbarComponents): The components to be added to the bundle.
enable_tools(bool): If True, show the workspace management tools; otherwise, only show the profile combo.
Returns: Returns:
ToolbarBundle: The workspace toolbar bundle. ToolbarBundle: The workspace toolbar bundle.
@@ -22,7 +22,7 @@ from qtpy.QtWidgets import (
) )
from typeguard import typechecked from typeguard import typechecked
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils.rpc_widget_handler import widget_handler
class LayoutManagerWidget(QWidget): class LayoutManagerWidget(QWidget):
@@ -28,7 +28,7 @@ from qtpy.QtCore import QObject, QTimer
from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidget from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidget
from bec_widgets import SafeProperty, SafeSlot from bec_widgets import SafeProperty, SafeSlot
from bec_widgets.utils import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
@@ -18,10 +18,10 @@ from qtpy.QtWidgets import (
) )
import bec_widgets import bec_widgets
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import ( from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
BECNotificationBroker, BECNotificationBroker,
@@ -11,9 +11,9 @@ from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QDoubleValidator from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import apply_theme, get_accent_colors from bec_widgets.utils.colors import apply_theme, get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import ( from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
DeviceUpdateUIComponents, DeviceUpdateUIComponents,
PositionerBoxBase, PositionerBoxBase,
@@ -12,9 +12,9 @@ from qtpy.QtCore import Signal
from qtpy.QtGui import QDoubleValidator from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import ( from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
DeviceUpdateUIComponents, DeviceUpdateUIComponents,
PositionerBoxBase, PositionerBoxBase,
@@ -7,7 +7,7 @@ from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from pydantic import field_validator from pydantic import field_validator
from bec_widgets.utils import ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.filter_io import FilterIO from bec_widgets.utils.filter_io import FilterIO
@@ -3,7 +3,7 @@ from bec_lib.device import Signal
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import Property from qtpy.QtCore import Property
from bec_widgets.utils import ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO from bec_widgets.utils.filter_io import FilterIO
@@ -19,7 +19,7 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.utils import ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme, get_accent_colors from bec_widgets.utils.colors import apply_theme, get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
@@ -5,10 +5,10 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal from qtpy.QtCore import Signal
from qtpy.QtWidgets import QPushButton, QSizePolicy, QTreeWidgetItem, QVBoxLayout, QWidget from qtpy.QtWidgets import QPushButton, QSizePolicy, QTreeWidgetItem, QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.ui_loader import UILoader
logger = bec_logger.logger logger = bec_logger.logger
+2 -2
View File
@@ -19,8 +19,8 @@ from scipy.interpolate import (
from scipy.spatial import cKDTree from scipy.spatial import cKDTree
from toolz import partition from toolz import partition
from bec_widgets.utils import Colors
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.actions import MaterialIconAction
@@ -611,7 +611,7 @@ class Heatmap(ImageBase):
scan_msg = self.scan_item.status_message scan_msg = self.scan_item.status_message
elif hasattr(self.scan_item, "metadata"): elif hasattr(self.scan_item, "metadata"):
metadata = self.scan_item.metadata["bec"] metadata = self.scan_item.metadata["bec"]
status = metadata["exit_status"] status = metadata["status"]
scan_id = metadata["scan_id"] scan_id = metadata["scan_id"]
scan_name = metadata["scan_name"] scan_name = metadata["scan_name"]
scan_type = metadata["scan_type"] scan_type = metadata["scan_type"]
@@ -4,9 +4,9 @@ import os
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.utils.ui_loader import UILoader
class HeatmapSettings(SettingWidget): class HeatmapSettings(SettingWidget):
+1 -1
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QTimer from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from bec_widgets.utils import ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.colors import Colors, apply_theme from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.plots.image.image_base import ImageBase from bec_widgets.widgets.plots.image.image_base import ImageBase
@@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError
from qtpy.QtCore import QPointF, Signal, SignalInstance from qtpy.QtCore import QPointF, Signal, SignalInstance
from qtpy.QtWidgets import QDialog, QVBoxLayout from qtpy.QtWidgets import QDialog, QVBoxLayout
from bec_widgets.utils import Colors from bec_widgets.utils.colors import Colors
from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.side_panel import SidePanel from bec_widgets.utils.side_panel import SidePanel
@@ -9,7 +9,8 @@ from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Signal from qtpy.QtCore import Signal
from qtpy.QtGui import QTransform from qtpy.QtGui import QTransform
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.widgets.plots.image.image_processor import ( from bec_widgets.widgets.plots.image.image_processor import (
ImageProcessor, ImageProcessor,
ImageStats, ImageStats,
@@ -20,7 +20,8 @@ from qtpy.QtWidgets import (
) )
from bec_widgets import BECWidget from bec_widgets import BECWidget
from bec_widgets.utils import BECDispatcher, ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.toolbars.actions import WidgetAction from bec_widgets.utils.toolbars.actions import WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
@@ -10,8 +10,8 @@ from qtpy.QtCore import Signal
from qtpy.QtGui import QColor from qtpy.QtGui import QColor
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
from bec_widgets.utils import Colors, ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
@@ -2,9 +2,9 @@ import os
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
@@ -10,7 +10,8 @@ from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Signal from qtpy.QtCore import Signal
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from bec_widgets.utils import Colors, ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.side_panel import SidePanel from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
@@ -2,9 +2,9 @@ import os
from qtpy.QtWidgets import QVBoxLayout, QWidget from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
+3 -1
View File
@@ -8,8 +8,10 @@ from bec_lib import bec_logger
from qtpy.QtCore import QPoint, QPointF, Qt, Signal from qtpy.QtCore import QPoint, QPointF, Qt, Signal
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
from bec_widgets.utils import ConnectionConfig, Crosshair, EntryValidator from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.crosshair import Crosshair
from bec_widgets.utils.entry_validator import EntryValidator
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.fps_counter import FPSCounter from bec_widgets.utils.fps_counter import FPSCounter
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
+1 -1
View File
@@ -10,7 +10,7 @@ from qtpy import QtCore
from qtpy.QtCore import QObject, Signal from qtpy.QtCore import QObject, Signal
from bec_widgets import SafeProperty from bec_widgets import SafeProperty
from bec_widgets.utils import BECConnector, ConnectionConfig from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import Colors from bec_widgets.utils.colors import Colors
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -8,7 +8,8 @@ from bec_lib import bec_logger
from pydantic import BaseModel, Field, ValidationError, field_validator from pydantic import BaseModel, Field, ValidationError, field_validator
from qtpy import QtCore from qtpy import QtCore
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import Colors
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
@@ -7,7 +7,8 @@ from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import QTimer, Signal from qtpy.QtCore import QTimer, Signal
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
from bec_widgets.utils import Colors, ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
@@ -2,9 +2,9 @@ import os
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.utils.ui_loader import UILoader
class ScatterCurveSettings(SettingWidget): class ScatterCurveSettings(SettingWidget):
@@ -2,9 +2,9 @@ import os
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
+2 -1
View File
@@ -8,7 +8,8 @@ from bec_lib import bec_logger
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from qtpy import QtCore from qtpy import QtCore
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import Colors
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots.waveform.waveform import Waveform from bec_widgets.widgets.plots.waveform.waveform import Waveform
@@ -50,9 +50,10 @@ from qtpy.QtWidgets import (
) )
from bec_widgets import SafeSlot from bec_widgets import SafeSlot
from bec_widgets.utils import ConnectionConfig, EntryValidator from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors from bec_widgets.utils.colors import Colors
from bec_widgets.utils.entry_validator import EntryValidator
from bec_widgets.utils.toolbars.actions import WidgetAction from bec_widgets.utils.toolbars.actions import WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
@@ -25,7 +25,7 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.utils import ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
from bec_widgets.utils.colors import Colors, apply_theme from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.container_utils import WidgetContainerUtils
@@ -6,8 +6,8 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import QPointF, QSize, Qt from qtpy.QtCore import QPointF, QSize, Qt
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils import Colors
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.actions import MaterialIconAction
@@ -19,9 +19,9 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
@@ -12,10 +12,10 @@ from pyqtgraph import SignalProxy
from qtpy.QtCore import QThreadPool, Signal from qtpy.QtCore import QThreadPool, Signal
from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.ui_loader import UILoader from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
@@ -1,58 +0,0 @@
"""Utilities for filtering and formatting in the LogPanel"""
from __future__ import annotations
import re
from collections import deque
from typing import Callable, Iterator
from bec_lib.logger import LogLevel
from bec_lib.messages import LogMessage
from qtpy.QtCore import QDateTime
LinesHtmlFormatter = Callable[[deque[LogMessage]], Iterator[str]]
LineFormatter = Callable[[LogMessage], str]
LineFilter = Callable[[LogMessage], bool] | None
ANSI_ESCAPE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def replace_escapes(s: str):
s = ANSI_ESCAPE_REGEX.sub("", s)
return s.replace(" ", "&nbsp;").replace("\n", "<br />").replace("\t", " ")
def level_filter(msg: LogMessage, thresh: int):
return LogLevel[msg.content["log_type"].upper()].value >= thresh
def noop_format(line: LogMessage):
_textline = line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
return replace_escapes(_textline.strip()) + "<br />"
def simple_color_format(line: LogMessage, colors: dict[LogLevel, str]):
color = colors.get(LogLevel[line.content["log_type"].upper()]) or colors[LogLevel.INFO]
return f'<font color="{color}">{noop_format(line)}</font>'
def create_formatter(line_format: LineFormatter, line_filter: LineFilter) -> LinesHtmlFormatter:
def _formatter(data: deque[LogMessage]):
if line_filter is not None:
return (line_format(line) for line in data if line_filter(line))
else:
return (line_format(line) for line in data)
return _formatter
def log_txt(line):
return line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
def log_time(line):
return QDateTime.fromMSecsSinceEpoch(int(line.log_msg["record"]["time"]["timestamp"] * 1000))
def log_svc(line):
return line.log_msg["service_name"]
@@ -30,7 +30,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML return DOM_XML
def group(self): def group(self):
return "BEC Services" return ""
def icon(self): def icon(self):
return designer_material_icon(LogPanel.ICON_NAME) return designer_material_icon(LogPanel.ICON_NAME)
@@ -51,7 +51,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "LogPanel" return "LogPanel"
def toolTip(self): def toolTip(self):
return "Displays a log panel" return "LogPanel"
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
+432 -329
View File
@@ -2,21 +2,31 @@
from __future__ import annotations from __future__ import annotations
import operator
import os import os
import re
from collections import deque from collections import deque
from functools import partial, reduce from dataclasses import dataclass
from re import Pattern from functools import partial
from typing import TYPE_CHECKING, Literal from typing import Iterable, Literal
from bec_lib.client import BECClient from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import LogLevel, bec_logger from bec_lib.logger import LogLevel, bec_logger
from bec_lib.messages import LogMessage, StatusMessage from bec_lib.messages import LogMessage, StatusMessage
from pyqtgraph import SignalProxy from bec_qthemes import material_icon
from qtpy.QtCore import QDateTime, QObject, Qt, Signal from qtpy.QtCore import Signal # type: ignore
from qtpy.QtGui import QFont from qtpy.QtCore import (
QAbstractTableModel,
QCoreApplication,
QDateTime,
QModelIndex,
QObject,
QPersistentModelIndex,
QSize,
QSortFilterProxyModel,
Qt,
QTimer,
)
from qtpy.QtGui import QColor
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, QApplication,
QCheckBox, QCheckBox,
@@ -25,204 +35,414 @@ from qtpy.QtWidgets import (
QDialog, QDialog,
QGridLayout, QGridLayout,
QHBoxLayout, QHBoxLayout,
QHeaderView,
QLabel, QLabel,
QLineEdit, QLineEdit,
QPushButton, QPushButton,
QScrollArea, QSizePolicy,
QTextEdit, QTableView,
QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from thefuzz import fuzz
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.colors import apply_theme, get_theme_palette from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.editors.text_box.text_box import TextBox
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECServiceStatusMixin
from bec_widgets.widgets.utility.logpanel._util import (
LineFilter,
LineFormatter,
LinesHtmlFormatter,
create_formatter,
level_filter,
log_svc,
log_time,
log_txt,
noop_format,
simple_color_format,
)
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import SignalInstance
logger = bec_logger.logger logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
# TODO: improve log color handling _DEFAULT_LOG_COLORS = {
DEFAULT_LOG_COLORS = { LogLevel.INFO.name: QColor("#FFFFFF"),
LogLevel.INFO: "#FFFFFF", LogLevel.SUCCESS.name: QColor("#00FF00"),
LogLevel.SUCCESS: "#00FF00", LogLevel.WARNING.name: QColor("#FFCC00"),
LogLevel.WARNING: "#FFCC00", LogLevel.ERROR.name: QColor("#FF0000"),
LogLevel.ERROR: "#FF0000", LogLevel.DEBUG.name: QColor("#0000CC"),
LogLevel.DEBUG: "#0000CC",
} }
@dataclass(frozen=True)
class _Constants:
FUZZ_THRESHOLD = 80
UPDATE_INTERVAL_MS = 200
headers = ["level", "timestamp", "service_name", "message", "function"]
_CONST = _Constants()
class TimestampUpdate:
def __init__(self, value: QDateTime | None, update_type: Literal["start", "end"]) -> None:
self.value = value
self.update_type = update_type
class BecLogsQueue(BECConnector, QObject): class BecLogsQueue(BECConnector, QObject):
"""Manages getting logs from BEC Redis and formatting them for display""" """Manages getting logs from BEC Redis and formatting them for display"""
RPC = False RPC = False
new_message = Signal() new_messages = Signal()
paused = Signal(bool)
_instance: BecLogsQueue | None = None
def __init__( @classmethod
self, def instance(cls):
parent: QObject | None, if cls._instance is None:
maxlen: int = 1000, cls._instance = cls(QCoreApplication.instance())
line_formatter: LineFormatter = noop_format, return cls._instance
**kwargs,
) -> None: def __init__(self, parent: QObject | None, maxlen: int = 2500, **kwargs) -> None:
if BecLogsQueue._instance:
raise RuntimeError("Create no more than one BecLogsQueue - use BecLogsQueue.instance()")
super().__init__(parent=parent, **kwargs) super().__init__(parent=parent, **kwargs)
self._timestamp_start: QDateTime | None = None
self._timestamp_end: QDateTime | None = None
self._max_length = maxlen self._max_length = maxlen
self._data: deque[LogMessage] = deque([], self._max_length) self._paused = False
self._display_queue: deque[str] = deque([], self._max_length) self._data = deque(
self._log_level: str | None = None (
self._search_query: Pattern | str | None = None item["data"]
self._selected_services: set[str] | None = None for item in self.bec_dispatcher.client.connector.xread(
self._set_formatter_and_update_filter(line_formatter) MessageEndpoints.log(), count=self._max_length, id="0"
# instance attribute still accessible after c++ object is deleted, so the callback can be unregistered )
),
maxlen=self._max_length,
)
self._incoming: deque[LogMessage] = deque([], maxlen=self._max_length)
self.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log()) self.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log())
self._update_timer = QTimer(self, interval=_CONST.UPDATE_INTERVAL_MS)
self._update_timer.timeout.connect(self._proc_update)
QCoreApplication.instance().aboutToQuit.connect(self.cleanup) # type: ignore
self._update_timer.start()
def __len__(self):
return len(self._data)
@SafeSlot()
def toggle_pause(self):
self._paused = not self._paused
self.paused.emit(self._paused)
def row_data(self, index: int) -> LogMessage | None:
if index < 0 or index > (len(self._data) - 1):
return None
return self._data[index]
def cell_data(self, row: int, key: str):
if key == "level":
return self._data[row].log_type.upper()
msg_item = self._data[row].log_msg
if isinstance(msg_item, str):
return msg_item
if key == "service_name":
return msg_item.get(key)
elif key in ["service_name", "function", "message"]:
return msg_item.get("record", {}).get(key)
elif key == "timestamp":
return msg_item.get("record", {}).get("time", {}).get("repr")
def log_timestamp(self, row: int) -> float:
msg_item = self._data[row].log_msg
if isinstance(msg_item, str):
return 0
return msg_item.get("record", {}).get("time", {}).get("timestamp")
def cleanup(self, *_): def cleanup(self, *_):
"""Stop listening to the Redis log stream""" """Stop listening to the Redis log stream"""
self.bec_dispatcher.disconnect_slot( self.bec_dispatcher.disconnect_slot(
self._process_incoming_log_msg, [MessageEndpoints.log()] self._process_incoming_log_msg, [MessageEndpoints.log()]
) )
self._update_timer.stop()
BecLogsQueue._instance = None
@SafeSlot(verify_sender=True) @SafeSlot(verify_sender=True)
def _process_incoming_log_msg(self, msg: dict, _metadata: dict): def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
try: try:
_msg = LogMessage(**msg) _msg = LogMessage(**msg)
self._data.append(_msg) self._incoming.append(_msg)
if self.filter is None or self.filter(_msg):
self._display_queue.append(self._line_formatter(_msg))
self.new_message.emit()
except Exception as e: except Exception as e:
if "Internal C++ object (BecLogsQueue) already deleted." in e.args: if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
return return
logger.warning(f"Error in LogPanel incoming message callback: {e}") logger.warning(f"Error in LogPanel incoming message callback: {e}")
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format): @SafeSlot(verify_sender=True)
self._line_formatter: LineFormatter = line_formatter def _proc_update(self):
self._queue_formatter: LinesHtmlFormatter = create_formatter( if self._paused or len(self._incoming) == 0:
self._line_formatter, self.filter
)
def _combine_filters(self, *args: LineFilter):
return lambda msg: reduce(operator.and_, [filt(msg) for filt in args if filt is not None])
def _create_re_filter(self) -> LineFilter:
if self._search_query is None:
return None
elif isinstance(self._search_query, str):
return lambda line: self._search_query in log_txt(line)
return lambda line: self._search_query.match(log_txt(line)) is not None
def _create_service_filter(self):
return (
lambda line: self._selected_services is None or log_svc(line) in self._selected_services
)
def _create_timestamp_filter(self) -> LineFilter:
s, e = self._timestamp_start, self._timestamp_end
if s is e is None:
return lambda msg: True
def _time_filter(msg):
msg_time = log_time(msg)
if s is None:
return msg_time <= e
if e is None:
return s <= msg_time
return s <= msg_time <= e
return _time_filter
@property
def filter(self) -> LineFilter:
"""A function which filters a log message based on all applied criteria"""
thresh = LogLevel[self._log_level].value if self._log_level is not None else 0
return self._combine_filters(
partial(level_filter, thresh=thresh),
self._create_re_filter(),
self._create_timestamp_filter(),
self._create_service_filter(),
)
def update_level_filter(self, level: str):
"""Change the log-level of the level filter"""
if level not in [l.name for l in LogLevel]:
logger.error(f"Logging level {level} unrecognized for filter!")
return return
self._log_level = level self._data.extend(self._incoming)
self._set_formatter_and_update_filter(self._line_formatter) self._incoming.clear()
self.new_messages.emit()
def update_search_filter(self, search_query: Pattern | str | None = None):
"""Change the string or regex to filter against"""
self._search_query = search_query
self._set_formatter_and_update_filter(self._line_formatter)
def update_time_filter(self, start: QDateTime | None, end: QDateTime | None): class BecLogsTableModel(QAbstractTableModel):
"""Change the start and/or end times to filter against""" def __init__(self, parent: QWidget | None = None):
self._timestamp_start = start super().__init__(parent)
self._timestamp_end = end self.log_queue = BecLogsQueue.instance()
self._set_formatter_and_update_filter(self._line_formatter) self.log_queue.new_messages.connect(self.handle_new_messages)
self._headers = _CONST.headers
def update_service_filter(self, services: set[str]): def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
"""Change the selected services to display""" return len(self.log_queue)
self._selected_services = services
self._set_formatter_and_update_filter(self._line_formatter)
def update_line_formatter(self, line_formatter: LineFormatter): def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
"""Update the formatter""" return len(self._headers)
self._set_formatter_and_update_filter(line_formatter)
def display_all(self) -> str: def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)):
"""Return formatted output for all log messages""" if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return "\n".join(self._queue_formatter(self._data.copy())) return self._headers[section]
return None
def format_new(self): def get_row_data(self, index: QModelIndex) -> LogMessage | None:
"""Return formatted output for the display queue""" """Return the row data for the given index."""
res = "\n".join(self._display_queue) if not index.isValid():
self._display_queue = deque([], self._max_length) return None
return res return self.log_queue.row_data(index.row())
def clear_logs(self): def timestamp(self, row: int):
"""Clear the cache and display queue""" return QDateTime.fromMSecsSinceEpoch(int(self.log_queue.log_timestamp(row) * 1000))
self._data = deque([])
self._display_queue = deque([])
def fetch_history(self): def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)):
"""Fetch all available messages from Redis""" """Return data for the given index and role."""
self._data = deque( if not index.isValid():
item["data"] return
for item in self.bec_dispatcher.client.connector.xread( if role in [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole]:
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length return self.log_queue.cell_data(index.row(), self._headers[index.column()])
) if role in [Qt.ItemDataRole.ForegroundRole]:
return self._map_log_level_color(self.log_queue.cell_data(index.row(), "level"))
def _map_log_level_color(self, data):
return _DEFAULT_LOG_COLORS.get(data)
def handle_new_messages(self):
self.dataChanged.emit(
self.index(0, 0), self.index(self.rowCount() - 1, self.columnCount() - 1)
) )
def unique_service_names_from_history(self) -> set[str]:
"""Go through the log history to determine active service names""" class LogMsgProxyModel(QSortFilterProxyModel):
return set(msg.log_msg["service_name"] for msg in self._data) show_service_column = Signal(bool)
def __init__(
self,
parent=None,
service_filter: set[str] | None = None,
level_filter: LogLevel | None = None,
):
super().__init__(parent)
self._service_filter = service_filter or set()
self._level_filter: LogLevel | None = level_filter
self._filter_text: str = ""
self._fuzzy_search: bool = False
self._time_filter_start: QDateTime | None = None
self._time_filter_end: QDateTime | None = None
def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[LogMessage | None]:
return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows)
def sourceModel(self) -> BecLogsTableModel:
return super().sourceModel() # type: ignore
@SafeSlot(int, int)
def refresh(self, *_):
self.invalidateRowsFilter()
@SafeSlot(None)
@SafeSlot(set)
def update_service_filter(self, filter: set[str]):
"""Filter to the selected services (show any service in the provided set)
Args:
filter (set[str] | None): set of services for which to show logs"""
self._service_filter = filter
self.show_service_column.emit(len(filter) != 1)
self.invalidateRowsFilter()
@SafeSlot(None)
@SafeSlot(LogLevel)
def update_level_filter(self, filter: LogLevel | None):
"""Filter to the selected log level
Args:
filter (str | None): lowest log level to show"""
self._level_filter = filter
self.invalidateRowsFilter()
@SafeSlot(str)
def update_filter_text(self, filter: str):
"""Filter messages based on text
Args:
filter (str | None): set of services for which to show logs"""
self._filter_text = filter
self.invalidateRowsFilter()
@SafeSlot(bool)
def update_fuzzy(self, state: bool):
"""Set text filter to fuzzy search or not
Args:
state (bool): fuzzy search on"""
self._fuzzy_search = state
self.invalidateRowsFilter()
@SafeSlot(TimestampUpdate)
def update_timestamp(self, update: TimestampUpdate):
if update.update_type == "start":
self._time_filter_start = update.value
else:
self._time_filter_end = update.value
self.invalidateRowsFilter()
def filterAcceptsRow(self, source_row: int, source_parent) -> bool:
# No service filter, and no filter text, display everything
possible_filters = [
self._service_filter,
self._level_filter,
self._filter_text,
self._time_filter_start,
self._time_filter_end,
]
if not any(map(bool, possible_filters)):
return True
model = self.sourceModel()
# Filter out services
if self._service_filter:
col = _CONST.headers.index("service_name")
if model.data(model.index(source_row, col, source_parent)) not in self._service_filter:
return False
# Filter out levels
if self._level_filter:
col = _CONST.headers.index("level")
level: str = model.data(model.index(source_row, col, source_parent)) # type: ignore
if LogLevel[level] < self._level_filter:
return False
# Filter time
if self._time_filter_start:
if model.timestamp(source_row) < self._time_filter_start:
return False
if self._time_filter_end:
if model.timestamp(source_row) > self._time_filter_end:
return False
# Filter message text - must go last because this can return True
if self._filter_text:
col = _CONST.headers.index("message")
msg: str = model.data(model.index(source_row, col, source_parent)).lower() # type: ignore
if self._fuzzy_search:
return fuzz.partial_ratio(self._filter_text.lower(), msg) >= _CONST.FUZZ_THRESHOLD
else:
return self._filter_text.lower() in msg.lower()
return True
class BecLogTableView(QTableView):
def __init__(self, *args, max_message_width: int = 1000, **kwargs) -> None:
super().__init__(*args, **kwargs)
header = QHeaderView(Qt.Orientation.Horizontal, parent=self)
header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
header.setStretchLastSection(True)
header.setMaximumSectionSize(max_message_width)
self.setHorizontalHeader(header)
def model(self) -> LogMsgProxyModel:
return super().model() # type: ignore
class LogPanel(BECWidget, QWidget):
"""Live display of the BEC logs in a table view."""
PLUGIN = True
ICON_NAME = "browse_activity"
def __init__(
self,
parent: QWidget | None = None,
max_message_width: int = 1000,
show_toolbar: bool = True,
service_filter: set[str] | None = None,
level_filter: LogLevel | None = None,
**kwargs,
) -> None:
super().__init__(parent=parent, **kwargs)
self._setup_models(service_filter=service_filter, level_filter=level_filter)
self._layout = QVBoxLayout()
self.setLayout(self._layout)
if show_toolbar:
self._setup_toolbar(client=self.client)
self._setup_table_view(max_message_width=max_message_width)
self._update_service_filter(service_filter or set())
if show_toolbar:
self._connect_toolbar()
self._proxy.show_service_column.connect(self._show_service_column)
colors = QApplication.instance().theme.accent_colors # type: ignore
dict_colors = QApplication.instance().theme.colors # type: ignore
_DEFAULT_LOG_COLORS.update(
{
LogLevel.INFO.name: dict_colors["FG"],
LogLevel.SUCCESS.name: colors.success,
LogLevel.WARNING.name: colors.warning,
LogLevel.ERROR.name: colors.emergency,
LogLevel.DEBUG.name: dict_colors["BORDER"],
}
)
self._table.scrollToBottom()
def _setup_models(self, service_filter: set[str] | None, level_filter: LogLevel | None):
self._model = BecLogsTableModel(parent=self)
self._proxy = LogMsgProxyModel(
parent=self, service_filter=service_filter, level_filter=level_filter
)
self._proxy.setSourceModel(self._model)
self._model.log_queue.new_messages.connect(self._proxy.refresh)
def _setup_table_view(self, max_message_width: int) -> None:
"""Setup the table view."""
self._table = BecLogTableView(self, max_message_width=max_message_width)
self._table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._layout.addWidget(self._table)
self._table.setModel(self._proxy)
self._table.setHorizontalScrollMode(QTableView.ScrollMode.ScrollPerPixel)
self._table.setTextElideMode(Qt.TextElideMode.ElideRight)
self._table.resizeColumnsToContents()
def _setup_toolbar(self, client: BECClient):
self._toolbar = LogPanelToolbar(self, client)
self._layout.addWidget(self._toolbar)
def _connect_toolbar(self):
self._toolbar.services_selected.connect(self._proxy.update_service_filter)
self._toolbar.search_textbox.textChanged.connect(self._proxy.update_filter_text)
self._toolbar.level_changed.connect(self._proxy.update_level_filter)
self._toolbar.fuzzy_changed.connect(self._proxy.update_fuzzy)
self._toolbar.timestamp_update.connect(self._proxy.update_timestamp)
self._toolbar.pause_button.clicked.connect(self._model.log_queue.toggle_pause)
self._model.log_queue.paused.connect(self._toolbar._update_pause_button_icon)
def _update_service_filter(self, filter: set[str]):
self._service_filter = filter
self._proxy.update_service_filter(filter)
self._table.setColumnHidden(
_CONST.headers.index("service_name"), len(self._service_filter) == 1
)
@SafeSlot(bool)
def _show_service_column(self, show: bool):
self._table.setColumnHidden(_CONST.headers.index("service_name"), not show)
def sizeHint(self) -> QSize:
return QSize(600, 300)
class LogPanelToolbar(QWidget): class LogPanelToolbar(QWidget):
services_selected = Signal(set)
level_changed = Signal(LogLevel)
fuzzy_changed = Signal(bool)
timestamp_update = Signal(TimestampUpdate)
services_selected: SignalInstance = Signal(set) def __init__(self, parent: QWidget | None = None, client: BECClient | None = None) -> None:
def __init__(self, parent: QWidget | None = None) -> None:
"""A toolbar for the logpanel, mainly used for managing the states of filters""" """A toolbar for the logpanel, mainly used for managing the states of filters"""
super().__init__(parent) super().__init__(parent)
@@ -231,51 +451,69 @@ class LogPanelToolbar(QWidget):
self._timestamp_end: QDateTime | None = None self._timestamp_end: QDateTime | None = None
self._unique_service_names: set[str] = set() self._unique_service_names: set[str] = set()
self._services_selected: set[str] | None = None self._services_selected: set[str] = set()
self.layout = QHBoxLayout(self) # type: ignore self._layout = QHBoxLayout(self)
self.service_choice_button = QPushButton("Select services", self) if client is not None:
self.layout.addWidget(self.service_choice_button) self.client = client
self.service_choice_button.clicked.connect(self._open_service_filter_dialog) self.service_choice_button = QPushButton("Select services", self)
self._layout.addWidget(self.service_choice_button)
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
self.service_list_update(self.client.service_status)
self._services_selected = self._unique_service_names
self.filter_level_dropdown = self._log_level_box() self.filter_level_dropdown = self._log_level_box()
self.layout.addWidget(self.filter_level_dropdown) self._layout.addWidget(self.filter_level_dropdown)
self.filter_level_dropdown.currentTextChanged.connect(self._emit_level)
self.clear_button = QPushButton("Clear all", self)
self.layout.addWidget(self.clear_button)
self.fetch_button = QPushButton("Fetch history", self)
self.layout.addWidget(self.fetch_button)
self._string_search_box() self._string_search_box()
self.timerange_button = QPushButton("Set time range", self) self.timerange_button = QPushButton("Set time range", self)
self.layout.addWidget(self.timerange_button) self._layout.addWidget(self.timerange_button)
self.timerange_button.clicked.connect(self._open_datetime_dialog)
@property self.pause_button = QToolButton()
def time_start(self): self.pause_button.setIcon(material_icon("pause", size=(20, 20), convert_to_pixmap=False))
return self._timestamp_start self._PLAYING_TOOLTIP = "Pause live log updates."
self._PAUSED_TOOLTIP = "Continue live log updates."
self.pause_button.setToolTip(self._PLAYING_TOOLTIP)
self._layout.addWidget(self.pause_button)
@property @SafeSlot(bool)
def time_end(self): def _update_pause_button_icon(self, paused):
return self._timestamp_end if paused:
icon = "play_arrow"
tooltip = self._PAUSED_TOOLTIP
else:
icon = "pause"
tooltip = self._PLAYING_TOOLTIP
self.pause_button.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
self.pause_button.setToolTip(tooltip)
def _string_search_box(self): def _string_search_box(self):
self.layout.addWidget(QLabel("Search: ")) self._layout.addWidget(QLabel("Search: "))
self.search_textbox = QLineEdit() self.search_textbox = QLineEdit()
self.layout.addWidget(self.search_textbox) self._layout.addWidget(self.search_textbox)
self.layout.addWidget(QLabel("Use regex: ")) self._layout.addWidget(QLabel("Fuzzy: "))
self.regex_enabled = QCheckBox() self.fuzzy = QCheckBox()
self.layout.addWidget(self.regex_enabled) self._layout.addWidget(self.fuzzy)
self.update_re_button = QPushButton("Update search", self) self.fuzzy.checkStateChanged.connect(self._emit_fuzzy)
self.layout.addWidget(self.update_re_button)
def _log_level_box(self): def _log_level_box(self):
box = QComboBox() box = QComboBox()
box.setToolTip("Display logs with equal or greater significance to the selected level.") box.setToolTip("Display logs with equal or greater significance to the selected level.")
[box.addItem(l.name) for l in LogLevel] [box.addItem(level.name) for level in LogLevel]
return box return box
@SafeSlot(str)
def _emit_level(self, level: str):
self.level_changed.emit(LogLevel[level])
@SafeSlot(Qt.CheckState)
def _emit_fuzzy(self, state: Qt.CheckState):
self.fuzzy_changed.emit(state == Qt.CheckState.Checked)
def _current_ts(self, selection_type: Literal["start", "end"]): def _current_ts(self, selection_type: Literal["start", "end"]):
if selection_type == "start": if selection_type == "start":
return self._timestamp_start return self._timestamp_start
@@ -284,6 +522,7 @@ class LogPanelToolbar(QWidget):
else: else:
raise ValueError(f"timestamps can only be for the start or end, not {selection_type}") raise ValueError(f"timestamps can only be for the start or end, not {selection_type}")
@SafeSlot()
def _open_datetime_dialog(self): def _open_datetime_dialog(self):
"""Open dialog window for timestamp filter selection""" """Open dialog window for timestamp filter selection"""
self._dt_dialog = QDialog(self) self._dt_dialog = QDialog(self)
@@ -312,8 +551,8 @@ class LogPanelToolbar(QWidget):
) )
_layout.addWidget(date_clear_button) _layout.addWidget(date_clear_button)
for v in [("start", label_start), ("end", label_end)]: date_button_set("start", label_start)
date_button_set(*v) date_button_set("end", label_end)
close_button = QPushButton("Close", parent=self._dt_dialog) close_button = QPushButton("Close", parent=self._dt_dialog)
close_button.clicked.connect(self._dt_dialog.accept) close_button.clicked.connect(self._dt_dialog.accept)
@@ -352,27 +591,23 @@ class LogPanelToolbar(QWidget):
self._timestamp_start = dt self._timestamp_start = dt
else: else:
self._timestamp_end = dt self._timestamp_end = dt
self.timestamp_update.emit(TimestampUpdate(value=dt, update_type=selection_type))
@SafeSlot(dict, set) def service_list_update(self, services_info: dict[str, StatusMessage]):
def service_list_update(
self, services_info: dict[str, StatusMessage], services_from_history: set[str], *_, **__
):
"""Change the list of services which can be selected""" """Change the list of services which can be selected"""
self._unique_service_names = set([s.split("/")[0] for s in services_info.keys()]) self._unique_service_names = set([s.split("/")[0] for s in services_info.keys()])
self._unique_service_names |= services_from_history
if self._services_selected is None:
self._services_selected = self._unique_service_names
@SafeSlot() @SafeSlot()
def _open_service_filter_dialog(self): def _open_service_filter_dialog(self):
self.service_list_update(self.client.service_status)
if len(self._unique_service_names) == 0 or self._services_selected is None: if len(self._unique_service_names) == 0 or self._services_selected is None:
return return
self._svc_dialog = QDialog(self) self._svc_dialog = QDialog(self)
self._svc_dialog.setWindowTitle(f"Select services to show logs from") self._svc_dialog.setWindowTitle("Select services to show logs from")
layout = QVBoxLayout() layout = QVBoxLayout()
self._svc_dialog.setLayout(layout) self._svc_dialog.setLayout(layout)
service_cb_grid = QGridLayout(parent=self._svc_dialog) service_cb_grid = QGridLayout()
layout.addLayout(service_cb_grid) layout.addLayout(service_cb_grid)
def check_box(name: str, checked: Qt.CheckState): def check_box(name: str, checked: Qt.CheckState):
@@ -398,146 +633,6 @@ class LogPanelToolbar(QWidget):
self._svc_dialog.deleteLater() self._svc_dialog.deleteLater()
class LogPanel(TextBox):
"""Displays a log panel"""
ICON_NAME = "terminal"
service_list_update = Signal(dict, set)
def __init__(
self,
parent=None,
client: BECClient | None = None,
service_status: BECServiceStatusMixin | None = None,
**kwargs,
):
"""Initialize the LogPanel widget."""
super().__init__(parent=parent, client=client, config={"text": ""}, **kwargs)
self._update_colors()
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
self._log_manager = BecLogsQueue(
parent=self, line_formatter=partial(simple_color_format, colors=self._colors)
)
self._proxy_update = SignalProxy(
self._log_manager.new_message, rateLimit=1, slot=self._on_append
)
self.toolbar = LogPanelToolbar(parent=self)
self.toolbar_area = QScrollArea()
self.toolbar_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.toolbar_area.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
self.toolbar_area.setFixedHeight(int(self.toolbar.clear_button.height() * 2))
self.toolbar_area.setWidget(self.toolbar)
self.layout.addWidget(self.toolbar_area)
self.toolbar.clear_button.clicked.connect(self._on_clear)
self.toolbar.fetch_button.clicked.connect(self._on_fetch)
self.toolbar.update_re_button.clicked.connect(self._on_re_update)
self.toolbar.search_textbox.returnPressed.connect(self._on_re_update)
self.toolbar.regex_enabled.checkStateChanged.connect(self._on_re_update)
self.toolbar.filter_level_dropdown.currentTextChanged.connect(self._set_level_filter)
self.toolbar.timerange_button.clicked.connect(self._choose_datetime)
self._service_status.services_update.connect(self._update_service_list)
self.service_list_update.connect(self.toolbar.service_list_update)
self.toolbar.services_selected.connect(self._update_service_filter)
self.text_box_text_edit.setFont(QFont("monospace", 12))
self.text_box_text_edit.setHtml("")
self.text_box_text_edit.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
self._connect_to_theme_change()
@SafeSlot(set)
def _update_service_filter(self, services: set[str]):
self._log_manager.update_service_filter(services)
self._on_redraw()
@SafeSlot(dict, dict)
def _update_service_list(self, services_info: dict[str, StatusMessage], *_, **__):
self.service_list_update.emit(
services_info, self._log_manager.unique_service_names_from_history()
)
@SafeSlot()
def _choose_datetime(self):
self.toolbar._open_datetime_dialog()
self._set_time_filter()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._on_redraw) # type: ignore
def _update_colors(self):
self._colors = DEFAULT_LOG_COLORS.copy()
self._colors.update({LogLevel.INFO: get_theme_palette().text().color().name()})
def _cursor_to_end(self):
c = self.text_box_text_edit.textCursor()
c.movePosition(c.MoveOperation.End)
self.text_box_text_edit.setTextCursor(c)
@SafeSlot()
@SafeSlot(str)
def _on_redraw(self, *_):
self._update_colors()
self._log_manager.update_line_formatter(partial(simple_color_format, colors=self._colors))
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot(verify_sender=True)
def _on_append(self, *_):
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
self._cursor_to_end()
@SafeSlot()
def _on_clear(self):
self._log_manager.clear_logs()
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
@SafeSlot(Qt.CheckState)
def _on_re_update(self, *_):
if self.toolbar.regex_enabled.isChecked():
try:
search_query = re.compile(self.toolbar.search_textbox.text())
except Exception as e:
logger.warning(f"Failed to compile search regex with error {e}")
search_query = None
logger.info(f"Setting LogPanel search regex to {search_query}")
else:
search_query = self.toolbar.search_textbox.text()
logger.info(f'Setting LogPanel search string to "{search_query}"')
self._log_manager.update_search_filter(search_query)
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
def _on_fetch(self):
self._log_manager.fetch_history()
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot(str)
def _set_level_filter(self, level: str):
self._log_manager.update_level_filter(level)
self._on_redraw()
@SafeSlot()
def _set_time_filter(self):
self._log_manager.update_time_filter(self.toolbar.time_start, self.toolbar.time_end)
self._on_redraw()
def cleanup(self):
self._service_status.cleanup()
self._log_manager.cleanup()
self._log_manager.deleteLater()
super().cleanup()
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
import sys import sys
@@ -545,7 +640,15 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv) app = QApplication(sys.argv)
apply_theme("dark") apply_theme("dark")
widget = LogPanel() panel = QWidget()
queue = BecLogsQueue(panel)
layout = QVBoxLayout(panel)
layout.addWidget(QLabel("All logs, no filters:"))
layout.addWidget(LogPanel())
layout.addWidget(QLabel("All services, level filter WARNING preapplied:"))
layout.addWidget(LogPanel(level_filter=LogLevel.WARNING))
layout.addWidget(QLabel('All services, service filter {"DeviceServer"} preapplied:'))
layout.addWidget(LogPanel(service_filter={"DeviceServer"}))
widget.show() panel.show()
sys.exit(app.exec()) sys.exit(app.exec())
@@ -3,8 +3,8 @@ from qtpy import QtCore, QtGui
from qtpy.QtCore import Property, Signal, Slot from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils import Colors
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors
class RoundedColorMapButton(ColorMapButton): class RoundedColorMapButton(ColorMapButton):
@@ -19,7 +19,7 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.utils import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.widget_highlighter import WidgetHighlighter from bec_widgets.utils.widget_highlighter import WidgetHighlighter
from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.utils.widget_io import WidgetHierarchy
+10 -11
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "bec_widgets" name = "bec_widgets"
version = "3.5.0" version = "3.7.2"
description = "BEC Widgets" description = "BEC Widgets"
requires-python = ">=3.11" requires-python = ">=3.11"
classifiers = [ classifiers = [
@@ -12,19 +12,19 @@ dependencies = [
"PyJWT~=2.9", "PyJWT~=2.9",
"PySide6==6.9.0", "PySide6==6.9.0",
"PySide6-QtAds==4.4.0", "PySide6-QtAds==4.4.0",
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console "bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
"bec_lib~=3.107,>=3.107.2", "bec_lib~=3.107,>=3.107.2",
"bec_qthemes~=1.0, >=1.3.4", "bec_qthemes~=1.0, >=1.3.4",
"black>=26,<27", # needed for bw-generate-cli "black>=26,<27", # needed for bw-generate-cli
"copier~=9.7", "copier~=9.7",
"darkdetect~=0.8", "darkdetect~=0.8",
"isort>=5.13, <9.0", # needed for bw-generate-cli "isort>=5.13, <9.0", # needed for bw-generate-cli
"markdown~=3.9", "markdown~=3.9",
"ophyd_devices~=1.29, >=1.29.1", "ophyd_devices~=1.29, >=1.29.1",
"pydantic~=2.0", "pydantic~=2.0",
"pylsp-bec~=1.2", "pylsp-bec~=1.2",
"pyqtgraph==0.13.7", "pyqtgraph==0.13.7",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtmonaco~=0.8, >=0.8.1", "qtmonaco~=0.8, >=0.8.1",
"qtpy~=2.4", "qtpy~=2.4",
"thefuzz~=0.22", "thefuzz~=0.22",
@@ -38,8 +38,8 @@ Homepage = "https://gitlab.psi.ch/bec/bec_widgets"
[project.scripts] [project.scripts]
bec-app = "bec_widgets.applications.main_app:main" bec-app = "bec_widgets.applications.main_app:main"
bec-designer = "bec_widgets.utils.bec_designer:main" bec-designer = "bec_widgets.utils.bec_designer:main"
bec-gui-server = "bec_widgets.cli.server:main" bec-gui-server = "bec_widgets.applications.companion_app:main"
bw-generate-cli = "bec_widgets.cli.generate_cli:main" bw-generate-cli = "bec_widgets.utils.generate_cli:main"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
@@ -52,12 +52,11 @@ dev = [
"pytest-xvfb~=3.0", "pytest-xvfb~=3.0",
"pytest~=8.0", "pytest~=8.0",
"pytest-cov~=6.1.1", "pytest-cov~=6.1.1",
"pytest-benchmark~=5.2",
"watchdog~=6.0", "watchdog~=6.0",
"pre_commit~=4.2", "pre_commit~=4.2",
] ]
qtermwidget = [ qtermwidget = ["pyside6_qtermwidget"]
"pyside6_qtermwidget",
]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
@@ -68,7 +67,7 @@ line-length = 100
skip-magic-trailing-comma = true skip-magic-trailing-comma = true
[tool.coverage.report] [tool.coverage.report]
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
exclude_lines = [ exclude_lines = [
"pragma: no cover", "pragma: no cover",
"if TYPE_CHECKING:", "if TYPE_CHECKING:",
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# BENCHMARK_TITLE: Import bec_widgets
set -euo pipefail
python -c 'import bec_widgets; print(bec_widgets.__file__)'
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# BENCHMARK_TITLE: BEC IPython client with companion app
set -euo pipefail
bec --post-startup-file tests/benchmarks/hyperfine/utils/exit_bec_startup.py
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# BENCHMARK_TITLE: BEC IPython client without companion app
set -euo pipefail
bec --nogui --post-startup-file tests/benchmarks/hyperfine/utils/exit_bec_startup.py
@@ -0,0 +1,5 @@
import time
_ip = get_ipython()
_ip.confirm_exit = False
_ip.ask_exit()
+18 -5
View File
@@ -1,3 +1,5 @@
import traceback
import pytest import pytest
import qtpy.QtCore import qtpy.QtCore
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
@@ -5,12 +7,14 @@ from qtpy.QtCore import QTimer
class TestableQTimer(QTimer): class TestableQTimer(QTimer):
_instances: list[tuple[QTimer, str]] = [] _instances: list[tuple[QTimer, str, str]] = []
_current_test_name: str = "" _current_test_name: str = ""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
TestableQTimer._instances.append((self, TestableQTimer._current_test_name)) tb = traceback.format_stack()
init_line = list(filter(lambda msg: "QTimer(" in msg, tb))[-1]
TestableQTimer._instances.append((self, TestableQTimer._current_test_name, init_line))
@classmethod @classmethod
def check_all_stopped(cls, qtbot): def check_all_stopped(cls, qtbot):
@@ -20,12 +24,21 @@ class TestableQTimer(QTimer):
except RuntimeError as e: except RuntimeError as e:
return "already deleted" in e.args[0] return "already deleted" in e.args[0]
def _format_timers(timers: list[tuple[QTimer, str, str]]):
return "\n".join(
f"Timer: {t[0]}\n in test: {t[1]}\n created at:{t[2]}" for t in timers
)
try: try:
qtbot.waitUntil(lambda: all(_is_done_or_deleted(timer) for timer, _ in cls._instances)) qtbot.waitUntil(
lambda: all(_is_done_or_deleted(timer) for timer, _, _ in cls._instances)
)
except QtBotTimeoutError as exc: except QtBotTimeoutError as exc:
active_timers = list(filter(lambda t: t[0].isActive(), cls._instances)) active_timers = list(filter(lambda t: t[0].isActive(), cls._instances))
(t.stop() for t, _ in cls._instances) (t.stop() for t, _, _ in cls._instances)
raise TimeoutError(f"Failed to stop all timers: {active_timers}") from exc raise TimeoutError(
f"Failed to stop all timers:\n{_format_timers(active_timers)}"
) from exc
cls._instances = [] cls._instances = []
+4 -3
View File
@@ -33,7 +33,7 @@ def threads_check_fixture(threads_check):
@pytest.fixture @pytest.fixture
def gui_id(): def gui_id():
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb""" """New gui id each time, to ensure no 'gui is alive' zombie key can perturb"""
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturb return f"figure_{random.randint(0, 100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturb
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@@ -51,6 +51,7 @@ def connected_client_gui_obj(qtbot, gui_id, bec_client_lib):
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000) qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
yield gui yield gui
finally: finally:
gui.bec.delete_all() # ensure clean state if (bec := getattr(gui, "bec", None)) is not None:
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000) bec.delete_all() # ensure clean state
qtbot.waitUntil(lambda: len(bec.widget_list()) == 0, timeout=10000)
gui.kill_server() gui.kill_server()
-1
View File
@@ -59,5 +59,4 @@ def test_run_line_scan_with_parameters_e2e(scan_control, bec_client_lib, qtbot):
last_scan = queue.scan_storage.storage[-1] last_scan = queue.scan_storage.storage[-1]
assert last_scan.status_message.info["scan_name"] == scan_name assert last_scan.status_message.info["scan_name"] == scan_name
assert last_scan.status_message.info["exp_time"] == kwargs["exp_time"] assert last_scan.status_message.info["exp_time"] == kwargs["exp_time"]
assert last_scan.status_message.info["scan_motors"] == [args["device"]]
assert last_scan.status_message.info["num_points"] == kwargs["steps"] assert last_scan.status_message.info["num_points"] == kwargs["steps"]
-1
View File
@@ -84,7 +84,6 @@ def test_scan_metadata_for_custom_scan(
last_scan = queue.scan_storage.storage[-1] last_scan = queue.scan_storage.storage[-1]
assert last_scan.status_message.info["scan_name"] == scan_name assert last_scan.status_message.info["scan_name"] == scan_name
assert last_scan.status_message.info["exp_time"] == kwargs["exp_time"] assert last_scan.status_message.info["exp_time"] == kwargs["exp_time"]
assert last_scan.status_message.info["scan_motors"] == [args["device"]]
assert last_scan.status_message.info["num_points"] == kwargs["steps"] assert last_scan.status_message.info["num_points"] == kwargs["steps"]
if valid: if valid:
@@ -260,22 +260,6 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# TODO re-enable when issue is resolved #560
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the LogPanel widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
# widget: client.LogPanel
# # No rpc calls to check so far
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT) @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed): def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the MineSweeper widget.""" """Test the MineSweeper widget."""
@@ -0,0 +1,27 @@
from __future__ import annotations
import pytest
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import mocked_client
@pytest.fixture
def dock_area(qtbot, mocked_client):
widget = BECDockArea(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_add_waveform_to_dock_area(benchmark, dock_area, qtbot, mocked_client):
"""Benchmark adding a Waveform widget to an existing dock area."""
def add_waveform():
dock_area.new("Waveform")
return dock_area
dock = benchmark(add_waveform)
assert dock is not None
+4 -4
View File
@@ -23,11 +23,11 @@ from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtCore import QEvent, QEventLoop from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtWidgets import QApplication, QMessageBox from qtpy.QtWidgets import QApplication, QMessageBox
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.tests.utils import DEVICES, DMMock from bec_widgets.tests.utils import DEVICES, DMMock
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
from bec_widgets.utils import error_popups from bec_widgets.utils import error_popups
from bec_widgets.utils.bec_dispatcher import QtRedisConnector from bec_widgets.utils.bec_dispatcher import QtRedisConnector
from bec_widgets.utils.rpc_register import RPCRegister
# Patch to set default RAISE_ERROR_DEFAULT to True for tests # Patch to set default RAISE_ERROR_DEFAULT to True for tests
# This means that by default, error popups will raise exceptions during tests # This means that by default, error popups will raise exceptions during tests
@@ -227,7 +227,7 @@ def create_history_file(file_path, data: dict, metadata: dict) -> messages.ScanH
msg = messages.ScanHistoryMessage( msg = messages.ScanHistoryMessage(
scan_id=metadata["scan_id"], scan_id=metadata["scan_id"],
scan_name=metadata["scan_name"], scan_name=metadata["scan_name"],
exit_status=metadata["exit_status"], exit_status=metadata["status"],
file_path=file_path, file_path=file_path,
scan_number=metadata["scan_number"], scan_number=metadata["scan_number"],
dataset_number=metadata["dataset_number"], dataset_number=metadata["dataset_number"],
@@ -274,7 +274,7 @@ def grid_scan_history_msg(tmpdir):
"scan_id": "test_scan", "scan_id": "test_scan",
"scan_name": "grid_scan", "scan_name": "grid_scan",
"scan_type": "step", "scan_type": "step",
"exit_status": "closed", "status": "closed",
"scan_number": 1, "scan_number": 1,
"dataset_number": 1, "dataset_number": 1,
"request_inputs": { "request_inputs": {
@@ -354,7 +354,7 @@ def scan_history_factory(tmpdir):
"scan_id": scan_id, "scan_id": scan_id,
"scan_name": scan_name, "scan_name": scan_name,
"scan_type": scan_type, "scan_type": scan_type,
"exit_status": "closed", "status": "closed",
"scan_number": scan_number, "scan_number": scan_number,
"dataset_number": dataset_number, "dataset_number": dataset_number,
"request_inputs": { "request_inputs": {
+1 -1
View File
@@ -5,7 +5,7 @@ import pytest
from qtpy.QtCore import QObject from qtpy.QtCore import QObject
from qtpy.QtWidgets import QApplication, QWidget from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.error_popups import SafeSlot as Slot from bec_widgets.utils.error_popups import SafeSlot as Slot
-1
View File
@@ -71,7 +71,6 @@ def bec_queue_msg_full():
}, },
"report_instructions": [{"scan_progress": 20}], "report_instructions": [{"scan_progress": 20}],
"scan_id": "2d704cc3-c172-404c-866d-608ce09fce40", "scan_id": "2d704cc3-c172-404c-866d-608ce09fce40",
"scan_motors": ["samx"],
"scan_number": 1289, "scan_number": 1289,
} }
], ],
@@ -9,7 +9,8 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
class _TestGlobalPlugin(RPCBase): ... class _TestGlobalPlugin(RPCBase):
_IMPORT_MODULE = "test.global.plugin.widgets"
mock_client_module_globals = SimpleNamespace() mock_client_module_globals = SimpleNamespace()
@@ -25,12 +26,13 @@ mock_client_module_globals.Widgets = _TestGlobalPlugin
def test_plugins_dont_clobber_client_globals(bec_logger: MagicMock): def test_plugins_dont_clobber_client_globals(bec_logger: MagicMock):
reload(client) reload(client)
bec_logger.logger.warning.assert_called_with( bec_logger.logger.warning.assert_called_with(
"Plugin widget Widgets from namespace(Widgets=<class 'tests.unit_tests.test_client_plugin_widgets._TestGlobalPlugin'>) conflicts with a built-in class!" "Plugin widget Widgets in test.global.plugin.widgets conflicts with a built-in class!"
) )
assert isinstance(client.Widgets, enum.EnumType) assert isinstance(client.Widgets, enum.EnumType)
class _TestDuplicatePlugin(RPCBase): ... class _TestDuplicatePlugin(RPCBase):
_IMPORT_MODULE = "test.duplicate.plugin.module"
mock_client_module_duplicate = SimpleNamespace() mock_client_module_duplicate = SimpleNamespace()
@@ -54,7 +56,7 @@ def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock):
reload(client) reload(client)
assert ( assert (
call( call(
f"Detected duplicate widget Waveform in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !" "Plugin widget Waveform in test.duplicate.plugin.module conflicts with a built-in class!"
) )
in bec_logger.logger.warning.mock_calls in bec_logger.logger.warning.mock_calls
) )
+2 -2
View File
@@ -4,9 +4,9 @@ from pydantic import ValidationError
from qtpy.QtGui import QColor from qtpy.QtGui import QColor
from qtpy.QtWidgets import QVBoxLayout, QWidget from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils import Colors, ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.widgets.plots.waveform.curve import CurveConfig from bec_widgets.widgets.plots.waveform.curve import CurveConfig
from tests.unit_tests.client_mocks import mocked_client from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget from tests.unit_tests.conftest import create_widget
+1 -1
View File
@@ -4,7 +4,7 @@ import pytest
from qtpy.QtCore import QPointF, Qt from qtpy.QtCore import QPointF, Qt
from qtpy.QtGui import QTransform from qtpy.QtGui import QTransform
from bec_widgets.utils import Crosshair from bec_widgets.utils.crosshair import Crosshair
from bec_widgets.widgets.plots.image.image_item import ImageItem from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.waveform.waveform import Waveform from bec_widgets.widgets.plots.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import mocked_client from tests.unit_tests.client_mocks import mocked_client
+2 -5
View File
@@ -146,10 +146,7 @@ class TestDeviceManagerViewDialogs:
group_combo: QtWidgets.QComboBox = dialog._control_widgets["group_combo"] group_combo: QtWidgets.QComboBox = dialog._control_widgets["group_combo"]
assert group_combo.count() == len(OPHYD_DEVICE_TEMPLATES) assert group_combo.count() == len(OPHYD_DEVICE_TEMPLATES)
# Test select a group from available templates
variant_combo = dialog._control_widgets["variant_combo"] variant_combo = dialog._control_widgets["variant_combo"]
assert variant_combo.isEnabled() is False
with qtbot.waitSignal(group_combo.currentTextChanged): with qtbot.waitSignal(group_combo.currentTextChanged):
epics_signal_index = group_combo.findText("EpicsSignal") epics_signal_index = group_combo.findText("EpicsSignal")
group_combo.setCurrentIndex(epics_signal_index) # Select "EpicsSignal" group group_combo.setCurrentIndex(epics_signal_index) # Select "EpicsSignal" group
@@ -235,7 +232,7 @@ class TestDeviceManagerViewDialogs:
sample_config = { sample_config = {
"name": "TestDevice", "name": "TestDevice",
"enabled": True, "enabled": True,
"deviceClass": "ophyd.EpicsSignal", "deviceClass": "ophyd_devices.EpicsSignal",
"readoutPriority": "baseline", "readoutPriority": "baseline",
"deviceConfig": {"read_pv": "X25DA-ES1-MOT:GET"}, "deviceConfig": {"read_pv": "X25DA-ES1-MOT:GET"},
} }
@@ -248,7 +245,7 @@ class TestDeviceManagerViewDialogs:
assert variant_combo.currentText() == "EpicsSignal" assert variant_combo.currentText() == "EpicsSignal"
config = dialog._device_config_template.get_config_fields() config = dialog._device_config_template.get_config_fields()
assert config["name"] == "TestDevice" assert config["name"] == "TestDevice"
assert config["deviceClass"] == "ophyd.EpicsSignal" assert config["deviceClass"] == "ophyd_devices.EpicsSignal"
assert config["deviceConfig"]["read_pv"] == "X25DA-ES1-MOT:GET" assert config["deviceConfig"]["read_pv"] == "X25DA-ES1-MOT:GET"
# Test now to add the device config with different validation results # Test now to add the device config with different validation results
+6 -7
View File
@@ -1863,9 +1863,14 @@ class TestWorkspaceProfileOperations:
with patch( with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.SaveProfileDialog", StubDialog "bec_widgets.widgets.containers.dock_area.dock_area.SaveProfileDialog", StubDialog
): ):
advanced_dock_area.save_profile(show_dialog=True) widgets_before_save = list(advanced_dock_area.widget_list())
with patch.object(advanced_dock_area, "load_profile") as mock_load_profile:
advanced_dock_area.save_profile(show_dialog=True)
qtbot.wait(100)
mock_load_profile.assert_not_called()
qtbot.wait(500) qtbot.wait(500)
assert list(advanced_dock_area.widget_list()) == widgets_before_save
source_manifest = read_manifest(helper.open_user(source_profile)) source_manifest = read_manifest(helper.open_user(source_profile))
new_manifest = read_manifest(helper.open_user(new_profile)) new_manifest = read_manifest(helper.open_user(new_profile))
@@ -2229,7 +2234,6 @@ class TestFlatToolbarActions:
"flat_progress_bar", "flat_progress_bar",
"flat_terminal", "flat_terminal",
"flat_bec_shell", "flat_bec_shell",
"flat_log_panel",
"flat_sbb_monitor", "flat_sbb_monitor",
] ]
@@ -2289,11 +2293,6 @@ class TestFlatToolbarActions:
action.trigger() action.trigger()
mock_new.assert_called_once_with(widget_type) mock_new.assert_called_once_with(widget_type)
def test_flat_log_panel_action_disabled(self, advanced_dock_area):
"""Test that flat log panel action is disabled."""
action = advanced_dock_area.toolbar.components.get_action("flat_log_panel").action
assert not action.isEnabled()
class TestModeTransitions: class TestModeTransitions:
"""Test mode transitions and state consistency.""" """Test mode transitions and state consistency."""
+11 -16
View File
@@ -5,7 +5,7 @@ import black
import isort import isort
import pytest import pytest
from bec_widgets.cli.generate_cli import ClientGenerator from bec_widgets.utils.generate_cli import ClientGenerator
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
# pylint: disable=missing-function-docstring # pylint: disable=missing-function-docstring
@@ -104,8 +104,7 @@ def test_client_generator_with_black_formatting():
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
from bec_widgets.utils.bec_plugin_helper import (get_all_plugin_widgets, from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module
get_plugin_client_module)
logger = bec_logger.logger logger = bec_logger.logger
@@ -123,31 +122,25 @@ def test_client_generator_with_black_formatting():
try: try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module() plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass): for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase: if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name not in _Widgets:
_Widgets[plugin_name] = plugin_name
if plugin_name in globals(): if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning( logger.warning(
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!" f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
) )
continue continue
if plugin_name not in _overlap: else:
globals()[plugin_name] = plugin_class globals()[plugin_name] = plugin_class
Widgets = _WidgetsEnumType("Widgets", _Widgets)
except ImportError as e: except ImportError as e:
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}") logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
class MockBECFigure(RPCBase): class MockBECFigure(RPCBase):
_IMPORT_MODULE = "tests.unit_tests.test_generate_cli_client"
@rpc_call @rpc_call
def add_plot(self, plot_id: str): def add_plot(self, plot_id: str):
""" """
@@ -162,6 +155,8 @@ def test_client_generator_with_black_formatting():
class MockBECWaveform1D(RPCBase): class MockBECWaveform1D(RPCBase):
_IMPORT_MODULE = "tests.unit_tests.test_generate_cli_client"
@rpc_call @rpc_call
def set_frequency(self, frequency: float) -> list: def set_frequency(self, frequency: float) -> list:
""" """
+106 -146
View File
@@ -7,163 +7,123 @@ from collections import deque
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from bec_lib.logger import LogLevel
from bec_lib.messages import LogMessage from bec_lib.messages import LogMessage
from bec_lib.redis_connector import StreamMessage
from qtpy.QtCore import QDateTime from qtpy.QtCore import QDateTime
from bec_widgets.widgets.utility.logpanel._util import ( from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel, TimestampUpdate
log_time,
replace_escapes,
simple_color_format,
)
from bec_widgets.widgets.utility.logpanel.logpanel import DEFAULT_LOG_COLORS, LogPanel
from .client_mocks import mocked_client from .client_mocks import mocked_client
TEST_TABLE_STRING = "2025-01-15 15:57:18 | bec_server.scan_server.scan_queue | [INFO] | \n \x1b[3m primary queue / ScanQueueStatus.RUNNING \x1b[0m\n┏━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┓\n\x1b[1m \x1b[0m\x1b[1m queue_id \x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_id\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mis_scan\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mtype\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_numb…\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mIQ status\x1b[0m\x1b[1m \x1b[0m┃\n┡━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━┩\n│ bbe50c82-6… │ None │ False │ mv │ None │ PENDING │\n└─────────────┴─────────┴─────────┴──────┴────────────┴───────────┘\n\n"
TEST_LOG_MESSAGES = [ TEST_LOG_MESSAGES = [
LogMessage( {"data": msg}
metadata={}, for msg in [
log_type="debug", LogMessage(
log_msg={
"text": "datetime | debug | test log message",
"record": {"time": {"timestamp": 123456789.000}},
"service_name": "ScanServer",
},
),
LogMessage(
metadata={},
log_type="info",
log_msg={
"text": "datetime | info | test log message",
"record": {"time": {"timestamp": 123456789.007}},
"service_name": "ScanServer",
},
),
LogMessage(
metadata={},
log_type="success",
log_msg={
"text": "datetime | success | test log message",
"record": {"time": {"timestamp": 123456789.012}},
"service_name": "ScanServer",
},
),
]
TEST_COMBINED_PLAINTEXT = "datetime | debug | test log message\ndatetime | info | test log message\ndatetime | success | test log message\n"
@pytest.fixture
def raw_queue():
yield deque(TEST_LOG_MESSAGES, maxlen=100)
@pytest.fixture
def log_panel(qtbot, mocked_client: MagicMock):
widget = LogPanel(client=mocked_client, service_status=MagicMock())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_log_panel_init(log_panel: LogPanel):
assert log_panel.plain_text == ""
def test_table_string_processing():
assert "\x1b" in TEST_TABLE_STRING
sanitized = replace_escapes(TEST_TABLE_STRING)
assert "\x1b" not in sanitized
assert " " not in sanitized
assert "\n" not in sanitized
@pytest.mark.parametrize(
["msg", "color"], zip(TEST_LOG_MESSAGES, ["#0000CC", "#FFFFFF", "#00FF00"])
)
def test_color_format(msg: LogMessage, color: str):
assert color in simple_color_format(msg, DEFAULT_LOG_COLORS)
def test_logpanel_output(qtbot, log_panel: LogPanel):
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
log_panel._on_redraw()
assert log_panel.plain_text == TEST_COMBINED_PLAINTEXT
def display_queue_empty():
print(log_panel._log_manager._display_queue)
return len(log_panel._log_manager._display_queue) == 0
next_text = "datetime | error | test log message"
msg = LogMessage(
metadata={},
log_type="error",
log_msg={"text": next_text, "record": {}, "service_name": "ScanServer"},
)
log_panel._log_manager._process_incoming_log_msg(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
qtbot.waitUntil(display_queue_empty, timeout=5000)
assert log_panel.plain_text == TEST_COMBINED_PLAINTEXT + next_text + "\n"
def test_level_filter(log_panel: LogPanel):
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
log_panel._log_manager.update_level_filter("INFO")
log_panel._on_redraw()
assert (
log_panel.plain_text
== "datetime | info | test log message\ndatetime | success | test log message\n"
)
def test_clear_button(log_panel: LogPanel):
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
log_panel.toolbar.clear_button.click()
assert log_panel._log_manager._data == deque([])
def test_timestamp_filter(log_panel: LogPanel):
log_panel._log_manager._timestamp_start = QDateTime(1973, 11, 29, 21, 33, 9, 5, 1)
pytest.approx(log_panel._log_manager._timestamp_start.toMSecsSinceEpoch() / 1000, 123456789.005)
log_panel._log_manager._timestamp_end = QDateTime(1973, 11, 29, 21, 33, 9, 10, 1)
pytest.approx(log_panel._log_manager._timestamp_end.toMSecsSinceEpoch() / 1000, 123456789.010)
filter_ = log_panel._log_manager._create_timestamp_filter()
assert not filter_(TEST_LOG_MESSAGES[0])
assert filter_(TEST_LOG_MESSAGES[1])
assert not filter_(TEST_LOG_MESSAGES[2])
def test_error_handling_in_callback(log_panel: LogPanel):
log_panel._log_manager.new_message = MagicMock()
with patch("bec_widgets.widgets.utility.logpanel.logpanel.logger") as logger:
# generally errors should be logged
log_panel._log_manager.new_message.emit = MagicMock(
side_effect=ValueError("Something went wrong")
)
msg = LogMessage(
metadata={}, metadata={},
log_type="debug", log_type="debug",
log_msg={ log_msg={
"text": "datetime | debug | test log message", "text": "datetime | debug | test log message",
"record": {"time": {"timestamp": 123456789.000}}, "record": {
"time": {"timestamp": 123456789.000, "repr": "2025-01-01 00:00:01"},
"message": "test debug message abcd",
"function": "_debug",
},
"service_name": "ScanServer",
},
),
LogMessage(
metadata={},
log_type="info",
log_msg={
"text": "datetime | info | test info log message",
"record": {
"time": {"timestamp": 123456789.007, "repr": "2025-01-01 00:00:02"},
"message": "test info message efgh",
"function": "_info",
},
"service_name": "DeviceServer",
},
),
LogMessage(
metadata={},
log_type="success",
log_msg={
"text": "datetime | success | test log message",
"record": {
"time": {"timestamp": 123456789.012, "repr": "2025-01-01 00:00:03"},
"message": "test success message ijkl",
"function": "_success",
},
"service_name": "ScanServer",
},
),
]
]
@pytest.fixture
def log_panel(qtbot, mocked_client):
mocked_client.connector.xread = lambda *_, **__: TEST_LOG_MESSAGES
widget = LogPanel()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget._model.log_queue.cleanup()
widget.close()
widget.deleteLater()
qtbot.wait(100)
def test_log_panel_init(qtbot, log_panel: LogPanel):
assert log_panel
def test_log_panel_filters(qtbot, log_panel: LogPanel):
assert log_panel._proxy.rowCount() == 3
# Service filter
log_panel._update_service_filter({"DeviceServer"})
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._update_service_filter(set())
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
# Text filter
log_panel._proxy.update_filter_text("efgh")
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._proxy.update_filter_text("")
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
# Time filter
log_panel._proxy.update_timestamp(
TimestampUpdate(value=QDateTime.fromMSecsSinceEpoch(123456789004), update_type="start")
)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 2, timeout=200)
log_panel._proxy.update_timestamp(
TimestampUpdate(value=QDateTime.fromMSecsSinceEpoch(123456789009), update_type="end")
)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._proxy.update_timestamp(TimestampUpdate(value=None, update_type="start"))
log_panel._proxy.update_timestamp(TimestampUpdate(value=None, update_type="end"))
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
# Level filter
log_panel._proxy.update_level_filter(LogLevel.SUCCESS)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._proxy.update_level_filter(None)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
def test_log_panel_update(qtbot, log_panel: LogPanel):
log_panel._model.log_queue._incoming.append(
LogMessage(
metadata={},
log_type="error",
log_msg={
"text": "datetime | error | test log message",
"record": {
"time": {"timestamp": 123456789.015, "repr": "2025-01-01 00:00:03"},
"message": "test error message xyz",
"function": "_error",
},
"service_name": "ScanServer", "service_name": "ScanServer",
}, },
) )
log_panel._log_manager._process_incoming_log_msg( )
msg.content, msg.metadata, _override_slot_params={"verify_sender": False} log_panel._model.log_queue._proc_update()
) qtbot.waitUntil(lambda: log_panel._model.rowCount() == 4, timeout=500)
logger.warning.assert_called_once()
# this specific error should be ignored and not relogged
log_panel._log_manager.new_message.emit = MagicMock(
side_effect=RuntimeError("Internal C++ object (BecLogsQueue) already deleted.")
)
log_panel._log_manager._process_incoming_log_msg(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
logger.warning.assert_called_once()
+1 -1
View File
@@ -8,7 +8,7 @@ from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
from qtpy.QtGui import QColor, QMouseEvent from qtpy.QtGui import QColor, QMouseEvent
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
from bec_widgets.utils import Colors from bec_widgets.utils.colors import Colors
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import ( from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import (
RingProgressBar, RingProgressBar,
RingProgressContainerWidget, RingProgressContainerWidget,
+1 -1
View File
@@ -1,4 +1,4 @@
from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.rpc_register import RPCRegister
class FakeObject: class FakeObject:
+1 -1
View File
@@ -5,7 +5,7 @@ import pytest
from bec_lib.service_config import ServiceConfig from bec_lib.service_config import ServiceConfig
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from bec_widgets.cli.server import GUIServer from bec_widgets.applications.companion_app import GUIServer
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.rpc_server import RegistryNotReadyError, RPCServer, SingleshotRPCRepeat from bec_widgets.utils.rpc_server import RegistryNotReadyError, RPCServer, SingleshotRPCRepeat
+2 -2
View File
@@ -1,8 +1,8 @@
from unittest.mock import patch from unittest.mock import patch
from bec_widgets.cli.rpc.rpc_widget_handler import RPCWidgetHandler
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
from bec_widgets.utils.rpc_widget_handler import RPCWidgetHandler
def test_rpc_widget_handler(): def test_rpc_widget_handler():
@@ -16,7 +16,7 @@ class _TestPluginWidget(BECWidget): ...
@patch( @patch(
"bec_widgets.cli.rpc.rpc_widget_handler.get_all_plugin_widgets", "bec_widgets.utils.rpc_widget_handler.get_all_plugin_widgets",
return_value=BECClassContainer( return_value=BECClassContainer(
[ [
BECClassInfo(name="DeviceComboBox", obj=_TestPluginWidget, module="", file=""), BECClassInfo(name="DeviceComboBox", obj=_TestPluginWidget, module="", file=""),
+2
View File
@@ -501,6 +501,7 @@ def test_changing_scans_remember_parameters(scan_control, mocked_client):
assert grid_kwargs["burst_at_each_point"] == kwargs["burst_at_each_point"] assert grid_kwargs["burst_at_each_point"] == kwargs["burst_at_each_point"]
@pytest.mark.skip(reason="Unreliable - GH issue #1134")
def test_get_scan_parameters_from_redis(scan_control, mocked_client): def test_get_scan_parameters_from_redis(scan_control, mocked_client):
scan_name = "line_scan" scan_name = "line_scan"
scan_control.comboBox_scan_selection.setCurrentText(scan_name) scan_control.comboBox_scan_selection.setCurrentText(scan_name)
@@ -585,6 +586,7 @@ def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl):
scans.grid_scan.assert_called_once_with(metadata=TEST_MD) scans.grid_scan.assert_called_once_with(metadata=TEST_MD)
@pytest.mark.skip(reason="Unreliable - GH issue #1134")
def test_restore_parameters_with_fewer_arg_bundles(scan_control, qtbot): def test_restore_parameters_with_fewer_arg_bundles(scan_control, qtbot):
""" """
Ensure that when more argument bundles are present than exist in the Ensure that when more argument bundles are present than exist in the