mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-05-04 22:04:21 +02:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60e6952b77 | |||
| a00f66a1f2 | |||
| ba4ba72f4a | |||
| 118e58adac | |||
| 551d38d901 | |||
| 999b7a2321 | |||
| 5dc373bd8e | |||
| 91afc775d5 | |||
| 55694ff2b9 | |||
| 5b68a51aaa | |||
| f13fa75e25 | |||
| 0cf84cd1d8 | |||
| 3e77f54034 | |||
| f7616102d8 | |||
| 5a497c3598 | |||
| 23e3644619 | |||
| a5db2dc340 | |||
| 2e8f43fcac | |||
| 09bb1121d8 | |||
| c9aaa77b3c | |||
| f7a1ee49a4 | |||
| 8e51c1adb6 |
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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())
|
||||
@@ -0,0 +1,451 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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())
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
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"
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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())
|
||||
@@ -0,0 +1,239 @@
|
||||
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: 10
|
||||
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: .
|
||||
|
||||
- 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
|
||||
@@ -1,19 +1,19 @@
|
||||
name: Full CI
|
||||
on:
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
description: "Branch of BEC Widgets to install"
|
||||
required: false
|
||||
type: string
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
description: "Branch of BEC Core to install"
|
||||
required: false
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
description: "Branch of Ophyd Devices to install"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
@@ -23,6 +23,7 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check_pr_status:
|
||||
@@ -33,6 +34,15 @@ jobs:
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
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:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
@@ -69,9 +79,9 @@ jobs:
|
||||
uses: ./.github/workflows/child_repos.yml
|
||||
with:
|
||||
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 }}
|
||||
|
||||
|
||||
plugin_repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
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 }}
|
||||
|
||||
secrets:
|
||||
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
|
||||
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
name: Run Pytest with different Python versions
|
||||
on:
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull request number'
|
||||
description: "Pull request number"
|
||||
required: false
|
||||
type: number
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
description: "Branch of BEC Core to install"
|
||||
required: false
|
||||
default: 'main'
|
||||
default: "main"
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
description: "Branch of Ophyd Devices to install"
|
||||
required: false
|
||||
default: 'main'
|
||||
default: "main"
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
description: "Branch of BEC Widgets to install"
|
||||
required: false
|
||||
default: 'main'
|
||||
default: "main"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
@@ -30,15 +30,14 @@ jobs:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
|
||||
env:
|
||||
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -56,4 +55,4 @@ jobs:
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
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
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
name: Run Pytest with Coverage
|
||||
on:
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull request number'
|
||||
description: "Pull request number"
|
||||
required: false
|
||||
type: number
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
description: "Branch of BEC Core to install"
|
||||
required: false
|
||||
default: 'main'
|
||||
default: "main"
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
description: "Branch of Ophyd Devices to install"
|
||||
required: false
|
||||
default: 'main'
|
||||
default: "main"
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
description: "Branch of BEC Widgets to install"
|
||||
required: false
|
||||
default: 'main'
|
||||
default: "main"
|
||||
type: string
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: true
|
||||
|
||||
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -55,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Run Pytest with 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
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -69,4 +67,4 @@ jobs:
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: bec-project/bec_widgets
|
||||
slug: bec-project/bec_widgets
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## 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)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+12
-18
@@ -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"]
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
@@ -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
|
||||
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.rpc_register import RPCRegister
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -20,13 +20,13 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
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.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
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.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.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from bec_widgets.cli.rpc import rpc_base
|
||||
|
||||
+127
-28
@@ -13,7 +13,7 @@ from typing import Literal, Optional
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
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
|
||||
|
||||
@@ -62,29 +62,19 @@ _Widgets = {
|
||||
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
||||
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):
|
||||
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():
|
||||
conflicting_file = (
|
||||
inspect.getfile(_plugin_widgets[plugin_name])
|
||||
if plugin_name in _plugin_widgets
|
||||
else f"{plugin_client}"
|
||||
)
|
||||
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
|
||||
if plugin_name not in _overlap:
|
||||
else:
|
||||
globals()[plugin_name] = plugin_class
|
||||
Widgets = _WidgetsEnumType("Widgets", _Widgets)
|
||||
except ImportError as 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):
|
||||
"""A view for administrators to change the current active experiment, manage messaging"""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.admin_view.admin_view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -100,6 +92,8 @@ class AdminView(RPCBase):
|
||||
|
||||
|
||||
class AutoUpdates(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.containers.auto_update.auto_updates"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enabled(self) -> "bool":
|
||||
@@ -136,6 +130,8 @@ class AutoUpdates(RPCBase):
|
||||
|
||||
|
||||
class AvailableDeviceResources(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -156,6 +152,8 @@ class AvailableDeviceResources(RPCBase):
|
||||
|
||||
|
||||
class BECDockArea(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.dock_area"
|
||||
|
||||
@rpc_call
|
||||
def new(
|
||||
self,
|
||||
@@ -391,6 +389,8 @@ class BECDockArea(RPCBase):
|
||||
|
||||
|
||||
class BECMainWindow(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.containers.main_window.main_window"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -413,6 +413,8 @@ class BECMainWindow(RPCBase):
|
||||
class BECProgressBar(RPCBase):
|
||||
"""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
|
||||
def set_value(self, value):
|
||||
"""
|
||||
@@ -486,6 +488,8 @@ class BECProgressBar(RPCBase):
|
||||
class BECQueue(RPCBase):
|
||||
"""Widget to display the BEC queue."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_queue.bec_queue"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -508,6 +512,8 @@ class BECQueue(RPCBase):
|
||||
class BECShell(RPCBase):
|
||||
"""A BecConsole pre-configured to run the BEC shell."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -530,6 +536,8 @@ class BECShell(RPCBase):
|
||||
class BECStatusBox(RPCBase):
|
||||
"""An autonomous widget to display the status of BEC services."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_status_box.bec_status_box"
|
||||
|
||||
@rpc_call
|
||||
def get_server_state(self) -> "str":
|
||||
"""
|
||||
@@ -565,6 +573,8 @@ class BECStatusBox(RPCBase):
|
||||
class BaseROI(RPCBase):
|
||||
"""Base class for all Region of Interest (ROI) implementations."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
@@ -694,6 +704,8 @@ class BaseROI(RPCBase):
|
||||
class BecConsole(RPCBase):
|
||||
"""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
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -716,6 +728,8 @@ class BecConsole(RPCBase):
|
||||
class CircularROI(RPCBase):
|
||||
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
@@ -843,6 +857,8 @@ class CircularROI(RPCBase):
|
||||
|
||||
|
||||
class Curve(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.curve"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -1009,6 +1025,8 @@ class Curve(RPCBase):
|
||||
class DapComboBox(RPCBase):
|
||||
"""Editable combobox listing the available DAP models."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.dap.dap_combo_box.dap_combo_box"
|
||||
|
||||
@rpc_call
|
||||
def select_y_axis(self, y_axis: str):
|
||||
"""
|
||||
@@ -1040,6 +1058,8 @@ class DapComboBox(RPCBase):
|
||||
class DeveloperView(RPCBase):
|
||||
"""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
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -1050,6 +1070,8 @@ class DeveloperView(RPCBase):
|
||||
class DeviceBrowser(RPCBase):
|
||||
"""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
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -1072,6 +1094,8 @@ class DeviceBrowser(RPCBase):
|
||||
class DeviceInitializationProgressBar(RPCBase):
|
||||
"""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
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -1094,6 +1118,8 @@ class DeviceInitializationProgressBar(RPCBase):
|
||||
class DeviceInputBase(RPCBase):
|
||||
"""Mixin base class for device input widgets."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.device_input.base_classes.device_input_base"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -1116,6 +1142,8 @@ class DeviceInputBase(RPCBase):
|
||||
class DeviceManagerView(RPCBase):
|
||||
"""A view for users to manage devices within the application."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.device_manager_view.device_manager_view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -1126,6 +1154,8 @@ class DeviceManagerView(RPCBase):
|
||||
class DockAreaView(RPCBase):
|
||||
"""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
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -1369,6 +1399,8 @@ class DockAreaView(RPCBase):
|
||||
class DockAreaWidget(RPCBase):
|
||||
"""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
|
||||
def new(
|
||||
self,
|
||||
@@ -1553,6 +1585,8 @@ class DockAreaWidget(RPCBase):
|
||||
class EllipticalROI(RPCBase):
|
||||
"""Elliptical Region of Interest with centre/width/height tracking and auto-labelling."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
@@ -1675,6 +1709,8 @@ class EllipticalROI(RPCBase):
|
||||
class Heatmap(RPCBase):
|
||||
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.heatmap.heatmap"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -2373,6 +2409,8 @@ class Heatmap(RPCBase):
|
||||
class Image(RPCBase):
|
||||
"""Image widget for displaying 2D data."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -2984,6 +3022,8 @@ class Image(RPCBase):
|
||||
|
||||
|
||||
class ImageItem(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image_item"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def color_map(self) -> "str":
|
||||
@@ -3134,6 +3174,8 @@ class ImageItem(RPCBase):
|
||||
|
||||
|
||||
class LaunchWindow(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.applications.launch_window"
|
||||
|
||||
@rpc_call
|
||||
def show_launcher(self):
|
||||
"""
|
||||
@@ -3148,33 +3190,38 @@ class LaunchWindow(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
|
||||
def set_plain_text(self, text: str) -> None:
|
||||
def remove(self):
|
||||
"""
|
||||
Set the plain text of the widget.
|
||||
|
||||
Args:
|
||||
text (str): The text to set.
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_html_text(self, text: str) -> None:
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
Set the HTML text of the widget.
|
||||
|
||||
Args:
|
||||
text (str): The text to set.
|
||||
@rpc_call
|
||||
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):
|
||||
"""MonacoDock is a dock widget that contains Monaco editor instances."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_dock"
|
||||
|
||||
@rpc_call
|
||||
def new(
|
||||
self,
|
||||
@@ -3359,6 +3406,8 @@ class MonacoDock(RPCBase):
|
||||
class MonacoWidget(RPCBase):
|
||||
"""A simple Monaco editor widget"""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_widget"
|
||||
|
||||
@rpc_call
|
||||
def set_text(
|
||||
self, text: "str", file_name: "str | None" = None, reset: "bool" = False
|
||||
@@ -3533,6 +3582,8 @@ class MonacoWidget(RPCBase):
|
||||
class MotorMap(RPCBase):
|
||||
"""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
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -4003,6 +4054,8 @@ class MotorMap(RPCBase):
|
||||
class MultiWaveform(RPCBase):
|
||||
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.multi_waveform.multi_waveform"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -4462,6 +4515,8 @@ class MultiWaveform(RPCBase):
|
||||
class PdfViewerWidget(RPCBase):
|
||||
"""A widget to display PDF documents with toolbar controls."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.utility.pdf_viewer.pdf_viewer"
|
||||
|
||||
@rpc_call
|
||||
def load_pdf(self, file_path: str):
|
||||
"""
|
||||
@@ -4593,6 +4648,10 @@ class PdfViewerWidget(RPCBase):
|
||||
class PositionIndicator(RPCBase):
|
||||
"""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
|
||||
def set_value(self, position: float):
|
||||
"""
|
||||
@@ -4658,6 +4717,10 @@ class PositionIndicator(RPCBase):
|
||||
class PositionerBox(RPCBase):
|
||||
"""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
|
||||
def set_positioner(self, positioner: "str | Positioner"):
|
||||
"""
|
||||
@@ -4690,6 +4753,8 @@ class PositionerBox(RPCBase):
|
||||
class PositionerBox2D(RPCBase):
|
||||
"""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
|
||||
def set_positioner_hor(self, positioner: "str | Positioner"):
|
||||
"""
|
||||
@@ -4759,6 +4824,8 @@ class PositionerBox2D(RPCBase):
|
||||
class PositionerControlLine(RPCBase):
|
||||
"""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
|
||||
def set_positioner(self, positioner: "str | Positioner"):
|
||||
"""
|
||||
@@ -4791,6 +4858,8 @@ class PositionerControlLine(RPCBase):
|
||||
class PositionerGroup(RPCBase):
|
||||
"""Simple Widget to control a positioner in box form"""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_group.positioner_group"
|
||||
|
||||
@rpc_call
|
||||
def set_positioners(self, device_names: "str"):
|
||||
"""
|
||||
@@ -4822,6 +4891,8 @@ class PositionerGroup(RPCBase):
|
||||
class RectangularROI(RPCBase):
|
||||
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
@@ -4951,6 +5022,8 @@ class RectangularROI(RPCBase):
|
||||
class ResumeButton(RPCBase):
|
||||
"""A button that continue scan queue."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.buttons.button_resume.button_resume"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -4971,6 +5044,8 @@ class ResumeButton(RPCBase):
|
||||
|
||||
|
||||
class Ring(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring"
|
||||
|
||||
@rpc_call
|
||||
def set_value(self, value: "int | float"):
|
||||
"""
|
||||
@@ -5064,6 +5139,8 @@ class Ring(RPCBase):
|
||||
|
||||
|
||||
class RingProgressBar(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -5143,12 +5220,14 @@ class RingProgressBar(RPCBase):
|
||||
class SBBMonitor(RPCBase):
|
||||
"""A widget to display the SBB monitor website."""
|
||||
|
||||
...
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.sbb_monitor.sbb_monitor"
|
||||
|
||||
|
||||
class ScanControl(RPCBase):
|
||||
"""Widget to submit new scans to the queue."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.scan_control.scan_control"
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
@@ -5172,6 +5251,8 @@ class ScanControl(RPCBase):
|
||||
class ScanProgressBar(RPCBase):
|
||||
"""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
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -5194,6 +5275,8 @@ class ScanProgressBar(RPCBase):
|
||||
class ScatterCurve(RPCBase):
|
||||
"""Scatter curve item for the scatter waveform widget."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_curve"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def color_map(self) -> "str":
|
||||
@@ -5203,6 +5286,8 @@ class ScatterCurve(RPCBase):
|
||||
|
||||
|
||||
class ScatterWaveform(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_waveform"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -5670,6 +5755,8 @@ class ScatterWaveform(RPCBase):
|
||||
|
||||
|
||||
class SignalLabel(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.utility.signal_label.signal_label"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def custom_label(self) -> "str":
|
||||
@@ -5814,6 +5901,8 @@ class SignalLabel(RPCBase):
|
||||
class TextBox(RPCBase):
|
||||
"""A widget that displays text in plain and HTML format"""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.text_box.text_box"
|
||||
|
||||
@rpc_call
|
||||
def set_plain_text(self, text: str) -> None:
|
||||
"""
|
||||
@@ -5836,6 +5925,8 @@ class TextBox(RPCBase):
|
||||
class ViewBase(RPCBase):
|
||||
"""Wrapper for a content widget used inside the main app's stacked view."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -5846,6 +5937,8 @@ class ViewBase(RPCBase):
|
||||
class Waveform(RPCBase):
|
||||
"""Widget for plotting waveforms."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.waveform"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -6424,6 +6517,8 @@ class Waveform(RPCBase):
|
||||
|
||||
|
||||
class WaveformViewInline(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -6432,6 +6527,8 @@ class WaveformViewInline(RPCBase):
|
||||
|
||||
|
||||
class WaveformViewPopup(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -6442,6 +6539,8 @@ class WaveformViewPopup(RPCBase):
|
||||
class WebsiteWidget(RPCBase):
|
||||
"""A simple widget to display a website"""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.website.website"
|
||||
|
||||
@rpc_call
|
||||
def set_url(self, url: str) -> None:
|
||||
"""
|
||||
|
||||
@@ -25,8 +25,8 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
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.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,9 +15,9 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
|
||||
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.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.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ from qtpy.QtGui import QFont, QPixmap
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
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.busy_loader import install_busy_loader
|
||||
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
|
||||
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.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import inspect
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import get_overloads
|
||||
|
||||
import black
|
||||
import isort
|
||||
@@ -18,20 +19,6 @@ from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
|
||||
|
||||
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:
|
||||
def __init__(self, base=False):
|
||||
@@ -54,7 +41,7 @@ from __future__ import annotations
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
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
|
||||
|
||||
@@ -111,27 +98,19 @@ _Widgets = {
|
||||
self.content += """
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
||||
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):
|
||||
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():
|
||||
conflicting_file = (
|
||||
inspect.getfile(_plugin_widgets[plugin_name])
|
||||
if plugin_name in _plugin_widgets
|
||||
else f"{plugin_client}"
|
||||
)
|
||||
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
|
||||
if plugin_name not in _overlap:
|
||||
else:
|
||||
globals()[plugin_name] = plugin_class
|
||||
Widgets = _WidgetsEnumType("Widgets", _Widgets)
|
||||
except ImportError as 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__
|
||||
|
||||
if class_name == "BECDockArea":
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
else:
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):\n"""
|
||||
|
||||
if cls.__doc__:
|
||||
# We only want the first line of the docstring
|
||||
@@ -162,13 +137,9 @@ class {class_name}(RPCBase):"""
|
||||
else:
|
||||
class_docs = cls.__doc__.split("\n")[1]
|
||||
self.content += f"""
|
||||
\"\"\"{class_docs}\"\"\"
|
||||
"""
|
||||
\"\"\"{class_docs}\"\"\"\n"""
|
||||
user_access_entries = self._get_user_access_entries(cls)
|
||||
if not user_access_entries:
|
||||
self.content += """...
|
||||
"""
|
||||
|
||||
self.content += f' _IMPORT_MODULE="{cls.__module__}"\n'
|
||||
for method_entry in user_access_entries:
|
||||
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
|
||||
if obj is None:
|
||||
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Iterable
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
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
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
|
||||
@@ -14,11 +14,11 @@ from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QWidget
|
||||
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_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
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.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
|
||||
|
||||
@@ -26,7 +26,7 @@ from qtpy.QtWidgets import (
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
for node in WidgetHierarchy.iter_widget_tree(
|
||||
@@ -468,7 +468,7 @@ class WidgetHierarchy:
|
||||
|
||||
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
|
||||
|
||||
# 1) Gather ALL QWidget-based BECConnector objects
|
||||
@@ -534,7 +534,7 @@ class WidgetHierarchy:
|
||||
Returns:
|
||||
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
|
||||
if not shb.isValid(widget):
|
||||
@@ -636,7 +636,7 @@ class WidgetHierarchy:
|
||||
Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
|
||||
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] = []
|
||||
if isinstance(widget, BECConnector):
|
||||
@@ -664,7 +664,7 @@ class WidgetHierarchy:
|
||||
return None
|
||||
|
||||
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
|
||||
if isinstance(ancestor_class, str):
|
||||
|
||||
@@ -13,9 +13,9 @@ from shiboken6 import isValid
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
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.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.widgets.containers.qt_ads import (
|
||||
CDockAreaWidget,
|
||||
|
||||
@@ -20,10 +20,10 @@ from qtpy.QtWidgets import (
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
from bec_widgets.applications.views.view import ViewTourSteps
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
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 (
|
||||
ExpandableMenuAction,
|
||||
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.services.bec_queue.bec_queue import BECQueue
|
||||
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
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -376,6 +376,7 @@ class BECDockArea(DockAreaWidget):
|
||||
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
|
||||
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
|
||||
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
|
||||
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel", "LogPanel"),
|
||||
}
|
||||
|
||||
# Create expandable menu actions (original behavior)
|
||||
@@ -487,9 +488,7 @@ class BECDockArea(DockAreaWidget):
|
||||
# first two items not needed for this part
|
||||
for key, (_, _, widget_type) in mapping.items():
|
||||
act = menu.actions[key].action
|
||||
if widget_type == "LogPanel":
|
||||
act.setEnabled(False) # keep disabled per issue #644
|
||||
elif key == "terminal":
|
||||
if key == "terminal":
|
||||
act.triggered.connect(
|
||||
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
|
||||
)
|
||||
@@ -510,10 +509,7 @@ class BECDockArea(DockAreaWidget):
|
||||
for action_id, (_, _, widget_type) in mapping.items():
|
||||
flat_action_id = f"flat_{action_id}"
|
||||
flat_action = self.toolbar.components.get_action(flat_action_id).action
|
||||
if widget_type == "LogPanel":
|
||||
flat_action.setEnabled(False) # keep disabled per issue #644
|
||||
else:
|
||||
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
|
||||
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_devices"])
|
||||
|
||||
@@ -22,7 +22,7 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
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):
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ from qtpy.QtCore import QObject, QTimer
|
||||
from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidget
|
||||
|
||||
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.widget_io import WidgetIO
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils import UILoader
|
||||
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.ui_loader import UILoader
|
||||
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 (
|
||||
BECNotificationBroker,
|
||||
|
||||
+1
-1
@@ -11,9 +11,9 @@ from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
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.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 (
|
||||
DeviceUpdateUIComponents,
|
||||
PositionerBoxBase,
|
||||
|
||||
+1
-1
@@ -12,9 +12,9 @@ from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
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 (
|
||||
DeviceUpdateUIComponents,
|
||||
PositionerBoxBase,
|
||||
|
||||
@@ -7,7 +7,7 @@ from bec_lib.device import Signal as BECSignal
|
||||
from bec_lib.logger import bec_logger
|
||||
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.error_popups import SafeProperty, SafeSlot
|
||||
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 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.error_popups import SafeSlot
|
||||
from bec_widgets.utils.filter_io import FilterIO
|
||||
|
||||
@@ -19,12 +19,13 @@ from qtpy.QtWidgets import (
|
||||
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.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
|
||||
from bec_widgets.widgets.control.scan_control.scan_info_adapter import ScanInfoAdapter
|
||||
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
@@ -97,6 +98,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
self._hide_scan_control_buttons = False
|
||||
self._hide_metadata = False
|
||||
self._hide_scan_selection_combobox = False
|
||||
self._scan_info_adapter = ScanInfoAdapter()
|
||||
|
||||
# Create and set main layout
|
||||
self._init_UI()
|
||||
@@ -191,11 +193,12 @@ class ScanControl(BECWidget, QWidget):
|
||||
MessageEndpoints.available_scans()
|
||||
).resource
|
||||
if self.config.allowed_scans is None:
|
||||
supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase"]
|
||||
supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase", "ScanBaseV4"]
|
||||
allowed_scans = [
|
||||
scan_name
|
||||
for scan_name, scan_info in self.available_scans.items()
|
||||
if scan_info["base_class"] in supported_scans and len(scan_info["gui_config"]) > 0
|
||||
if scan_info["base_class"] in supported_scans
|
||||
and self._scan_info_adapter.has_scan_ui_config(scan_info)
|
||||
]
|
||||
|
||||
else:
|
||||
@@ -390,14 +393,14 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.reset_layout()
|
||||
selected_scan_info = self.available_scans.get(scan_name, {})
|
||||
|
||||
gui_config = selected_scan_info.get("gui_config", {})
|
||||
self.arg_group = gui_config.get("arg_group", None)
|
||||
self.kwarg_groups = gui_config.get("kwarg_groups", None)
|
||||
gui_config = self._scan_info_adapter.build_scan_ui_config(selected_scan_info)
|
||||
arg_group = gui_config.get("arg_group", None)
|
||||
kwarg_groups = gui_config.get("kwarg_groups", [])
|
||||
|
||||
if bool(self.arg_group["arg_inputs"]):
|
||||
self.add_arg_group(self.arg_group)
|
||||
if len(self.kwarg_groups) > 0:
|
||||
self.add_kwargs_boxes(self.kwarg_groups)
|
||||
if arg_group and bool(arg_group.get("arg_inputs")):
|
||||
self.add_arg_group(arg_group)
|
||||
if kwarg_groups:
|
||||
self.add_kwargs_boxes(kwarg_groups)
|
||||
|
||||
self.update()
|
||||
self.adjustSize()
|
||||
|
||||
@@ -209,6 +209,8 @@ class ScanGroupBox(QGroupBox):
|
||||
|
||||
self.labels = []
|
||||
self.widgets = []
|
||||
self._widget_configs = {}
|
||||
self._column_labels = {}
|
||||
self.selected_devices = {}
|
||||
|
||||
self.init_box(self.config)
|
||||
@@ -247,6 +249,7 @@ class ScanGroupBox(QGroupBox):
|
||||
label = QLabel(text=display_name)
|
||||
self.layout.addWidget(label, row, column_index)
|
||||
self.labels.append(label)
|
||||
self._column_labels[column_index] = label
|
||||
|
||||
def add_input_widgets(self, group_inputs: dict, row) -> None:
|
||||
"""
|
||||
@@ -272,21 +275,30 @@ class ScanGroupBox(QGroupBox):
|
||||
if default == "_empty":
|
||||
default = None
|
||||
widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default)
|
||||
self._apply_numeric_precision(widget, item)
|
||||
self._apply_numeric_limits(widget, item)
|
||||
if isinstance(widget, DeviceLineEdit):
|
||||
widget.set_device_filter(BECDeviceFilter.DEVICE)
|
||||
self.selected_devices[widget] = ""
|
||||
widget.device_selected.connect(self.emit_device_selected)
|
||||
widget.textChanged.connect(
|
||||
lambda text, device_widget=widget: self._handle_device_text_changed(
|
||||
device_widget, text
|
||||
)
|
||||
)
|
||||
if isinstance(widget, ScanLiteralsComboBox):
|
||||
widget.set_literals(item["type"].get("Literal", []))
|
||||
tooltip = item.get("tooltip", None)
|
||||
if tooltip is not None:
|
||||
widget.setToolTip(item["tooltip"])
|
||||
self._widget_configs[widget] = item
|
||||
self._apply_unit_metadata(widget, item)
|
||||
self.layout.addWidget(widget, row, column_index)
|
||||
self.widgets.append(widget)
|
||||
|
||||
@Slot(str)
|
||||
def emit_device_selected(self, device_name):
|
||||
self.selected_devices[self.sender()] = device_name.strip()
|
||||
sender = self.sender()
|
||||
self.selected_devices[sender] = device_name.strip()
|
||||
if isinstance(sender, DeviceLineEdit):
|
||||
self._update_reference_units(sender, self._device_units(sender.get_current_device()))
|
||||
selected_devices_str = " ".join(self.selected_devices.values())
|
||||
self.device_selected.emit(selected_devices_str)
|
||||
|
||||
@@ -313,6 +325,7 @@ class ScanGroupBox(QGroupBox):
|
||||
for widget in self.widgets[-len(self.inputs) :]:
|
||||
if isinstance(widget, DeviceLineEdit):
|
||||
self.selected_devices[widget] = ""
|
||||
self._widget_configs.pop(widget, None)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
self.widgets = self.widgets[: -len(self.inputs)]
|
||||
@@ -325,6 +338,7 @@ class ScanGroupBox(QGroupBox):
|
||||
for widget in list(self.widgets):
|
||||
if isinstance(widget, DeviceLineEdit):
|
||||
self.selected_devices.pop(widget, None)
|
||||
self._widget_configs.pop(widget, None)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
self.layout.removeWidget(widget)
|
||||
@@ -423,3 +437,119 @@ class ScanGroupBox(QGroupBox):
|
||||
if widget.arg_name == key:
|
||||
WidgetIO.set_value(widget, value)
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
def _unit_tooltip(item: dict, units: str | None = None) -> str | None:
|
||||
tooltip = item.get("tooltip", None)
|
||||
reference_units = item.get("reference_units", None)
|
||||
units = units or item.get("units", None)
|
||||
tooltip_parts = [tooltip] if tooltip else []
|
||||
if units:
|
||||
tooltip_parts.append(f"Units: {units}")
|
||||
elif reference_units:
|
||||
tooltip_parts.append(f"Units from: {reference_units}")
|
||||
if tooltip_parts:
|
||||
return "\n".join(tooltip_parts)
|
||||
return None
|
||||
|
||||
def _apply_unit_metadata(self, widget, item: dict, units: str | None = None) -> None:
|
||||
units = units or item.get("units", None)
|
||||
tooltip = self._unit_tooltip(item, units)
|
||||
widget.setToolTip(tooltip or "")
|
||||
if hasattr(widget, "setSuffix"):
|
||||
widget.setSuffix(f" {units}" if units else "")
|
||||
|
||||
def _refresh_column_label(self, column: int, item: dict) -> None:
|
||||
if column not in self._column_labels:
|
||||
return
|
||||
self._column_labels[column].setText(item.get("display_name", item.get("name", None)))
|
||||
|
||||
@staticmethod
|
||||
def _device_units(device) -> str | None:
|
||||
egu = getattr(device, "egu", None)
|
||||
if not callable(egu):
|
||||
return None
|
||||
try:
|
||||
return egu()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch engineering units from device %s", device)
|
||||
return None
|
||||
|
||||
def _widget_position(self, widget) -> tuple[int, int] | None:
|
||||
for row in range(self.layout.rowCount()):
|
||||
for column in range(self.layout.columnCount()):
|
||||
item = self.layout.itemAtPosition(row, column)
|
||||
if item is not None and item.widget() is widget:
|
||||
return row, column
|
||||
return None
|
||||
|
||||
def _update_reference_units(self, device_widget: DeviceLineEdit, units: str | None) -> None:
|
||||
position = self._widget_position(device_widget)
|
||||
if position is None:
|
||||
return
|
||||
source_row, _ = position
|
||||
source_name = device_widget.arg_name
|
||||
|
||||
for widget in self.widgets:
|
||||
item = self._widget_configs.get(widget, {})
|
||||
if item.get("reference_units") != source_name:
|
||||
continue
|
||||
widget_position = self._widget_position(widget)
|
||||
if widget_position is None:
|
||||
continue
|
||||
row, column = widget_position
|
||||
if self.box_type == "args" and row != source_row:
|
||||
continue
|
||||
self._apply_unit_metadata(widget, item, units)
|
||||
self._refresh_column_label(column, item)
|
||||
|
||||
def _handle_device_text_changed(self, device_widget: DeviceLineEdit, device_name: str) -> None:
|
||||
if not device_widget.validate_device(device_name):
|
||||
self.selected_devices[device_widget] = ""
|
||||
self._update_reference_units(device_widget, None)
|
||||
|
||||
@staticmethod
|
||||
def _apply_numeric_precision(widget: ScanDoubleSpinBox, item: dict) -> None:
|
||||
if not isinstance(widget, ScanDoubleSpinBox):
|
||||
return
|
||||
|
||||
precision = item.get("precision")
|
||||
if precision is None:
|
||||
return
|
||||
|
||||
try:
|
||||
widget.setDecimals(max(0, int(precision)))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Ignoring invalid precision %r for parameter %s", precision, item.get("name")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _apply_numeric_limits(widget: ScanDoubleSpinBox | ScanSpinBox, item: dict) -> None:
|
||||
if isinstance(widget, ScanSpinBox):
|
||||
minimum = -2147483647 # largest int which qt allows
|
||||
maximum = 2147483647
|
||||
if item.get("ge") is not None:
|
||||
minimum = int(item["ge"])
|
||||
if item.get("gt") is not None:
|
||||
minimum = int(item["gt"]) + 1
|
||||
if item.get("le") is not None:
|
||||
maximum = int(item["le"])
|
||||
if item.get("lt") is not None:
|
||||
maximum = int(item["lt"]) - 1
|
||||
widget.setRange(minimum, maximum)
|
||||
return
|
||||
|
||||
if isinstance(widget, ScanDoubleSpinBox):
|
||||
minimum = -float("inf")
|
||||
maximum = float("inf")
|
||||
step = 10 ** (-widget.decimals())
|
||||
if item.get("ge") is not None:
|
||||
minimum = float(item["ge"])
|
||||
if item.get("gt") is not None:
|
||||
minimum = float(item["gt"]) + step
|
||||
if item.get("le") is not None:
|
||||
maximum = float(item["le"])
|
||||
if item.get("lt") is not None:
|
||||
maximum = float(item["lt"]) - step
|
||||
widget.setRange(minimum, maximum)
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Helpers for translating BEC scan metadata into ScanControl UI configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
AnnotationValue = str | dict[str, Any] | list[Any] | None
|
||||
ScanArgumentMetadata = dict[str, Any]
|
||||
SignatureEntry = dict[str, Any]
|
||||
ScanInputConfig = dict[str, Any]
|
||||
ScanInfo = dict[str, Any]
|
||||
ScanUIConfig = dict[str, Any]
|
||||
|
||||
|
||||
class ScanInfoAdapter:
|
||||
"""Normalize available-scan payloads into the structure consumed by ``ScanControl``."""
|
||||
|
||||
@staticmethod
|
||||
def has_scan_ui_config(scan_info: ScanInfo) -> bool:
|
||||
"""Check whether a scan exposes enough metadata to build a UI.
|
||||
|
||||
Args:
|
||||
scan_info (ScanInfo): Available-scan payload for one scan.
|
||||
|
||||
Returns:
|
||||
bool: ``True`` when a supported GUI metadata field is present.
|
||||
"""
|
||||
return bool(scan_info.get("gui_visibility") or scan_info.get("gui_config"))
|
||||
|
||||
@staticmethod
|
||||
def format_display_name(name: str) -> str:
|
||||
"""Convert a parameter name into a user-facing label.
|
||||
|
||||
Args:
|
||||
name (str): Raw parameter name.
|
||||
|
||||
Returns:
|
||||
str: Formatted display label such as ``Exp Time``.
|
||||
"""
|
||||
parts = re.split(r"(_|\d+)", name)
|
||||
return " ".join(part.capitalize() for part in parts if part.isalnum()).strip()
|
||||
|
||||
@staticmethod
|
||||
def resolve_tooltip(scan_argument: ScanArgumentMetadata) -> str | None:
|
||||
"""Resolve the tooltip text from parsed ``ScanArgument`` metadata.
|
||||
|
||||
Args:
|
||||
scan_argument (ScanArgumentMetadata): Parsed ``ScanArgument`` metadata.
|
||||
|
||||
Returns:
|
||||
str | None: Explicit tooltip text if provided, otherwise the description fallback.
|
||||
"""
|
||||
return scan_argument.get("tooltip") or scan_argument.get("description")
|
||||
|
||||
@staticmethod
|
||||
def parse_annotation(
|
||||
annotation: AnnotationValue,
|
||||
) -> tuple[AnnotationValue, ScanArgumentMetadata]:
|
||||
"""Extract the serialized base annotation and ``ScanArgument`` metadata.
|
||||
|
||||
Args:
|
||||
annotation (AnnotationValue): Serialized annotation payload from BEC.
|
||||
|
||||
Returns:
|
||||
tuple[AnnotationValue, ScanArgumentMetadata]: The unwrapped annotation and parsed
|
||||
``ScanArgument`` metadata.
|
||||
"""
|
||||
scan_argument: ScanArgumentMetadata = {}
|
||||
if isinstance(annotation, list):
|
||||
annotation = next(
|
||||
(entry for entry in annotation if entry != "NoneType"),
|
||||
annotation[0] if annotation else "_empty",
|
||||
)
|
||||
if isinstance(annotation, dict) and "Annotated" in annotation:
|
||||
annotated = annotation["Annotated"]
|
||||
annotation = annotated.get("type", "_empty")
|
||||
scan_argument = annotated.get("metadata", {}).get("ScanArgument", {}) or {}
|
||||
return annotation, scan_argument
|
||||
|
||||
@staticmethod
|
||||
def scan_arg_type_from_annotation(annotation: AnnotationValue) -> AnnotationValue:
|
||||
"""Normalize an annotation value to the widget type expected by ``ScanControl``.
|
||||
|
||||
Args:
|
||||
annotation (AnnotationValue): Serialized or parsed annotation value.
|
||||
|
||||
Returns:
|
||||
AnnotationValue: The normalized type identifier used by the widget layer.
|
||||
"""
|
||||
if isinstance(annotation, dict):
|
||||
return annotation
|
||||
if annotation in ("_empty", None):
|
||||
return "str"
|
||||
return annotation
|
||||
|
||||
def scan_input_from_signature(
|
||||
self, param: SignatureEntry, arg: bool = False
|
||||
) -> ScanInputConfig:
|
||||
"""Build one ScanControl input description from a signature entry.
|
||||
|
||||
Args:
|
||||
param (SignatureEntry): Serialized signature entry.
|
||||
arg (bool): Whether the parameter belongs to the positional arg bundle.
|
||||
|
||||
Returns:
|
||||
ScanInputConfig: Normalized input configuration for ``ScanControl``.
|
||||
"""
|
||||
annotation, scan_argument = self.parse_annotation(param.get("annotation"))
|
||||
return self._build_scan_input(
|
||||
name=param["name"],
|
||||
annotation=annotation,
|
||||
scan_argument=scan_argument,
|
||||
arg=arg,
|
||||
default=None if arg else param.get("default", None),
|
||||
)
|
||||
|
||||
def scan_input_from_arg_input(
|
||||
self, name: str, item_type: AnnotationValue, signature_by_name: dict[str, SignatureEntry]
|
||||
) -> ScanInputConfig:
|
||||
"""Build one arg-bundle input description from ``arg_input`` metadata.
|
||||
|
||||
Args:
|
||||
name (str): Argument name from ``arg_input``.
|
||||
item_type (AnnotationValue): Serialized argument type from ``arg_input``.
|
||||
signature_by_name (dict[str, SignatureEntry]): Signature entries indexed by
|
||||
parameter name.
|
||||
|
||||
Returns:
|
||||
ScanInputConfig: Normalized input configuration for one arg-bundle field.
|
||||
"""
|
||||
if name in signature_by_name:
|
||||
scan_input = self.scan_input_from_signature(signature_by_name[name], arg=True)
|
||||
scan_input["type"] = self.scan_arg_type_from_annotation(
|
||||
self.parse_annotation(signature_by_name[name].get("annotation"))[0]
|
||||
)
|
||||
else:
|
||||
annotation, scan_argument = self.parse_annotation(item_type)
|
||||
scan_input = self._build_scan_input(
|
||||
name=name,
|
||||
annotation=annotation,
|
||||
scan_argument=scan_argument,
|
||||
arg=True,
|
||||
default=None,
|
||||
)
|
||||
if scan_input["type"] in ("_empty", None):
|
||||
scan_input["type"] = item_type
|
||||
return scan_input
|
||||
|
||||
def _build_scan_input(
|
||||
self,
|
||||
name: str,
|
||||
annotation: AnnotationValue,
|
||||
scan_argument: ScanArgumentMetadata,
|
||||
*,
|
||||
arg: bool,
|
||||
default: Any,
|
||||
) -> ScanInputConfig:
|
||||
"""Build one normalized ScanControl input configuration.
|
||||
|
||||
Args:
|
||||
name (str): Parameter name.
|
||||
annotation (AnnotationValue): Parsed annotation value.
|
||||
scan_argument (ScanArgumentMetadata): Parsed ``ScanArgument`` metadata.
|
||||
arg (bool): Whether the parameter belongs to the positional arg bundle.
|
||||
default (Any): Default value for the parameter.
|
||||
|
||||
Returns:
|
||||
ScanInputConfig: Normalized input configuration.
|
||||
"""
|
||||
return {
|
||||
"arg": arg,
|
||||
"name": name,
|
||||
"type": self.scan_arg_type_from_annotation(annotation),
|
||||
"display_name": scan_argument.get("display_name") or self.format_display_name(name),
|
||||
"tooltip": self.resolve_tooltip(scan_argument),
|
||||
"default": default,
|
||||
"expert": scan_argument.get("expert", False),
|
||||
"precision": scan_argument.get("precision"),
|
||||
"units": scan_argument.get("units"),
|
||||
"reference_units": scan_argument.get("reference_units"),
|
||||
"gt": scan_argument.get("gt"),
|
||||
"ge": scan_argument.get("ge"),
|
||||
"lt": scan_argument.get("lt"),
|
||||
"le": scan_argument.get("le"),
|
||||
"alternative_group": scan_argument.get("alternative_group"),
|
||||
}
|
||||
|
||||
def build_scan_ui_config(self, scan_info: ScanInfo) -> ScanUIConfig:
|
||||
"""Normalize one available-scan entry into the widget UI configuration.
|
||||
|
||||
Args:
|
||||
scan_info (ScanInfo): Available-scan payload for one scan.
|
||||
|
||||
Returns:
|
||||
ScanUIConfig: Legacy group structure consumed by ``ScanControl`` and
|
||||
``ScanGroupBox``.
|
||||
"""
|
||||
gui_visualization = (
|
||||
scan_info.get("gui_visualization") or scan_info.get("gui_visibility") or {}
|
||||
)
|
||||
if not gui_visualization and scan_info.get("gui_config"):
|
||||
return scan_info["gui_config"]
|
||||
|
||||
signature = scan_info.get("signature", [])
|
||||
signature_by_name = {entry["name"]: entry for entry in signature}
|
||||
|
||||
arg_group = None
|
||||
arg_input = scan_info.get("arg_input", {})
|
||||
if isinstance(arg_input, dict) and arg_input:
|
||||
bundle_size = scan_info.get("arg_bundle_size", {})
|
||||
inputs = [
|
||||
self.scan_input_from_arg_input(name, item_type, signature_by_name)
|
||||
for name, item_type in arg_input.items()
|
||||
]
|
||||
arg_group = {
|
||||
"name": "Scan Arguments",
|
||||
"bundle": bundle_size.get("bundle"),
|
||||
"arg_inputs": arg_input,
|
||||
"inputs": inputs,
|
||||
"min": bundle_size.get("min"),
|
||||
"max": bundle_size.get("max"),
|
||||
}
|
||||
|
||||
kwarg_groups = []
|
||||
arg_names = set(arg_input) if isinstance(arg_input, dict) else set()
|
||||
for group_name, input_names in gui_visualization.items():
|
||||
inputs = []
|
||||
for input_name in input_names:
|
||||
if input_name in arg_names or input_name not in signature_by_name:
|
||||
continue
|
||||
param = signature_by_name[input_name]
|
||||
if param.get("kind") in ("VAR_POSITIONAL", "VAR_KEYWORD"):
|
||||
continue
|
||||
inputs.append(self.scan_input_from_signature(param))
|
||||
if inputs:
|
||||
kwarg_groups.append({"name": group_name, "inputs": inputs})
|
||||
|
||||
return {
|
||||
"scan_class_name": scan_info.get("class"),
|
||||
"arg_group": arg_group,
|
||||
"kwarg_groups": kwarg_groups,
|
||||
}
|
||||
@@ -5,10 +5,10 @@ from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Signal
|
||||
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.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ from scipy.interpolate import (
|
||||
from scipy.spatial import cKDTree
|
||||
from toolz import partition
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
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.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
@@ -611,7 +611,7 @@ class Heatmap(ImageBase):
|
||||
scan_msg = self.scan_item.status_message
|
||||
elif hasattr(self.scan_item, "metadata"):
|
||||
metadata = self.scan_item.metadata["bec"]
|
||||
status = metadata["exit_status"]
|
||||
status = metadata["status"]
|
||||
scan_id = metadata["scan_id"]
|
||||
scan_name = metadata["scan_name"]
|
||||
scan_type = metadata["scan_type"]
|
||||
|
||||
@@ -4,9 +4,9 @@ import os
|
||||
|
||||
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.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
|
||||
class HeatmapSettings(SettingWidget):
|
||||
|
||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QTimer
|
||||
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.error_popups import SafeProperty, SafeSlot
|
||||
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.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.error_popups import SafeProperty, SafeSlot
|
||||
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.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 (
|
||||
ImageProcessor,
|
||||
ImageStats,
|
||||
|
||||
@@ -20,7 +20,8 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
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.bundles import ToolbarBundle
|
||||
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.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors, apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
|
||||
@@ -2,9 +2,9 @@ import os
|
||||
|
||||
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.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
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.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.side_panel import SidePanel
|
||||
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 bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ from bec_lib import bec_logger
|
||||
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
|
||||
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.crosshair import Crosshair
|
||||
from bec_widgets.utils.entry_validator import EntryValidator
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.fps_counter import FPSCounter
|
||||
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
|
||||
|
||||
@@ -10,7 +10,7 @@ from qtpy import QtCore
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -8,7 +8,8 @@ from bec_lib import bec_logger
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
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
|
||||
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.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.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
|
||||
@@ -2,9 +2,9 @@ import os
|
||||
|
||||
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.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
|
||||
class ScatterCurveSettings(SettingWidget):
|
||||
|
||||
@@ -2,9 +2,9 @@ import os
|
||||
|
||||
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.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ from bec_lib import bec_logger
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
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
|
||||
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.utils import ConnectionConfig, EntryValidator
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
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.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||
|
||||
@@ -25,7 +25,7 @@ from qtpy.QtWidgets import (
|
||||
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.colors import Colors, apply_theme
|
||||
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.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
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.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
|
||||
@@ -19,9 +19,9 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
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.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QThreadPool, Signal
|
||||
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.error_popups import SafeSlot
|
||||
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.widgets.services.device_browser.device_item import DeviceItem
|
||||
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(" ", " ").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
|
||||
|
||||
def group(self):
|
||||
return "BEC Services"
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(LogPanel.ICON_NAME)
|
||||
@@ -51,7 +51,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return "LogPanel"
|
||||
|
||||
def toolTip(self):
|
||||
return "Displays a log panel"
|
||||
return "LogPanel"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
|
||||
@@ -2,21 +2,31 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
import os
|
||||
import re
|
||||
from collections import deque
|
||||
from functools import partial, reduce
|
||||
from re import Pattern
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Iterable, Literal
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import LogLevel, bec_logger
|
||||
from bec_lib.messages import LogMessage, StatusMessage
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QDateTime, QObject, Qt, Signal
|
||||
from qtpy.QtGui import QFont
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtCore import (
|
||||
QAbstractTableModel,
|
||||
QCoreApplication,
|
||||
QDateTime,
|
||||
QModelIndex,
|
||||
QObject,
|
||||
QPersistentModelIndex,
|
||||
QSize,
|
||||
QSortFilterProxyModel,
|
||||
Qt,
|
||||
QTimer,
|
||||
)
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
@@ -25,204 +35,414 @@ from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QTextEdit,
|
||||
QSizePolicy,
|
||||
QTableView,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from thefuzz import fuzz
|
||||
|
||||
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.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
|
||||
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# TODO: improve log color handling
|
||||
DEFAULT_LOG_COLORS = {
|
||||
LogLevel.INFO: "#FFFFFF",
|
||||
LogLevel.SUCCESS: "#00FF00",
|
||||
LogLevel.WARNING: "#FFCC00",
|
||||
LogLevel.ERROR: "#FF0000",
|
||||
LogLevel.DEBUG: "#0000CC",
|
||||
_DEFAULT_LOG_COLORS = {
|
||||
LogLevel.INFO.name: QColor("#FFFFFF"),
|
||||
LogLevel.SUCCESS.name: QColor("#00FF00"),
|
||||
LogLevel.WARNING.name: QColor("#FFCC00"),
|
||||
LogLevel.ERROR.name: QColor("#FF0000"),
|
||||
LogLevel.DEBUG.name: QColor("#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):
|
||||
"""Manages getting logs from BEC Redis and formatting them for display"""
|
||||
|
||||
RPC = False
|
||||
new_message = Signal()
|
||||
new_messages = Signal()
|
||||
paused = Signal(bool)
|
||||
_instance: BecLogsQueue | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QObject | None,
|
||||
maxlen: int = 1000,
|
||||
line_formatter: LineFormatter = noop_format,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
@classmethod
|
||||
def instance(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = cls(QCoreApplication.instance())
|
||||
return cls._instance
|
||||
|
||||
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)
|
||||
self._timestamp_start: QDateTime | None = None
|
||||
self._timestamp_end: QDateTime | None = None
|
||||
self._max_length = maxlen
|
||||
self._data: deque[LogMessage] = deque([], self._max_length)
|
||||
self._display_queue: deque[str] = deque([], self._max_length)
|
||||
self._log_level: str | None = None
|
||||
self._search_query: Pattern | str | None = None
|
||||
self._selected_services: set[str] | None = None
|
||||
self._set_formatter_and_update_filter(line_formatter)
|
||||
# instance attribute still accessible after c++ object is deleted, so the callback can be unregistered
|
||||
self._paused = False
|
||||
self._data = deque(
|
||||
(
|
||||
item["data"]
|
||||
for item in self.bec_dispatcher.client.connector.xread(
|
||||
MessageEndpoints.log(), count=self._max_length, id="0"
|
||||
)
|
||||
),
|
||||
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._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, *_):
|
||||
"""Stop listening to the Redis log stream"""
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self._process_incoming_log_msg, [MessageEndpoints.log()]
|
||||
)
|
||||
self._update_timer.stop()
|
||||
BecLogsQueue._instance = None
|
||||
|
||||
@SafeSlot(verify_sender=True)
|
||||
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
|
||||
try:
|
||||
_msg = LogMessage(**msg)
|
||||
self._data.append(_msg)
|
||||
if self.filter is None or self.filter(_msg):
|
||||
self._display_queue.append(self._line_formatter(_msg))
|
||||
self.new_message.emit()
|
||||
self._incoming.append(_msg)
|
||||
except Exception as e:
|
||||
if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
|
||||
return
|
||||
logger.warning(f"Error in LogPanel incoming message callback: {e}")
|
||||
|
||||
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
|
||||
self._line_formatter: LineFormatter = line_formatter
|
||||
self._queue_formatter: LinesHtmlFormatter = create_formatter(
|
||||
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!")
|
||||
@SafeSlot(verify_sender=True)
|
||||
def _proc_update(self):
|
||||
if self._paused or len(self._incoming) == 0:
|
||||
return
|
||||
self._log_level = level
|
||||
self._set_formatter_and_update_filter(self._line_formatter)
|
||||
self._data.extend(self._incoming)
|
||||
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):
|
||||
"""Change the start and/or end times to filter against"""
|
||||
self._timestamp_start = start
|
||||
self._timestamp_end = end
|
||||
self._set_formatter_and_update_filter(self._line_formatter)
|
||||
class BecLogsTableModel(QAbstractTableModel):
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.log_queue = BecLogsQueue.instance()
|
||||
self.log_queue.new_messages.connect(self.handle_new_messages)
|
||||
self._headers = _CONST.headers
|
||||
|
||||
def update_service_filter(self, services: set[str]):
|
||||
"""Change the selected services to display"""
|
||||
self._selected_services = services
|
||||
self._set_formatter_and_update_filter(self._line_formatter)
|
||||
def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
|
||||
return len(self.log_queue)
|
||||
|
||||
def update_line_formatter(self, line_formatter: LineFormatter):
|
||||
"""Update the formatter"""
|
||||
self._set_formatter_and_update_filter(line_formatter)
|
||||
def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
|
||||
return len(self._headers)
|
||||
|
||||
def display_all(self) -> str:
|
||||
"""Return formatted output for all log messages"""
|
||||
return "\n".join(self._queue_formatter(self._data.copy()))
|
||||
def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)):
|
||||
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
|
||||
return self._headers[section]
|
||||
return None
|
||||
|
||||
def format_new(self):
|
||||
"""Return formatted output for the display queue"""
|
||||
res = "\n".join(self._display_queue)
|
||||
self._display_queue = deque([], self._max_length)
|
||||
return res
|
||||
def get_row_data(self, index: QModelIndex) -> LogMessage | None:
|
||||
"""Return the row data for the given index."""
|
||||
if not index.isValid():
|
||||
return None
|
||||
return self.log_queue.row_data(index.row())
|
||||
|
||||
def clear_logs(self):
|
||||
"""Clear the cache and display queue"""
|
||||
self._data = deque([])
|
||||
self._display_queue = deque([])
|
||||
def timestamp(self, row: int):
|
||||
return QDateTime.fromMSecsSinceEpoch(int(self.log_queue.log_timestamp(row) * 1000))
|
||||
|
||||
def fetch_history(self):
|
||||
"""Fetch all available messages from Redis"""
|
||||
self._data = deque(
|
||||
item["data"]
|
||||
for item in self.bec_dispatcher.client.connector.xread(
|
||||
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length
|
||||
)
|
||||
def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)):
|
||||
"""Return data for the given index and role."""
|
||||
if not index.isValid():
|
||||
return
|
||||
if role in [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole]:
|
||||
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"""
|
||||
return set(msg.log_msg["service_name"] for msg in self._data)
|
||||
|
||||
class LogMsgProxyModel(QSortFilterProxyModel):
|
||||
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):
|
||||
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) -> None:
|
||||
def __init__(self, parent: QWidget | None = None, client: BECClient | None = None) -> None:
|
||||
"""A toolbar for the logpanel, mainly used for managing the states of filters"""
|
||||
super().__init__(parent)
|
||||
|
||||
@@ -231,51 +451,69 @@ class LogPanelToolbar(QWidget):
|
||||
self._timestamp_end: QDateTime | None = None
|
||||
|
||||
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)
|
||||
self.layout.addWidget(self.service_choice_button)
|
||||
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
|
||||
if client is not None:
|
||||
self.client = client
|
||||
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.layout.addWidget(self.filter_level_dropdown)
|
||||
|
||||
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._layout.addWidget(self.filter_level_dropdown)
|
||||
self.filter_level_dropdown.currentTextChanged.connect(self._emit_level)
|
||||
|
||||
self._string_search_box()
|
||||
|
||||
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
|
||||
def time_start(self):
|
||||
return self._timestamp_start
|
||||
self.pause_button = QToolButton()
|
||||
self.pause_button.setIcon(material_icon("pause", size=(20, 20), convert_to_pixmap=False))
|
||||
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
|
||||
def time_end(self):
|
||||
return self._timestamp_end
|
||||
@SafeSlot(bool)
|
||||
def _update_pause_button_icon(self, paused):
|
||||
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):
|
||||
self.layout.addWidget(QLabel("Search: "))
|
||||
self._layout.addWidget(QLabel("Search: "))
|
||||
self.search_textbox = QLineEdit()
|
||||
self.layout.addWidget(self.search_textbox)
|
||||
self.layout.addWidget(QLabel("Use regex: "))
|
||||
self.regex_enabled = QCheckBox()
|
||||
self.layout.addWidget(self.regex_enabled)
|
||||
self.update_re_button = QPushButton("Update search", self)
|
||||
self.layout.addWidget(self.update_re_button)
|
||||
self._layout.addWidget(self.search_textbox)
|
||||
self._layout.addWidget(QLabel("Fuzzy: "))
|
||||
self.fuzzy = QCheckBox()
|
||||
self._layout.addWidget(self.fuzzy)
|
||||
self.fuzzy.checkStateChanged.connect(self._emit_fuzzy)
|
||||
|
||||
def _log_level_box(self):
|
||||
box = QComboBox()
|
||||
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
|
||||
|
||||
@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"]):
|
||||
if selection_type == "start":
|
||||
return self._timestamp_start
|
||||
@@ -284,6 +522,7 @@ class LogPanelToolbar(QWidget):
|
||||
else:
|
||||
raise ValueError(f"timestamps can only be for the start or end, not {selection_type}")
|
||||
|
||||
@SafeSlot()
|
||||
def _open_datetime_dialog(self):
|
||||
"""Open dialog window for timestamp filter selection"""
|
||||
self._dt_dialog = QDialog(self)
|
||||
@@ -312,8 +551,8 @@ class LogPanelToolbar(QWidget):
|
||||
)
|
||||
_layout.addWidget(date_clear_button)
|
||||
|
||||
for v in [("start", label_start), ("end", label_end)]:
|
||||
date_button_set(*v)
|
||||
date_button_set("start", label_start)
|
||||
date_button_set("end", label_end)
|
||||
|
||||
close_button = QPushButton("Close", parent=self._dt_dialog)
|
||||
close_button.clicked.connect(self._dt_dialog.accept)
|
||||
@@ -352,27 +591,23 @@ class LogPanelToolbar(QWidget):
|
||||
self._timestamp_start = dt
|
||||
else:
|
||||
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], services_from_history: set[str], *_, **__
|
||||
):
|
||||
def service_list_update(self, services_info: dict[str, StatusMessage]):
|
||||
"""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 |= services_from_history
|
||||
if self._services_selected is None:
|
||||
self._services_selected = self._unique_service_names
|
||||
|
||||
@SafeSlot()
|
||||
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:
|
||||
return
|
||||
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()
|
||||
self._svc_dialog.setLayout(layout)
|
||||
|
||||
service_cb_grid = QGridLayout(parent=self._svc_dialog)
|
||||
service_cb_grid = QGridLayout()
|
||||
layout.addLayout(service_cb_grid)
|
||||
|
||||
def check_box(name: str, checked: Qt.CheckState):
|
||||
@@ -398,146 +633,6 @@ class LogPanelToolbar(QWidget):
|
||||
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
|
||||
import sys
|
||||
|
||||
@@ -545,7 +640,15 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
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())
|
||||
|
||||
@@ -3,8 +3,8 @@ from qtpy import QtCore, QtGui
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
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.colors import Colors
|
||||
|
||||
|
||||
class RoundedColorMapButton(ColorMapButton):
|
||||
|
||||
@@ -19,7 +19,7 @@ from qtpy.QtWidgets import (
|
||||
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_io import WidgetHierarchy
|
||||
|
||||
|
||||
+10
-11
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "3.5.0"
|
||||
version = "3.7.1"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
@@ -12,19 +12,19 @@ dependencies = [
|
||||
"PyJWT~=2.9",
|
||||
"PySide6==6.9.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_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",
|
||||
"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",
|
||||
"ophyd_devices~=1.29, >=1.29.1",
|
||||
"pydantic~=2.0",
|
||||
"pylsp-bec~=1.2",
|
||||
"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",
|
||||
"qtpy~=2.4",
|
||||
"thefuzz~=0.22",
|
||||
@@ -38,8 +38,8 @@ Homepage = "https://gitlab.psi.ch/bec/bec_widgets"
|
||||
[project.scripts]
|
||||
bec-app = "bec_widgets.applications.main_app:main"
|
||||
bec-designer = "bec_widgets.utils.bec_designer:main"
|
||||
bec-gui-server = "bec_widgets.cli.server:main"
|
||||
bw-generate-cli = "bec_widgets.cli.generate_cli:main"
|
||||
bec-gui-server = "bec_widgets.applications.companion_app:main"
|
||||
bw-generate-cli = "bec_widgets.utils.generate_cli:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
@@ -52,12 +52,11 @@ dev = [
|
||||
"pytest-xvfb~=3.0",
|
||||
"pytest~=8.0",
|
||||
"pytest-cov~=6.1.1",
|
||||
"pytest-benchmark~=5.2",
|
||||
"watchdog~=6.0",
|
||||
"pre_commit~=4.2",
|
||||
]
|
||||
qtermwidget = [
|
||||
"pyside6_qtermwidget",
|
||||
]
|
||||
qtermwidget = ["pyside6_qtermwidget"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
@@ -68,7 +67,7 @@ line-length = 100
|
||||
skip-magic-trailing-comma = true
|
||||
|
||||
[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 = [
|
||||
"pragma: no cover",
|
||||
"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
@@ -1,3 +1,5 @@
|
||||
import traceback
|
||||
|
||||
import pytest
|
||||
import qtpy.QtCore
|
||||
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||
@@ -5,12 +7,14 @@ from qtpy.QtCore import QTimer
|
||||
|
||||
|
||||
class TestableQTimer(QTimer):
|
||||
_instances: list[tuple[QTimer, str]] = []
|
||||
_instances: list[tuple[QTimer, str, str]] = []
|
||||
_current_test_name: str = ""
|
||||
|
||||
def __init__(self, *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
|
||||
def check_all_stopped(cls, qtbot):
|
||||
@@ -20,12 +24,21 @@ class TestableQTimer(QTimer):
|
||||
except RuntimeError as e:
|
||||
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:
|
||||
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:
|
||||
active_timers = list(filter(lambda t: t[0].isActive(), cls._instances))
|
||||
(t.stop() for t, _ in cls._instances)
|
||||
raise TimeoutError(f"Failed to stop all timers: {active_timers}") from exc
|
||||
(t.stop() for t, _, _ in cls._instances)
|
||||
raise TimeoutError(
|
||||
f"Failed to stop all timers:\n{_format_timers(active_timers)}"
|
||||
) from exc
|
||||
cls._instances = []
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ def threads_check_fixture(threads_check):
|
||||
@pytest.fixture
|
||||
def gui_id():
|
||||
"""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")
|
||||
@@ -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)
|
||||
yield gui
|
||||
finally:
|
||||
gui.bec.delete_all() # ensure clean state
|
||||
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
|
||||
if (bec := getattr(gui, "bec", None)) is not None:
|
||||
bec.delete_all() # ensure clean state
|
||||
qtbot.waitUntil(lambda: len(bec.widget_list()) == 0, timeout=10000)
|
||||
gui.kill_server()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# 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)
|
||||
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""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
|
||||
@@ -23,11 +23,11 @@ from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||
from qtpy.QtCore import QEvent, QEventLoop
|
||||
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.utils import bec_dispatcher as bec_dispatcher_module
|
||||
from bec_widgets.utils import error_popups
|
||||
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
|
||||
# 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(
|
||||
scan_id=metadata["scan_id"],
|
||||
scan_name=metadata["scan_name"],
|
||||
exit_status=metadata["exit_status"],
|
||||
exit_status=metadata["status"],
|
||||
file_path=file_path,
|
||||
scan_number=metadata["scan_number"],
|
||||
dataset_number=metadata["dataset_number"],
|
||||
@@ -274,7 +274,7 @@ def grid_scan_history_msg(tmpdir):
|
||||
"scan_id": "test_scan",
|
||||
"scan_name": "grid_scan",
|
||||
"scan_type": "step",
|
||||
"exit_status": "closed",
|
||||
"status": "closed",
|
||||
"scan_number": 1,
|
||||
"dataset_number": 1,
|
||||
"request_inputs": {
|
||||
@@ -354,7 +354,7 @@ def scan_history_factory(tmpdir):
|
||||
"scan_id": scan_id,
|
||||
"scan_name": scan_name,
|
||||
"scan_type": scan_type,
|
||||
"exit_status": "closed",
|
||||
"status": "closed",
|
||||
"scan_number": scan_number,
|
||||
"dataset_number": dataset_number,
|
||||
"request_inputs": {
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from qtpy.QtCore import QObject
|
||||
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 SafeSlot as Slot
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase
|
||||
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()
|
||||
@@ -25,12 +26,13 @@ mock_client_module_globals.Widgets = _TestGlobalPlugin
|
||||
def test_plugins_dont_clobber_client_globals(bec_logger: MagicMock):
|
||||
reload(client)
|
||||
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)
|
||||
|
||||
|
||||
class _TestDuplicatePlugin(RPCBase): ...
|
||||
class _TestDuplicatePlugin(RPCBase):
|
||||
_IMPORT_MODULE = "test.duplicate.plugin.module"
|
||||
|
||||
|
||||
mock_client_module_duplicate = SimpleNamespace()
|
||||
@@ -54,7 +56,7 @@ def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock):
|
||||
reload(client)
|
||||
assert (
|
||||
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
|
||||
)
|
||||
|
||||
@@ -4,9 +4,9 @@ from pydantic import ValidationError
|
||||
from qtpy.QtGui import QColor
|
||||
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.colors import apply_theme
|
||||
from bec_widgets.utils.colors import Colors, apply_theme
|
||||
from bec_widgets.widgets.plots.waveform.curve import CurveConfig
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
from qtpy.QtCore import QPointF, Qt
|
||||
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.waveform.waveform import Waveform
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
|
||||
@@ -2229,7 +2229,6 @@ class TestFlatToolbarActions:
|
||||
"flat_progress_bar",
|
||||
"flat_terminal",
|
||||
"flat_bec_shell",
|
||||
"flat_log_panel",
|
||||
"flat_sbb_monitor",
|
||||
]
|
||||
|
||||
@@ -2289,11 +2288,6 @@ class TestFlatToolbarActions:
|
||||
action.trigger()
|
||||
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:
|
||||
"""Test mode transitions and state consistency."""
|
||||
|
||||
@@ -5,7 +5,7 @@ import black
|
||||
import isort
|
||||
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
|
||||
|
||||
# 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_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
|
||||
|
||||
@@ -123,31 +122,25 @@ def test_client_generator_with_black_formatting():
|
||||
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
||||
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):
|
||||
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():
|
||||
conflicting_file = (
|
||||
inspect.getfile(_plugin_widgets[plugin_name])
|
||||
if plugin_name in _plugin_widgets
|
||||
else f"{plugin_client}"
|
||||
)
|
||||
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
|
||||
if plugin_name not in _overlap:
|
||||
else:
|
||||
globals()[plugin_name] = plugin_class
|
||||
Widgets = _WidgetsEnumType("Widgets", _Widgets)
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
|
||||
|
||||
class MockBECFigure(RPCBase):
|
||||
_IMPORT_MODULE = "tests.unit_tests.test_generate_cli_client"
|
||||
|
||||
@rpc_call
|
||||
def add_plot(self, plot_id: str):
|
||||
"""
|
||||
@@ -162,6 +155,8 @@ def test_client_generator_with_black_formatting():
|
||||
|
||||
|
||||
class MockBECWaveform1D(RPCBase):
|
||||
_IMPORT_MODULE = "tests.unit_tests.test_generate_cli_client"
|
||||
|
||||
@rpc_call
|
||||
def set_frequency(self, frequency: float) -> list:
|
||||
"""
|
||||
|
||||
+106
-146
@@ -7,163 +7,123 @@ from collections import deque
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.logger import LogLevel
|
||||
from bec_lib.messages import LogMessage
|
||||
from bec_lib.redis_connector import StreamMessage
|
||||
from qtpy.QtCore import QDateTime
|
||||
|
||||
from bec_widgets.widgets.utility.logpanel._util import (
|
||||
log_time,
|
||||
replace_escapes,
|
||||
simple_color_format,
|
||||
)
|
||||
from bec_widgets.widgets.utility.logpanel.logpanel import DEFAULT_LOG_COLORS, LogPanel
|
||||
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel, TimestampUpdate
|
||||
|
||||
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 = [
|
||||
LogMessage(
|
||||
metadata={},
|
||||
log_type="debug",
|
||||
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(
|
||||
{"data": msg}
|
||||
for msg in [
|
||||
LogMessage(
|
||||
metadata={},
|
||||
log_type="debug",
|
||||
log_msg={
|
||||
"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",
|
||||
},
|
||||
)
|
||||
log_panel._log_manager._process_incoming_log_msg(
|
||||
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
|
||||
)
|
||||
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()
|
||||
)
|
||||
log_panel._model.log_queue._proc_update()
|
||||
qtbot.waitUntil(lambda: log_panel._model.rowCount() == 4, timeout=500)
|
||||
|
||||
@@ -8,7 +8,7 @@ from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from qtpy.QtGui import QColor, QMouseEvent
|
||||
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 (
|
||||
RingProgressBar,
|
||||
RingProgressContainerWidget,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.rpc_register import RPCRegister
|
||||
|
||||
|
||||
class FakeObject:
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
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.rpc_server import RegistryNotReadyError, RPCServer, SingleshotRPCRepeat
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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.plugin_utils import BECClassContainer, BECClassInfo
|
||||
from bec_widgets.utils.rpc_widget_handler import RPCWidgetHandler
|
||||
|
||||
|
||||
def test_rpc_widget_handler():
|
||||
@@ -16,7 +16,7 @@ class _TestPluginWidget(BECWidget): ...
|
||||
|
||||
|
||||
@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(
|
||||
[
|
||||
BECClassInfo(name="DeviceComboBox", obj=_TestPluginWidget, module="", file=""),
|
||||
|
||||
@@ -277,6 +277,133 @@ def test_populate_scans(scan_control, mocked_client):
|
||||
assert sorted(items) == sorted(expected_scans)
|
||||
|
||||
|
||||
def test_scan_control_uses_gui_visibility_and_signature(qtbot, mocked_client):
|
||||
scan_info = {
|
||||
"class": "AnnotatedScan",
|
||||
"base_class": "ScanBase",
|
||||
"arg_input": {
|
||||
"device": "DeviceBase",
|
||||
"start": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Start Position",
|
||||
"description": "Start position",
|
||||
"tooltip": "Custom start tooltip",
|
||||
"expert": False,
|
||||
"alternative_group": None,
|
||||
"units": None,
|
||||
"reference_units": "device",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"stop": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": None,
|
||||
"description": "Stop position",
|
||||
"tooltip": None,
|
||||
"expert": False,
|
||||
"alternative_group": None,
|
||||
"units": None,
|
||||
"reference_units": "device",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"arg_bundle_size": {"bundle": 3, "min": 1, "max": None},
|
||||
"gui_visibility": {
|
||||
"Movement Parameters": ["steps", "step_size"],
|
||||
"Acquisition Parameters": ["exp_time", "relative"],
|
||||
},
|
||||
"required_kwargs": [],
|
||||
"signature": [
|
||||
{"name": "args", "kind": "VAR_POSITIONAL", "default": "_empty", "annotation": "_empty"},
|
||||
{"name": "steps", "kind": "KEYWORD_ONLY", "default": 10, "annotation": "int"},
|
||||
{
|
||||
"name": "step_size",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": None,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Step Size Custom",
|
||||
"description": "Step size",
|
||||
"tooltip": "Custom step tooltip",
|
||||
"expert": False,
|
||||
"alternative_group": "scan_resolution",
|
||||
"units": "mm",
|
||||
"reference_units": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "exp_time",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": 0,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": None,
|
||||
"description": None,
|
||||
"tooltip": "Exposure time",
|
||||
"expert": False,
|
||||
"alternative_group": None,
|
||||
"units": "s",
|
||||
"reference_units": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{"name": "relative", "kind": "KEYWORD_ONLY", "default": False, "annotation": "bool"},
|
||||
{"name": "kwargs", "kind": "VAR_KEYWORD", "default": "_empty", "annotation": "_empty"},
|
||||
],
|
||||
}
|
||||
mocked_client.connector.set(
|
||||
MessageEndpoints.available_scans(),
|
||||
AvailableResourceMessage(resource={"annotated_scan": scan_info}),
|
||||
)
|
||||
|
||||
widget = ScanControl(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
widget.comboBox_scan_selection.setCurrentText("annotated_scan")
|
||||
|
||||
assert widget.comboBox_scan_selection.count() == 1
|
||||
assert widget.arg_box.layout.itemAtPosition(0, 1).widget().text() == "Start Position"
|
||||
assert widget.arg_box.widgets[1].toolTip() == "Custom start tooltip\nUnits from: device"
|
||||
with patch.object(mocked_client.device_manager.devices.samx, "egu", return_value="mm"):
|
||||
WidgetIO.set_value(widget.arg_box.widgets[0], "samx")
|
||||
assert widget.arg_box.layout.itemAtPosition(0, 1).widget().text() == "Start Position"
|
||||
assert widget.arg_box.widgets[1].suffix() == " mm"
|
||||
assert widget.arg_box.widgets[1].toolTip() == "Custom start tooltip\nUnits: mm"
|
||||
widget.arg_box.widgets[0].setText("not_a_device")
|
||||
assert widget.arg_box.layout.itemAtPosition(0, 1).widget().text() == "Start Position"
|
||||
assert widget.arg_box.widgets[1].suffix() == ""
|
||||
assert widget.arg_box.widgets[1].toolTip() == "Custom start tooltip\nUnits from: device"
|
||||
assert [box.title() for box in widget.kwarg_boxes] == [
|
||||
"Movement Parameters",
|
||||
"Acquisition Parameters",
|
||||
]
|
||||
assert widget.kwarg_boxes[0].layout.itemAtPosition(0, 1).widget().text() == "Step Size Custom"
|
||||
assert widget.kwarg_boxes[0].widgets[1].suffix() == " mm"
|
||||
assert widget.kwarg_boxes[0].widgets[1].toolTip() == "Custom step tooltip\nUnits: mm"
|
||||
assert widget.kwarg_boxes[1].layout.itemAtPosition(0, 0).widget().text() == "Exp Time"
|
||||
assert widget.kwarg_boxes[1].widgets[0].toolTip() == "Exposure time\nUnits: s"
|
||||
|
||||
|
||||
def test_current_scan(scan_control, mocked_client):
|
||||
current_scan = scan_control.current_scan
|
||||
wrong_scan = "error_scan"
|
||||
@@ -501,6 +628,7 @@ def test_changing_scans_remember_parameters(scan_control, mocked_client):
|
||||
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):
|
||||
scan_name = "line_scan"
|
||||
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
|
||||
@@ -585,6 +713,7 @@ def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl):
|
||||
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):
|
||||
"""
|
||||
Ensure that when more argument bundles are present than exist in the
|
||||
|
||||
@@ -157,3 +157,81 @@ def test_arg_box(qtbot):
|
||||
# Widget 2
|
||||
assert arg_box.widgets[2].__class__.__name__ == "ScanSpinBox"
|
||||
assert arg_box.widgets[2].arg_name
|
||||
|
||||
|
||||
def test_spinbox_limits_from_scan_info(qtbot):
|
||||
group_input = {
|
||||
"name": "Kwarg Test",
|
||||
"inputs": [
|
||||
{
|
||||
"arg": False,
|
||||
"name": "exp_time",
|
||||
"type": "float",
|
||||
"display_name": "Exp Time",
|
||||
"tooltip": "Exposure time in seconds",
|
||||
"default": 2.0,
|
||||
"expert": False,
|
||||
"precision": 3,
|
||||
"gt": 1.5,
|
||||
"ge": None,
|
||||
"lt": 5.0,
|
||||
"le": None,
|
||||
},
|
||||
{
|
||||
"arg": False,
|
||||
"name": "num_points",
|
||||
"type": "int",
|
||||
"display_name": "Num Points",
|
||||
"tooltip": "Number of points",
|
||||
"default": 4,
|
||||
"expert": False,
|
||||
"gt": None,
|
||||
"ge": 3,
|
||||
"lt": 9,
|
||||
"le": None,
|
||||
},
|
||||
{
|
||||
"arg": False,
|
||||
"name": "settling_time",
|
||||
"type": "float",
|
||||
"display_name": "Settling Time",
|
||||
"tooltip": "Settling time in seconds",
|
||||
"default": 0.5,
|
||||
"expert": False,
|
||||
"gt": None,
|
||||
"ge": 0.2,
|
||||
"lt": None,
|
||||
"le": 3.5,
|
||||
},
|
||||
{
|
||||
"arg": False,
|
||||
"name": "steps",
|
||||
"type": "int",
|
||||
"display_name": "Steps",
|
||||
"tooltip": "Number of steps",
|
||||
"default": 4,
|
||||
"expert": False,
|
||||
"gt": 0,
|
||||
"ge": None,
|
||||
"lt": None,
|
||||
"le": 10,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
kwarg_box = ScanGroupBox(box_type="kwargs", config=group_input)
|
||||
|
||||
exp_time = kwarg_box.widgets[0]
|
||||
num_points = kwarg_box.widgets[1]
|
||||
settling_time = kwarg_box.widgets[2]
|
||||
steps = kwarg_box.widgets[3]
|
||||
|
||||
assert exp_time.decimals() == 3
|
||||
assert exp_time.minimum() == 1.501
|
||||
assert exp_time.maximum() == 4.999
|
||||
assert num_points.minimum() == 3
|
||||
assert num_points.maximum() == 8
|
||||
assert settling_time.minimum() == 0.2
|
||||
assert settling_time.maximum() == 3.5
|
||||
assert steps.minimum() == 1
|
||||
assert steps.maximum() == 10
|
||||
|
||||
Reference in New Issue
Block a user