mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-13 16:40:56 +02:00
Compare commits
133 Commits
v3.4.4
...
feature/sounds
| Author | SHA1 | Date | |
|---|---|---|---|
| 9213360c44 | |||
| d2cbd84479 | |||
| 3dfed232ef | |||
| 64ed28ba4f | |||
| 434f9f561f | |||
| 768c138576 | |||
| 9550866b67 | |||
| 64cbf93d64 | |||
| 4bb7e811dd | |||
| 08650e86a3 | |||
| 563603b80e | |||
| d07d03c1be | |||
| 6aa1f7e74a | |||
| 2546cc484d | |||
| b20897f4bf | |||
| 7e6dca4912 | |||
| f6f590cabd | |||
| f78bc26a26 | |||
| aca2c4a7a5 | |||
| 6db198e684 | |||
| 6f3ee6316b | |||
| 3d93cf2f01 | |||
| e547ec71ae | |||
| e8bd80377e | |||
| e8e67f68a2 | |||
| 51f7652b1f | |||
| 007f9306a6 | |||
| acfc1b4b88 | |||
| af125e2222 | |||
| b2e0b79210 | |||
| 1427c70cfb | |||
| 154ae6026a | |||
| 9f94ca7748 | |||
| 3796984182 | |||
| 8a180eaa7b | |||
| 4572760b56 | |||
| e42a9824cc | |||
| 2fb7fb2ff4 | |||
| c8275fcfd5 | |||
| 07515d24be | |||
| 859563abb3 | |||
| bd66afb98d | |||
| 8e1e282fac | |||
| 878745b99a | |||
| e41e60956b | |||
| ed68eb5ac6 | |||
| b119c5ad76 | |||
| 9a58dba414 | |||
| c9fc0a82b9 | |||
| 668b1bd9cd | |||
| 1a6c8bf30f | |||
| c346bd0f18 | |||
| 5f86e41a03 | |||
| f7a48b5f6a | |||
| b4beb274da | |||
| 80694d151f | |||
| f03a5d9e85 | |||
| 5e8f0e8083 | |||
| 9eb05416ab | |||
| ab6a1aecc1 | |||
| d99db7d042 | |||
| a976837cff | |||
| 56427a7f0c | |||
| c4d4b78846 | |||
| 2dc0227d38 | |||
| 2d8e1eed4d | |||
| 3b579e740f | |||
| b8740c9594 | |||
| d5bf10e216 | |||
| 3a165b26ed | |||
| faa200bf5c | |||
| b0fc0d325e | |||
| daa1ba020c | |||
| 3d934a8c38 | |||
| c47b246a9f | |||
| bb6c0bb08f | |||
| ce456572d7 | |||
| e22ab7e4c1 | |||
| 0cd000dfa1 | |||
| 1057db9d76 | |||
| be35e249f9 | |||
| cdd833dfc2 | |||
| 3c7834b492 | |||
| acd35a2786 | |||
| 108b249f1d | |||
| 085f9fa271 | |||
| 79931faf55 | |||
| 6b3cebe9cb | |||
| 5cc82425f0 | |||
| bb1544ecb7 | |||
| 8ad0e46d98 | |||
| 9d92f8b53a | |||
| c1d5069a48 | |||
| 0b1f0b4c26 | |||
| cc825972c2 | |||
| 17865a2c33 | |||
| 0728811238 | |||
| 717d74b19e | |||
| dd32caf6e8 | |||
| 603edede9c | |||
| 30ef25533a | |||
| 73b44cffb2 | |||
| a614d662d6 | |||
| 3f1aa80756 | |||
| 409c9e5bfa | |||
| 19b5c8f724 | |||
| 5056ef8946 | |||
| 551d38d901 | |||
| 999b7a2321 | |||
| 5dc373bd8e | |||
| 91afc775d5 | |||
| 55694ff2b9 | |||
| 5b68a51aaa | |||
| f13fa75e25 | |||
| 0cf84cd1d8 | |||
| 3e77f54034 | |||
| f7616102d8 | |||
| 5a497c3598 | |||
| 23e3644619 | |||
| a5db2dc340 | |||
| 2e8f43fcac | |||
| 09bb1121d8 | |||
| c9aaa77b3c | |||
| f7a1ee49a4 | |||
| 8e51c1adb6 | |||
| 846b6e6968 | |||
| f562c61e3c | |||
| bda5d38965 | |||
| 9b0ec9dd79 | |||
| 1754e759f0 | |||
| 308e84d0e1 | |||
| fa2ef83bb9 | |||
| 02cb393bb0 |
@@ -62,4 +62,4 @@ runs:
|
||||
uv pip install --system -e ./ophyd_devices
|
||||
uv pip install --system -e ./bec/bec_lib[dev]
|
||||
uv pip install --system -e ./bec/bec_ipython_client
|
||||
uv pip install --system -e ./bec_widgets[dev,pyside6]
|
||||
uv pip install --system -e ./bec_widgets[dev,qtermwidget]
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
##########################
|
||||
### AI-generated file. ###
|
||||
##########################
|
||||
|
||||
"""Aggregate and merge benchmark JSON files.
|
||||
|
||||
The workflow runs the same benchmark suite on multiple independent runners.
|
||||
This script reads every JSON file produced by those attempts, normalizes the
|
||||
contained benchmark values, and writes a compact mapping JSON where each value is
|
||||
the median across attempts. It can also merge independent hyperfine JSON files
|
||||
from one runner into a single hyperfine-style JSON file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from compare_benchmarks import Benchmark, extract_benchmarks
|
||||
|
||||
|
||||
def collect_benchmarks(paths: list[Path]) -> dict[str, list[Benchmark]]:
|
||||
"""Collect benchmarks from multiple JSON files.
|
||||
|
||||
Args:
|
||||
paths (list[Path]): Paths to hyperfine, pytest-benchmark, or compact
|
||||
mapping JSON files.
|
||||
|
||||
Returns:
|
||||
dict[str, list[Benchmark]]: Benchmarks grouped by benchmark name.
|
||||
"""
|
||||
|
||||
collected: dict[str, list[Benchmark]] = {}
|
||||
for path in paths:
|
||||
for name, benchmark in extract_benchmarks(path).items():
|
||||
collected.setdefault(name, []).append(benchmark)
|
||||
return collected
|
||||
|
||||
|
||||
def aggregate(collected: dict[str, list[Benchmark]]) -> dict[str, dict[str, object]]:
|
||||
"""Aggregate grouped benchmarks using the median value.
|
||||
|
||||
Args:
|
||||
collected (dict[str, list[Benchmark]]): Benchmarks grouped by benchmark
|
||||
name.
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, object]]: Compact mapping JSON data. Each benchmark
|
||||
contains ``value``, ``unit``, ``metric``, ``attempts``, and
|
||||
``attempt_values``.
|
||||
"""
|
||||
|
||||
aggregated: dict[str, dict[str, object]] = {}
|
||||
for name, benchmarks in sorted(collected.items()):
|
||||
values = [benchmark.value for benchmark in benchmarks]
|
||||
unit = next((benchmark.unit for benchmark in benchmarks if benchmark.unit), "")
|
||||
metric = next((benchmark.metric for benchmark in benchmarks if benchmark.metric), "value")
|
||||
aggregated[name] = {
|
||||
"value": statistics.median(values),
|
||||
"unit": unit,
|
||||
"metric": f"median-of-attempt-{metric}",
|
||||
"attempts": len(values),
|
||||
"attempt_values": values,
|
||||
}
|
||||
return aggregated
|
||||
|
||||
|
||||
def merge_hyperfine_results(paths: list[Path]) -> dict[str, Any]:
|
||||
"""Merge hyperfine result files.
|
||||
|
||||
Args:
|
||||
paths (list[Path]): Hyperfine JSON files to merge.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Hyperfine-style JSON object containing all result rows.
|
||||
|
||||
Raises:
|
||||
ValueError: If any file has no hyperfine ``results`` list.
|
||||
"""
|
||||
|
||||
merged: dict[str, Any] = {"results": []}
|
||||
for path in paths:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
results = data.get("results", []) if isinstance(data, dict) else None
|
||||
if not isinstance(results, list):
|
||||
raise ValueError(f"{path} has no hyperfine results list")
|
||||
merged["results"].extend(results)
|
||||
return merged
|
||||
|
||||
|
||||
def main_from_paths(input_dir: Path, output: Path) -> int:
|
||||
"""Aggregate all JSON files in a directory and write the result.
|
||||
|
||||
Args:
|
||||
input_dir (Path): Directory containing benchmark JSON files.
|
||||
output (Path): Path where the aggregate JSON should be written.
|
||||
|
||||
Returns:
|
||||
int: Always ``0`` on success.
|
||||
|
||||
Raises:
|
||||
ValueError: If no JSON files are found in ``input_dir``.
|
||||
"""
|
||||
|
||||
paths = sorted(input_dir.rglob("*.json"))
|
||||
if not paths:
|
||||
raise ValueError(f"No benchmark JSON files found in {input_dir}")
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(
|
||||
json.dumps(aggregate(collect_benchmarks(paths)), indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def merge_from_paths(input_dir: Path, output: Path) -> int:
|
||||
"""Merge all hyperfine JSON files in a directory and write the result.
|
||||
|
||||
Args:
|
||||
input_dir (Path): Directory containing hyperfine JSON files.
|
||||
output (Path): Path where the merged JSON should be written.
|
||||
|
||||
Returns:
|
||||
int: Always ``0`` on success.
|
||||
|
||||
Raises:
|
||||
ValueError: If no JSON files are found in ``input_dir``.
|
||||
"""
|
||||
|
||||
paths = sorted(input_dir.glob("*.json"))
|
||||
if not paths:
|
||||
raise ValueError(f"No hyperfine JSON files found in {input_dir}")
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(
|
||||
json.dumps(merge_hyperfine_results(paths), indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the benchmark aggregation command line interface.
|
||||
|
||||
Returns:
|
||||
int: Always ``0`` on success.
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=("aggregate", "merge-hyperfine"),
|
||||
default="aggregate",
|
||||
help="Operation to perform.",
|
||||
)
|
||||
parser.add_argument("--input-dir", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.mode == "merge-hyperfine":
|
||||
return merge_from_paths(input_dir=args.input_dir, output=args.output)
|
||||
return main_from_paths(input_dir=args.input_dir, output=args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,454 @@
|
||||
##########################
|
||||
### AI-generated file. ###
|
||||
##########################
|
||||
|
||||
"""Compare benchmark JSON files and write a GitHub Actions summary.
|
||||
|
||||
The script supports JSON emitted by hyperfine, JSON emitted by pytest-benchmark,
|
||||
and a compact mapping format generated by ``aggregate_benchmarks.py``. Timing
|
||||
formats prefer median values and fall back to mean values when median values are
|
||||
not present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Benchmark:
|
||||
"""Normalized benchmark result.
|
||||
|
||||
Attributes:
|
||||
name (str): Stable benchmark name used to match baseline and current results.
|
||||
value (float): Numeric benchmark value used for comparison.
|
||||
unit (str): Display unit for the value, for example ``"s"``.
|
||||
metric (str): Source metric name, for example ``"median"`` or ``"mean"``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
value: float
|
||||
unit: str
|
||||
metric: str = "value"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Comparison:
|
||||
"""Comparison between one baseline benchmark and one current benchmark.
|
||||
|
||||
Attributes:
|
||||
name (str): Benchmark name.
|
||||
baseline (float): Baseline benchmark value.
|
||||
current (float): Current benchmark value.
|
||||
delta_percent (float): Percent change from baseline to current.
|
||||
unit (str): Display unit for both values.
|
||||
metric (str): Current result metric used for comparison.
|
||||
regressed (bool): Whether the change exceeds the configured threshold in
|
||||
the worse direction.
|
||||
improved (bool): Whether the change exceeds the configured threshold in
|
||||
the better direction.
|
||||
"""
|
||||
|
||||
name: str
|
||||
baseline: float
|
||||
current: float
|
||||
delta_percent: float
|
||||
unit: str
|
||||
metric: str
|
||||
regressed: bool
|
||||
improved: bool
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
"""Read JSON data from a file.
|
||||
|
||||
Args:
|
||||
path (Path): Path to the JSON file.
|
||||
|
||||
Returns:
|
||||
Any: Parsed JSON value.
|
||||
"""
|
||||
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
return json.load(stream)
|
||||
|
||||
|
||||
def _as_float(value: Any) -> float | None:
|
||||
"""Convert a value to a finite float.
|
||||
|
||||
Args:
|
||||
value (Any): Value to convert.
|
||||
|
||||
Returns:
|
||||
float | None: Converted finite float, or ``None`` if conversion fails.
|
||||
"""
|
||||
|
||||
try:
|
||||
result = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if math.isfinite(result):
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def _extract_hyperfine(data: dict[str, Any]) -> dict[str, Benchmark]:
|
||||
"""Extract normalized benchmarks from hyperfine JSON.
|
||||
|
||||
Args:
|
||||
data (dict[str, Any]): Parsed hyperfine JSON object.
|
||||
|
||||
Returns:
|
||||
dict[str, Benchmark]: Benchmarks keyed by command name.
|
||||
"""
|
||||
|
||||
benchmarks: dict[str, Benchmark] = {}
|
||||
for result in data.get("results", []):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
name = str(result.get("command") or result.get("name") or "").strip()
|
||||
metric = "median"
|
||||
value = _as_float(result.get(metric))
|
||||
if value is None:
|
||||
metric = "mean"
|
||||
value = _as_float(result.get(metric))
|
||||
if name and value is not None:
|
||||
benchmarks[name] = Benchmark(name=name, value=value, unit="s", metric=metric)
|
||||
return benchmarks
|
||||
|
||||
|
||||
def _extract_pytest_benchmark(data: dict[str, Any]) -> dict[str, Benchmark]:
|
||||
"""Extract normalized benchmarks from pytest-benchmark JSON.
|
||||
|
||||
Args:
|
||||
data (dict[str, Any]): Parsed pytest-benchmark JSON object.
|
||||
|
||||
Returns:
|
||||
dict[str, Benchmark]: Benchmarks keyed by full benchmark name.
|
||||
"""
|
||||
|
||||
benchmarks: dict[str, Benchmark] = {}
|
||||
for benchmark in data.get("benchmarks", []):
|
||||
if not isinstance(benchmark, dict):
|
||||
continue
|
||||
|
||||
name = str(benchmark.get("fullname") or benchmark.get("name") or "").strip()
|
||||
stats = benchmark.get("stats", {})
|
||||
value = None
|
||||
metric = "median"
|
||||
if isinstance(stats, dict):
|
||||
value = _as_float(stats.get(metric))
|
||||
if value is None:
|
||||
metric = "mean"
|
||||
value = _as_float(stats.get(metric))
|
||||
if name and value is not None:
|
||||
benchmarks[name] = Benchmark(name=name, value=value, unit="s", metric=metric)
|
||||
return benchmarks
|
||||
|
||||
|
||||
def _extract_simple_mapping(data: dict[str, Any]) -> dict[str, Benchmark]:
|
||||
"""Extract normalized benchmarks from a compact mapping JSON object.
|
||||
|
||||
Args:
|
||||
data (dict[str, Any]): Parsed mapping where each benchmark is either a
|
||||
raw number or an object containing ``value``, ``unit``, and ``metric``.
|
||||
|
||||
Returns:
|
||||
dict[str, Benchmark]: Benchmarks keyed by mapping key.
|
||||
"""
|
||||
|
||||
benchmarks: dict[str, Benchmark] = {}
|
||||
|
||||
for name, raw_value in data.items():
|
||||
if name in {"version", "context", "commit", "timestamp"}:
|
||||
continue
|
||||
|
||||
value = _as_float(raw_value)
|
||||
unit = ""
|
||||
metric = "value"
|
||||
if value is None and isinstance(raw_value, dict):
|
||||
value = _as_float(raw_value.get("value"))
|
||||
unit = str(raw_value.get("unit") or "")
|
||||
metric = str(raw_value.get("metric") or "value")
|
||||
|
||||
if value is not None:
|
||||
benchmarks[str(name)] = Benchmark(name=str(name), value=value, unit=unit, metric=metric)
|
||||
|
||||
return benchmarks
|
||||
|
||||
|
||||
def extract_benchmarks(path: Path) -> dict[str, Benchmark]:
|
||||
"""Extract normalized benchmarks from a supported JSON file.
|
||||
|
||||
Args:
|
||||
path (Path): Path to a hyperfine, pytest-benchmark, or compact mapping
|
||||
JSON file.
|
||||
|
||||
Returns:
|
||||
dict[str, Benchmark]: Normalized benchmarks keyed by name.
|
||||
|
||||
Raises:
|
||||
ValueError: If the JSON root is not an object or no supported benchmark
|
||||
entries can be extracted.
|
||||
"""
|
||||
|
||||
data = _read_json(path)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"{path} must contain a JSON object")
|
||||
|
||||
extractors = (_extract_hyperfine, _extract_pytest_benchmark, _extract_simple_mapping)
|
||||
for extractor in extractors:
|
||||
benchmarks = extractor(data)
|
||||
if benchmarks:
|
||||
return benchmarks
|
||||
|
||||
raise ValueError(f"No supported benchmark entries found in {path}")
|
||||
|
||||
|
||||
def compare_benchmarks(
|
||||
baseline: dict[str, Benchmark],
|
||||
current: dict[str, Benchmark],
|
||||
threshold_percent: float,
|
||||
higher_is_better: bool,
|
||||
) -> tuple[list[Comparison], list[str], list[str]]:
|
||||
"""Compare baseline benchmarks with current benchmarks.
|
||||
|
||||
Args:
|
||||
baseline (dict[str, Benchmark]): Baseline benchmarks keyed by name.
|
||||
current (dict[str, Benchmark]): Current benchmarks keyed by name.
|
||||
threshold_percent (float): Regression threshold in percent.
|
||||
higher_is_better (bool): If ``True``, lower current values are treated as
|
||||
regressions. If ``False``, higher current values are treated as
|
||||
regressions.
|
||||
|
||||
Returns:
|
||||
tuple[list[Comparison], list[str], list[str]]: Comparisons for common
|
||||
benchmark names, names missing from current results, and names newly
|
||||
present in current results.
|
||||
"""
|
||||
|
||||
comparisons: list[Comparison] = []
|
||||
missing_in_current: list[str] = []
|
||||
new_in_current: list[str] = []
|
||||
|
||||
for name, baseline_benchmark in sorted(baseline.items()):
|
||||
current_benchmark = current.get(name)
|
||||
if current_benchmark is None:
|
||||
missing_in_current.append(name)
|
||||
continue
|
||||
|
||||
if baseline_benchmark.value == 0:
|
||||
delta_percent = 0.0
|
||||
else:
|
||||
delta_percent = (
|
||||
(current_benchmark.value - baseline_benchmark.value)
|
||||
/ abs(baseline_benchmark.value)
|
||||
* 100
|
||||
)
|
||||
|
||||
if higher_is_better:
|
||||
regressed = delta_percent <= -threshold_percent
|
||||
improved = delta_percent >= threshold_percent
|
||||
else:
|
||||
regressed = delta_percent >= threshold_percent
|
||||
improved = delta_percent <= -threshold_percent
|
||||
|
||||
comparisons.append(
|
||||
Comparison(
|
||||
name=name,
|
||||
baseline=baseline_benchmark.value,
|
||||
current=current_benchmark.value,
|
||||
delta_percent=delta_percent,
|
||||
unit=current_benchmark.unit or baseline_benchmark.unit,
|
||||
metric=current_benchmark.metric,
|
||||
regressed=regressed,
|
||||
improved=improved,
|
||||
)
|
||||
)
|
||||
|
||||
for name in sorted(set(current) - set(baseline)):
|
||||
new_in_current.append(name)
|
||||
|
||||
return comparisons, missing_in_current, new_in_current
|
||||
|
||||
|
||||
def _format_value(value: float, unit: str) -> str:
|
||||
"""Format a benchmark value for Markdown output.
|
||||
|
||||
Args:
|
||||
value (float): Numeric benchmark value.
|
||||
unit (str): Display unit.
|
||||
|
||||
Returns:
|
||||
str: Formatted value with optional unit suffix.
|
||||
"""
|
||||
|
||||
suffix = f" {unit}" if unit else ""
|
||||
return f"{value:.6g}{suffix}"
|
||||
|
||||
|
||||
def _format_status(comparison: Comparison) -> str:
|
||||
"""Format a comparison status for Markdown output."""
|
||||
|
||||
if comparison.regressed:
|
||||
return ":red_circle: regressed"
|
||||
if comparison.improved:
|
||||
return ":green_circle: improved"
|
||||
return "ok"
|
||||
|
||||
|
||||
def write_summary(
|
||||
path: Path,
|
||||
comparisons: list[Comparison],
|
||||
missing_in_current: list[str],
|
||||
new_in_current: list[str],
|
||||
threshold_percent: float,
|
||||
higher_is_better: bool,
|
||||
) -> None:
|
||||
"""Write a Markdown benchmark comparison summary.
|
||||
|
||||
Args:
|
||||
path (Path): Path where the summary should be written.
|
||||
comparisons (list[Comparison]): Comparison rows for matching benchmarks.
|
||||
missing_in_current (list[str]): Baseline benchmark names missing from the
|
||||
current result.
|
||||
new_in_current (list[str]): Current benchmark names not present in the
|
||||
baseline result.
|
||||
threshold_percent (float): Regression threshold in percent.
|
||||
higher_is_better (bool): Whether higher benchmark values are considered
|
||||
better.
|
||||
"""
|
||||
|
||||
regressions = [comparison for comparison in comparisons if comparison.regressed]
|
||||
improvements = [comparison for comparison in comparisons if comparison.improved]
|
||||
direction = "higher is better" if higher_is_better else "lower is better"
|
||||
sorted_comparisons = sorted(comparisons, key=lambda comparison: comparison.name)
|
||||
|
||||
lines = [
|
||||
"<!-- bw-benchmark-comment -->",
|
||||
"## Benchmark comparison",
|
||||
"",
|
||||
f"Threshold: {threshold_percent:g}% ({direction}).",
|
||||
f"Result: {len(regressions)} regression(s), {len(improvements)} improvement(s) beyond threshold.",
|
||||
]
|
||||
lines.append("")
|
||||
|
||||
if regressions:
|
||||
lines.extend(
|
||||
[
|
||||
f"{len(regressions)} benchmark(s) regressed beyond the configured threshold.",
|
||||
"",
|
||||
"| Benchmark | Baseline | Current | Change |",
|
||||
"| --- | ---: | ---: | ---: |",
|
||||
]
|
||||
)
|
||||
for comparison in regressions:
|
||||
lines.append(
|
||||
"| "
|
||||
f"{comparison.name} | "
|
||||
f"{_format_value(comparison.baseline, comparison.unit)} | "
|
||||
f"{_format_value(comparison.current, comparison.unit)} | "
|
||||
f"{comparison.delta_percent:+.2f}% |"
|
||||
)
|
||||
else:
|
||||
lines.append("No benchmark regression exceeded the configured threshold.")
|
||||
|
||||
lines.append("")
|
||||
|
||||
if improvements:
|
||||
lines.extend(
|
||||
[
|
||||
f"{len(improvements)} benchmark(s) improved beyond the configured threshold.",
|
||||
"",
|
||||
"| Benchmark | Baseline | Current | Change |",
|
||||
"| --- | ---: | ---: | ---: |",
|
||||
]
|
||||
)
|
||||
for comparison in improvements:
|
||||
lines.append(
|
||||
"| "
|
||||
f"{comparison.name} | "
|
||||
f"{_format_value(comparison.baseline, comparison.unit)} | "
|
||||
f"{_format_value(comparison.current, comparison.unit)} | "
|
||||
f"{comparison.delta_percent:+.2f}% |"
|
||||
)
|
||||
else:
|
||||
lines.append("No benchmark improvement exceeded the configured threshold.")
|
||||
|
||||
if sorted_comparisons:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>All benchmark results</summary>",
|
||||
"",
|
||||
"| Benchmark | Baseline | Current | Change | Status |",
|
||||
"| --- | ---: | ---: | ---: | --- |",
|
||||
]
|
||||
)
|
||||
for comparison in sorted_comparisons:
|
||||
lines.append(
|
||||
"| "
|
||||
f"{comparison.name} | "
|
||||
f"{_format_value(comparison.baseline, comparison.unit)} | "
|
||||
f"{_format_value(comparison.current, comparison.unit)} | "
|
||||
f"{comparison.delta_percent:+.2f}% | "
|
||||
f"{_format_status(comparison)} |"
|
||||
)
|
||||
lines.extend(["", "</details>"])
|
||||
|
||||
if missing_in_current:
|
||||
lines.extend(["", "Missing benchmarks in the current run:"])
|
||||
lines.extend(f"- `{name}`" for name in missing_in_current)
|
||||
|
||||
if new_in_current:
|
||||
lines.extend(["", "New benchmarks in the current run:"])
|
||||
lines.extend(f"- `{name}`" for name in new_in_current)
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the benchmark comparison command line interface.
|
||||
|
||||
Returns:
|
||||
int: ``1`` when a regression exceeds the threshold, otherwise ``0``.
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--baseline", required=True, type=Path)
|
||||
parser.add_argument("--current", required=True, type=Path)
|
||||
parser.add_argument("--summary", required=True, type=Path)
|
||||
parser.add_argument("--threshold-percent", required=True, type=float)
|
||||
parser.add_argument("--higher-is-better", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
baseline = extract_benchmarks(args.baseline)
|
||||
current = extract_benchmarks(args.current)
|
||||
comparisons, missing_in_current, new_in_current = compare_benchmarks(
|
||||
baseline=baseline,
|
||||
current=current,
|
||||
threshold_percent=args.threshold_percent,
|
||||
higher_is_better=args.higher_is_better,
|
||||
)
|
||||
|
||||
write_summary(
|
||||
path=args.summary,
|
||||
comparisons=comparisons,
|
||||
missing_in_current=missing_in_current,
|
||||
new_in_current=new_in_current,
|
||||
threshold_percent=args.threshold_percent,
|
||||
higher_is_better=args.higher_is_better,
|
||||
)
|
||||
|
||||
return 1 if any(comparison.regressed for comparison in comparisons) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
##########################
|
||||
### AI-generated file. ###
|
||||
##########################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p benchmark-results
|
||||
benchmark_json="${BENCHMARK_JSON:-benchmark-results/current.json}"
|
||||
benchmark_root="$(dirname "$benchmark_json")"
|
||||
hyperfine_benchmark_dir="${BENCHMARK_HYPERFINE_DIR:-tests/benchmarks/hyperfine}"
|
||||
pytest_benchmark_dirs="${BENCHMARK_PYTEST_DIRS:-${BENCHMARK_PYTEST_DIR:-}}"
|
||||
benchmark_work_dir="$benchmark_root/raw-results"
|
||||
hyperfine_json_dir="$benchmark_work_dir/hyperfine"
|
||||
pytest_json="$benchmark_work_dir/pytest.json"
|
||||
|
||||
shopt -s nullglob
|
||||
benchmark_scripts=()
|
||||
benchmark_scripts=("$hyperfine_benchmark_dir"/benchmark_*.sh)
|
||||
shopt -u nullglob
|
||||
|
||||
pytest_dirs=()
|
||||
for pytest_benchmark_dir in $pytest_benchmark_dirs; do
|
||||
if [ -d "$pytest_benchmark_dir" ]; then
|
||||
pytest_dirs+=("$pytest_benchmark_dir")
|
||||
else
|
||||
echo "Pytest benchmark directory not found: $pytest_benchmark_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#benchmark_scripts[@]}" -eq 0 ] && [ "${#pytest_dirs[@]}" -eq 0 ]; then
|
||||
echo "No benchmark scripts or pytest benchmarks found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Benchmark Python: $(command -v python)"
|
||||
python -c 'import sys; print(sys.version)'
|
||||
|
||||
rm -rf "$benchmark_work_dir"
|
||||
mkdir -p "$hyperfine_json_dir"
|
||||
|
||||
if [ "${#benchmark_scripts[@]}" -gt 0 ]; then
|
||||
for benchmark_script in "${benchmark_scripts[@]}"; do
|
||||
title="$(sed -n 's/^# BENCHMARK_TITLE:[[:space:]]*//p' "$benchmark_script" | head -n 1)"
|
||||
if [ -z "$title" ]; then
|
||||
title="$(basename "$benchmark_script" .sh)"
|
||||
fi
|
||||
benchmark_name="$(basename "$benchmark_script" .sh)"
|
||||
benchmark_result_json="$hyperfine_json_dir/$benchmark_name.json"
|
||||
echo "Preflight benchmark script: $benchmark_script"
|
||||
bash "$benchmark_script"
|
||||
|
||||
hyperfine \
|
||||
--show-output \
|
||||
--warmup 1 \
|
||||
--runs 5 \
|
||||
--command-name "$title" \
|
||||
--export-json "$benchmark_result_json" \
|
||||
"bash $(printf "%q" "$benchmark_script")"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "${#pytest_dirs[@]}" -gt 0 ]; then
|
||||
pytest \
|
||||
-q "${pytest_dirs[@]}" \
|
||||
--benchmark-only \
|
||||
--benchmark-json "$pytest_json"
|
||||
fi
|
||||
|
||||
python .github/scripts/aggregate_benchmarks.py \
|
||||
--input-dir "$benchmark_work_dir" \
|
||||
--output "$benchmark_json"
|
||||
@@ -0,0 +1,125 @@
|
||||
##########################
|
||||
### AI-generated file. ###
|
||||
##########################
|
||||
|
||||
"""Run a command with BEC e2e services available."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import bec_lib
|
||||
from bec_ipython_client import BECIPythonClient
|
||||
from bec_lib.redis_connector import RedisConnector
|
||||
from bec_lib.service_config import ServiceConfig, ServiceConfigModel
|
||||
from redis import Redis
|
||||
|
||||
|
||||
def _wait_for_redis(host: str, port: int) -> None:
|
||||
client = Redis(host=host, port=port)
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if client.ping():
|
||||
return
|
||||
except Exception:
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"Redis did not start on {host}:{port}")
|
||||
|
||||
|
||||
def _start_redis(files_path: Path, host: str, port: int) -> subprocess.Popen:
|
||||
redis_server = shutil.which("redis-server")
|
||||
if redis_server is None:
|
||||
raise RuntimeError("redis-server executable not found")
|
||||
|
||||
return subprocess.Popen(
|
||||
[
|
||||
redis_server,
|
||||
"--bind",
|
||||
host,
|
||||
"--port",
|
||||
str(port),
|
||||
"--save",
|
||||
"",
|
||||
"--appendonly",
|
||||
"no",
|
||||
"--dir",
|
||||
str(files_path),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _write_configs(files_path: Path, host: str, port: int) -> Path:
|
||||
test_config = files_path / "test_config.yaml"
|
||||
services_config = files_path / "services_config.yaml"
|
||||
|
||||
bec_lib_path = Path(bec_lib.__file__).resolve().parent
|
||||
shutil.copyfile(bec_lib_path / "tests" / "test_config.yaml", test_config)
|
||||
|
||||
service_config = ServiceConfigModel(
|
||||
redis={"host": host, "port": port}, file_writer={"base_path": str(files_path)}
|
||||
)
|
||||
services_config.write_text(service_config.model_dump_json(indent=4), encoding="utf-8")
|
||||
return services_config
|
||||
|
||||
|
||||
def _load_demo_config(services_config: Path) -> None:
|
||||
bec = BECIPythonClient(ServiceConfig(services_config), RedisConnector, forced=True)
|
||||
bec.start()
|
||||
try:
|
||||
bec.config.load_demo_config()
|
||||
finally:
|
||||
bec.shutdown()
|
||||
bec._client._reset_singleton()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command[:1] == ["--"]:
|
||||
args.command = args.command[1:]
|
||||
if not args.command:
|
||||
raise ValueError("No command provided")
|
||||
|
||||
host = "127.0.0.1"
|
||||
port = 6379
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="bec-benchmark-") as tmp:
|
||||
files_path = Path(tmp)
|
||||
services_config = _write_configs(files_path, host, port)
|
||||
redis_process = _start_redis(files_path, host, port)
|
||||
processes = None
|
||||
service_handler = None
|
||||
try:
|
||||
_wait_for_redis(host, port)
|
||||
|
||||
from bec_server.bec_server_utils.service_handler import ServiceHandler
|
||||
|
||||
service_handler = ServiceHandler(
|
||||
bec_path=files_path, config_path=services_config, interface="subprocess"
|
||||
)
|
||||
processes = service_handler.start()
|
||||
_load_demo_config(services_config)
|
||||
|
||||
env = os.environ.copy()
|
||||
return subprocess.run(args.command, env=env, check=False).returncode
|
||||
finally:
|
||||
if service_handler is not None and processes is not None:
|
||||
service_handler.stop(processes)
|
||||
redis_process.terminate()
|
||||
try:
|
||||
redis_process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
redis_process.kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,242 @@
|
||||
name: BW Benchmarks
|
||||
|
||||
on: [ workflow_call ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
BENCHMARK_JSON: benchmark-results/current.json
|
||||
BENCHMARK_BASELINE_JSON: gh-pages-benchmark-data/benchmarks/latest.json
|
||||
BENCHMARK_SUMMARY: benchmark-results/summary.md
|
||||
BENCHMARK_COMMAND: "bash .github/scripts/run_benchmarks.sh"
|
||||
BENCHMARK_THRESHOLD_PERCENT: 20
|
||||
BENCHMARK_HIGHER_IS_BETTER: false
|
||||
|
||||
jobs:
|
||||
benchmark_attempt:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -el {0}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
attempt: [ 1, 2, 3 ]
|
||||
|
||||
env:
|
||||
BENCHMARK_JSON: benchmark-results/current-${{ matrix.attempt }}.json
|
||||
BEC_CORE_BRANCH: main
|
||||
OPHYD_DEVICES_BRANCH: main
|
||||
PLUGIN_REPO_BRANCH: main
|
||||
BENCHMARK_PYTEST_DIRS: tests/unit_tests/benchmarks
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Conda
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
auto-update-conda: true
|
||||
auto-activate-base: true
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
sudo apt-get -y install ttyd hyperfine redis-server
|
||||
|
||||
- name: Install full e2e environment
|
||||
run: |
|
||||
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
git clone --branch "$BEC_CORE_BRANCH" https://github.com/bec-project/bec.git
|
||||
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
git clone --branch "$OPHYD_DEVICES_BRANCH" https://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
|
||||
git clone --branch "$PLUGIN_REPO_BRANCH" https://github.com/bec-project/bec_testing_plugin.git
|
||||
cd ./bec
|
||||
conda create -q -n test-environment python=3.11
|
||||
conda activate test-environment
|
||||
source ./bin/install_bec_dev.sh -t
|
||||
cd ../
|
||||
python -m pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin pytest-benchmark
|
||||
|
||||
mkdir -p "$(dirname "$BENCHMARK_JSON")"
|
||||
python .github/scripts/run_with_bec_servers.py -- bash -lc "$BENCHMARK_COMMAND"
|
||||
test -s "$BENCHMARK_JSON"
|
||||
|
||||
- name: Upload benchmark artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bw-benchmark-json-${{ matrix.attempt }}
|
||||
path: ${{ env.BENCHMARK_JSON }}
|
||||
|
||||
benchmark:
|
||||
needs: [ benchmark_attempt ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Download benchmark attempts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: bw-benchmark-json-*
|
||||
path: benchmark-results/attempts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Aggregate benchmark attempts
|
||||
run: |
|
||||
python .github/scripts/aggregate_benchmarks.py \
|
||||
--input-dir benchmark-results/attempts \
|
||||
--output "$BENCHMARK_JSON"
|
||||
|
||||
- name: Upload aggregate benchmark artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bw-benchmark-json
|
||||
path: ${{ env.BENCHMARK_JSON }}
|
||||
|
||||
- name: Fetch gh-pages benchmark data
|
||||
run: |
|
||||
if git ls-remote --exit-code --heads origin gh-pages; then
|
||||
git clone --depth=1 --branch gh-pages "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" gh-pages-benchmark-data
|
||||
else
|
||||
mkdir -p gh-pages-benchmark-data
|
||||
fi
|
||||
|
||||
- name: Compare with latest gh-pages benchmark
|
||||
id: compare
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if [ ! -s "$BENCHMARK_BASELINE_JSON" ]; then
|
||||
mkdir -p "$(dirname "$BENCHMARK_SUMMARY")"
|
||||
{
|
||||
echo "<!-- bw-benchmark-comment -->"
|
||||
echo "## Benchmark comparison"
|
||||
echo
|
||||
echo "No benchmark baseline was found on gh-pages."
|
||||
} > "$BENCHMARK_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
args=(
|
||||
--baseline "$BENCHMARK_BASELINE_JSON"
|
||||
--current "$BENCHMARK_JSON"
|
||||
--summary "$BENCHMARK_SUMMARY"
|
||||
--threshold-percent "$BENCHMARK_THRESHOLD_PERCENT"
|
||||
)
|
||||
|
||||
if [ "$BENCHMARK_HIGHER_IS_BETTER" = "true" ]; then
|
||||
args+=(--higher-is-better)
|
||||
fi
|
||||
|
||||
set +e
|
||||
python .github/scripts/compare_benchmarks.py "${args[@]}"
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
if [ ! -s "$BENCHMARK_SUMMARY" ]; then
|
||||
mkdir -p "$(dirname "$BENCHMARK_SUMMARY")"
|
||||
{
|
||||
echo "<!-- bw-benchmark-comment -->"
|
||||
echo "## Benchmark comparison"
|
||||
echo
|
||||
echo "Benchmark comparison failed before writing a summary."
|
||||
} > "$BENCHMARK_SUMMARY"
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
|
||||
- name: Find existing benchmark PR comment
|
||||
if: github.event_name == 'pull_request'
|
||||
id: fc
|
||||
uses: peter-evans/find-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: github-actions[bot]
|
||||
body-includes: "<!-- bw-benchmark-comment -->"
|
||||
|
||||
- name: Create or update benchmark PR comment
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: peter-evans/create-or-update-comment@v5
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
body-path: ${{ env.BENCHMARK_SUMMARY }}
|
||||
edit-mode: replace
|
||||
|
||||
- name: Fail on benchmark regression
|
||||
if: github.event_name == 'pull_request' && steps.compare.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
publish:
|
||||
needs: [ benchmark ]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Download aggregate benchmark artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: bw-benchmark-json
|
||||
path: benchmark-results
|
||||
|
||||
- name: Verify aggregate benchmark artifact
|
||||
run: test -s "$BENCHMARK_JSON"
|
||||
|
||||
- name: Prepare gh-pages for publishing
|
||||
run: |
|
||||
# Clean up any existing worktree/directory
|
||||
if [ -d gh-pages-benchmark-data ]; then
|
||||
git worktree remove gh-pages-benchmark-data --force || rm -rf gh-pages-benchmark-data
|
||||
fi
|
||||
|
||||
if git ls-remote --exit-code --heads origin gh-pages; then
|
||||
git fetch --depth=1 origin gh-pages
|
||||
git worktree add gh-pages-benchmark-data FETCH_HEAD
|
||||
else
|
||||
git worktree add --detach gh-pages-benchmark-data
|
||||
git -C gh-pages-benchmark-data checkout --orphan gh-pages
|
||||
git -C gh-pages-benchmark-data rm -rf .
|
||||
fi
|
||||
|
||||
- name: Publish benchmark data to gh-pages
|
||||
working-directory: gh-pages-benchmark-data
|
||||
run: |
|
||||
mkdir -p benchmarks/history
|
||||
cp "../$BENCHMARK_JSON" benchmarks/latest.json
|
||||
cp "../$BENCHMARK_JSON" "benchmarks/history/${GITHUB_SHA}.json"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add benchmarks/latest.json "benchmarks/history/${GITHUB_SHA}.json"
|
||||
git commit -m "Update BW benchmark data for ${GITHUB_SHA}" || exit 0
|
||||
git push origin HEAD:gh-pages
|
||||
@@ -45,6 +45,18 @@ jobs:
|
||||
cd ./bec
|
||||
pip install pytest pytest-random-order
|
||||
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./bec_server/tests ./bec_ipython_client/tests/client_tests ./bec_lib/tests
|
||||
|
||||
- name: Upload BEC unit test artifacts if job fails
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bec-unit-test-artifacts
|
||||
path: |
|
||||
./bec/report.xml
|
||||
./bec/logs/*.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
bec-e2e-test:
|
||||
name: BEC End2End Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -62,3 +74,12 @@ jobs:
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
PYTHON_VERSION: '3.11'
|
||||
|
||||
- name: Upload BEC e2e logs if job fails
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bec-e2e-test-logs
|
||||
path: ./_e2e_test_checkout_/bec/logs/*.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -177,4 +177,6 @@ cython_debug/
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
#.idea/
|
||||
#
|
||||
tombi.toml
|
||||
|
||||
+495
@@ -1,6 +1,501 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v3.15.0 (2026-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **beamline-states**: Better pydantic model handling
|
||||
([`64cbf93`](https://github.com/bec-project/bec_widgets/commit/64cbf93d64895cc8af9522506512c4e8f5de939d))
|
||||
|
||||
- **bec_widget**: Removal of non existing TYPE check for old dock area
|
||||
([`08650e8`](https://github.com/bec-project/bec_widgets/commit/08650e86a3d3aa1098514bad3d8119401ace1d3f))
|
||||
|
||||
- **device-input**: Align validity styling
|
||||
([`6db198e`](https://github.com/bec-project/bec_widgets/commit/6db198e68422967c1c6d8ffb749b993e0e4975e1))
|
||||
|
||||
- **notification-banner**: Eventfilter guard for QStandartItem
|
||||
([`d07d03c`](https://github.com/bec-project/bec_widgets/commit/d07d03c1be7b405e52126cfc507fdcd79093cb45))
|
||||
|
||||
- **notification-center**: Sync light theme styling
|
||||
([`f78bc26`](https://github.com/bec-project/bec_widgets/commit/f78bc26a26bfebfc7df83a9c6cd7dd5c95a35d05))
|
||||
|
||||
- **pydantic**: Adoption to new ScanArgument refactor from bec
|
||||
([`3dfed23`](https://github.com/bec-project/bec_widgets/commit/3dfed232efb8baff14ebe62b6b14e62e0a4acd36))
|
||||
|
||||
- **widget_it**: Device/signal combobox handler
|
||||
([`434f9f5`](https://github.com/bec-project/bec_widgets/commit/434f9f561fca199aadc798db682a7e863a3246b3))
|
||||
|
||||
### Build System
|
||||
|
||||
- **bec**: Bump bec_lib and bec_ipython_client to v3.134
|
||||
([`9550866`](https://github.com/bec-project/bec_widgets/commit/9550866b677bb304ab0ea490827d0d0c09064722))
|
||||
|
||||
### Features
|
||||
|
||||
- **beamline-states**: Add state manager widget
|
||||
([`2546cc4`](https://github.com/bec-project/bec_widgets/commit/2546cc484d1c483fd45f1b80847a7e0200faba43))
|
||||
|
||||
- **beamline-states**: Collapse all functionality with cleanup of not used settings widgets if state
|
||||
is not dirty
|
||||
([`768c138`](https://github.com/bec-project/bec_widgets/commit/768c138576ad924dfad334888583131cc452e4f0))
|
||||
|
||||
- **dock-area**: Expose beamline state manager
|
||||
([`6aa1f7e`](https://github.com/bec-project/bec_widgets/commit/6aa1f7e74ae4497d449b03e600e5c3fbbfc34be0))
|
||||
|
||||
- **forms**: Add pydantic widget form
|
||||
([`b20897f`](https://github.com/bec-project/bec_widgets/commit/b20897f4bf2c05653e57bd79c5e292883d1f8ee8))
|
||||
|
||||
- **forms**: Unified pydantic and scan control adapter for pydantic models
|
||||
([`563603b`](https://github.com/bec-project/bec_widgets/commit/563603b80e52ec6746a57fa68b1f7b2dbc101439))
|
||||
|
||||
- **widget_io**: Register handler
|
||||
([`7e6dca4`](https://github.com/bec-project/bec_widgets/commit/7e6dca49120fba480a8756a0cb951b88a2df3584))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **beamline-states**: Beamlinestatemanager widget moved to separate module
|
||||
([`4bb7e81`](https://github.com/bec-project/bec_widgets/commit/4bb7e811dd514e69e9a507505b976c3b8de1d035))
|
||||
|
||||
- **colors**: Consolidate theme helpers
|
||||
([`aca2c4a`](https://github.com/bec-project/bec_widgets/commit/aca2c4a7a50e85b0d0f61251f71bc097a258599f))
|
||||
|
||||
- **main-window**: Remove status-tip override
|
||||
([`f6f590c`](https://github.com/bec-project/bec_widgets/commit/f6f590cabdfd66f4bb4f8095414db5d120b0f9a1))
|
||||
|
||||
- **notification_banner**: Remove defensive patterns
|
||||
([`64ed28b`](https://github.com/bec-project/bec_widgets/commit/64ed28ba4f554958305cc9cf37da1a124ba2a99b))
|
||||
|
||||
|
||||
## v3.14.0 (2026-06-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **bec_progress_bar**: Replace the custom paint event progressbar with native QProgressBar
|
||||
([`007f930`](https://github.com/bec-project/bec_widgets/commit/007f9306a62f60cf66a268f608984b6a954b8653))
|
||||
|
||||
- **progress**: Scan progress reset on_scan_status in unified backend
|
||||
([`3d93cf2`](https://github.com/bec-project/bec_widgets/commit/3d93cf2f01778a9825a94adcba44a26fbeaf4be4))
|
||||
|
||||
- **ring**: Progresssignal fetch logic back
|
||||
([`e8bd803`](https://github.com/bec-project/bec_widgets/commit/e8bd80377e0bee40142c5cd0d4a9c35d35f2d950))
|
||||
|
||||
- **scan_control**: Remove parent from layout to prevent `QLayout: Attempting to add QLayout "" to
|
||||
ScanGroupBox "", which already has a layout`
|
||||
([`e8e67f6`](https://github.com/bec-project/bec_widgets/commit/e8e67f68a2912c69a7df3d82daaa67fab3ae1139))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- **child_repos**: Artifact logs upload if child pipelines fail
|
||||
([`acfc1b4`](https://github.com/bec-project/bec_widgets/commit/acfc1b4b883b2a3cf0596c881489cb2c953dd219))
|
||||
|
||||
### Features
|
||||
|
||||
- **progress**: Progress is tracked from bec; unified progress backend
|
||||
([`51f7652`](https://github.com/bec-project/bec_widgets/commit/51f7652b1fe59db6bf94a8183ae0e3a715601aa6))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **bec_progress**: Simplification of chunk radius calculation
|
||||
([`e547ec7`](https://github.com/bec-project/bec_widgets/commit/e547ec71ae1f45db72d9b8cde0b5fe564466333c))
|
||||
|
||||
### Testing
|
||||
|
||||
- **e2e**: Increase rpc test_available_widgets timout back to 100
|
||||
([`af125e2`](https://github.com/bec-project/bec_widgets/commit/af125e2222ff11a73f28dadb3a5d93e409ad010e))
|
||||
|
||||
|
||||
## v3.13.5 (2026-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Change prints into proper logs
|
||||
([`c8275fc`](https://github.com/bec-project/bec_widgets/commit/c8275fcfd5c920393df3aa201c32a632ac8086a5))
|
||||
|
||||
- **abort_button**: From __future__ import annotations
|
||||
([`3796984`](https://github.com/bec-project/bec_widgets/commit/37969841822c8c38c23a1d8fca8e38aec684957b))
|
||||
|
||||
- **client_utils**: Increase default rpc timeout to 60s
|
||||
([`07515d2`](https://github.com/bec-project/bec_widgets/commit/07515d24be6e930b1b40170fc710255914cb7454))
|
||||
|
||||
- **client_utils**: Stop output reader thread on shutdown
|
||||
([`4572760`](https://github.com/bec-project/bec_widgets/commit/4572760b56ca2ab6435db3a6a4ba0d270e9008d1))
|
||||
|
||||
- **companion_app**: Disable logging of bec_lib.scan_items on widget side
|
||||
([`bd66afb`](https://github.com/bec-project/bec_widgets/commit/bd66afb98dcb76ca87b0db1334df3c1af0a9dbad))
|
||||
|
||||
- **forms**: Gridlayout applied to widget which already has layout
|
||||
([`1427c70`](https://github.com/bec-project/bec_widgets/commit/1427c70cfb6f84bbced7f72ec5cfa55ac0b9b742))
|
||||
|
||||
- **launch_window**: Exclude launcher check for non-parented widgets for BECMainWindow
|
||||
([`ed68eb5`](https://github.com/bec-project/bec_widgets/commit/ed68eb5ac6b20cfc7ca2c0b91864dc54fb579499))
|
||||
|
||||
- **launcher**: Avoid orphan widgets detection and logging
|
||||
([`9f94ca7`](https://github.com/bec-project/bec_widgets/commit/9f94ca7748d73a30622ecbaef384f4bc73a3d2fb))
|
||||
|
||||
- **logging**: Removed args/kwargs from logging messages
|
||||
([`2fb7fb2`](https://github.com/bec-project/bec_widgets/commit/2fb7fb2ff487863c3bc931498496da74b25e52d8))
|
||||
|
||||
- **rpc**: Additional logs
|
||||
([`e41e609`](https://github.com/bec-project/bec_widgets/commit/e41e60956b54890b70b3390b981196c9477abd93))
|
||||
|
||||
- **rpc**: Client/server rpc handshake for shutdown
|
||||
([`8a180ea`](https://github.com/bec-project/bec_widgets/commit/8a180eaa7be5c1603d893cf3b50585f88f9b0c83))
|
||||
|
||||
- **rpc**: Log dispatcher receipt before qt callback
|
||||
([`878745b`](https://github.com/bec-project/bec_widgets/commit/878745b99ac1e22c0fbddecc294e599469a2adfe))
|
||||
|
||||
- **rpc**: More robust shutdown section with PID logging
|
||||
([`e42a982`](https://github.com/bec-project/bec_widgets/commit/e42a9824ccd54b71a3141aaf2aa4e02af6a13782))
|
||||
|
||||
- **rpc_server**: Log warning if rpc call is repeated
|
||||
([`859563a`](https://github.com/bec-project/bec_widgets/commit/859563abb3e94ff55886e72db3177522900a89b8))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **client_utils**: Simplify PID fetching
|
||||
([`154ae60`](https://github.com/bec-project/bec_widgets/commit/154ae6026a6471b7c1db42f7c2ff3dc7be4b4afb))
|
||||
|
||||
- **rpc**: Share logging helpers
|
||||
([`8e1e282`](https://github.com/bec-project/bec_widgets/commit/8e1e282fac22ab6f726049758306c7ca17af70eb))
|
||||
|
||||
|
||||
## v3.13.4 (2026-05-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **positioner_box**: Fix STOP button
|
||||
([`9a58dba`](https://github.com/bec-project/bec_widgets/commit/9a58dba414d9eec32fd7de7fc64c97c38f020b84))
|
||||
|
||||
|
||||
## v3.13.3 (2026-05-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **tests**: Rename description attribute to _description in FakeDevice
|
||||
([`668b1bd`](https://github.com/bec-project/bec_widgets/commit/668b1bd9cd158fc12cff2c340d7317f30a212121))
|
||||
|
||||
|
||||
## v3.13.2 (2026-05-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **tests**: Rename description attribute to _description in FakePositioner
|
||||
([`c346bd0`](https://github.com/bec-project/bec_widgets/commit/c346bd0f18ce873ff5ca6c59150c9581c9edca8d))
|
||||
|
||||
|
||||
## v3.13.1 (2026-05-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use .show instead of .start
|
||||
([`b4beb27`](https://github.com/bec-project/bec_widgets/commit/b4beb274da745da618f9b37ec241cd0109c088f1))
|
||||
|
||||
- **gui**: Replace window.show() with window.raise_window() and add hide() method
|
||||
([`f7a48b5`](https://github.com/bec-project/bec_widgets/commit/f7a48b5f6a51d391dca26ca42d03bad4f278ff22))
|
||||
|
||||
|
||||
## v3.13.0 (2026-05-21)
|
||||
|
||||
### Features
|
||||
|
||||
- **rpc-base**: Set default RPC timeout and allow customization
|
||||
([`f03a5d9`](https://github.com/bec-project/bec_widgets/commit/f03a5d9e853bd62b8ec1bad1c1e112fe01befe70))
|
||||
|
||||
|
||||
## v3.12.2 (2026-05-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **toggle**: Disable styling implemented
|
||||
([`9eb0541`](https://github.com/bec-project/bec_widgets/commit/9eb05416ab68dcb88732dca8974c665030d34e0b))
|
||||
|
||||
|
||||
## v3.12.1 (2026-05-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **device_input**: Correct cleanup unsubscribe
|
||||
([`56427a7`](https://github.com/bec-project/bec_widgets/commit/56427a7f0c3a89fe847d415c8b45212e663434c4))
|
||||
|
||||
- **device_input**: Ensure callback is removed after cleanup
|
||||
([`d99db7d`](https://github.com/bec-project/bec_widgets/commit/d99db7d04208945b86a39d65022b211ba093caed))
|
||||
|
||||
- **signal_combobox**: Signature matched for update_signals_from_filters
|
||||
([`a976837`](https://github.com/bec-project/bec_widgets/commit/a976837cff612349f2a3f17900903c203bc3d250))
|
||||
|
||||
|
||||
## v3.12.0 (2026-05-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan-control**: Filter out private scans from allowed scans
|
||||
([`2dc0227`](https://github.com/bec-project/bec_widgets/commit/2dc0227d38f0e217e252a5e5751bafd60363a5a4))
|
||||
|
||||
- **scan-control**: Hide hidden scan arguments
|
||||
([`2d8e1ee`](https://github.com/bec-project/bec_widgets/commit/2d8e1eed4d6503c42a38c8de910ddaa54132405d))
|
||||
|
||||
- **scan-control**: Reject unsupported scan input types
|
||||
([`3b579e7`](https://github.com/bec-project/bec_widgets/commit/3b579e740f36c60c3635681a9b2c35b518498f58))
|
||||
|
||||
- **scan-control**: Skip duplicate visible scan kwargs
|
||||
([`b8740c9`](https://github.com/bec-project/bec_widgets/commit/b8740c95941d36102f07a51d74a50e6f262a6646))
|
||||
|
||||
### Features
|
||||
|
||||
- Add support for new scan signatures including units
|
||||
([`d5bf10e`](https://github.com/bec-project/bec_widgets/commit/d5bf10e21682ae8270078c7858a036bafbabf10e))
|
||||
|
||||
|
||||
## v3.11.1 (2026-05-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan progressbar**: Fix device subscription cleanup
|
||||
([`faa200b`](https://github.com/bec-project/bec_widgets/commit/faa200bf5c3cf0c5bebb9858700106899f583695))
|
||||
|
||||
|
||||
## v3.11.0 (2026-05-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove device/signal line edit and abstraction layer for combobox/lineEdit
|
||||
([`bb6c0bb`](https://github.com/bec-project/bec_widgets/commit/bb6c0bb08fc9802bec0d6b9994a76a5bcf2a3a81))
|
||||
|
||||
- **scan_control**: Scangroupbox enforce correct device combobox type in correct order
|
||||
([`c47b246`](https://github.com/bec-project/bec_widgets/commit/c47b246a9fd5c9aff2512c2744b8ff19c87e6e03))
|
||||
|
||||
### Features
|
||||
|
||||
- **device_input**: Comboboxes can have line edit like autocomplete
|
||||
([`3d934a8`](https://github.com/bec-project/bec_widgets/commit/3d934a8c3825b17319c3cb99750b96042e0bc230))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **device_input**: Consolidation of device/signal combobox logic; docsrtings added
|
||||
([`daa1ba0`](https://github.com/bec-project/bec_widgets/commit/daa1ba020ce6d05800186d2467a496c1024e8aa5))
|
||||
|
||||
|
||||
## v3.10.0 (2026-05-13)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix link to doc page
|
||||
([`0cd000d`](https://github.com/bec-project/bec_widgets/commit/0cd000dfa1bb6f1b4d286e5aab30299361f436f6))
|
||||
|
||||
### Features
|
||||
|
||||
- Bl plugin menu in BECDockArea
|
||||
([`e22ab7e`](https://github.com/bec-project/bec_widgets/commit/e22ab7e4c10552e22aaaa9dbc30d098fbfa9c49c))
|
||||
|
||||
|
||||
## v3.9.1 (2026-05-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Logpanel fixture overwriting xread
|
||||
([`3c7834b`](https://github.com/bec-project/bec_widgets/commit/3c7834b492a5d2da13689f58b20caf38dda9ac1d))
|
||||
|
||||
- **scan_control**: Restore scan parameters from history are fetched on demand with button
|
||||
([`acd35a2`](https://github.com/bec-project/bec_widgets/commit/acd35a278660ce4962167af6237b5d12007f0774))
|
||||
|
||||
|
||||
## v3.9.0 (2026-05-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Test bw-generate-cli
|
||||
([`085f9fa`](https://github.com/bec-project/bec_widgets/commit/085f9fa271a0a8e339bff83f235011ac4a9d29ea))
|
||||
|
||||
- **dock_area**: Icon fetching for toolbar import optimised
|
||||
([`79931fa`](https://github.com/bec-project/bec_widgets/commit/79931faf554fd0978c54d6562aa1b5fc4ab823b2))
|
||||
|
||||
- **jupyter_console_widget**: Widget_handler API fix
|
||||
([`6b3cebe`](https://github.com/bec-project/bec_widgets/commit/6b3cebe9cbdb5c02ae2aa14b0f624a51c9c2ca4c))
|
||||
|
||||
### Features
|
||||
|
||||
- Move to lazy widget import
|
||||
([`5cc8242`](https://github.com/bec-project/bec_widgets/commit/5cc82425f07d76e881ae59a121a3af77f227bfee))
|
||||
|
||||
### Testing
|
||||
|
||||
- Fix available scans endpoint operation
|
||||
([`bb1544e`](https://github.com/bec-project/bec_widgets/commit/bb1544ecb70612267e2b03ba041c6f656789d63c))
|
||||
|
||||
|
||||
## v3.8.1 (2026-05-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **web_links**: Update documentation links in BECWebLinksMixin
|
||||
([`9d92f8b`](https://github.com/bec-project/bec_widgets/commit/9d92f8b53a6ffe57a9dffad797580228023bf6e1))
|
||||
|
||||
|
||||
## v3.8.0 (2026-05-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **dock_area**: Change to show_dialo=False for CLI profile baseline restore
|
||||
([`0b1f0b4`](https://github.com/bec-project/bec_widgets/commit/0b1f0b4c262ff31469b7114b9f00bf0a7b85e8f2))
|
||||
|
||||
- **dock_area**: Cli call load_profile has restore_baseline kwarg
|
||||
([`cc82597`](https://github.com/bec-project/bec_widgets/commit/cc825972c202cd9ded32f8b2d1ce5f822c2ebdba))
|
||||
|
||||
### Features
|
||||
|
||||
- **dock_area**: Add CLI restore current profile from baseline with optional confirmation dialog
|
||||
([`17865a2`](https://github.com/bec-project/bec_widgets/commit/17865a2c338a4a1f944659dde4ec05c25a8dd963))
|
||||
|
||||
|
||||
## v3.7.3 (2026-05-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **dock_area**: Profile names changed, default->baseline, user->runtime
|
||||
([`dd32caf`](https://github.com/bec-project/bec_widgets/commit/dd32caf6e815fd1922b6aae84d00decad9dbf869))
|
||||
|
||||
### Testing
|
||||
|
||||
- **dock_area**: Remove low-value tests
|
||||
([`717d74b`](https://github.com/bec-project/bec_widgets/commit/717d74b19e8c6960209190c47ba32732ffaa0094))
|
||||
|
||||
|
||||
## v3.7.2 (2026-04-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **dock-area**: Avoid switching profile when saving new profile
|
||||
([`73b44cf`](https://github.com/bec-project/bec_widgets/commit/73b44cffb219347cacb609f3b93068eda6701b42))
|
||||
|
||||
- **workspace-actions**: Use try/finally and restore previous blocked state in refresh_profiles
|
||||
([`30ef255`](https://github.com/bec-project/bec_widgets/commit/30ef25533af9df5a9bd9e69808dc49fdf22f4318))
|
||||
|
||||
Agent-Logs-Url:
|
||||
https://github.com/bec-project/bec_widgets/sessions/004cb4bc-5015-485e-a803-1e63876b7024
|
||||
|
||||
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
|
||||
|
||||
### Build System
|
||||
|
||||
- Add pytest-benchmark dependency
|
||||
([`551d38d`](https://github.com/bec-project/bec_widgets/commit/551d38d90111361e64bfcf10a1409be71dd298bc))
|
||||
|
||||
### Chores
|
||||
|
||||
- Update header comments in script files to indicate AI generation
|
||||
([`3f1aa80`](https://github.com/bec-project/bec_widgets/commit/3f1aa80756368454c3631cb6cf9d29db28af177a))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Add benchmark workflow
|
||||
([`999b7a2`](https://github.com/bec-project/bec_widgets/commit/999b7a2321f2f222c04b056a2db4280f66de9c48))
|
||||
|
||||
- Fix benchmark upload
|
||||
([`19b5c8f`](https://github.com/bec-project/bec_widgets/commit/19b5c8f724dbdb7421957c290f9213bf072392df))
|
||||
|
||||
- Increase threshold to 20 percent
|
||||
([`409c9e5`](https://github.com/bec-project/bec_widgets/commit/409c9e5bfacdfc003f1bc8c9944f01798bd3818e))
|
||||
|
||||
### Testing
|
||||
|
||||
- Fix assertions after updating ophyd devices templates
|
||||
([`a614d66`](https://github.com/bec-project/bec_widgets/commit/a614d662d6da716a49afb4ed3a3903f108210386))
|
||||
|
||||
Co-authored-by: Copilot <copilot@github.com>
|
||||
|
||||
- Remove references to "scan_motors" in tests
|
||||
([`5056ef8`](https://github.com/bec-project/bec_widgets/commit/5056ef8946d03a20e802709d3fe81c84c195fe41))
|
||||
|
||||
|
||||
## v3.7.1 (2026-04-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **heatmap**: Fix access to status from metadata
|
||||
([`55694ff`](https://github.com/bec-project/bec_widgets/commit/55694ff2b96581e03c63c8b8e068e2db79bcf780))
|
||||
|
||||
### Testing
|
||||
|
||||
- Fix exit status and status access in tests
|
||||
([`91afc77`](https://github.com/bec-project/bec_widgets/commit/91afc775d59b4ba31bed3585847a67c301acf9b0))
|
||||
|
||||
|
||||
## v3.7.0 (2026-04-21)
|
||||
|
||||
### Features
|
||||
|
||||
- Move companion app to applications
|
||||
([`0cf84cd`](https://github.com/bec-project/bec_widgets/commit/0cf84cd1d839ac4a39ffb5fb9ba57d432e04348a))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Cleanup of imports
|
||||
([`3e77f54`](https://github.com/bec-project/bec_widgets/commit/3e77f540345f56b9f184a332fcdd50d4d4c8c621))
|
||||
|
||||
|
||||
## v3.6.0 (2026-04-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Change resize mode to interactive
|
||||
([`a5db2dc`](https://github.com/bec-project/bec_widgets/commit/a5db2dc340f3386e68b300fd4528a44f87cbbf97))
|
||||
|
||||
- Small usability changes
|
||||
([`5a497c3`](https://github.com/bec-project/bec_widgets/commit/5a497c3598c2d8f27916d91d53c646d5d6d3a4a7))
|
||||
|
||||
### Features
|
||||
|
||||
- Add button/slot to pause/unpause logs
|
||||
([`23e3644`](https://github.com/bec-project/bec_widgets/commit/23e3644619de958bcfdb8a0b2ee1f7c2ce05b235))
|
||||
|
||||
- Add logpanel to menu
|
||||
([`2e8f43f`](https://github.com/bec-project/bec_widgets/commit/2e8f43fcac581cd1c227308198565d142a1bf276))
|
||||
|
||||
- Migrate logpanel to table model/view
|
||||
([`09bb112`](https://github.com/bec-project/bec_widgets/commit/09bb1121d83bac1f6e4827daa476fbe7cd5b3a80))
|
||||
|
||||
|
||||
## v3.5.1 (2026-04-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Don't assume attr exists if we timed out waiting for it
|
||||
([`f7a1ee4`](https://github.com/bec-project/bec_widgets/commit/f7a1ee49a42c58ba315c8957b45a80d862ffe745))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Don't import real widgets in client
|
||||
([`8e51c1a`](https://github.com/bec-project/bec_widgets/commit/8e51c1adb6a7658c54846794cf97b774cbac2193))
|
||||
|
||||
|
||||
## v3.5.0 (2026-04-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Connect signals the correct way around
|
||||
([`f562c61`](https://github.com/bec-project/bec_widgets/commit/f562c61e3cec3387f6821bad74403beeb3436355))
|
||||
|
||||
- Create new bec shell if deleted
|
||||
([`1754e75`](https://github.com/bec-project/bec_widgets/commit/1754e759f0c59f2f4063f661bacd334127326947))
|
||||
|
||||
- Formatting in plugin template
|
||||
([`fa2ef83`](https://github.com/bec-project/bec_widgets/commit/fa2ef83bb9dfeeb4c5fc7cd77168c16101c32693))
|
||||
|
||||
- **bec_console**: Persistent bec session
|
||||
([`9b0ec9d`](https://github.com/bec-project/bec_widgets/commit/9b0ec9dd79ad1adc5d211dd703db7441da965f34))
|
||||
|
||||
### Features
|
||||
|
||||
- Add qtermwidget plugin and replace web term
|
||||
([`02cb393`](https://github.com/bec-project/bec_widgets/commit/02cb393bb086165dc64917b633d5570d02e1a2a9))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Code cleanup
|
||||
([`bda5d38`](https://github.com/bec-project/bec_widgets/commit/bda5d389651bb2b13734cd31159679e85b1bd583))
|
||||
|
||||
|
||||
## v3.4.4 (2026-04-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -192,8 +192,7 @@ Positioner boxes and tweak controls handle precise moves, homing, and calibratio
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of
|
||||
the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
The documentation can be found [here](https://bec.readthedocs.io/).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+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
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
import darkdetect
|
||||
@@ -19,8 +20,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
|
||||
|
||||
@@ -63,6 +64,7 @@ class GUIServer:
|
||||
self.app: QApplication | None = None
|
||||
self.launcher_window: LaunchWindow | None = None
|
||||
self.dispatcher: BECDispatcher | None = None
|
||||
self._shutdown_started = False
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
@@ -74,6 +76,7 @@ class GUIServer:
|
||||
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
|
||||
bec_logger._update_sinks()
|
||||
|
||||
bec_logger.disabled_modules = ["bec_lib.scan_items"]
|
||||
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): # type: ignore
|
||||
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): # type: ignore
|
||||
self._run()
|
||||
@@ -122,17 +125,8 @@ class GUIServer:
|
||||
self.app.aboutToQuit.connect(self.shutdown)
|
||||
self.app.setQuitOnLastWindowClosed(True)
|
||||
|
||||
def sigint_handler(*args):
|
||||
# display message, for people to let it terminate gracefully
|
||||
print("Caught SIGINT, exiting")
|
||||
# Widgets should be all closed.
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
|
||||
widget.close()
|
||||
self.shutdown()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGTERM, sigint_handler)
|
||||
signal.signal(signal.SIGINT, self.request_shutdown)
|
||||
signal.signal(signal.SIGTERM, self.request_shutdown)
|
||||
|
||||
sys.exit(self.app.exec())
|
||||
|
||||
@@ -149,16 +143,67 @@ class GUIServer:
|
||||
)
|
||||
self.app.setWindowIcon(icon)
|
||||
|
||||
def request_shutdown(self, signum=None, _frame=None):
|
||||
"""
|
||||
Request Qt application shutdown from an RPC call or OS signal.
|
||||
|
||||
Cleanup itself is handled by ``shutdown()``, which is connected to
|
||||
``QApplication.aboutToQuit``. Calling it directly here would run BEC/RPC
|
||||
teardown before Qt has processed the widget close events.
|
||||
"""
|
||||
signal_name = signal.Signals(signum).name if signum is not None else "shutdown"
|
||||
pid = os.getpid()
|
||||
if self.app is None:
|
||||
logger.info(f"Caught {signal_name}, shutting down GUI server pid={pid} without app")
|
||||
self.shutdown()
|
||||
return
|
||||
|
||||
widgets = [
|
||||
f"{widget.__class__.__name__}(objectName={widget.objectName()!r})"
|
||||
for widget in self.app.topLevelWidgets()
|
||||
]
|
||||
logger.info(
|
||||
f"Caught {signal_name}, requesting GUI server shutdown pid={pid} "
|
||||
f"top_level_widgets={widgets}"
|
||||
)
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for widget in self.app.topLevelWidgets():
|
||||
widget.close()
|
||||
self.app.quit()
|
||||
|
||||
@staticmethod
|
||||
def _run_shutdown_step(step: str, callback):
|
||||
try:
|
||||
callback()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"GUIServer shutdown step failed pid={os.getpid()} step={step}: {exc}\n"
|
||||
f"{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
def shutdown(self):
|
||||
logger.info("Shutdown GUIServer", repr(self))
|
||||
if self.launcher_window and shiboken6.isValid(self.launcher_window):
|
||||
self.launcher_window.close()
|
||||
self.launcher_window.deleteLater()
|
||||
if pylsp_server.is_running():
|
||||
pylsp_server.stop()
|
||||
if self.dispatcher:
|
||||
self.dispatcher.stop_cli_server()
|
||||
self.dispatcher.disconnect_all()
|
||||
if self._shutdown_started:
|
||||
return
|
||||
self._shutdown_started = True
|
||||
logger.info(f"Shutdown GUIServer pid={os.getpid()} {repr(self)}")
|
||||
|
||||
def close_launcher_window():
|
||||
if self.launcher_window and shiboken6.isValid(self.launcher_window):
|
||||
self.launcher_window.close()
|
||||
self.launcher_window.deleteLater()
|
||||
|
||||
def stop_pylsp_server():
|
||||
if pylsp_server.is_running():
|
||||
pylsp_server.stop()
|
||||
|
||||
def stop_dispatcher():
|
||||
if self.dispatcher:
|
||||
self.dispatcher.stop_cli_server()
|
||||
self.dispatcher.disconnect_all()
|
||||
|
||||
self._run_shutdown_step("close_launcher_window", close_launcher_window)
|
||||
self._run_shutdown_step("stop_pylsp_server", stop_pylsp_server)
|
||||
self._run_shutdown_step("stop_dispatcher", stop_dispatcher)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -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
|
||||
@@ -207,6 +207,7 @@ class LaunchWindow(BECMainWindow):
|
||||
|
||||
self.app = QApplication.instance()
|
||||
self.tiles: dict[str, LaunchTile] = {}
|
||||
self._logged_unparented_connections: set[str] = set()
|
||||
# Track the smallest main‑label font size chosen so far
|
||||
self._min_main_label_pt: int | None = None
|
||||
|
||||
@@ -655,53 +656,83 @@ class LaunchWindow(BECMainWindow):
|
||||
super().showEvent(event)
|
||||
self.setFixedSize(self.size())
|
||||
|
||||
def _launcher_is_last_widget(self, connections: dict) -> bool:
|
||||
def _has_external_window(self, connections: dict) -> bool:
|
||||
"""
|
||||
Check if the launcher is the last widget in the application.
|
||||
Check if any registered non-launcher connection owns a top-level Qt window.
|
||||
"""
|
||||
|
||||
# get all parents of connections
|
||||
for connection in connections.values():
|
||||
try:
|
||||
parent = connection.parent()
|
||||
if parent is None and connection.objectName() != self.objectName():
|
||||
logger.info(
|
||||
f"Found non-launcher connection without parent: {connection.objectName()}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting parent of connection: {e}")
|
||||
return False
|
||||
return True
|
||||
if self._connection_belongs_to_launcher(connection):
|
||||
continue
|
||||
if isinstance(connection, QWidget) and connection.isWindow():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _log_unparented_connections(self, connections: dict) -> None:
|
||||
"""
|
||||
Log non-launcher RPC connections that remain without an active top-level window.
|
||||
"""
|
||||
for connection in connections.values():
|
||||
if self._connection_belongs_to_launcher(connection):
|
||||
continue
|
||||
if isinstance(connection, QWidget) and connection.isWindow():
|
||||
continue
|
||||
|
||||
connection_description = (
|
||||
f"type={type(connection).__name__} objectName={connection.objectName()!r} "
|
||||
f"gui_id={connection.gui_id!r}"
|
||||
)
|
||||
if connection_description in self._logged_unparented_connections:
|
||||
continue
|
||||
self._logged_unparented_connections.add(connection_description)
|
||||
logger.warning(
|
||||
"Registered non-launcher RPC connection has no active top-level window: "
|
||||
f"{connection_description}"
|
||||
)
|
||||
|
||||
def _connection_belongs_to_launcher(self, connection: QObject) -> bool:
|
||||
"""
|
||||
Check whether a registered connection is the launcher itself or part of its Qt hierarchy.
|
||||
"""
|
||||
if connection is self or connection.gui_id == self.gui_id:
|
||||
return True
|
||||
|
||||
parent = connection.parent()
|
||||
while parent is not None:
|
||||
if parent is self:
|
||||
return True
|
||||
parent = parent.parent()
|
||||
|
||||
return False
|
||||
|
||||
def _turn_off_the_lights(self, connections: dict):
|
||||
"""
|
||||
If there is only one connection remaining, it is the launcher, so we show it.
|
||||
Once the launcher is closed as the last window, we quit the application.
|
||||
"""
|
||||
if self._launcher_is_last_widget(connections):
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
if self._has_external_window(connections):
|
||||
self.hide()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(True) # type: ignore
|
||||
self.app.setQuitOnLastWindowClosed(False) # type: ignore
|
||||
return
|
||||
|
||||
self.hide()
|
||||
self._log_unparented_connections(connections)
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(False) # type: ignore
|
||||
self.app.setQuitOnLastWindowClosed(True) # type: ignore
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""
|
||||
Close the launcher window.
|
||||
"""
|
||||
connections = self.register.list_all_connections()
|
||||
if self._launcher_is_last_widget(connections):
|
||||
event.accept()
|
||||
if self._has_external_window(connections):
|
||||
event.ignore()
|
||||
self.hide()
|
||||
return
|
||||
|
||||
event.ignore()
|
||||
self.hide()
|
||||
event.accept()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -16,9 +16,9 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
|
||||
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ class DeveloperWidget(DockAreaWidget):
|
||||
|
||||
self.console = BECShell(self, rpc_exposed=False)
|
||||
self.console.setObjectName("BEC Shell")
|
||||
self.terminal = WebConsole(self, rpc_exposed=False)
|
||||
self.terminal = BecConsole(self, rpc_exposed=False)
|
||||
self.terminal.setObjectName("Terminal")
|
||||
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
|
||||
self.monaco.setObjectName("MonacoEditor")
|
||||
@@ -410,23 +410,3 @@ class DeveloperWidget(DockAreaWidget):
|
||||
"""Clean up resources used by the developer widget."""
|
||||
self.delete_all()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
# developer_view.setWindowTitle("Developer View")
|
||||
# developer_view.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
from bec_widgets.cli.rpc import rpc_base
|
||||
|
||||
+248
-88
@@ -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
|
||||
|
||||
@@ -32,6 +32,8 @@ _Widgets = {
|
||||
"BECQueue": "BECQueue",
|
||||
"BECShell": "BECShell",
|
||||
"BECStatusBox": "BECStatusBox",
|
||||
"BeamlineStateManager": "BeamlineStateManager",
|
||||
"BecConsole": "BecConsole",
|
||||
"DapComboBox": "DapComboBox",
|
||||
"DeviceBrowser": "DeviceBrowser",
|
||||
"Heatmap": "Heatmap",
|
||||
@@ -56,35 +58,24 @@ _Widgets = {
|
||||
"SignalLabel": "SignalLabel",
|
||||
"TextBox": "TextBox",
|
||||
"Waveform": "Waveform",
|
||||
"WebConsole": "WebConsole",
|
||||
"WebsiteWidget": "WebsiteWidget",
|
||||
}
|
||||
|
||||
|
||||
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 +83,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 +93,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 +131,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 +153,8 @@ class AvailableDeviceResources(RPCBase):
|
||||
|
||||
|
||||
class BECDockArea(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.dock_area"
|
||||
|
||||
@rpc_call
|
||||
def new(
|
||||
self,
|
||||
@@ -342,10 +341,10 @@ class BECDockArea(RPCBase):
|
||||
Save the current workspace profile.
|
||||
|
||||
On first save of a given name:
|
||||
- writes a default copy to states/default/<name>.ini with tag=default and created_at
|
||||
- writes a user copy to states/user/<name>.ini with tag=user and created_at
|
||||
On subsequent saves of user-owned profiles:
|
||||
- updates both the default and user copies so restore uses the latest snapshot.
|
||||
- writes a baseline copy to profiles/baseline/<name>.ini with created_at
|
||||
- writes a runtime copy to profiles/runtime/<name>.ini with created_at
|
||||
On subsequent saves:
|
||||
- updates both the baseline and runtime copies so restore uses the latest snapshot.
|
||||
Read-only bundled profiles cannot be overwritten.
|
||||
|
||||
Args:
|
||||
@@ -360,15 +359,31 @@ class BECDockArea(RPCBase):
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def load_profile(self, name: "str | None" = None):
|
||||
def load_profile(self, name: "str | None" = None, restore_baseline: "bool" = False):
|
||||
"""
|
||||
Load a workspace profile.
|
||||
|
||||
Before switching, persist the current profile to the user copy.
|
||||
Prefer loading the user copy; fall back to the default copy.
|
||||
Before switching, persist the current profile to the runtime copy.
|
||||
Prefer loading the runtime copy; fall back to the baseline copy. When
|
||||
``restore_baseline`` is True, first overwrite the runtime copy with the
|
||||
baseline profile and then load it.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||
restore_baseline (bool): If True, restore the runtime copy from the
|
||||
baseline before loading. Defaults to False.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def restore_baseline_profile(self, name: "str | None" = None, show_dialog: "bool" = False):
|
||||
"""
|
||||
Overwrite the runtime copy of *name* with the baseline.
|
||||
If *name* is None, target the currently active profile.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile to restore. If None, uses the current profile.
|
||||
show_dialog (bool): If True, ask for confirmation before restoring.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
@@ -391,6 +406,8 @@ class BECDockArea(RPCBase):
|
||||
|
||||
|
||||
class BECMainWindow(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.containers.main_window.main_window"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -411,7 +428,9 @@ class BECMainWindow(RPCBase):
|
||||
|
||||
|
||||
class BECProgressBar(RPCBase):
|
||||
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
|
||||
"""A BEC progress bar backed by Qt's native QProgressBar."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.progress.bec_progressbar.bec_progressbar"
|
||||
|
||||
@rpc_call
|
||||
def set_value(self, value):
|
||||
@@ -486,6 +505,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):
|
||||
"""
|
||||
@@ -506,7 +527,9 @@ class BECQueue(RPCBase):
|
||||
|
||||
|
||||
class BECShell(RPCBase):
|
||||
"""A WebConsole pre-configured to run the BEC shell."""
|
||||
"""A BecConsole pre-configured to run the BEC shell."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
@@ -530,6 +553,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 +590,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":
|
||||
@@ -691,9 +718,87 @@ class BaseROI(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class BeamlineStateManager(RPCBase):
|
||||
"""Widget displaying and managing all BEC beamline states."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.services.beamline_states.beamline_state_manager"
|
||||
|
||||
@rpc_call
|
||||
def clear_filters(self) -> "None":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def collapse_all(self) -> "None":
|
||||
"""
|
||||
Collapse the settings panel of all displayed state pills.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def state_summary(self) -> "dict[str, dict[str, str]]":
|
||||
"""
|
||||
Return all beamline states (including filtered ones) with their current status and label.
|
||||
|
||||
Returns:
|
||||
dict: Mapping of state name to a dictionary with ``status`` and ``label`` keys.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@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 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":
|
||||
@@ -821,6 +926,8 @@ class CircularROI(RPCBase):
|
||||
|
||||
|
||||
class Curve(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.curve"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -987,6 +1094,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):
|
||||
"""
|
||||
@@ -1018,6 +1127,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":
|
||||
"""
|
||||
@@ -1028,6 +1139,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):
|
||||
"""
|
||||
@@ -1050,27 +1163,7 @@ class DeviceBrowser(RPCBase):
|
||||
class DeviceInitializationProgressBar(RPCBase):
|
||||
"""A progress bar that displays the progress of device initialization."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@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 DeviceInputBase(RPCBase):
|
||||
"""Mixin base class for device input widgets."""
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
@@ -1094,6 +1187,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":
|
||||
"""
|
||||
@@ -1104,6 +1199,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":
|
||||
"""
|
||||
@@ -1296,10 +1393,10 @@ class DockAreaView(RPCBase):
|
||||
Save the current workspace profile.
|
||||
|
||||
On first save of a given name:
|
||||
- writes a default copy to states/default/<name>.ini with tag=default and created_at
|
||||
- writes a user copy to states/user/<name>.ini with tag=user and created_at
|
||||
On subsequent saves of user-owned profiles:
|
||||
- updates both the default and user copies so restore uses the latest snapshot.
|
||||
- writes a baseline copy to profiles/baseline/<name>.ini with created_at
|
||||
- writes a runtime copy to profiles/runtime/<name>.ini with created_at
|
||||
On subsequent saves:
|
||||
- updates both the baseline and runtime copies so restore uses the latest snapshot.
|
||||
Read-only bundled profiles cannot be overwritten.
|
||||
|
||||
Args:
|
||||
@@ -1314,15 +1411,31 @@ class DockAreaView(RPCBase):
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def load_profile(self, name: "str | None" = None):
|
||||
def load_profile(self, name: "str | None" = None, restore_baseline: "bool" = False):
|
||||
"""
|
||||
Load a workspace profile.
|
||||
|
||||
Before switching, persist the current profile to the user copy.
|
||||
Prefer loading the user copy; fall back to the default copy.
|
||||
Before switching, persist the current profile to the runtime copy.
|
||||
Prefer loading the runtime copy; fall back to the baseline copy. When
|
||||
``restore_baseline`` is True, first overwrite the runtime copy with the
|
||||
baseline profile and then load it.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||
restore_baseline (bool): If True, restore the runtime copy from the
|
||||
baseline before loading. Defaults to False.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def restore_baseline_profile(self, name: "str | None" = None, show_dialog: "bool" = False):
|
||||
"""
|
||||
Overwrite the runtime copy of *name* with the baseline.
|
||||
If *name* is None, target the currently active profile.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile to restore. If None, uses the current profile.
|
||||
show_dialog (bool): If True, ask for confirmation before restoring.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
@@ -1347,6 +1460,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,
|
||||
@@ -1531,6 +1646,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":
|
||||
@@ -1653,6 +1770,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):
|
||||
"""
|
||||
@@ -2351,6 +2470,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):
|
||||
"""
|
||||
@@ -2962,6 +3083,8 @@ class Image(RPCBase):
|
||||
|
||||
|
||||
class ImageItem(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image_item"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def color_map(self) -> "str":
|
||||
@@ -3112,6 +3235,8 @@ class ImageItem(RPCBase):
|
||||
|
||||
|
||||
class LaunchWindow(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.applications.launch_window"
|
||||
|
||||
@rpc_call
|
||||
def show_launcher(self):
|
||||
"""
|
||||
@@ -3126,33 +3251,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,
|
||||
@@ -3337,6 +3467,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
|
||||
@@ -3511,6 +3643,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):
|
||||
"""
|
||||
@@ -3981,6 +4115,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):
|
||||
"""
|
||||
@@ -4440,6 +4576,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):
|
||||
"""
|
||||
@@ -4571,6 +4709,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):
|
||||
"""
|
||||
@@ -4636,6 +4778,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"):
|
||||
"""
|
||||
@@ -4668,6 +4814,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"):
|
||||
"""
|
||||
@@ -4737,6 +4885,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"):
|
||||
"""
|
||||
@@ -4769,6 +4919,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"):
|
||||
"""
|
||||
@@ -4800,6 +4952,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":
|
||||
@@ -4929,6 +5083,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):
|
||||
"""
|
||||
@@ -4949,6 +5105,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"):
|
||||
"""
|
||||
@@ -5042,6 +5200,8 @@ class Ring(RPCBase):
|
||||
|
||||
|
||||
class RingProgressBar(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -5121,12 +5281,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):
|
||||
"""
|
||||
@@ -5150,6 +5312,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):
|
||||
"""
|
||||
@@ -5172,6 +5336,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":
|
||||
@@ -5181,6 +5347,8 @@ class ScatterCurve(RPCBase):
|
||||
|
||||
|
||||
class ScatterWaveform(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_waveform"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -5648,6 +5816,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":
|
||||
@@ -5792,6 +5962,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:
|
||||
"""
|
||||
@@ -5814,6 +5986,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":
|
||||
"""
|
||||
@@ -5824,6 +5998,8 @@ class ViewBase(RPCBase):
|
||||
class Waveform(RPCBase):
|
||||
"""Widget for plotting waveforms."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.waveform"
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -6402,6 +6578,8 @@ class Waveform(RPCBase):
|
||||
|
||||
|
||||
class WaveformViewInline(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -6410,6 +6588,8 @@ class WaveformViewInline(RPCBase):
|
||||
|
||||
|
||||
class WaveformViewPopup(RPCBase):
|
||||
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||
|
||||
@rpc_call
|
||||
def activate(self) -> "None":
|
||||
"""
|
||||
@@ -6417,31 +6597,11 @@ class WaveformViewPopup(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class WebConsole(RPCBase):
|
||||
"""A simple widget to display a website"""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@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 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:
|
||||
"""
|
||||
|
||||
+159
-11
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -33,6 +34,12 @@ else:
|
||||
logger = bec_logger.logger
|
||||
|
||||
IGNORE_WIDGETS = ["LaunchWindow"]
|
||||
PROCESS_TERMINATION_TIMEOUT = 10
|
||||
PROCESS_OUTPUT_THREAD_JOIN_TIMEOUT = 2
|
||||
PROCESS_OUTPUT_SELECT_TIMEOUT = 0.2
|
||||
GRACEFUL_SERVER_SHUTDOWN_RPC_TIMEOUT = 3
|
||||
GRACEFUL_SERVER_SHUTDOWN_TIMEOUT = 5
|
||||
OUTPUT_READER_STOP_EVENT_ATTR = "_bec_output_reader_stop_event"
|
||||
|
||||
RegistryState: TypeAlias = dict[
|
||||
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
|
||||
@@ -53,14 +60,16 @@ def _filter_output(output: str) -> str:
|
||||
return output
|
||||
|
||||
|
||||
def _get_output(process, logger) -> None:
|
||||
def _get_output(process, logger, stop_event: threading.Event | None = None) -> None:
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.info}
|
||||
stream_buffer = {process.stdout: [], process.stderr: []}
|
||||
try:
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
os.set_blocking(process.stderr.fileno(), False)
|
||||
while process.poll() is None:
|
||||
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
|
||||
while process.poll() is None and not (stop_event and stop_event.is_set()):
|
||||
readylist, _, _ = select.select(
|
||||
[process.stdout, process.stderr], [], [], PROCESS_OUTPUT_SELECT_TIMEOUT
|
||||
)
|
||||
for stream in (process.stdout, process.stderr):
|
||||
buf = stream_buffer[stream]
|
||||
if stream in readylist:
|
||||
@@ -75,6 +84,95 @@ def _get_output(process, logger) -> None:
|
||||
logger.error(f"Error reading process output: {str(e)}")
|
||||
|
||||
|
||||
def _process_group_snapshot(process) -> str:
|
||||
try:
|
||||
pgid = os.getpgid(process.pid)
|
||||
except ProcessLookupError:
|
||||
return "Process group snapshot unavailable: process already exited"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ps", "-o", "pid,ppid,pgid,stat,command", "-g", str(pgid)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
except Exception as exc:
|
||||
return f"Process group snapshot unavailable: {exc}"
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
return f"Process group snapshot empty for pgid={pgid}"
|
||||
return output
|
||||
|
||||
|
||||
def _terminate_plot_process(process, logger, timeout: float = PROCESS_TERMINATION_TIMEOUT) -> None:
|
||||
if process.poll() is not None:
|
||||
return
|
||||
|
||||
process_info = f"pid={process.pid} command={process.args}"
|
||||
try:
|
||||
pgid = os.getpgid(process.pid)
|
||||
process_info = f"pid={process.pid} pgid={pgid} command={process.args}"
|
||||
logger.info(f"Terminating GUI process group {process_info}")
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
process.wait(timeout=timeout)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to terminate GUI process group; terminating process only.")
|
||||
logger.info(f"GUI process termination failure details: {exc}. pid={process.pid}")
|
||||
process.terminate()
|
||||
|
||||
try:
|
||||
process.wait(timeout=timeout)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(f"GUI process did not stop within {timeout}s; killing it.")
|
||||
logger.info(
|
||||
f"GUI process force-kill details: {process_info}\n"
|
||||
f"{_process_group_snapshot(process)}"
|
||||
)
|
||||
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except ProcessLookupError as e:
|
||||
logger.error(f"Failed to kill GUI process group: {e}")
|
||||
process.wait(timeout=timeout)
|
||||
return
|
||||
process.wait(timeout=timeout)
|
||||
|
||||
|
||||
def _wait_for_process_exit(process, timeout: float) -> bool:
|
||||
try:
|
||||
process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _join_process_output_thread(process, thread: threading.Thread | None, logger) -> None:
|
||||
if thread is None:
|
||||
return
|
||||
thread.join(timeout=PROCESS_OUTPUT_THREAD_JOIN_TIMEOUT)
|
||||
if not thread.is_alive():
|
||||
return
|
||||
|
||||
if stop_event := getattr(thread, OUTPUT_READER_STOP_EVENT_ATTR, None):
|
||||
stop_event.set()
|
||||
|
||||
for stream in (process.stdout, process.stderr):
|
||||
if stream is None:
|
||||
continue
|
||||
try:
|
||||
stream.close()
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to close stream {str(e)}")
|
||||
thread.join(timeout=PROCESS_OUTPUT_THREAD_JOIN_TIMEOUT)
|
||||
if thread.is_alive():
|
||||
logger.warning("GUI process output reader thread did not stop after process shutdown.")
|
||||
logger.info(f"GUI process output reader thread details: pid={process.pid}")
|
||||
|
||||
|
||||
def _start_plot_process(
|
||||
gui_id: str,
|
||||
gui_class_id: str,
|
||||
@@ -126,8 +224,14 @@ def _start_plot_process(
|
||||
if logger is None:
|
||||
process_output_processing_thread = None
|
||||
else:
|
||||
process_output_stop_event = threading.Event()
|
||||
process_output_processing_thread = threading.Thread(
|
||||
target=_get_output, args=(process, logger)
|
||||
target=_get_output, args=(process, logger, process_output_stop_event)
|
||||
)
|
||||
setattr(
|
||||
process_output_processing_thread,
|
||||
OUTPUT_READER_STOP_EVENT_ATTR,
|
||||
process_output_stop_event,
|
||||
)
|
||||
process_output_processing_thread.start()
|
||||
return process, process_output_processing_thread
|
||||
@@ -222,6 +326,7 @@ class BECGuiClient(RPCBase):
|
||||
self._ipython_registry: dict[str, RPCReference] = {}
|
||||
self.available_widgets = AvailableWidgetsNamespace()
|
||||
register_serializer_extension()
|
||||
self._rpc_timeout = 60
|
||||
|
||||
####################
|
||||
#### Client API ####
|
||||
@@ -232,6 +337,16 @@ class BECGuiClient(RPCBase):
|
||||
"""The launcher object."""
|
||||
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
|
||||
|
||||
def set_rpc_timeout(self, timeout: float):
|
||||
"""Set the timeout for RPC calls to the GUI server.
|
||||
|
||||
Args:
|
||||
timeout(float): The timeout in seconds.
|
||||
"""
|
||||
if not isinstance(timeout, (int, float)) or timeout < 0:
|
||||
raise ValueError("Timeout must be a non-negative number.")
|
||||
self._rpc_timeout = timeout
|
||||
|
||||
def _safe_register_stream(self, endpoint: EndpointInfo, cb: Callable, **kwargs):
|
||||
"""Check if already registered for registration in idempotent functions."""
|
||||
if not self._client.connector.any_stream_is_registered(endpoint, cb=cb):
|
||||
@@ -358,7 +473,7 @@ class BECGuiClient(RPCBase):
|
||||
)
|
||||
|
||||
if not self._check_if_server_is_alive():
|
||||
self.start(wait=True)
|
||||
self.show(wait=True)
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
return self._new_impl(
|
||||
@@ -454,11 +569,13 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
if self._process:
|
||||
logger.success("Stopping GUI...")
|
||||
self._process.terminate()
|
||||
if self._process_output_processing_thread:
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
if not self._request_server_shutdown():
|
||||
_terminate_plot_process(self._process, logger)
|
||||
_join_process_output_thread(
|
||||
self._process, self._process_output_processing_thread, logger
|
||||
)
|
||||
self._process = None
|
||||
self._process_output_processing_thread = None
|
||||
|
||||
# Unregister the registry state
|
||||
self._client.connector.unregister(
|
||||
@@ -477,6 +594,37 @@ class BECGuiClient(RPCBase):
|
||||
#### Private methods ####
|
||||
#########################
|
||||
|
||||
def _request_server_shutdown(self) -> bool:
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
return True
|
||||
process_details = f"pid={self._process.pid} command={self._process.args}"
|
||||
logger.info(f"Requesting graceful GUI shutdown {process_details}")
|
||||
try:
|
||||
self.launcher._run_rpc( # pylint: disable=protected-access
|
||||
"system.shutdown",
|
||||
wait_for_rpc_response=True,
|
||||
timeout=GRACEFUL_SERVER_SHUTDOWN_RPC_TIMEOUT,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not confirm graceful GUI shutdown via RPC; "
|
||||
"falling back to process termination."
|
||||
)
|
||||
logger.info(f"Graceful GUI shutdown RPC failure details: {exc}. {process_details}")
|
||||
return False
|
||||
if _wait_for_process_exit(self._process, GRACEFUL_SERVER_SHUTDOWN_TIMEOUT):
|
||||
logger.info(f"GUI server exited after graceful shutdown {process_details}")
|
||||
return True
|
||||
logger.warning(
|
||||
"GUI server did not exit after graceful shutdown request; "
|
||||
"falling back to process termination."
|
||||
)
|
||||
logger.info(
|
||||
f"Graceful GUI shutdown timeout details: {process_details}\n"
|
||||
f"{_process_group_snapshot(self._process)}"
|
||||
)
|
||||
return False
|
||||
|
||||
def _check_if_server_is_alive(self):
|
||||
"""Checks if the process is alive"""
|
||||
if self._process is None:
|
||||
@@ -550,7 +698,7 @@ class BECGuiClient(RPCBase):
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
window.raise_window()
|
||||
|
||||
def _show_all(self):
|
||||
with wait_for_server(self):
|
||||
@@ -569,7 +717,7 @@ class BECGuiClient(RPCBase):
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("raise") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window._run_rpc("raise") # type: ignore[attr-defined]
|
||||
window.raise_window()
|
||||
|
||||
def _raise_all(self):
|
||||
with wait_for_server(self):
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# This file was automatically generated by generate_cli.py
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
# pylint: skip-file
|
||||
|
||||
designer_plugins = {
|
||||
"AbortButton": ("bec_widgets.widgets.control.buttons.button_abort.button_abort", "AbortButton"),
|
||||
"BECColorMapWidget": (
|
||||
"bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget",
|
||||
"BECColorMapWidget",
|
||||
),
|
||||
"BECMainWindow": ("bec_widgets.widgets.containers.main_window.main_window", "BECMainWindow"),
|
||||
"BECProgressBar": (
|
||||
"bec_widgets.widgets.progress.bec_progressbar.bec_progressbar",
|
||||
"BECProgressBar",
|
||||
),
|
||||
"BECQueue": ("bec_widgets.widgets.services.bec_queue.bec_queue", "BECQueue"),
|
||||
"BECShell": ("bec_widgets.widgets.editors.bec_console.bec_console", "BECShell"),
|
||||
"BECSpinBox": ("bec_widgets.widgets.utility.spinbox.decimal_spinbox", "BECSpinBox"),
|
||||
"BECStatusBox": ("bec_widgets.widgets.services.bec_status_box.bec_status_box", "BECStatusBox"),
|
||||
"BeamlineStateManager": (
|
||||
"bec_widgets.widgets.services.beamline_states.beamline_state_manager",
|
||||
"BeamlineStateManager",
|
||||
),
|
||||
"BecConsole": ("bec_widgets.widgets.editors.bec_console.bec_console", "BecConsole"),
|
||||
"ColorButton": ("bec_widgets.widgets.utility.visual.color_button.color_button", "ColorButton"),
|
||||
"ColorButtonNative": (
|
||||
"bec_widgets.widgets.utility.visual.color_button_native.color_button_native",
|
||||
"ColorButtonNative",
|
||||
),
|
||||
"ColormapSelector": (
|
||||
"bec_widgets.widgets.utility.visual.colormap_selector.colormap_selector",
|
||||
"ColormapSelector",
|
||||
),
|
||||
"DapComboBox": ("bec_widgets.widgets.dap.dap_combo_box.dap_combo_box", "DapComboBox"),
|
||||
"DarkModeButton": (
|
||||
"bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button",
|
||||
"DarkModeButton",
|
||||
),
|
||||
"DeviceBrowser": (
|
||||
"bec_widgets.widgets.services.device_browser.device_browser",
|
||||
"DeviceBrowser",
|
||||
),
|
||||
"DeviceComboBox": (
|
||||
"bec_widgets.widgets.control.device_input.device_combobox.device_combobox",
|
||||
"DeviceComboBox",
|
||||
),
|
||||
"Heatmap": ("bec_widgets.widgets.plots.heatmap.heatmap", "Heatmap"),
|
||||
"IDEExplorer": ("bec_widgets.widgets.utility.ide_explorer.ide_explorer", "IDEExplorer"),
|
||||
"Image": ("bec_widgets.widgets.plots.image.image", "Image"),
|
||||
"LMFitDialog": ("bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog", "LMFitDialog"),
|
||||
"LogPanel": ("bec_widgets.widgets.utility.logpanel.logpanel", "LogPanel"),
|
||||
"Minesweeper": ("bec_widgets.widgets.games.minesweeper", "Minesweeper"),
|
||||
"MonacoWidget": ("bec_widgets.widgets.editors.monaco.monaco_widget", "MonacoWidget"),
|
||||
"MotorMap": ("bec_widgets.widgets.plots.motor_map.motor_map", "MotorMap"),
|
||||
"MultiWaveform": ("bec_widgets.widgets.plots.multi_waveform.multi_waveform", "MultiWaveform"),
|
||||
"PdfViewerWidget": ("bec_widgets.widgets.utility.pdf_viewer.pdf_viewer", "PdfViewerWidget"),
|
||||
"PositionIndicator": (
|
||||
"bec_widgets.widgets.control.device_control.position_indicator.position_indicator",
|
||||
"PositionIndicator",
|
||||
),
|
||||
"PositionerBox": (
|
||||
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box",
|
||||
"PositionerBox",
|
||||
),
|
||||
"PositionerBox2D": (
|
||||
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d",
|
||||
"PositionerBox2D",
|
||||
),
|
||||
"PositionerControlLine": (
|
||||
"bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line",
|
||||
"PositionerControlLine",
|
||||
),
|
||||
"PositionerGroup": (
|
||||
"bec_widgets.widgets.control.device_control.positioner_group.positioner_group",
|
||||
"PositionerGroup",
|
||||
),
|
||||
"ResetButton": ("bec_widgets.widgets.control.buttons.button_reset.button_reset", "ResetButton"),
|
||||
"ResumeButton": (
|
||||
"bec_widgets.widgets.control.buttons.button_resume.button_resume",
|
||||
"ResumeButton",
|
||||
),
|
||||
"RingProgressBar": (
|
||||
"bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar",
|
||||
"RingProgressBar",
|
||||
),
|
||||
"SBBMonitor": ("bec_widgets.widgets.editors.sbb_monitor.sbb_monitor", "SBBMonitor"),
|
||||
"ScanControl": ("bec_widgets.widgets.control.scan_control.scan_control", "ScanControl"),
|
||||
"ScanMetadata": ("bec_widgets.widgets.editors.scan_metadata.scan_metadata", "ScanMetadata"),
|
||||
"ScanProgressBar": (
|
||||
"bec_widgets.widgets.progress.scan_progressbar.scan_progressbar",
|
||||
"ScanProgressBar",
|
||||
),
|
||||
"ScatterWaveform": (
|
||||
"bec_widgets.widgets.plots.scatter_waveform.scatter_waveform",
|
||||
"ScatterWaveform",
|
||||
),
|
||||
"SignalComboBox": (
|
||||
"bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox",
|
||||
"SignalComboBox",
|
||||
),
|
||||
"SignalLabel": ("bec_widgets.widgets.utility.signal_label.signal_label", "SignalLabel"),
|
||||
"SpinnerWidget": ("bec_widgets.widgets.utility.spinner.spinner", "SpinnerWidget"),
|
||||
"StopButton": ("bec_widgets.widgets.control.buttons.stop_button.stop_button", "StopButton"),
|
||||
"TextBox": ("bec_widgets.widgets.editors.text_box.text_box", "TextBox"),
|
||||
"ToggleSwitch": ("bec_widgets.widgets.utility.toggle.toggle", "ToggleSwitch"),
|
||||
"Waveform": ("bec_widgets.widgets.plots.waveform.waveform", "Waveform"),
|
||||
"WebsiteWidget": ("bec_widgets.widgets.editors.website.website", "WebsiteWidget"),
|
||||
"WidgetFinderComboBox": (
|
||||
"bec_widgets.widgets.utility.widget_finder.widget_finder",
|
||||
"WidgetFinderComboBox",
|
||||
),
|
||||
}
|
||||
|
||||
widget_icons = {
|
||||
"AbortButton": "cancel",
|
||||
"BECColorMapWidget": "palette",
|
||||
"BECMainWindow": "widgets",
|
||||
"BECProgressBar": "page_control",
|
||||
"BECQueue": "edit_note",
|
||||
"BECShell": "hub",
|
||||
"BECSpinBox": "123",
|
||||
"BECStatusBox": "widgets",
|
||||
"BeamlineStateManager": "format_list_bulleted",
|
||||
"BecConsole": "terminal",
|
||||
"ColorButton": "colors",
|
||||
"ColorButtonNative": "colors",
|
||||
"ColormapSelector": "palette",
|
||||
"DapComboBox": "data_exploration",
|
||||
"DarkModeButton": "dark_mode",
|
||||
"DeviceBrowser": "lists",
|
||||
"DeviceComboBox": "list_alt",
|
||||
"Heatmap": "dataset",
|
||||
"IDEExplorer": "widgets",
|
||||
"Image": "image",
|
||||
"LMFitDialog": "monitoring",
|
||||
"LogPanel": "browse_activity",
|
||||
"Minesweeper": "videogame_asset",
|
||||
"MonacoWidget": "code",
|
||||
"MotorMap": "my_location",
|
||||
"MultiWaveform": "ssid_chart",
|
||||
"PdfViewerWidget": "picture_as_pdf",
|
||||
"PositionIndicator": "horizontal_distribute",
|
||||
"PositionerBox": "switch_right",
|
||||
"PositionerBox2D": "switch_right",
|
||||
"PositionerControlLine": "switch_left",
|
||||
"PositionerGroup": "grid_view",
|
||||
"ResetButton": "restart_alt",
|
||||
"ResumeButton": "resume",
|
||||
"RingProgressBar": "track_changes",
|
||||
"SBBMonitor": "train",
|
||||
"ScanControl": "tune",
|
||||
"ScanMetadata": "list_alt",
|
||||
"ScanProgressBar": "timelapse",
|
||||
"ScatterWaveform": "scatter_plot",
|
||||
"SignalComboBox": "list_alt",
|
||||
"SignalLabel": "scoreboard",
|
||||
"SpinnerWidget": "progress_activity",
|
||||
"StopButton": "dangerous",
|
||||
"TextBox": "chat",
|
||||
"ToggleSwitch": "toggle_on",
|
||||
"Waveform": "show_chart",
|
||||
"WebsiteWidget": "travel_explore",
|
||||
"WidgetFinderComboBox": "frame_inspect",
|
||||
}
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -9,6 +10,7 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.device import DeviceBaseWithConfig
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -24,6 +26,9 @@ else:
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
_DEFAULT_RPC_TIMEOUT = object()
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def _name_arg(arg):
|
||||
if isinstance(arg, DeviceBaseWithConfig):
|
||||
@@ -154,6 +159,7 @@ class RPCReference:
|
||||
|
||||
|
||||
class RPCBase:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str | None = None,
|
||||
@@ -207,12 +213,16 @@ class RPCBase:
|
||||
# Use explicit call to ensure action name is 'raise' (not 'raise_')
|
||||
return self._run_rpc("raise")
|
||||
|
||||
def hide(self):
|
||||
"""Hide this widget (or its container)."""
|
||||
return self._run_rpc("hide")
|
||||
|
||||
def _run_rpc(
|
||||
self,
|
||||
method,
|
||||
*args,
|
||||
wait_for_rpc_response=True,
|
||||
timeout=5,
|
||||
wait_for_rpc_response: bool = True,
|
||||
timeout: float | None | object = _DEFAULT_RPC_TIMEOUT,
|
||||
gui_id: str | None = None,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
@@ -223,13 +233,16 @@ class RPCBase:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
timeout: The timeout for the RPC response.
|
||||
timeout: The timeout for the RPC response. If omitted, the client's default RPC
|
||||
timeout is used. If explicitly set to None, wait indefinitely.
|
||||
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
if timeout is _DEFAULT_RPC_TIMEOUT:
|
||||
timeout = self._root._rpc_timeout
|
||||
if method in ["show", "hide", "raise"] and gui_id is None:
|
||||
obj = self._root._server_registry.get(self._gui_id)
|
||||
if obj is None:
|
||||
@@ -251,12 +264,39 @@ class RPCBase:
|
||||
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||
)
|
||||
|
||||
target_gui_id = gui_id or self._gui_id
|
||||
sent_at = time.time()
|
||||
deadline = sent_at + timeout if timeout is not None else None
|
||||
rpc_msg.metadata.update(
|
||||
{
|
||||
"method": method,
|
||||
"receiver": receiver,
|
||||
"target_gui_id": target_gui_id,
|
||||
"object_name": self.object_name,
|
||||
"wait_for_response": wait_for_rpc_response,
|
||||
"timeout": timeout,
|
||||
"sent_at": sent_at,
|
||||
"deadline": deadline,
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
"Sending GUI RPC request "
|
||||
f"request_id={request_id} method={method} receiver={receiver} "
|
||||
f"target_gui_id={target_gui_id} object_name={self.object_name} "
|
||||
f"wait_for_response={wait_for_rpc_response} timeout={timeout}"
|
||||
)
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
if wait_for_rpc_response:
|
||||
try:
|
||||
finished = self._msg_wait_event.wait(timeout)
|
||||
if not finished:
|
||||
logger.error(
|
||||
"GUI RPC response timeout "
|
||||
f"request_id={request_id} method={method} receiver={receiver} "
|
||||
f"target_gui_id={target_gui_id} object_name={self.object_name} "
|
||||
f"timeout={timeout}"
|
||||
)
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
finally:
|
||||
self._msg_wait_event.clear()
|
||||
@@ -268,6 +308,12 @@ class RPCBase:
|
||||
# the _on_rpc_response method
|
||||
assert isinstance(self._rpc_response, messages.RequestResponseMessage)
|
||||
|
||||
logger.info(
|
||||
"Received GUI RPC response "
|
||||
f"request_id={request_id} method={method} receiver={receiver} "
|
||||
f"target_gui_id={target_gui_id} object_name={self.object_name} "
|
||||
f"accepted={self._rpc_response.accepted}"
|
||||
)
|
||||
if not self._rpc_response.accepted:
|
||||
raise ValueError(self._rpc_response.message["error"])
|
||||
msg_result = self._rpc_response.message.get("result")
|
||||
@@ -276,6 +322,7 @@ class RPCBase:
|
||||
|
||||
def _on_rpc_response(self, msg_obj: MessageObject) -> None:
|
||||
msg = cast(messages.RequestResponseMessage, msg_obj.value)
|
||||
logger.debug(f"GUI RPC response callback received: {msg}")
|
||||
self._rpc_response = msg
|
||||
self._msg_wait_event.set()
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
"""Handler class for creating widgets from RPC messages."""
|
||||
|
||||
def __init__(self):
|
||||
self._widget_classes = None
|
||||
|
||||
@property
|
||||
def widget_classes(self) -> dict[str, type[BECWidget]]:
|
||||
"""
|
||||
Get the available widget classes.
|
||||
|
||||
Returns:
|
||||
dict: The available widget classes.
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
return self._widget_classes # type: ignore
|
||||
|
||||
def update_available_widgets(self):
|
||||
"""
|
||||
Update the available widgets.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self._widget_classes = (
|
||||
get_custom_classes("bec_widgets", packages=("widgets", "applications"))
|
||||
+ get_all_plugin_widgets()
|
||||
).as_dict(IGNORE_WIDGETS)
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECWidget:
|
||||
"""
|
||||
Create a widget from an RPC message.
|
||||
|
||||
Args:
|
||||
widget_type(str): The type of the widget.
|
||||
name (str): The name of the widget.
|
||||
**kwargs: The keyword arguments for the widget.
|
||||
|
||||
Returns:
|
||||
widget(BECWidget): The created widget.
|
||||
"""
|
||||
widget_class = self.widget_classes.get(widget_type) # type: ignore
|
||||
if widget_class:
|
||||
return widget_class(**kwargs)
|
||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||
|
||||
|
||||
widget_handler = RPCWidgetHandler()
|
||||
@@ -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
|
||||
|
||||
@@ -206,7 +206,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
|
||||
def _populate_registry_widgets(self):
|
||||
try:
|
||||
widget_handler.update_available_widgets()
|
||||
items = sorted(widget_handler.widget_classes.keys())
|
||||
except Exception as exc:
|
||||
print(f"Failed to load registered widgets: {exc}")
|
||||
@@ -335,20 +334,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
|
||||
If kwargs does not contain `object_name`, it will default to the provided shortcut.
|
||||
"""
|
||||
# Ensure registry is loaded
|
||||
widget_handler.update_available_widgets()
|
||||
cls = widget_handler.widget_classes.get(widget_type)
|
||||
if cls is None:
|
||||
raise ValueError(f"Unknown registered widget type: {widget_type}")
|
||||
|
||||
if kwargs is None:
|
||||
kwargs = {"object_name": shortcut}
|
||||
else:
|
||||
kwargs = dict(kwargs)
|
||||
kwargs.setdefault("object_name", shortcut)
|
||||
|
||||
# Instantiate and add
|
||||
widget = cls(**kwargs)
|
||||
widget = widget_handler.create_widget(widget_type, **kwargs)
|
||||
if not isinstance(widget, QWidget):
|
||||
raise TypeError(
|
||||
f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}"
|
||||
|
||||
@@ -15,7 +15,7 @@ class FakeDevice(BECDevice):
|
||||
super().__init__(name=name)
|
||||
self._enabled = enabled
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._readout_priority = readout_priority
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
@@ -74,7 +74,7 @@ class FakeDevice(BECDevice):
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
return self._description
|
||||
|
||||
|
||||
class FakePositioner(BECPositioner):
|
||||
@@ -96,7 +96,7 @@ class FakePositioner(BECPositioner):
|
||||
self._limits = limits
|
||||
self._readout_priority = readout_priority
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd_devices.SimPositioner",
|
||||
@@ -176,7 +176,7 @@ class FakePositioner(BECPositioner):
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
return self._description
|
||||
|
||||
@property
|
||||
def precision(self):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ from __future__ import annotations
|
||||
import collections
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
|
||||
from typing import TYPE_CHECKING, Any, DefaultDict, Hashable, Union
|
||||
|
||||
import louie
|
||||
import redis
|
||||
@@ -15,6 +16,7 @@ from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
from bec_widgets.utils.rpc_logging import elapsed_seconds, format_elapsed
|
||||
from bec_widgets.utils.serialization import register_serializer_extension
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -25,6 +27,39 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.rpc_server import RPCServer
|
||||
|
||||
|
||||
def _log_rpc_dispatcher_receive(msg_content: Any, metadata: Any) -> None:
|
||||
if not isinstance(msg_content, dict) or not isinstance(metadata, dict):
|
||||
return
|
||||
request_id = metadata.get("request_id")
|
||||
method = msg_content.get("action")
|
||||
parameter = msg_content.get("parameter")
|
||||
if request_id is None or method is None or not isinstance(parameter, dict):
|
||||
return
|
||||
|
||||
dispatch_received_at = time.time()
|
||||
sent_at = metadata.get("sent_at")
|
||||
deadline = metadata.get("deadline")
|
||||
timeout = metadata.get("timeout")
|
||||
dispatch_latency = elapsed_seconds(sent_at, dispatch_received_at)
|
||||
stale_on_dispatch = deadline is not None and dispatch_received_at > deadline
|
||||
target_gui_id = parameter.get("gui_id") or metadata.get("target_gui_id")
|
||||
|
||||
logger.info(
|
||||
"GUI RPC dispatcher received request before Qt callback emit "
|
||||
f"request_id={request_id} method={method} receiver={metadata.get('receiver')} "
|
||||
f"target_gui_id={target_gui_id} object_name={metadata.get('object_name')} "
|
||||
f"timeout={timeout} dispatch_latency_s={format_elapsed(dispatch_latency)} "
|
||||
f"stale_on_dispatch={stale_on_dispatch}"
|
||||
)
|
||||
if stale_on_dispatch:
|
||||
logger.warning(
|
||||
"GUI RPC dispatcher received request after client timeout deadline "
|
||||
f"request_id={request_id} method={method} receiver={metadata.get('receiver')} "
|
||||
f"target_gui_id={target_gui_id} object_name={metadata.get('object_name')} "
|
||||
f"timeout={timeout} dispatch_latency_s={format_elapsed(dispatch_latency)}"
|
||||
)
|
||||
|
||||
|
||||
class QtThreadSafeCallback(QObject):
|
||||
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
|
||||
|
||||
@@ -88,10 +123,12 @@ class QtRedisConnector(RedisConnector):
|
||||
|
||||
# we can notice kwargs are lost when passed to Qt slot
|
||||
metadata = msg.metadata
|
||||
_log_rpc_dispatcher_receive(msg.content, metadata)
|
||||
cb(msg.content, metadata)
|
||||
else:
|
||||
# from stream
|
||||
msg = msg["data"]
|
||||
_log_rpc_dispatcher_receive(msg.content, msg.metadata)
|
||||
cb(msg.content, msg.metadata)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import importlib.metadata
|
||||
import inspect
|
||||
import pkgutil
|
||||
import traceback
|
||||
from functools import lru_cache
|
||||
from importlib import util as importlib_util
|
||||
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
|
||||
from types import ModuleType
|
||||
@@ -11,7 +12,11 @@ from typing import Generator
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
||||
from bec_widgets.utils.plugin_utils import (
|
||||
BECClassContainer,
|
||||
BECClassInfo,
|
||||
rpc_widget_registry_from_source,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -53,6 +58,14 @@ def _submodule_by_name(module: ModuleType, name: str):
|
||||
return None
|
||||
|
||||
|
||||
def _submodule_spec_by_name(module: ModuleType, name: str) -> ModuleSpec | None:
|
||||
for module_info in pkgutil.iter_modules(module.__path__):
|
||||
if module_info.name != name or not isinstance(module_info.module_finder, FileFinder):
|
||||
continue
|
||||
return module_info.module_finder.find_spec(module_info.name)
|
||||
return None
|
||||
|
||||
|
||||
def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
|
||||
"""Find any BECWidget subclasses in the given module and return them with their info."""
|
||||
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
|
||||
@@ -90,16 +103,64 @@ def get_plugin_client_module() -> ModuleType | None:
|
||||
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
|
||||
|
||||
|
||||
def get_plugin_designer_module() -> ModuleType | None:
|
||||
"""If there is a plugin repository installed, return the designer module."""
|
||||
return (
|
||||
_submodule_by_name(plugin, "designer_plugins") if (plugin := user_widget_plugin()) else None
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_plugin_rpc_widget_registry() -> dict[str, tuple[str, str]]:
|
||||
"""If there is a plugin repository installed, return the RPC widget registry."""
|
||||
plugin = user_widget_plugin()
|
||||
if plugin is None:
|
||||
return {}
|
||||
|
||||
client_spec = _submodule_spec_by_name(plugin, "client")
|
||||
if client_spec is not None and client_spec.origin:
|
||||
try:
|
||||
return rpc_widget_registry_from_source(client_spec.origin)
|
||||
except (OSError, SyntaxError) as exc:
|
||||
logger.warning(f"Could not parse plugin RPC widget registry: {exc}")
|
||||
|
||||
client_module = get_plugin_client_module()
|
||||
if client_module is None:
|
||||
return {}
|
||||
registry = {}
|
||||
for plugin_name, plugin_class in inspect.getmembers(client_module, inspect.isclass):
|
||||
if hasattr(plugin_class, "_IMPORT_MODULE"):
|
||||
registry[plugin_name] = (plugin_class._IMPORT_MODULE, plugin_class.__name__)
|
||||
return registry
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_plugin_designer_registry() -> dict[str, tuple[str, str]]:
|
||||
"""If there is a plugin repository installed, return the designer plugin registry."""
|
||||
designer_module = get_plugin_designer_module()
|
||||
if designer_module and hasattr(designer_module, "designer_plugins"):
|
||||
return designer_module.designer_plugins
|
||||
return {}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_plugin_widget_icons() -> dict[str, str]:
|
||||
"""If there is a plugin repository installed, return the designer widget icon registry."""
|
||||
designer_module = get_plugin_designer_module()
|
||||
if designer_module and hasattr(designer_module, "widget_icons"):
|
||||
return designer_module.widget_icons
|
||||
return {}
|
||||
|
||||
|
||||
def get_all_plugin_widgets() -> BECClassContainer:
|
||||
"""If there is a plugin repository installed, load all widgets from it."""
|
||||
if plugin := user_widget_plugin():
|
||||
return _all_widgets_from_all_submods(plugin)
|
||||
else:
|
||||
return BECClassContainer()
|
||||
return BECClassContainer()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
widgets = get_plugin_rpc_widget_registry()
|
||||
client = get_plugin_client_module()
|
||||
print(get_all_plugin_widgets())
|
||||
...
|
||||
|
||||
@@ -10,17 +10,16 @@ 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
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.busy_loader import BusyLoaderOverlay
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -331,32 +330,34 @@ class BECWidget(BECConnector):
|
||||
# All widgets need to call super().cleanup() in their cleanup method
|
||||
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
|
||||
self.rpc_register.remove_rpc(self)
|
||||
children = self.findChildren(BECWidget)
|
||||
for child in children:
|
||||
if not shiboken6.isValid(child):
|
||||
# If the child is not valid, it means it has already been deleted
|
||||
continue
|
||||
child.close()
|
||||
child.deleteLater()
|
||||
children = self.findChildren(BECWidget)
|
||||
for child in children:
|
||||
if not shiboken6.isValid(child):
|
||||
# If the child is not valid, it means it has already been deleted
|
||||
continue
|
||||
child.close()
|
||||
child.deleteLater()
|
||||
|
||||
# Tear down busy overlay explicitly to stop spinner and remove filters
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and shiboken6.isValid(overlay):
|
||||
try:
|
||||
overlay.hide()
|
||||
filt = getattr(overlay, "_filter", None)
|
||||
if filt is not None and shiboken6.isValid(filt):
|
||||
try:
|
||||
self.removeEventFilter(filt)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
|
||||
# Tear down busy overlay explicitly to stop spinner and remove filters
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and shiboken6.isValid(overlay):
|
||||
try:
|
||||
overlay.hide()
|
||||
filt = getattr(overlay, "_filter", None)
|
||||
if filt is not None and shiboken6.isValid(filt):
|
||||
try:
|
||||
self.removeEventFilter(filt)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Failed to remove event filter from busy overlay: {exc}"
|
||||
)
|
||||
|
||||
# Cleanup the overlay widget. This will call cleanup on the custom widget if present.
|
||||
# Cleanup the overlay widget. This will call cleanup on the custom widget if present.
|
||||
|
||||
overlay.cleanup()
|
||||
overlay.deleteLater()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to delete busy overlay: {exc}")
|
||||
overlay.cleanup()
|
||||
overlay.deleteLater()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to delete busy overlay: {exc}")
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
|
||||
+34
-65
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
@@ -21,8 +21,7 @@ logger = bec_logger.logger
|
||||
def get_theme_name():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
return "dark"
|
||||
else:
|
||||
return QApplication.instance().theme.theme
|
||||
return QApplication.instance().theme.theme
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
@@ -58,6 +57,25 @@ def apply_theme(theme: Literal["dark", "light"]):
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
|
||||
|
||||
def theme_color(theme: Any | None, key: str, fallback: QColor | str) -> QColor:
|
||||
"""
|
||||
Return a QColor from a BEC theme, or the fallback when no theme is set.
|
||||
"""
|
||||
|
||||
fallback_color = fallback if isinstance(fallback, QColor) else QColor(str(fallback))
|
||||
if theme is None:
|
||||
return fallback_color
|
||||
return theme.color(key, fallback_color.name())
|
||||
|
||||
|
||||
def rgba(color: QColor | str, alpha: int) -> str:
|
||||
"""
|
||||
Return a QSS-compatible rgba string.
|
||||
"""
|
||||
qcolor = color if isinstance(color, QColor) else QColor(str(color))
|
||||
return f"rgba({qcolor.red()}, {qcolor.green()}, {qcolor.blue()}, {alpha})"
|
||||
|
||||
|
||||
class Colors:
|
||||
@staticmethod
|
||||
def list_available_colormaps() -> list[str]:
|
||||
@@ -150,25 +168,6 @@ class Colors:
|
||||
|
||||
return ge.colorMap()
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
|
||||
Returns:
|
||||
list: List of angles calculated using the golden ratio.
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def set_theme_offset(theme: Literal["light", "dark"] | None = None, offset=0.2) -> tuple:
|
||||
"""
|
||||
@@ -239,20 +238,7 @@ class Colors:
|
||||
else:
|
||||
positions = np.linspace(min_pos, max_pos, num)
|
||||
|
||||
# Sample colors from the colormap at the calculated positions
|
||||
colors = cmap.map(positions, mode="float")
|
||||
color_list = []
|
||||
|
||||
for color in colors:
|
||||
if format.upper() == "HEX":
|
||||
color_list.append(QColor.fromRgbF(*color).name())
|
||||
elif format.upper() == "RGB":
|
||||
color_list.append(tuple((np.array(color) * 255).astype(int)))
|
||||
elif format.upper() == "QCOLOR":
|
||||
color_list.append(QColor.fromRgbF(*color))
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
return color_list
|
||||
return Colors._format_mapped_colors(cmap.map(positions, mode="float"), format)
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(
|
||||
@@ -288,20 +274,19 @@ class Colors:
|
||||
positions = np.mod(np.arange(num) * golden_angle_conjugate, 1)
|
||||
positions = min_pos + positions * (max_pos - min_pos)
|
||||
|
||||
# Sample colors from the colormap at the calculated positions
|
||||
colors = cmap.map(positions, mode="float")
|
||||
color_list = []
|
||||
return Colors._format_mapped_colors(cmap.map(positions, mode="float"), format)
|
||||
|
||||
for color in colors:
|
||||
if format.upper() == "HEX":
|
||||
color_list.append(QColor.fromRgbF(*color).name())
|
||||
elif format.upper() == "RGB":
|
||||
color_list.append(tuple((np.array(color) * 255).astype(int)))
|
||||
elif format.upper() == "QCOLOR":
|
||||
color_list.append(QColor.fromRgbF(*color))
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
return color_list
|
||||
@staticmethod
|
||||
def _format_mapped_colors(colors: np.ndarray, format: Literal["QColor", "HEX", "RGB"]) -> list:
|
||||
color_format = format.upper()
|
||||
if color_format not in {"QCOLOR", "HEX", "RGB"}:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
|
||||
if color_format == "QCOLOR":
|
||||
return [QColor.fromRgbF(*color) for color in colors]
|
||||
if color_format == "HEX":
|
||||
return [QColor.fromRgbF(*color).name() for color in colors]
|
||||
return [tuple((np.array(color) * 255).astype(int)) for color in colors]
|
||||
|
||||
@staticmethod
|
||||
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
|
||||
@@ -325,22 +310,6 @@ class Colors:
|
||||
raise ValueError("HEX color must be 6 or 8 characters long.")
|
||||
return (r, g, b, alpha)
|
||||
|
||||
@staticmethod
|
||||
def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str:
|
||||
"""
|
||||
Convert RGBA color to HEX.
|
||||
|
||||
Args:
|
||||
r(int): Red value (0-255).
|
||||
g(int): Green value (0-255).
|
||||
b(int): Blue value (0-255).
|
||||
a(int): Alpha value (0-255). Default is 255 (opaque).
|
||||
|
||||
Returns:
|
||||
hec_color(str): HEX color string.
|
||||
"""
|
||||
return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a)
|
||||
|
||||
@staticmethod
|
||||
def validate_color(color: tuple | str) -> tuple | str:
|
||||
"""
|
||||
|
||||
+90
-317
@@ -1,12 +1,12 @@
|
||||
"""Module for handling filter I/O operations in BEC Widgets for input fields.
|
||||
These operations include filtering device/signal names and/or device types.
|
||||
"""
|
||||
"""Small helpers for populating editable combo boxes used by device inputs."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QStringListModel
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
|
||||
from qtpy.QtCore import QSignalBlocker
|
||||
from qtpy.QtWidgets import QComboBox
|
||||
from typeguard import TypeCheckError
|
||||
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
@@ -14,329 +14,102 @@ from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class WidgetFilterHandler(ABC):
|
||||
"""Abstract base class for widget filter handlers"""
|
||||
def replace_combobox_items(
|
||||
combo_box: QComboBox,
|
||||
items: list[str | tuple],
|
||||
*,
|
||||
preserve_current_text: bool = False,
|
||||
block_signals: bool = False,
|
||||
) -> None:
|
||||
"""Replace all combobox entries.
|
||||
|
||||
@abstractmethod
|
||||
def set_selection(self, widget, selection: list[str | tuple]) -> None:
|
||||
"""Set the filtered_selection for the widget
|
||||
|
||||
Args:
|
||||
widget: Widget instance
|
||||
selection (list[str | tuple]): Filtered selection of items.
|
||||
If tuple, it contains (text, data) pairs.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def check_input(self, widget, text: str) -> bool:
|
||||
"""Check if the input text is in the filtered selection
|
||||
|
||||
Args:
|
||||
widget: Widget instance
|
||||
text (str): Input text
|
||||
|
||||
Returns:
|
||||
bool: True if the input text is in the filtered selection
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def update_with_kind(
|
||||
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
||||
) -> list[str | tuple]:
|
||||
"""Update the selection based on the kind of signal.
|
||||
|
||||
Args:
|
||||
kind (Kind): The kind of signal to filter.
|
||||
signal_filter (set): Set of signal kinds to filter.
|
||||
device_info (dict): Dictionary containing device information.
|
||||
device_name (str): Name of the device.
|
||||
|
||||
Returns:
|
||||
list[str | tuple]: A list of filtered signals based on the kind.
|
||||
"""
|
||||
# This method should be implemented in subclasses or extended as needed
|
||||
|
||||
def update_with_bec_signal_class(
|
||||
self,
|
||||
signal_class_filter: str | list[str],
|
||||
client,
|
||||
ndim_filter: int | list[int] | None = None,
|
||||
) -> list[tuple[str, str, dict]]:
|
||||
"""Update the selection based on signal classes using device_manager.get_bec_signals.
|
||||
|
||||
Args:
|
||||
signal_class_filter (str|list[str]): List of signal class names to filter.
|
||||
client: BEC client instance.
|
||||
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
|
||||
If provided, only signals with matching ndim will be included.
|
||||
|
||||
Returns:
|
||||
list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples.
|
||||
"""
|
||||
if not client or not hasattr(client, "device_manager"):
|
||||
return []
|
||||
|
||||
try:
|
||||
signals = client.device_manager.get_bec_signals(signal_class_filter)
|
||||
except TypeCheckError as e:
|
||||
logger.warning(f"Error retrieving signals: {e}")
|
||||
return []
|
||||
|
||||
if ndim_filter is None:
|
||||
return signals
|
||||
|
||||
if isinstance(ndim_filter, int):
|
||||
ndim_filter = [ndim_filter]
|
||||
|
||||
filtered_signals = []
|
||||
for device_name, signal_name, signal_config in signals:
|
||||
ndim = None
|
||||
if isinstance(signal_config, dict):
|
||||
ndim = signal_config.get("describe", {}).get("signal_info", {}).get("ndim")
|
||||
|
||||
if ndim in ndim_filter:
|
||||
filtered_signals.append((device_name, signal_name, signal_config))
|
||||
|
||||
return filtered_signals
|
||||
|
||||
|
||||
class LineEditFilterHandler(WidgetFilterHandler):
|
||||
"""Handler for QLineEdit widget"""
|
||||
|
||||
def set_selection(self, widget: QLineEdit, selection: list[str | tuple]) -> None:
|
||||
"""Set the selection for the widget to the completer model
|
||||
|
||||
Args:
|
||||
widget (QLineEdit): The QLineEdit widget
|
||||
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
|
||||
"""
|
||||
if isinstance(selection, tuple):
|
||||
# If selection is a tuple, it contains (text, data) pairs
|
||||
selection = [text for text, _ in selection]
|
||||
if not isinstance(widget.completer, QCompleter):
|
||||
completer = QCompleter(widget)
|
||||
widget.setCompleter(completer)
|
||||
widget.completer.setModel(QStringListModel(selection, widget))
|
||||
|
||||
def check_input(self, widget: QLineEdit, text: str) -> bool:
|
||||
"""Check if the input text is in the filtered selection
|
||||
|
||||
Args:
|
||||
widget (QLineEdit): The QLineEdit widget
|
||||
text (str): Input text
|
||||
|
||||
Returns:
|
||||
bool: True if the input text is in the filtered selection
|
||||
"""
|
||||
model = widget.completer.model()
|
||||
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
|
||||
return text in model_data
|
||||
|
||||
def update_with_kind(
|
||||
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
||||
) -> list[str | tuple]:
|
||||
"""Update the selection based on the kind of signal.
|
||||
|
||||
Args:
|
||||
kind (Kind): The kind of signal to filter.
|
||||
signal_filter (set): Set of signal kinds to filter.
|
||||
device_info (dict): Dictionary containing device information.
|
||||
device_name (str): Name of the device.
|
||||
|
||||
Returns:
|
||||
list[str | tuple]: A list of filtered signals based on the kind.
|
||||
"""
|
||||
|
||||
return [
|
||||
signal
|
||||
for signal, signal_info in device_info.items()
|
||||
if kind in signal_filter and (signal_info.get("kind_str", None) == str(kind.name))
|
||||
]
|
||||
|
||||
|
||||
class ComboBoxFilterHandler(WidgetFilterHandler):
|
||||
"""Handler for QComboBox widget"""
|
||||
|
||||
def set_selection(self, widget: QComboBox, selection: list[str | tuple]) -> None:
|
||||
"""Set the selection for the widget to the completer model
|
||||
|
||||
Args:
|
||||
widget (QComboBox): The QComboBox widget
|
||||
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
|
||||
"""
|
||||
widget.clear()
|
||||
if len(selection) == 0:
|
||||
return
|
||||
for element in selection:
|
||||
if isinstance(element, str):
|
||||
widget.addItem(element)
|
||||
elif isinstance(element, tuple):
|
||||
# If element is a tuple, it contains (text, data) pairs
|
||||
widget.addItem(*element)
|
||||
|
||||
def check_input(self, widget: QComboBox, text: str) -> bool:
|
||||
"""Check if the input text is in the filtered selection
|
||||
|
||||
Args:
|
||||
widget (QComboBox): The QComboBox widget
|
||||
text (str): Input text
|
||||
|
||||
Returns:
|
||||
bool: True if the input text is in the filtered selection
|
||||
"""
|
||||
return text in [widget.itemText(i) for i in range(widget.count())]
|
||||
|
||||
def update_with_kind(
|
||||
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
||||
) -> list[str | tuple]:
|
||||
"""Update the selection based on the kind of signal.
|
||||
|
||||
Args:
|
||||
kind (Kind): The kind of signal to filter.
|
||||
signal_filter (set): Set of signal kinds to filter.
|
||||
device_info (dict): Dictionary containing device information.
|
||||
device_name (str): Name of the device.
|
||||
|
||||
Returns:
|
||||
list[str | tuple]: A list of filtered signals based on the kind.
|
||||
"""
|
||||
out = []
|
||||
for signal, signal_info in device_info.items():
|
||||
if kind not in signal_filter or (signal_info.get("kind_str", None) != str(kind.name)):
|
||||
continue
|
||||
obj_name = signal_info.get("obj_name", "")
|
||||
component_name = signal_info.get("component_name", "")
|
||||
signal_wo_device = obj_name.removeprefix(f"{device_name}_")
|
||||
if not signal_wo_device:
|
||||
signal_wo_device = obj_name
|
||||
|
||||
if signal_wo_device != signal and component_name.replace(".", "_") != signal_wo_device:
|
||||
# If the object name is not the same as the signal name, we use the object name
|
||||
# to display in the combobox.
|
||||
out.append((f"{signal_wo_device} ({signal})", signal_info))
|
||||
else:
|
||||
# If the object name is the same as the signal name, we do not change it.
|
||||
out.append((signal, signal_info))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class FilterIO:
|
||||
"""Public interface to set filters for input widgets.
|
||||
It supports the list of widgets stored in class attribute _handlers.
|
||||
Args:
|
||||
combo_box: Combobox whose entries should be replaced.
|
||||
items: Entries to add. String entries are added as display text. Tuple entries are
|
||||
passed to ``QComboBox.addItem`` as ``(text, data)``.
|
||||
preserve_current_text: If True, restore the combobox text after replacing the items.
|
||||
block_signals: If True, block combobox signals while the items are replaced.
|
||||
"""
|
||||
current_text = combo_box.currentText()
|
||||
signal_blocker = QSignalBlocker(combo_box) if block_signals else nullcontext()
|
||||
with signal_blocker:
|
||||
combo_box.clear()
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
combo_box.addItem(item)
|
||||
else:
|
||||
combo_box.addItem(*item)
|
||||
if preserve_current_text:
|
||||
combo_box.setCurrentText(current_text)
|
||||
|
||||
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
|
||||
|
||||
@staticmethod
|
||||
def set_selection(widget, selection: list[str | tuple], ignore_errors=True):
|
||||
"""
|
||||
Retrieve value from the widget instance.
|
||||
def signal_items_for_kind(
|
||||
*, kind: Kind, signal_filter: set[Kind], device_info: dict, device_name: str
|
||||
) -> list[tuple[str, dict]]:
|
||||
"""Build display entries for signals matching a BEC signal kind.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
selection (list[str | tuple]): Filtered selection of items.
|
||||
If tuple, it contains (text, data) pairs.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().set_selection(widget=widget, selection=selection)
|
||||
if not ignore_errors:
|
||||
raise ValueError(
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
return None
|
||||
Args:
|
||||
kind: Signal kind to collect.
|
||||
signal_filter: Enabled signal kinds.
|
||||
device_info: Signal metadata from the BEC device info dictionary.
|
||||
device_name: Name of the device owning the signals.
|
||||
|
||||
@staticmethod
|
||||
def check_input(widget, text: str, ignore_errors=True):
|
||||
"""
|
||||
Check if the input text is in the filtered selection.
|
||||
Returns:
|
||||
Combobox entries as ``(display_text, signal_info)`` tuples.
|
||||
"""
|
||||
items: list[tuple[str, dict]] = []
|
||||
for signal_name, signal_info in device_info.items():
|
||||
if kind not in signal_filter or signal_info.get("kind_str") != kind.name:
|
||||
continue
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
text(str): Input text.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
obj_name = signal_info.get("obj_name", "")
|
||||
component_name = signal_info.get("component_name", "")
|
||||
signal_without_device = obj_name.removeprefix(f"{device_name}_")
|
||||
if not signal_without_device:
|
||||
signal_without_device = obj_name
|
||||
|
||||
Returns:
|
||||
bool: True if the input text is in the filtered selection.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().check_input(widget=widget, text=text)
|
||||
if not ignore_errors:
|
||||
raise ValueError(
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
return None
|
||||
if (
|
||||
signal_without_device != signal_name
|
||||
and component_name.replace(".", "_") != signal_without_device
|
||||
):
|
||||
items.append((f"{signal_without_device} ({signal_name})", signal_info))
|
||||
else:
|
||||
items.append((signal_name, signal_info))
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def update_with_kind(
|
||||
widget, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
||||
) -> list[str | tuple]:
|
||||
"""
|
||||
Update the selection based on the kind of signal.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
kind (Kind): The kind of signal to filter.
|
||||
signal_filter (set): Set of signal kinds to filter.
|
||||
device_info (dict): Dictionary containing device information.
|
||||
device_name (str): Name of the device.
|
||||
def get_bec_signals_for_classes(
|
||||
*, client, signal_class_filter: str | list[str], ndim_filter: int | list[int] | None = None
|
||||
) -> list[tuple[str, str, dict]]:
|
||||
"""Return BEC signals filtered by signal class and optional dimensionality.
|
||||
|
||||
Returns:
|
||||
list[str | tuple]: A list of filtered signals based on the kind.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().update_with_kind(
|
||||
kind=kind,
|
||||
signal_filter=signal_filter,
|
||||
device_info=device_info,
|
||||
device_name=device_name,
|
||||
)
|
||||
raise ValueError(
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
Args:
|
||||
client: BEC client that provides ``device_manager.get_bec_signals``.
|
||||
signal_class_filter: Signal class name or class names passed to the device manager.
|
||||
ndim_filter: Optional dimensionality filter. If provided, only signals whose
|
||||
``describe.signal_info.ndim`` is in this value are returned.
|
||||
|
||||
@staticmethod
|
||||
def update_with_signal_class(
|
||||
widget, signal_class_filter: list[str], client, ndim_filter: int | list[int] | None = None
|
||||
) -> list[tuple[str, str, dict]]:
|
||||
"""
|
||||
Update the selection based on signal classes using device_manager.get_bec_signals.
|
||||
Returns:
|
||||
Tuples of ``(device_name, signal_name, signal_config)`` for matching signals.
|
||||
"""
|
||||
if not client or not hasattr(client, "device_manager"):
|
||||
return []
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
signal_class_filter (list[str]): List of signal class names to filter.
|
||||
client: BEC client instance.
|
||||
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
|
||||
If provided, only signals with matching ndim will be included.
|
||||
try:
|
||||
signals = client.device_manager.get_bec_signals(signal_class_filter)
|
||||
except TypeCheckError as exc:
|
||||
logger.warning(f"Error retrieving signals: {exc}")
|
||||
return []
|
||||
|
||||
Returns:
|
||||
list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().update_with_bec_signal_class(
|
||||
signal_class_filter=signal_class_filter, client=client, ndim_filter=ndim_filter
|
||||
)
|
||||
raise ValueError(
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
if ndim_filter is None:
|
||||
return signals
|
||||
|
||||
@staticmethod
|
||||
def _find_handler(widget):
|
||||
"""
|
||||
Find the appropriate handler for the widget by checking its base classes.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
|
||||
Returns:
|
||||
handler_class: The handler class if found, otherwise None.
|
||||
"""
|
||||
for base in type(widget).__mro__:
|
||||
if base in FilterIO._handlers:
|
||||
return FilterIO._handlers[base]
|
||||
return None
|
||||
accepted_ndim = [ndim_filter] if isinstance(ndim_filter, int) else ndim_filter
|
||||
filtered_signals: list[tuple[str, str, dict]] = []
|
||||
for device_name, signal_name, signal_config in signals:
|
||||
ndim = None
|
||||
if isinstance(signal_config, dict):
|
||||
ndim = signal_config.get("describe", {}).get("signal_info", {}).get("ndim")
|
||||
if ndim in accepted_ndim:
|
||||
filtered_signals.append((device_name, signal_name, signal_config))
|
||||
return filtered_signals
|
||||
|
||||
@@ -150,7 +150,7 @@ class TypedForm(BECWidget, QWidget):
|
||||
self.adjustSize()
|
||||
|
||||
def _new_grid_layout(self):
|
||||
new_grid = QGridLayout(self)
|
||||
new_grid = QGridLayout()
|
||||
new_grid.setContentsMargins(0, 0, 0, 0)
|
||||
return new_grid
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from bec_lib.scan_args import ScanArgument
|
||||
from pydantic import BaseModel
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
from bec_widgets.utils.scan_arg_metadata import ui_config_from_metadata
|
||||
|
||||
NUMERIC_BOUND_KEYS = {"gt", "ge", "lt", "le"}
|
||||
|
||||
|
||||
def pydantic_model_input_configs(model: type[BaseModel]) -> list[dict[str, Any]]:
|
||||
"""Return scan-control-style field items for a Pydantic model."""
|
||||
configs = []
|
||||
for name, info in model.model_fields.items():
|
||||
metadata: dict[str, Any] = {}
|
||||
for entry in info.metadata:
|
||||
if isinstance(entry, ScanArgument):
|
||||
metadata.update(entry.model_dump(exclude_none=True))
|
||||
continue
|
||||
for key in NUMERIC_BOUND_KEYS:
|
||||
value = getattr(entry, key, None)
|
||||
if value is not None:
|
||||
metadata.setdefault(key, value)
|
||||
|
||||
if isinstance(info.json_schema_extra, Mapping):
|
||||
metadata.update(dict(info.json_schema_extra))
|
||||
|
||||
if info.description and metadata.get("description") is None:
|
||||
metadata["description"] = info.description
|
||||
|
||||
default: Any
|
||||
if info.default is not PydanticUndefined:
|
||||
default = info.default
|
||||
elif info.default_factory is not None:
|
||||
default = info.get_default(call_default_factory=True)
|
||||
else:
|
||||
default = None
|
||||
|
||||
display_name = metadata.get("display_name") or info.title
|
||||
if display_name is None:
|
||||
display_name = name.replace("_", " ").capitalize()
|
||||
|
||||
item = ui_config_from_metadata(
|
||||
name=name, metadata=metadata, default=default, display_name=display_name
|
||||
)
|
||||
item.update({key: value for key, value in metadata.items() if key not in item})
|
||||
configs.append(item)
|
||||
|
||||
return configs
|
||||
@@ -0,0 +1,815 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import NoneType
|
||||
from typing import Any, Literal, get_args, get_origin
|
||||
|
||||
from bec_lib.device import DeviceBase, Signal
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic.fields import FieldInfo
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtCore import Signal as QtSignal
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QSpinBox,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.forms_from_types.pydantic_model_info_adapter import (
|
||||
NUMERIC_BOUND_KEYS,
|
||||
pydantic_model_input_configs,
|
||||
)
|
||||
from bec_widgets.utils.scan_arg_metadata import (
|
||||
apply_numeric_limits,
|
||||
apply_numeric_precision,
|
||||
apply_unit_metadata,
|
||||
device_units,
|
||||
)
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
|
||||
|
||||
|
||||
class OptionalValueWidget(QWidget):
|
||||
"""Wrap a value widget with an enable checkbox for optional Pydantic fields.
|
||||
|
||||
Attributes:
|
||||
value_changed: Signal emitted with the current value whenever the checkbox
|
||||
state or wrapped widget value changes.
|
||||
"""
|
||||
|
||||
value_changed = QtSignal(object)
|
||||
|
||||
def __init__(self, value_widget: QWidget, parent: QWidget | None = None) -> None:
|
||||
"""Create an optional-value wrapper.
|
||||
|
||||
Args:
|
||||
value_widget: Input widget used when the optional value is enabled.
|
||||
parent: Optional parent widget.
|
||||
"""
|
||||
super().__init__(parent=parent)
|
||||
self._value_widget = value_widget
|
||||
self._checkbox = QCheckBox(self)
|
||||
self._checkbox.setToolTip("Enable value")
|
||||
self._value_widget.setParent(self)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
layout.addWidget(self._checkbox)
|
||||
layout.addWidget(self._value_widget, 1)
|
||||
|
||||
self._checkbox.toggled.connect(self._on_enabled_changed)
|
||||
WidgetIO.connect_widget_change_signal(self._value_widget, self._emit_current_value)
|
||||
self._on_enabled_changed(False)
|
||||
|
||||
@property
|
||||
def value_widget(self) -> QWidget:
|
||||
"""Return the wrapped input widget.
|
||||
|
||||
Returns:
|
||||
The widget that edits the non-``None`` value.
|
||||
"""
|
||||
return self._value_widget
|
||||
|
||||
@property
|
||||
def checkbox(self) -> QCheckBox:
|
||||
"""Return the checkbox controlling whether the value is enabled.
|
||||
|
||||
Returns:
|
||||
The enable checkbox.
|
||||
"""
|
||||
return self._checkbox
|
||||
|
||||
def value(self) -> Any:
|
||||
"""Return the current optional value.
|
||||
|
||||
Returns:
|
||||
``None`` when the checkbox is unchecked; otherwise the wrapped widget value.
|
||||
"""
|
||||
if not self._checkbox.isChecked():
|
||||
return None
|
||||
return WidgetIO.get_value(self._value_widget)
|
||||
|
||||
def set_value(self, value: Any) -> None:
|
||||
"""Set the optional value.
|
||||
|
||||
Args:
|
||||
value: Value to set on the wrapped widget. ``None`` disables the value.
|
||||
"""
|
||||
enabled = value is not None
|
||||
self._checkbox.setChecked(enabled)
|
||||
self._value_widget.setEnabled(enabled)
|
||||
if enabled:
|
||||
WidgetIO.set_value(self._value_widget, value)
|
||||
|
||||
def _on_enabled_changed(self, enabled: bool) -> None:
|
||||
self._value_widget.setEnabled(enabled)
|
||||
self.value_changed.emit(self.value())
|
||||
|
||||
def _emit_current_value(self, *_args) -> None:
|
||||
self.value_changed.emit(self.value())
|
||||
|
||||
|
||||
class PydanticWidgetForm(QWidget):
|
||||
"""Generate a Qt form from a Pydantic model.
|
||||
|
||||
The form maps Pydantic field annotations to Qt widgets, applies supported
|
||||
field metadata, and exposes typed and raw data accessors for the generated
|
||||
fields.
|
||||
|
||||
Attributes:
|
||||
changed: Signal emitted whenever a generated input widget changes.
|
||||
validity_changed: Signal emitted by :meth:`validate` with the current
|
||||
validation result.
|
||||
"""
|
||||
|
||||
changed = QtSignal()
|
||||
validity_changed = QtSignal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: type[BaseModel],
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
data: BaseModel | dict[str, Any] | None = None,
|
||||
read_only_fields: set[str] | None = None,
|
||||
client=None,
|
||||
) -> None:
|
||||
"""Create a generated form for a Pydantic model.
|
||||
|
||||
Args:
|
||||
model: Pydantic model class used to generate fields and validate data.
|
||||
parent: Optional parent widget.
|
||||
data: Optional initial model instance or raw field-value mapping.
|
||||
read_only_fields: Field names that should be displayed but not editable.
|
||||
client: Optional BEC client passed to domain-specific widgets such as
|
||||
device and signal combo boxes.
|
||||
"""
|
||||
super().__init__(parent=parent)
|
||||
self._model = model
|
||||
self._client = client
|
||||
self._read_only_fields = set(read_only_fields or set())
|
||||
self._widgets: dict[str, QWidget] = {}
|
||||
self._field_configs: dict[str, dict[str, Any]] = {}
|
||||
self._baseline: dict[str, Any] = {}
|
||||
|
||||
self._layout = QFormLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setHorizontalSpacing(10)
|
||||
self._layout.setVerticalSpacing(8)
|
||||
self._layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
self._layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._populate()
|
||||
if data is not None:
|
||||
self.set_data(data)
|
||||
self.mark_clean()
|
||||
|
||||
@property
|
||||
def model(self) -> type[BaseModel]:
|
||||
"""Return the active Pydantic model class.
|
||||
|
||||
Returns:
|
||||
The model class currently used by this form.
|
||||
"""
|
||||
return self._model
|
||||
|
||||
@property
|
||||
def widgets(self) -> dict[str, QWidget]:
|
||||
"""Return generated field widgets keyed by model field name.
|
||||
|
||||
Returns:
|
||||
A shallow copy of the field-widget mapping. Optional fields return
|
||||
their outer :class:`OptionalValueWidget`.
|
||||
"""
|
||||
return dict(self._widgets)
|
||||
|
||||
def field_widget(self, name: str) -> QWidget:
|
||||
"""Return the generated widget for a field.
|
||||
|
||||
Args:
|
||||
name: Model field name.
|
||||
|
||||
Returns:
|
||||
The generated field widget. Optional fields return their outer
|
||||
:class:`OptionalValueWidget`.
|
||||
|
||||
Raises:
|
||||
KeyError: If no widget exists for ``name``.
|
||||
"""
|
||||
return self._widgets[name]
|
||||
|
||||
def input_widget(self, name: str) -> QWidget:
|
||||
"""Return the direct input widget for a field.
|
||||
|
||||
Args:
|
||||
name: Model field name.
|
||||
|
||||
Returns:
|
||||
The editable input widget. Optional fields return the wrapped value
|
||||
widget instead of the outer optional wrapper.
|
||||
|
||||
Raises:
|
||||
KeyError: If no widget exists for ``name``.
|
||||
"""
|
||||
widget = self._widgets[name]
|
||||
if isinstance(widget, OptionalValueWidget):
|
||||
return widget.value_widget
|
||||
return widget
|
||||
|
||||
def input_widgets(self) -> dict[str, QWidget]:
|
||||
"""Return direct input widgets keyed by model field name.
|
||||
|
||||
Returns:
|
||||
Mapping of field names to editable input widgets.
|
||||
"""
|
||||
return {name: self.input_widget(name) for name in self._widgets}
|
||||
|
||||
def input_widgets_by_type(self, widget_type: type[QWidget]) -> list[QWidget]:
|
||||
"""Return direct input widgets matching a widget type.
|
||||
|
||||
Args:
|
||||
widget_type: Qt widget class to match with ``isinstance``.
|
||||
|
||||
Returns:
|
||||
List of input widgets matching ``widget_type``.
|
||||
"""
|
||||
return [
|
||||
widget for widget in self.input_widgets().values() if isinstance(widget, widget_type)
|
||||
]
|
||||
|
||||
def set_model(self, model: type[BaseModel], data: dict[str, Any] | None = None) -> None:
|
||||
"""Replace the active model and rebuild the form.
|
||||
|
||||
Args:
|
||||
model: New Pydantic model class.
|
||||
data: Optional initial data for the new model. When omitted, values
|
||||
from fields shared with the previous model are preserved.
|
||||
"""
|
||||
old_data = self.raw_data()
|
||||
self.cleanup()
|
||||
self._model = model
|
||||
self._populate()
|
||||
if data is None:
|
||||
data = {key: value for key, value in old_data.items() if key in model.model_fields}
|
||||
self.set_partial_data(data)
|
||||
self.mark_clean()
|
||||
|
||||
def set_data(self, data: BaseModel | dict[str, Any]) -> None:
|
||||
"""Set form values from a model instance or mapping.
|
||||
|
||||
Args:
|
||||
data: Pydantic model instance or raw field-value mapping.
|
||||
"""
|
||||
values = data.model_dump() if isinstance(data, BaseModel) else dict(data)
|
||||
self.set_partial_data(values)
|
||||
|
||||
def set_partial_data(self, data: dict[str, Any]) -> None:
|
||||
"""Set values for fields present in the form.
|
||||
|
||||
Unknown keys are ignored, which allows callers to pass larger model
|
||||
dumps or backend payloads safely.
|
||||
|
||||
Args:
|
||||
data: Field-value mapping to apply.
|
||||
"""
|
||||
for name, value in data.items():
|
||||
if name not in self._widgets:
|
||||
continue
|
||||
self._set_widget_value(name, value)
|
||||
self._refresh_reference_units()
|
||||
self.changed.emit()
|
||||
|
||||
def raw_data(self) -> dict[str, Any]:
|
||||
"""Return current widget values without Pydantic validation.
|
||||
|
||||
Returns:
|
||||
Mapping of model field names to raw widget values.
|
||||
"""
|
||||
return {name: self._read_widget_value(name) for name in self._widgets}
|
||||
|
||||
def get_data(self) -> dict[str, Any]:
|
||||
"""Return current data after Pydantic validation.
|
||||
|
||||
Returns:
|
||||
Validated model data as a dictionary.
|
||||
|
||||
Raises:
|
||||
ValidationError: If Pydantic validation fails.
|
||||
ValueError: If domain widget validation fails.
|
||||
"""
|
||||
return self.model_instance().model_dump()
|
||||
|
||||
def model_instance(self) -> BaseModel:
|
||||
"""Return the current values as a Pydantic model instance.
|
||||
|
||||
Returns:
|
||||
Validated instance of the active model class.
|
||||
|
||||
Raises:
|
||||
ValidationError: If Pydantic validation fails.
|
||||
ValueError: If domain widget validation fails.
|
||||
"""
|
||||
self._validate_domain_widgets()
|
||||
return self._model.model_validate(self.raw_data())
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Validate the current form values.
|
||||
|
||||
Returns:
|
||||
``True`` when current values validate successfully, otherwise ``False``.
|
||||
"""
|
||||
try:
|
||||
self.get_data()
|
||||
except (ValidationError, ValueError):
|
||||
self.validity_changed.emit(False)
|
||||
return False
|
||||
self.validity_changed.emit(True)
|
||||
return True
|
||||
|
||||
def dirty_fields(self) -> set[str]:
|
||||
"""Return fields whose raw values differ from the clean baseline.
|
||||
|
||||
Returns:
|
||||
Set of dirty field names.
|
||||
"""
|
||||
current = self.raw_data()
|
||||
fields = set(current) | set(self._baseline)
|
||||
return {field for field in fields if current.get(field) != self._baseline.get(field)}
|
||||
|
||||
def mark_clean(self) -> None:
|
||||
"""Store the current raw values as the clean baseline."""
|
||||
self._baseline = self.raw_data()
|
||||
|
||||
def reset_to_baseline(self) -> None:
|
||||
"""Restore the form values to the current clean baseline."""
|
||||
self.set_partial_data(self._baseline)
|
||||
|
||||
def editable_data(self) -> dict[str, Any]:
|
||||
"""Return validated data excluding read-only fields.
|
||||
|
||||
Returns:
|
||||
Validated editable field values.
|
||||
|
||||
Raises:
|
||||
ValidationError: If Pydantic validation fails.
|
||||
ValueError: If domain widget validation fails.
|
||||
"""
|
||||
return {
|
||||
key: value
|
||||
for key, value in self.get_data().items()
|
||||
if key not in self._read_only_fields
|
||||
}
|
||||
|
||||
def raw_editable_data(self) -> dict[str, Any]:
|
||||
"""Return raw widget data excluding read-only fields.
|
||||
|
||||
Returns:
|
||||
Raw editable field values.
|
||||
"""
|
||||
return {
|
||||
key: value
|
||||
for key, value in self.raw_data().items()
|
||||
if key not in self._read_only_fields
|
||||
}
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Close and schedule deletion of all generated field widgets."""
|
||||
while self._layout.rowCount():
|
||||
row = self._layout.takeRow(0)
|
||||
for item in (row.labelItem, row.fieldItem):
|
||||
widget = item.widget() if item is not None else None
|
||||
if widget is not None:
|
||||
widget.close()
|
||||
# Detach before deleteLater: a child pending deletion that still has a
|
||||
# signal connection into this form crashes if the form is garbage
|
||||
# collected before the deferred delete is processed.
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
self._widgets.clear()
|
||||
self._field_configs.clear()
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
self.cleanup()
|
||||
super().closeEvent(event)
|
||||
|
||||
def _populate(self) -> None:
|
||||
for config in pydantic_model_input_configs(self._model):
|
||||
name = config["name"]
|
||||
info = self._model.model_fields[name]
|
||||
widget = self._create_widget(name, info)
|
||||
label_text = config["display_name"]
|
||||
self._layout.addRow(label_text, widget)
|
||||
label = self._layout.labelForField(widget)
|
||||
if label is not None:
|
||||
label.setProperty("_model_field_name", name)
|
||||
if config.get("tooltip") and label is not None:
|
||||
label.setToolTip(config["tooltip"])
|
||||
widget.setEnabled(name not in self._read_only_fields)
|
||||
self._widgets[name] = widget
|
||||
self._field_configs[name] = config
|
||||
self._set_widget_value(name, config["default"])
|
||||
self._apply_field_metadata(name)
|
||||
self._connect_widget(widget)
|
||||
|
||||
self._connect_device_signal_widgets()
|
||||
self._connect_reference_unit_widgets()
|
||||
self._refresh_reference_units()
|
||||
|
||||
def _create_widget(self, name: str, info: FieldInfo) -> QWidget:
|
||||
annotation = info.annotation
|
||||
args = get_args(annotation)
|
||||
optional = NoneType in args
|
||||
non_none_args = tuple(arg for arg in args if arg is not NoneType)
|
||||
value_annotation = non_none_args[0] if len(non_none_args) == 1 else annotation
|
||||
|
||||
widget = self._create_value_widget(name, value_annotation)
|
||||
numeric = value_annotation in (int, float) or any(
|
||||
arg in (int, float) for arg in get_args(value_annotation)
|
||||
)
|
||||
if optional and (numeric or value_annotation is bool):
|
||||
return OptionalValueWidget(widget, parent=self)
|
||||
return widget
|
||||
|
||||
def _create_value_widget(self, name: str, annotation: Any) -> QWidget:
|
||||
args = get_args(annotation)
|
||||
if (
|
||||
isinstance(annotation, type)
|
||||
and issubclass(annotation, Signal)
|
||||
or any(isinstance(arg, type) and issubclass(arg, Signal) for arg in args)
|
||||
):
|
||||
return SignalComboBox(
|
||||
parent=self,
|
||||
client=self._client,
|
||||
require_device=self._model_has_device_field(),
|
||||
arg_name=name,
|
||||
)
|
||||
if (
|
||||
isinstance(annotation, type)
|
||||
and issubclass(annotation, DeviceBase)
|
||||
or any(isinstance(arg, type) and issubclass(arg, DeviceBase) for arg in args)
|
||||
):
|
||||
return DeviceComboBox(parent=self, client=self._client, arg_name=name)
|
||||
if get_origin(annotation) is Literal:
|
||||
widget = QComboBox(self)
|
||||
widget.addItems([str(value) for value in get_args(annotation)])
|
||||
return widget
|
||||
if annotation is bool:
|
||||
return QCheckBox(self)
|
||||
if annotation is int:
|
||||
spin_box = QSpinBox(self)
|
||||
spin_box.setRange(-2147483647, 2147483647)
|
||||
return spin_box
|
||||
if annotation is float:
|
||||
spin_box = BECSpinBox(self)
|
||||
spin_box.setRange(-1_000_000_000, 1_000_000_000)
|
||||
return spin_box
|
||||
return QLineEdit(self)
|
||||
|
||||
def _apply_field_metadata(self, name: str) -> None:
|
||||
config = self._field_configs[name]
|
||||
field_widget = self._widgets[name]
|
||||
input_widget = self.input_widget(name)
|
||||
|
||||
if config.get("precision") is not None:
|
||||
apply_numeric_precision(input_widget, config)
|
||||
if any(config.get(key) is not None for key in NUMERIC_BOUND_KEYS):
|
||||
apply_numeric_limits(input_widget, config)
|
||||
|
||||
apply_unit_metadata(field_widget, config)
|
||||
if input_widget is not field_widget:
|
||||
apply_unit_metadata(input_widget, config)
|
||||
|
||||
def _connect_widget(self, widget: QWidget) -> None:
|
||||
if isinstance(widget, OptionalValueWidget):
|
||||
widget.value_changed.connect(lambda _value: self.changed.emit())
|
||||
return
|
||||
WidgetIO.connect_widget_change_signal(widget, lambda *_args: self.changed.emit())
|
||||
|
||||
def _connect_device_signal_widgets(self) -> None:
|
||||
devices = [
|
||||
widget for widget in self._widgets.values() if isinstance(widget, DeviceComboBox)
|
||||
]
|
||||
signals = [
|
||||
widget for widget in self._widgets.values() if isinstance(widget, SignalComboBox)
|
||||
]
|
||||
if not devices or not signals:
|
||||
return
|
||||
device_widget = devices[0]
|
||||
for signal_widget in signals:
|
||||
device_widget.device_selected.connect(signal_widget.set_device)
|
||||
device_widget.device_reset.connect(lambda w=signal_widget: w.set_device(None))
|
||||
if device_widget.currentText().strip():
|
||||
signal_widget.set_device(device_widget.currentText().strip())
|
||||
|
||||
def _connect_reference_unit_widgets(self) -> None:
|
||||
for name, widget in self.input_widgets().items():
|
||||
if not isinstance(widget, DeviceComboBox):
|
||||
continue
|
||||
widget.device_selected.connect(
|
||||
lambda _device_name, field_name=name: self._update_reference_units(field_name)
|
||||
)
|
||||
widget.device_reset.connect(
|
||||
lambda field_name=name: self._apply_reference_units(field_name, None)
|
||||
)
|
||||
widget.currentTextChanged.connect(
|
||||
lambda text, field_name=name: self._handle_reference_device_text(field_name, text)
|
||||
)
|
||||
|
||||
def _refresh_reference_units(self) -> None:
|
||||
for name, widget in self.input_widgets().items():
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
self._update_reference_units(name)
|
||||
|
||||
def _update_reference_units(self, source_name: str) -> None:
|
||||
widget = self.input_widget(source_name)
|
||||
if not isinstance(widget, DeviceComboBox) or not widget.is_valid_input:
|
||||
self._apply_reference_units(source_name, None)
|
||||
return
|
||||
self._apply_reference_units(source_name, device_units(widget.get_current_device()))
|
||||
|
||||
def _apply_reference_units(self, source_name: str, units: str | None) -> None:
|
||||
for field_name, config in self._field_configs.items():
|
||||
if config.get("reference_units") != source_name:
|
||||
continue
|
||||
field_widget = self.field_widget(field_name)
|
||||
input_widget = self.input_widget(field_name)
|
||||
apply_unit_metadata(field_widget, config, units)
|
||||
if input_widget is not field_widget:
|
||||
apply_unit_metadata(input_widget, config, units)
|
||||
|
||||
def _handle_reference_device_text(self, source_name: str, device_name: str) -> None:
|
||||
widget = self.input_widget(source_name)
|
||||
if isinstance(widget, DeviceComboBox) and not widget.validate_device(device_name):
|
||||
self._apply_reference_units(source_name, None)
|
||||
|
||||
def _validate_domain_widgets(self) -> None:
|
||||
for widget in self._widgets.values():
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
device = widget.currentText().strip()
|
||||
if not device:
|
||||
raise ValueError("Device is required.")
|
||||
if not widget.is_valid_input:
|
||||
raise ValueError(f"Device '{device}' is not available.")
|
||||
if isinstance(widget, SignalComboBox):
|
||||
signal = widget.get_signal_name().strip()
|
||||
if signal and not widget.is_valid_input:
|
||||
raise ValueError(f"Signal '{signal}' is not available.")
|
||||
|
||||
def _read_widget_value(self, name: str) -> Any:
|
||||
widget = self._widgets[name]
|
||||
info = self._model.model_fields[name]
|
||||
if isinstance(widget, OptionalValueWidget):
|
||||
return widget.value()
|
||||
if isinstance(widget, QLineEdit):
|
||||
value = WidgetIO.get_value(widget)
|
||||
return None if NoneType in get_args(info.annotation) and value == "" else value
|
||||
if isinstance(widget, QComboBox) and get_origin(info.annotation) is Literal:
|
||||
return WidgetIO.get_value(widget, as_string=True)
|
||||
return WidgetIO.get_value(widget)
|
||||
|
||||
def _set_widget_value(self, name: str, value: Any) -> None:
|
||||
widget = self._widgets[name]
|
||||
if isinstance(widget, OptionalValueWidget):
|
||||
widget.set_value(value)
|
||||
return
|
||||
if value is None:
|
||||
if isinstance(widget, QLineEdit):
|
||||
value = ""
|
||||
elif isinstance(widget, QCheckBox):
|
||||
value = False
|
||||
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
|
||||
value = 0
|
||||
WidgetIO.set_value(widget, value)
|
||||
|
||||
def _model_has_device_field(self) -> bool:
|
||||
for field in self._model.model_fields.values():
|
||||
annotation = field.annotation
|
||||
args = get_args(annotation)
|
||||
has_device = (
|
||||
isinstance(annotation, type)
|
||||
and issubclass(annotation, DeviceBase)
|
||||
or any(isinstance(arg, type) and issubclass(arg, DeviceBase) for arg in args)
|
||||
)
|
||||
has_signal = (
|
||||
isinstance(annotation, type)
|
||||
and issubclass(annotation, Signal)
|
||||
or any(isinstance(arg, type) and issubclass(arg, Signal) for arg in args)
|
||||
)
|
||||
if has_device and not has_signal:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import json
|
||||
import sys
|
||||
|
||||
from bec_lib.scan_args import ScanArgument
|
||||
from pydantic import Field
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QPushButton, QTabWidget, QTextEdit, QVBoxLayout
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
class BasicScanConfig(BaseModel):
|
||||
"""Plain Pydantic fields without GUI metadata."""
|
||||
|
||||
sample_name: str
|
||||
enabled: bool = True
|
||||
repeats: int = 3
|
||||
|
||||
class LimitConfig(BaseModel):
|
||||
"""Normal Pydantic Field metadata."""
|
||||
|
||||
mode: Literal["monitor", "scan", "calibration"] = "scan"
|
||||
low_limit: (
|
||||
float | None
|
||||
) # example of the field without additional metadata, still works in form
|
||||
high_limit: float | None = Field(
|
||||
default=10.0,
|
||||
title="High limit",
|
||||
description="Optional upper allowed value.",
|
||||
json_schema_extra={"precision": 4},
|
||||
)
|
||||
tolerance: float = Field(
|
||||
default=0.1,
|
||||
title="Tolerance",
|
||||
description="Warning tolerance around configured limits.",
|
||||
json_schema_extra={"precision": 4},
|
||||
)
|
||||
|
||||
class ScanArgumentConfig(BaseModel):
|
||||
"""ScanArgument metadata applied through Field extras."""
|
||||
|
||||
settling_time: float = Field(
|
||||
default=0.0,
|
||||
**ScanArgument(
|
||||
display_name="Settling time",
|
||||
description="Time to wait after moving.",
|
||||
units="s",
|
||||
precision=3,
|
||||
ge=0,
|
||||
).model_dump(),
|
||||
)
|
||||
frames: int = Field(
|
||||
default=1,
|
||||
**ScanArgument(
|
||||
display_name="Frames", description="Number of frames per trigger.", ge=1
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
class DeviceSignalLimitsConfig(BaseModel):
|
||||
"""Device, signal, and numeric fields whose units follow the selected device."""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
device: DeviceBase | str = Field(
|
||||
default="",
|
||||
**ScanArgument(display_name="Device", description="Positioner device.").model_dump(),
|
||||
)
|
||||
signal: Signal | str | None = Field(
|
||||
default=None,
|
||||
**ScanArgument(display_name="Signal", description="Device signal.").model_dump(),
|
||||
)
|
||||
low_limit: float | None = Field(
|
||||
default=None,
|
||||
**ScanArgument(
|
||||
display_name="Low limit",
|
||||
description="Optional lower limit.",
|
||||
reference_units="device",
|
||||
precision=4,
|
||||
).model_dump(),
|
||||
)
|
||||
high_limit: float | None = Field(
|
||||
default=None,
|
||||
**ScanArgument(
|
||||
display_name="High limit",
|
||||
description="Optional upper limit.",
|
||||
reference_units="device",
|
||||
precision=4,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
class DisplayConfig(BaseModel):
|
||||
title: str | None = Field(
|
||||
default=None, title="Title", description="Optional display title."
|
||||
)
|
||||
show_grid: bool = Field(default=True, title="Show grid")
|
||||
refresh_interval: int = Field(
|
||||
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
|
||||
)
|
||||
|
||||
class DeviceAndSignalConfig(BaseModel):
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
title: str | None = Field(
|
||||
default=None, title="Title", description="Optional display title."
|
||||
)
|
||||
device: DeviceBase | str = Field(
|
||||
default="", title="Device", description="BEC device selection."
|
||||
)
|
||||
signal: Signal | str | None = Field(
|
||||
default=None,
|
||||
title="Signal",
|
||||
description="Signal selection scoped to the selected device.",
|
||||
)
|
||||
refresh_interval: int = Field(
|
||||
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
|
||||
)
|
||||
|
||||
class DeviceOnlyConfig(BaseModel):
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
title: str | None = Field(
|
||||
default=None, title="Title", description="Optional display title."
|
||||
)
|
||||
device: DeviceBase | str = Field(
|
||||
default="", title="Device", description="BEC device selection."
|
||||
)
|
||||
refresh_interval: int = Field(
|
||||
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
|
||||
)
|
||||
|
||||
class SignalOnlyConfig(BaseModel):
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
title: str | None = Field(
|
||||
default=None, title="Title", description="Optional display title."
|
||||
)
|
||||
signal: Signal | str | None = Field(
|
||||
default=None,
|
||||
title="Signal",
|
||||
description="Global BEC signal selection without a device field.",
|
||||
)
|
||||
refresh_interval: int = Field(
|
||||
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
|
||||
)
|
||||
|
||||
class ExampleWindow(QWidget):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setWindowTitle("PydanticWidgetForm example")
|
||||
|
||||
self._tabs = QTabWidget(self)
|
||||
self._output = QTextEdit(self)
|
||||
self._output.setReadOnly(True)
|
||||
self._output.setPlaceholderText("Validated form data appears here.")
|
||||
self._forms: list[PydanticWidgetForm] = []
|
||||
|
||||
self._add_form("Basic", PydanticWidgetForm(BasicScanConfig))
|
||||
self._add_form("Limits", PydanticWidgetForm(LimitConfig))
|
||||
self._add_form("ScanArgument", PydanticWidgetForm(ScanArgumentConfig))
|
||||
self._add_form("Display", PydanticWidgetForm(DisplayConfig))
|
||||
self._add_form("Device + signal", PydanticWidgetForm(DeviceAndSignalConfig))
|
||||
self._add_form("Device limits", PydanticWidgetForm(DeviceSignalLimitsConfig))
|
||||
self._add_form("Device only", PydanticWidgetForm(DeviceOnlyConfig))
|
||||
self._add_form("Signal only", PydanticWidgetForm(SignalOnlyConfig))
|
||||
|
||||
show_data = QPushButton("Show current tab data", self)
|
||||
show_data.clicked.connect(self._show_current_data)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(QLabel("Generated forms from Pydantic models", self))
|
||||
layout.addWidget(self._tabs)
|
||||
layout.addWidget(show_data)
|
||||
layout.addWidget(self._output)
|
||||
|
||||
def _add_form(self, title: str, form: PydanticWidgetForm) -> None:
|
||||
form.changed.connect(lambda _form=form: self._on_form_changed(_form))
|
||||
self._forms.append(form)
|
||||
self._tabs.addTab(form, title)
|
||||
|
||||
def _show_current_data(self, _checked: bool = False, *, validate: bool = True) -> None:
|
||||
form = self._forms[self._tabs.currentIndex()]
|
||||
if validate:
|
||||
try:
|
||||
data = form.get_data()
|
||||
except (ValidationError, ValueError) as exc:
|
||||
self._output.setPlainText(str(exc))
|
||||
return
|
||||
key = "data"
|
||||
else:
|
||||
data = form.raw_data()
|
||||
key = "raw_data"
|
||||
self._output.setPlainText(
|
||||
json.dumps(
|
||||
{key: data, "dirty_fields": sorted(form.dirty_fields())}, indent=2, default=str
|
||||
)
|
||||
)
|
||||
|
||||
def _on_form_changed(self, form: PydanticWidgetForm) -> None:
|
||||
if form is self._forms[self._tabs.currentIndex()]:
|
||||
self._show_current_data(validate=False)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
window = ExampleWindow()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -7,31 +7,22 @@ import inspect
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import get_overloads
|
||||
|
||||
import black
|
||||
import isort
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property as QtProperty
|
||||
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator, plugin_filenames
|
||||
from bec_widgets.utils.generate_designer_plugin import (
|
||||
DesignerPluginGenerator,
|
||||
DesignerPluginInfo,
|
||||
plugin_filenames,
|
||||
)
|
||||
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 +45,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 +102,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 +129,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 +141,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:
|
||||
@@ -279,6 +254,58 @@ class {class_name}(RPCBase):"""
|
||||
file.write(formatted_content)
|
||||
|
||||
|
||||
def write_designer_plugins(plugin_infos: list[DesignerPluginInfo], file_name: str):
|
||||
"""
|
||||
Write a registry of Qt widget classes with designer plugins.
|
||||
|
||||
Args:
|
||||
plugin_infos(list[DesignerPluginInfo]): The designer plugin metadata to write.
|
||||
file_name(str): The name of the file to write to.
|
||||
"""
|
||||
plugin_infos = sorted(plugin_infos, key=lambda info: info.plugin_name_pascal)
|
||||
content = """# This file was automatically generated by generate_cli.py
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
# pylint: skip-file
|
||||
|
||||
designer_plugins = {
|
||||
"""
|
||||
for info in plugin_infos:
|
||||
widget_module = info.plugin_class.__module__
|
||||
widget_class = info.plugin_name_pascal
|
||||
content += f' "{info.plugin_name_pascal}": ("{widget_module}", "{widget_class}"),\n'
|
||||
|
||||
content += """
|
||||
}
|
||||
|
||||
widget_icons = {
|
||||
"""
|
||||
for info in plugin_infos:
|
||||
content += f' "{info.plugin_name_pascal}": "{info.icon_name}",\n'
|
||||
|
||||
content += """
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
formatted_content = black.format_str(content, mode=black.Mode(line_length=100))
|
||||
except black.NothingChanged:
|
||||
formatted_content = content
|
||||
|
||||
config = isort.Config(
|
||||
profile="black",
|
||||
line_length=100,
|
||||
multi_line_output=3,
|
||||
include_trailing_comma=False,
|
||||
known_first_party=["bec_widgets"],
|
||||
)
|
||||
formatted_content = isort.code(formatted_content, config=config)
|
||||
|
||||
with open(file_name, "w", encoding="utf-8") as file:
|
||||
file.write(formatted_content)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point for the script, controlled by command line arguments.
|
||||
@@ -332,6 +359,8 @@ def main():
|
||||
else:
|
||||
non_overwrite_classes = []
|
||||
|
||||
designer_plugin_infos = []
|
||||
|
||||
for cls in rpc_classes.plugins:
|
||||
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
|
||||
|
||||
@@ -339,21 +368,30 @@ def main():
|
||||
logger.error(
|
||||
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
|
||||
)
|
||||
continue
|
||||
|
||||
plugin = DesignerPluginGenerator(cls)
|
||||
if not hasattr(plugin, "info"):
|
||||
if not hasattr(plugin, "info") or plugin.excluded:
|
||||
continue
|
||||
|
||||
def _exists(file: str):
|
||||
return os.path.exists(os.path.join(plugin.info.base_path, file))
|
||||
|
||||
if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)):
|
||||
if _exists(plugin.filenames.plugin):
|
||||
designer_plugin_infos.append(plugin.info)
|
||||
logger.debug(
|
||||
f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists."
|
||||
)
|
||||
continue
|
||||
|
||||
plugin.run()
|
||||
designer_plugin_infos.append(plugin.info)
|
||||
|
||||
# Write designer_plugins.py with plugin import metadata for all widgets with designer plugins.
|
||||
designer_plugins_path = module_dir / client_subdir / "designer_plugins.py"
|
||||
logger.info(f"Generating designer plugin registry at {designer_plugins_path}")
|
||||
write_designer_plugins(designer_plugin_infos, str(designer_plugins_path))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
@@ -29,6 +29,7 @@ class DesignerPluginInfo:
|
||||
self.plugin_name_pascal = plugin_class.__name__
|
||||
self.plugin_name_snake = pascal_to_snake(self.plugin_name_pascal)
|
||||
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
|
||||
self.icon_name = getattr(plugin_class, "ICON_NAME", "")
|
||||
plugin_module = (
|
||||
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
|
||||
)
|
||||
@@ -63,6 +64,10 @@ class DesignerPluginGenerator:
|
||||
def filenames(self):
|
||||
return plugin_filenames(self.info.plugin_name_snake)
|
||||
|
||||
@property
|
||||
def excluded(self):
|
||||
return self._excluded
|
||||
|
||||
def run(self, validate=True):
|
||||
if self._excluded:
|
||||
print(f"Plugin {self.widget.__name__} is excluded from generation.")
|
||||
|
||||
@@ -22,7 +22,7 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
return QWidget()
|
||||
t = {plugin_name_pascal}(parent)
|
||||
return t
|
||||
|
||||
|
||||
@@ -1,56 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
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_widget import BECWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
|
||||
|
||||
def get_plugin_widgets() -> dict[str, BECConnector]:
|
||||
"""
|
||||
Get all available widgets from the plugin directory. Widgets are classes that inherit from BECConnector.
|
||||
The plugins are provided through python plugins and specified in the respective pyproject.toml file using
|
||||
the following key:
|
||||
|
||||
[project.entry-points."bec.widgets.user_widgets"]
|
||||
plugin_widgets = "path.to.plugin.module"
|
||||
|
||||
e.g.
|
||||
[project.entry-points."bec.widgets.user_widgets"]
|
||||
plugin_widgets = "pxiii_bec.bec_widgets.widgets"
|
||||
|
||||
assuming that the widgets module for the package pxiii_bec is located at pxiii_bec/bec_widgets/widgets and
|
||||
contains the widgets to be loaded within the pxiii_bec/bec_widgets/widgets/__init__.py file.
|
||||
|
||||
Returns:
|
||||
dict[str, BECConnector]: A dictionary of widget names and their respective classes.
|
||||
"""
|
||||
modules = _get_available_plugins("bec.widgets.user_widgets")
|
||||
loaded_plugins = {}
|
||||
print(modules)
|
||||
for module in modules:
|
||||
mods = inspect.getmembers(module, predicate=_filter_plugins)
|
||||
for name, mod_cls in mods:
|
||||
if name in loaded_plugins:
|
||||
print(f"Duplicated widgets plugin {name}.")
|
||||
loaded_plugins[name] = mod_cls
|
||||
return loaded_plugins
|
||||
|
||||
|
||||
def _filter_plugins(obj):
|
||||
return inspect.isclass(obj) and issubclass(obj, BECConnector)
|
||||
|
||||
|
||||
def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
|
||||
"""
|
||||
Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be
|
||||
@@ -66,6 +32,8 @@ def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
|
||||
Returns:
|
||||
dict[str, AutoUpdates]: A dictionary of widget names and their respective classes.
|
||||
"""
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
|
||||
modules = _get_available_plugins("bec.widgets.auto_updates")
|
||||
loaded_plugins = {}
|
||||
for module in modules:
|
||||
@@ -168,6 +136,11 @@ class BECClassContainer:
|
||||
|
||||
def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer:
|
||||
"""Collect classes from a package subtree (for example ``widgets`` or ``applications``)."""
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
collection = BECClassContainer()
|
||||
try:
|
||||
anchor_module = importlib.import_module(f"{repo_name}.{package}")
|
||||
@@ -194,17 +167,18 @@ def _collect_classes_from_package(repo_name: str, package: str) -> BECClassConta
|
||||
|
||||
for name in dir(module):
|
||||
obj = getattr(module, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
|
||||
continue
|
||||
if isinstance(obj, type):
|
||||
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
|
||||
if issubclass(obj, BECConnector):
|
||||
class_info.is_connector = True
|
||||
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
|
||||
class_info.is_widget = True
|
||||
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
|
||||
class_info.is_plugin = True
|
||||
collection.add_class(class_info)
|
||||
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
|
||||
if issubclass(obj, BECConnector):
|
||||
class_info.is_connector = True
|
||||
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
|
||||
class_info.is_widget = True
|
||||
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
|
||||
class_info.is_plugin = True
|
||||
collection.add_class(class_info)
|
||||
return collection
|
||||
|
||||
|
||||
@@ -229,3 +203,89 @@ def get_custom_classes(
|
||||
for package in selected_packages:
|
||||
collection += _collect_classes_from_package(repo_name, package)
|
||||
return collection
|
||||
|
||||
|
||||
def _get_designer_registry() -> dict[str, tuple[str, str]]:
|
||||
from bec_widgets.cli.designer_plugins import designer_plugins
|
||||
|
||||
return designer_plugins
|
||||
|
||||
|
||||
def _resolve_widget_from_registry(import_path: str, widget_name: str) -> type[QWidget]:
|
||||
widget = importlib.import_module(import_path)
|
||||
return getattr(widget, widget_name)
|
||||
|
||||
|
||||
def designer_plugin_exists(name: str) -> bool:
|
||||
from bec_widgets.utils.bec_plugin_helper import get_plugin_designer_registry
|
||||
|
||||
internal_registry = _get_designer_registry()
|
||||
external_registry = get_plugin_designer_registry()
|
||||
return name in internal_registry or name in external_registry
|
||||
|
||||
|
||||
def get_designer_plugin(name: str, raise_on_missing: bool = True) -> type[QWidget] | None:
|
||||
from bec_widgets.utils.bec_plugin_helper import get_plugin_designer_registry
|
||||
|
||||
internal_registry = _get_designer_registry()
|
||||
external_registry = get_plugin_designer_registry()
|
||||
if name in external_registry:
|
||||
import_path, widget_name = external_registry[name]
|
||||
return _resolve_widget_from_registry(import_path, widget_name)
|
||||
if name in internal_registry:
|
||||
import_path, widget_name = internal_registry[name]
|
||||
return _resolve_widget_from_registry(import_path, widget_name)
|
||||
|
||||
if raise_on_missing:
|
||||
raise ValueError(
|
||||
f"Designer plugin {name} not found in either internal or external registry."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def rpc_widget_registry_from_source(path: str | Path) -> dict[str, tuple[str, str]]:
|
||||
"""Parse a generated RPC client module and return its widget registry."""
|
||||
source_path = Path(path)
|
||||
module_node = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
|
||||
registry = {}
|
||||
for node in module_node.body:
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
for item in node.body:
|
||||
if not isinstance(item, ast.Assign):
|
||||
continue
|
||||
if not any(
|
||||
isinstance(target, ast.Name) and target.id == "_IMPORT_MODULE"
|
||||
for target in item.targets
|
||||
):
|
||||
continue
|
||||
if isinstance(item.value, ast.Constant) and isinstance(item.value.value, str):
|
||||
registry[node.name] = (item.value.value, node.name)
|
||||
break
|
||||
return registry
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_rpc_widget_registry() -> dict[str, tuple[str, str]]:
|
||||
client_path = Path(__file__).resolve().parents[1] / "cli" / "client.py"
|
||||
return rpc_widget_registry_from_source(client_path)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def rpc_widget_registry() -> dict[str, tuple[str, str]]:
|
||||
from bec_widgets.utils.bec_plugin_helper import get_plugin_rpc_widget_registry
|
||||
|
||||
internal_registry = get_rpc_widget_registry()
|
||||
external_registry = get_plugin_rpc_widget_registry()
|
||||
return {**external_registry, **internal_registry}
|
||||
|
||||
|
||||
def get_rpc_widget(name: str, raise_on_missing: bool = True) -> type[QWidget] | None:
|
||||
registry = rpc_widget_registry()
|
||||
if name in registry:
|
||||
import_path, widget_name = registry[name]
|
||||
return _resolve_widget_from_registry(import_path, widget_name)
|
||||
|
||||
if raise_on_missing:
|
||||
raise ValueError(f"RPC widget {name} not found in registry.")
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def elapsed_seconds(start: float | int | None, stop: float) -> float | None:
|
||||
if start is None:
|
||||
return None
|
||||
try:
|
||||
return max(0.0, stop - float(start))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def format_elapsed(elapsed: float | None) -> str:
|
||||
if elapsed is None:
|
||||
return "unknown"
|
||||
return f"{elapsed:.3f}"
|
||||
+114
-11
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import time
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
@@ -11,14 +12,15 @@ from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from qtpy.QtWidgets import QApplication, 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_logging import elapsed_seconds, format_elapsed
|
||||
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
|
||||
@@ -115,27 +117,107 @@ class RPCServer:
|
||||
if request_id is None:
|
||||
logger.error("Received RPC instruction without request_id")
|
||||
return
|
||||
method = msg.get("action")
|
||||
parameter = msg.get("parameter", {})
|
||||
args = parameter.get("args", [])
|
||||
kwargs = parameter.get("kwargs", {})
|
||||
target_gui_id = parameter.get("gui_id")
|
||||
sent_at = metadata.get("sent_at")
|
||||
deadline = metadata.get("deadline")
|
||||
timeout = metadata.get("timeout")
|
||||
received_at = time.time()
|
||||
receive_latency = elapsed_seconds(sent_at, received_at)
|
||||
stale_on_receive = deadline is not None and received_at > deadline
|
||||
logger.info(
|
||||
"GUI RPC server received request "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"target_gui_id={target_gui_id} timeout={timeout} "
|
||||
f"receive_latency_s={format_elapsed(receive_latency)} "
|
||||
f"stale_on_receive={stale_on_receive}"
|
||||
)
|
||||
if stale_on_receive:
|
||||
logger.warning(
|
||||
"GUI RPC server received request after client timeout deadline "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"target_gui_id={target_gui_id} timeout={timeout} "
|
||||
f"receive_latency_s={format_elapsed(receive_latency)}"
|
||||
)
|
||||
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
||||
|
||||
# Shutdown must acknowledge before teardown starts. The generic RPC path
|
||||
# below publishes successful responses through QTimer.singleShot(0);
|
||||
# for system.shutdown that would race with the queued app quit and
|
||||
# dispatcher shutdown scheduled by _shutdown_gui_server().
|
||||
if method == "system.shutdown":
|
||||
execution_start = time.perf_counter()
|
||||
try:
|
||||
self.run_system_rpc(method, args, kwargs)
|
||||
except Exception:
|
||||
execution_duration = time.perf_counter() - execution_start
|
||||
content = traceback.format_exc()
|
||||
logger.error(
|
||||
"GUI RPC server shutdown request failed "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"execution_duration_s={execution_duration:.3f}\n{content}"
|
||||
)
|
||||
self.send_response(request_id, False, {"error": content})
|
||||
else:
|
||||
execution_duration = time.perf_counter() - execution_start
|
||||
logger.info(
|
||||
"GUI RPC server acknowledged shutdown request "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"execution_duration_s={execution_duration:.3f}"
|
||||
)
|
||||
self.send_response(request_id, True, {"result": None})
|
||||
return
|
||||
|
||||
execution_start = time.perf_counter()
|
||||
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
||||
try:
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
if method.startswith("system."):
|
||||
res = self.run_system_rpc(method, args, kwargs)
|
||||
else:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
obj = self.get_object_from_config(parameter)
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception:
|
||||
execution_duration = time.perf_counter() - execution_start
|
||||
content = traceback.format_exc()
|
||||
logger.error(f"Error while executing RPC instruction: {content}")
|
||||
logger.error(
|
||||
"GUI RPC server execution failed "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"target_gui_id={target_gui_id} execution_duration_s={execution_duration:.3f}\n"
|
||||
f"{content}"
|
||||
)
|
||||
self.send_response(request_id, False, {"error": content})
|
||||
else:
|
||||
execution_duration = time.perf_counter() - execution_start
|
||||
response_stale = deadline is not None and time.time() > deadline
|
||||
logger.info(
|
||||
"GUI RPC server executed request "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"target_gui_id={target_gui_id} execution_duration_s={execution_duration:.3f} "
|
||||
f"response_after_client_deadline={response_stale}"
|
||||
)
|
||||
if response_stale:
|
||||
logger.warning(
|
||||
"GUI RPC server response is late for client timeout "
|
||||
f"request_id={request_id} method={method} gui_id={self.gui_id} "
|
||||
f"target_gui_id={target_gui_id} timeout={timeout} "
|
||||
f"execution_duration_s={execution_duration:.3f}"
|
||||
)
|
||||
logger.debug(f"RPC instruction executed successfully: {res}")
|
||||
self._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat()
|
||||
QTimer.singleShot(0, lambda: self.serialize_result_and_send(request_id, res))
|
||||
|
||||
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
||||
log_message = (
|
||||
"GUI RPC server publishing response "
|
||||
f"request_id={request_id} gui_id={self.gui_id} accepted={accepted}"
|
||||
)
|
||||
if accepted:
|
||||
logger.info(log_message)
|
||||
else:
|
||||
logger.error(log_message)
|
||||
self.client.connector.set_and_publish(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
messages.RequestResponseMessage(accepted=accepted, message=msg),
|
||||
@@ -236,10 +318,23 @@ class RPCServer:
|
||||
def run_system_rpc(self, method: str, args: list, kwargs: dict):
|
||||
if method == "system.launch_dock_area":
|
||||
return self._launch_dock_area(*args, **kwargs)
|
||||
if method == "system.shutdown":
|
||||
return self._shutdown_gui_server()
|
||||
if method == "system.list_capabilities":
|
||||
return {"system.launch_dock_area": True}
|
||||
return {"system.launch_dock_area": True, "system.shutdown": True}
|
||||
raise ValueError(f"Unknown system RPC method: {method}")
|
||||
|
||||
@staticmethod
|
||||
def _shutdown_gui_server() -> None:
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
gui_server = getattr(app, "gui_server", None)
|
||||
if gui_server is not None and hasattr(gui_server, "request_shutdown"):
|
||||
QTimer.singleShot(0, gui_server.request_shutdown)
|
||||
return
|
||||
QTimer.singleShot(0, app.quit)
|
||||
|
||||
@staticmethod
|
||||
def _launch_dock_area(
|
||||
name: str | None = None,
|
||||
@@ -297,7 +392,14 @@ class RPCServer:
|
||||
res = self.serialize_object(res)
|
||||
except RegistryNotReadyError:
|
||||
try:
|
||||
self._rpc_singleshot_repeats[request_id] += retry_delay
|
||||
repeat = self._rpc_singleshot_repeats[request_id]
|
||||
repeat += retry_delay
|
||||
logger.warning(
|
||||
"GUI RPC result serialization delayed; retrying "
|
||||
f"request_id={request_id} retry_delay_ms={retry_delay} "
|
||||
f"accumulated_delay_ms={repeat.accumulated_delay} "
|
||||
f"max_delay_ms={repeat.max_delay}"
|
||||
)
|
||||
QTimer.singleShot(
|
||||
retry_delay, lambda: self.serialize_result_and_send(request_id, res)
|
||||
)
|
||||
@@ -407,8 +509,9 @@ class RPCServer:
|
||||
container_proxy = parent.gui_id
|
||||
else:
|
||||
container_proxy = None
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
container_proxy = None
|
||||
logger.error(f"Error while serializing RPC result: {e}")
|
||||
|
||||
if wait and not self.rpc_register.object_is_registered(connector):
|
||||
raise RegistryNotReadyError(f"Connector {connector} not registered yet")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_widgets.utils.plugin_utils import get_rpc_widget, rpc_widget_registry
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
"""Handler class for creating widgets from RPC messages."""
|
||||
|
||||
def __init__(self):
|
||||
self._widget_registry = None
|
||||
|
||||
@property
|
||||
def widget_classes(self) -> dict[str, tuple[str, str]]:
|
||||
"""
|
||||
Get the available widget classes.
|
||||
|
||||
Returns:
|
||||
dict: The available widget classes.
|
||||
"""
|
||||
registry = rpc_widget_registry()
|
||||
if not registry:
|
||||
return {}
|
||||
return registry
|
||||
|
||||
@staticmethod
|
||||
def create_widget(widget_type, **kwargs) -> BECWidget:
|
||||
"""
|
||||
Create a widget from an RPC message.
|
||||
|
||||
Args:
|
||||
widget_type(str): The type of the widget.
|
||||
name (str): The name of the widget.
|
||||
**kwargs: The keyword arguments for the widget.
|
||||
|
||||
Returns:
|
||||
widget(BECWidget): The created widget.
|
||||
"""
|
||||
widget = get_rpc_widget(widget_type, raise_on_missing=False)
|
||||
if widget:
|
||||
return widget(**kwargs)
|
||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||
|
||||
|
||||
widget_handler = RPCWidgetHandler()
|
||||
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from qtpy.QtWidgets import QDoubleSpinBox, QSpinBox, QWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
UNIT_TOOLTIP_PREFIXES = ("Units:", "Units from:")
|
||||
|
||||
|
||||
def format_display_name(name: str) -> str:
|
||||
"""Convert a raw argument name into a user-facing label."""
|
||||
parts = re.split(r"(_|\d+)", name)
|
||||
return " ".join(part.capitalize() for part in parts if part.isalnum()).strip()
|
||||
|
||||
|
||||
def resolve_tooltip(scan_argument: Mapping[str, Any]) -> str | None:
|
||||
"""Resolve explicit tooltip text, falling back to the description."""
|
||||
return scan_argument.get("tooltip") or scan_argument.get("description")
|
||||
|
||||
|
||||
def ui_config_from_metadata(
|
||||
name: str,
|
||||
metadata: Mapping[str, Any],
|
||||
*,
|
||||
default: Any = None,
|
||||
input_type: Any = None,
|
||||
arg: bool = False,
|
||||
display_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the normalized scan-input item consumed by form widgets."""
|
||||
return {
|
||||
"arg": arg,
|
||||
"name": name,
|
||||
"type": input_type,
|
||||
"display_name": display_name or metadata.get("display_name") or format_display_name(name),
|
||||
"tooltip": resolve_tooltip(metadata),
|
||||
"default": default,
|
||||
"expert": metadata.get("expert", False),
|
||||
"hidden": metadata.get("hidden", False),
|
||||
"precision": metadata.get("precision"),
|
||||
"units": metadata.get("units"),
|
||||
"reference_units": metadata.get("reference_units"),
|
||||
"reference_limits": metadata.get("reference_limits"),
|
||||
"gt": metadata.get("gt"),
|
||||
"ge": metadata.get("ge"),
|
||||
"lt": metadata.get("lt"),
|
||||
"le": metadata.get("le"),
|
||||
"alternative_group": metadata.get("alternative_group"),
|
||||
}
|
||||
|
||||
|
||||
def unit_tooltip(item: Mapping[str, Any], units: str | None = None) -> str | None:
|
||||
"""Build tooltip text from scan argument unit metadata."""
|
||||
tooltip = item.get("tooltip")
|
||||
reference_units = item.get("reference_units")
|
||||
units = units or item.get("units")
|
||||
|
||||
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(str(part) for part in tooltip_parts)
|
||||
return None
|
||||
|
||||
|
||||
def strip_unit_tooltip(tooltip: str) -> str:
|
||||
"""Remove unit lines added by :func:`apply_unit_metadata`."""
|
||||
return "\n".join(
|
||||
line for line in tooltip.splitlines() if not line.startswith(UNIT_TOOLTIP_PREFIXES)
|
||||
).strip()
|
||||
|
||||
|
||||
def apply_unit_metadata(widget: QWidget, item: Mapping[str, Any], units: str | None = None) -> None:
|
||||
"""Apply unit tooltip text and numeric suffix metadata to a widget."""
|
||||
units = units or item.get("units")
|
||||
tooltip = unit_tooltip(item, units)
|
||||
existing_tooltip = strip_unit_tooltip(widget.toolTip())
|
||||
base_tooltip = item.get("tooltip")
|
||||
if base_tooltip and existing_tooltip == base_tooltip:
|
||||
existing_tooltip = ""
|
||||
|
||||
if tooltip:
|
||||
widget.setToolTip(f"{existing_tooltip}\n{tooltip}" if existing_tooltip else tooltip)
|
||||
else:
|
||||
widget.setToolTip(existing_tooltip)
|
||||
|
||||
if hasattr(widget, "setSuffix"):
|
||||
widget.setSuffix(f" {units}" if units else "")
|
||||
|
||||
|
||||
def device_units(device: object) -> str | None:
|
||||
"""Return engineering units from a BEC device object when available."""
|
||||
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 apply_numeric_precision(widget: QWidget, item: Mapping[str, Any]) -> None:
|
||||
"""Apply decimal precision metadata to spinboxes supporting ``setDecimals``."""
|
||||
if not hasattr(widget, "setDecimals"):
|
||||
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")
|
||||
)
|
||||
|
||||
|
||||
def apply_numeric_limits(widget: QWidget, item: Mapping[str, Any]) -> None:
|
||||
"""Apply ``gt/ge/lt/le`` numeric bounds to Qt spinboxes."""
|
||||
if isinstance(widget, QSpinBox) and not isinstance(widget, QDoubleSpinBox):
|
||||
minimum = -2147483647
|
||||
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, QDoubleSpinBox):
|
||||
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,63 @@
|
||||
from pathlib import Path
|
||||
|
||||
from qtpy.QtCore import QUrl
|
||||
from qtpy.QtMultimedia import QAudioOutput, QMediaPlayer
|
||||
from qtpy.QtWidgets import QApplication, QComboBox, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class SoundPlayerWidget(QWidget):
|
||||
"""Simple widget to preview bundled sound assets."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.setWindowTitle("Sound Player")
|
||||
|
||||
self._sounds_dir = Path(__file__).resolve().parent.parent / "assets" / "sounds"
|
||||
self._player = QMediaPlayer(self)
|
||||
self._audio_output = QAudioOutput(self)
|
||||
self._player.setAudioOutput(self._audio_output)
|
||||
|
||||
self.sound_combo_box = QComboBox(self)
|
||||
self.play_button = QPushButton("Play", self)
|
||||
|
||||
self._populate_sounds()
|
||||
self.play_button.clicked.connect(self.play_selected_sound)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.sound_combo_box)
|
||||
layout.addWidget(self.play_button)
|
||||
|
||||
self.resize(420, 100)
|
||||
|
||||
def _populate_sounds(self) -> None:
|
||||
"""Load bundled sound assets into the combo box."""
|
||||
sound_files = sorted(self._sounds_dir.glob("*.mp3"))
|
||||
for sound_file in sound_files:
|
||||
self.sound_combo_box.addItem(sound_file.stem, str(sound_file))
|
||||
|
||||
self.play_button.setEnabled(bool(sound_files))
|
||||
if not sound_files:
|
||||
self.sound_combo_box.addItem("No sounds found")
|
||||
|
||||
def play_selected_sound(self) -> None:
|
||||
"""Play the currently selected sound asset."""
|
||||
sound_path = self.sound_combo_box.currentData()
|
||||
if not sound_path:
|
||||
return
|
||||
|
||||
self._player.setSource(QUrl.fromLocalFile(sound_path))
|
||||
self._player.stop()
|
||||
self._player.play()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
|
||||
widget = SoundPlayerWidget()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -27,8 +27,10 @@ from qtpy.QtWidgets import (
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||
BECDeviceFilter,
|
||||
DeviceComboBox,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy import PYQT6, PYSIDE6
|
||||
from qtpy import PYSIDE6
|
||||
from qtpy.QtCore import QFile, QIODevice
|
||||
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
from bec_widgets.utils.plugin_utils import get_designer_plugin
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -12,16 +10,14 @@ if PYSIDE6:
|
||||
from qtpy.QtUiTools import QUiLoader
|
||||
|
||||
class CustomUiLoader(QUiLoader):
|
||||
def __init__(self, baseinstance, custom_widgets: dict | None = None):
|
||||
def __init__(self, baseinstance):
|
||||
super().__init__(baseinstance)
|
||||
self.custom_widgets = custom_widgets or {}
|
||||
|
||||
self.baseinstance = baseinstance
|
||||
|
||||
def createWidget(self, class_name, parent=None, name=""):
|
||||
if class_name in self.custom_widgets:
|
||||
widget = self.custom_widgets[class_name](self.baseinstance)
|
||||
return widget
|
||||
widget = get_designer_plugin(class_name, raise_on_missing=False)
|
||||
if widget is not None:
|
||||
return widget(self.baseinstance)
|
||||
return super().createWidget(class_name, self.baseinstance, name)
|
||||
|
||||
|
||||
@@ -31,16 +27,9 @@ class UILoader:
|
||||
def __init__(self, parent=None):
|
||||
self.parent = parent
|
||||
|
||||
self.custom_widgets = (
|
||||
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
|
||||
).as_dict()
|
||||
|
||||
if PYSIDE6:
|
||||
self.loader = self.load_ui_pyside6
|
||||
elif PYQT6:
|
||||
self.loader = self.load_ui_pyqt6
|
||||
else:
|
||||
if not PYSIDE6:
|
||||
raise ImportError("No compatible Qt bindings found.")
|
||||
self.loader = self.load_ui_pyside6
|
||||
|
||||
def load_ui_pyside6(self, ui_file, parent=None):
|
||||
"""
|
||||
@@ -53,7 +42,7 @@ class UILoader:
|
||||
QWidget: The loaded widget.
|
||||
"""
|
||||
parent = parent or self.parent
|
||||
loader = CustomUiLoader(parent, self.custom_widgets)
|
||||
loader = CustomUiLoader(parent)
|
||||
file = QFile(ui_file)
|
||||
if not file.open(QIODevice.ReadOnly):
|
||||
raise IOError(f"Cannot open file: {ui_file}")
|
||||
@@ -61,71 +50,6 @@ class UILoader:
|
||||
file.close()
|
||||
return widget
|
||||
|
||||
def load_ui_pyqt6(self, ui_file, parent=None):
|
||||
"""
|
||||
Specific loader for PyQt6 using loadUi.
|
||||
Args:
|
||||
ui_file(str): Path to the .ui file.
|
||||
parent(QWidget): Parent widget.
|
||||
|
||||
Returns:
|
||||
QWidget: The loaded widget.
|
||||
"""
|
||||
from PyQt6.uic.Loader.loader import DynamicUILoader
|
||||
|
||||
class CustomDynamicUILoader(DynamicUILoader):
|
||||
def __init__(self, package, custom_widgets: dict = None):
|
||||
super().__init__(package)
|
||||
self.custom_widgets = custom_widgets or {}
|
||||
|
||||
def _handle_custom_widgets(self, el):
|
||||
"""Handle the <customwidgets> element."""
|
||||
|
||||
def header2module(header):
|
||||
"""header2module(header) -> string
|
||||
|
||||
Convert paths to C++ header files to according Python modules
|
||||
>>> header2module("foo/bar/baz.h")
|
||||
'foo.bar.baz'
|
||||
"""
|
||||
|
||||
if header.endswith(".h"):
|
||||
header = header[:-2]
|
||||
|
||||
mpath = []
|
||||
for part in header.split("/"):
|
||||
# Ignore any empty parts or those that refer to the current
|
||||
# directory.
|
||||
if part not in ("", "."):
|
||||
if part == "..":
|
||||
# We should allow this for Python3.
|
||||
raise SyntaxError(
|
||||
"custom widget header file name may not contain '..'."
|
||||
)
|
||||
|
||||
mpath.append(part)
|
||||
|
||||
return ".".join(mpath)
|
||||
|
||||
for custom_widget in el:
|
||||
classname = custom_widget.findtext("class")
|
||||
header = custom_widget.findtext("header")
|
||||
if header:
|
||||
header = self._translate_bec_widgets_header(header)
|
||||
self.factory.addCustomWidget(
|
||||
classname,
|
||||
custom_widget.findtext("extends") or "QWidget",
|
||||
header2module(header),
|
||||
)
|
||||
|
||||
def _translate_bec_widgets_header(self, header):
|
||||
for name, value in self.custom_widgets.items():
|
||||
if header == DesignerPluginInfo.pascal_to_snake(name):
|
||||
return value.__module__
|
||||
return header
|
||||
|
||||
return CustomDynamicUILoader("", self.custom_widgets).loadUi(ui_file, parent)
|
||||
|
||||
def load_ui(self, ui_file, parent=None):
|
||||
"""
|
||||
Universal UI loader method.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,7 +85,11 @@ class ComboBoxHandler(WidgetHandler):
|
||||
|
||||
def set_value(self, widget: QComboBox, value: int | str) -> None:
|
||||
if isinstance(value, str):
|
||||
value = widget.findText(value)
|
||||
index = widget.findText(value)
|
||||
if index < 0 and widget.isEditable():
|
||||
widget.setCurrentText(value)
|
||||
return
|
||||
value = index
|
||||
if isinstance(value, int):
|
||||
widget.setCurrentIndex(value)
|
||||
|
||||
@@ -95,6 +99,45 @@ class ComboBoxHandler(WidgetHandler):
|
||||
widget.currentIndexChanged.connect(lambda idx, w=widget: slot(w, self.get_value(w)))
|
||||
|
||||
|
||||
class DeviceComboBoxHandler(ComboBoxHandler):
|
||||
"""Handler for BEC device comboboxes. The widget value is the device name."""
|
||||
|
||||
def get_value(self, widget, **kwargs) -> str:
|
||||
return widget.currentText().strip()
|
||||
|
||||
def set_value(self, widget, value: str | None) -> None:
|
||||
device = "" if value is None else str(value)
|
||||
if not device:
|
||||
widget.setCurrentText("")
|
||||
return
|
||||
widget.set_device(device)
|
||||
if widget.currentText() != device:
|
||||
widget.setCurrentText(device)
|
||||
|
||||
def connect_change_signal(self, widget, slot):
|
||||
widget.currentTextChanged.connect(lambda text, w=widget: slot(w, text.strip()))
|
||||
|
||||
|
||||
class SignalComboBoxHandler(ComboBoxHandler):
|
||||
"""Handler for BEC signal comboboxes. The widget value is the signal object name."""
|
||||
|
||||
def get_value(self, widget, **kwargs) -> str | None:
|
||||
signal = widget.get_signal_name().strip()
|
||||
return signal or None
|
||||
|
||||
def set_value(self, widget, value: str | None) -> None:
|
||||
signal = "" if value is None else str(value)
|
||||
if not signal:
|
||||
widget.setCurrentText("")
|
||||
return
|
||||
widget.set_signal(signal)
|
||||
if widget.currentText() != signal and widget.get_signal_name() != signal:
|
||||
widget.setCurrentText(signal)
|
||||
|
||||
def connect_change_signal(self, widget, slot):
|
||||
widget.currentTextChanged.connect(lambda _text, w=widget: slot(w, self.get_value(w)))
|
||||
|
||||
|
||||
class TableWidgetHandler(WidgetHandler):
|
||||
"""Handler for QTableWidget widgets."""
|
||||
|
||||
@@ -203,6 +246,28 @@ class WidgetIO:
|
||||
ToggleSwitch: ToggleSwitchHandler,
|
||||
QSlider: SlideHandler,
|
||||
}
|
||||
_deferred_handlers_registered = False
|
||||
|
||||
@classmethod
|
||||
def _register_deferred_handlers(cls) -> None:
|
||||
"""
|
||||
Register handlers for widgets that import this module themselves and therefore
|
||||
cannot be imported here at module level without a circular import. The import is
|
||||
deferred to the first handler lookup, when all modules are fully initialized.
|
||||
"""
|
||||
if cls._deferred_handlers_registered:
|
||||
return
|
||||
cls._deferred_handlers_registered = True
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||
DeviceComboBox,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
|
||||
SignalComboBox,
|
||||
)
|
||||
|
||||
cls._handlers[DeviceComboBox] = DeviceComboBoxHandler
|
||||
cls._handlers[SignalComboBox] = SignalComboBoxHandler
|
||||
|
||||
@staticmethod
|
||||
def get_value(widget, ignore_errors=False, **kwargs):
|
||||
@@ -278,6 +343,7 @@ class WidgetIO:
|
||||
Returns:
|
||||
handler_class: The handler class if found, otherwise None.
|
||||
"""
|
||||
WidgetIO._register_deferred_handlers()
|
||||
for base in type(widget).__mro__:
|
||||
if base in WidgetIO._handlers:
|
||||
return WidgetIO._handlers[base]
|
||||
@@ -418,7 +484,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 +534,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 +600,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 +702,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 +730,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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Mapping, Sequence
|
||||
|
||||
import slugify
|
||||
@@ -19,11 +20,15 @@ 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.cli.designer_plugins import widget_icons
|
||||
from bec_widgets.utils.bec_plugin_helper import (
|
||||
get_plugin_rpc_widget_registry,
|
||||
get_plugin_widget_icons,
|
||||
)
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.plugin_utils import get_rpc_widget_registry
|
||||
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,
|
||||
@@ -35,25 +40,25 @@ from bec_widgets.utils.widget_state_manager import WidgetStateManager
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
||||
SETTINGS_KEYS,
|
||||
default_profile_candidates,
|
||||
baseline_profile_candidates,
|
||||
delete_profile_files,
|
||||
get_last_profile,
|
||||
is_profile_read_only,
|
||||
is_quick_select,
|
||||
list_profiles,
|
||||
list_quick_profiles,
|
||||
load_default_profile_screenshot,
|
||||
load_user_profile_screenshot,
|
||||
load_baseline_profile_screenshot,
|
||||
load_runtime_profile_screenshot,
|
||||
now_iso_utc,
|
||||
open_default_settings,
|
||||
open_user_settings,
|
||||
open_baseline_settings,
|
||||
open_runtime_settings,
|
||||
profile_origin,
|
||||
profile_origin_display,
|
||||
read_manifest,
|
||||
restore_user_from_default,
|
||||
restore_runtime_from_baseline,
|
||||
runtime_profile_candidates,
|
||||
set_last_profile,
|
||||
set_quick_select,
|
||||
user_profile_candidates,
|
||||
write_manifest,
|
||||
)
|
||||
from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
|
||||
@@ -65,22 +70,7 @@ from bec_widgets.widgets.containers.dock_area.toolbar_components.workspace_actio
|
||||
WorkspaceConnection,
|
||||
workspace_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
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.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -90,6 +80,19 @@ PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_
|
||||
StartupProfile = Literal["restore", "skip"] | str | None
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _plugin_toolbar_actions() -> dict[str, tuple[str, str, str]]:
|
||||
plugin_registry = get_plugin_rpc_widget_registry()
|
||||
internal_registry = get_rpc_widget_registry()
|
||||
plugin_icons = get_plugin_widget_icons()
|
||||
|
||||
return {
|
||||
widget_name: (plugin_icons.get(widget_name, "widgets"), f"Add {widget_name}", widget_name)
|
||||
for widget_name in sorted(plugin_registry)
|
||||
if widget_name not in internal_registry
|
||||
}
|
||||
|
||||
|
||||
class BECDockArea(DockAreaWidget):
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
@@ -108,6 +111,7 @@ class BECDockArea(DockAreaWidget):
|
||||
"list_profiles",
|
||||
"save_profile",
|
||||
"load_profile",
|
||||
"restore_baseline_profile",
|
||||
"delete_profile",
|
||||
]
|
||||
|
||||
@@ -143,6 +147,10 @@ class BECDockArea(DockAreaWidget):
|
||||
self._mode = mode
|
||||
|
||||
# Toolbar
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import (
|
||||
DarkModeButton,
|
||||
)
|
||||
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self.dark_mode_button.setVisible(enable_profile_management)
|
||||
self._setup_toolbar()
|
||||
@@ -235,11 +243,8 @@ class BECDockArea(DockAreaWidget):
|
||||
def _load_initial_profile(self, name: str) -> None:
|
||||
"""Load the initial profile."""
|
||||
self.load_profile(name)
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
combo.blockSignals(True)
|
||||
if not self._empty_profile_active:
|
||||
combo.setCurrentText(name)
|
||||
combo.blockSignals(False)
|
||||
self._set_workspace_combo_text_silent(name)
|
||||
|
||||
def _start_empty_workspace(self) -> None:
|
||||
"""
|
||||
@@ -344,38 +349,47 @@ class BECDockArea(DockAreaWidget):
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
|
||||
plot_actions = {
|
||||
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
|
||||
"waveform": (widget_icons["Waveform"], "Add Waveform", "Waveform"),
|
||||
"scatter_waveform": (
|
||||
ScatterWaveform.ICON_NAME,
|
||||
widget_icons["ScatterWaveform"],
|
||||
"Add Scatter Waveform",
|
||||
"ScatterWaveform",
|
||||
),
|
||||
"multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"),
|
||||
"image": (Image.ICON_NAME, "Add Image", "Image"),
|
||||
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"),
|
||||
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"),
|
||||
"multi_waveform": (
|
||||
widget_icons["MultiWaveform"],
|
||||
"Add Multi Waveform",
|
||||
"MultiWaveform",
|
||||
),
|
||||
"image": (widget_icons["Image"], "Add Image", "Image"),
|
||||
"motor_map": (widget_icons["MotorMap"], "Add Motor Map", "MotorMap"),
|
||||
"heatmap": (widget_icons["Heatmap"], "Add Heatmap", "Heatmap"),
|
||||
}
|
||||
device_actions = {
|
||||
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
|
||||
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
|
||||
"scan_control": (widget_icons["ScanControl"], "Add Scan Control", "ScanControl"),
|
||||
"positioner_box": (widget_icons["PositionerBox"], "Add Device Box", "PositionerBox"),
|
||||
"positioner_box_2D": (
|
||||
PositionerBox2D.ICON_NAME,
|
||||
widget_icons["PositionerBox2D"],
|
||||
"Add Device 2D Box",
|
||||
"PositionerBox2D",
|
||||
),
|
||||
}
|
||||
util_actions = {
|
||||
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
|
||||
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
|
||||
"queue": (widget_icons["BECQueue"], "Add Scan Queue", "BECQueue"),
|
||||
"status": (widget_icons["BECStatusBox"], "Add BEC Status Box", "BECStatusBox"),
|
||||
"progress_bar": (
|
||||
RingProgressBar.ICON_NAME,
|
||||
widget_icons["RingProgressBar"],
|
||||
"Add Circular ProgressBar",
|
||||
"RingProgressBar",
|
||||
),
|
||||
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
|
||||
"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"),
|
||||
"terminal": (widget_icons["BecConsole"], "Add Terminal", "BecConsole"),
|
||||
"bec_shell": (widget_icons["BECShell"], "Add BEC Shell", "BECShell"),
|
||||
"sbb_monitor": (widget_icons["SBBMonitor"], "Add SBB Monitor", "SBBMonitor"),
|
||||
"log_panel": (widget_icons["LogPanel"], "Add LogPanel", "LogPanel"),
|
||||
"beamline_state_manager": (
|
||||
widget_icons["BeamlineStateManager"],
|
||||
"Add Beamline State Manager",
|
||||
"BeamlineStateManager",
|
||||
),
|
||||
}
|
||||
|
||||
# Create expandable menu actions (original behavior)
|
||||
@@ -400,6 +414,10 @@ class BECDockArea(DockAreaWidget):
|
||||
_build_menu("menu_devices", "Add Device Control ", device_actions)
|
||||
_build_menu("menu_utils", "Add Utils ", util_actions)
|
||||
|
||||
plugin_actions = _plugin_toolbar_actions()
|
||||
if plugin_actions:
|
||||
_build_menu("menu_plugins", "Add Plugins ", plugin_actions)
|
||||
|
||||
# Create flat toolbar bundles for each widget type
|
||||
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
|
||||
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
|
||||
@@ -470,14 +488,16 @@ class BECDockArea(DockAreaWidget):
|
||||
bda.add_action("dark_mode")
|
||||
self.toolbar.add_bundle(bda)
|
||||
|
||||
self._apply_toolbar_layout()
|
||||
|
||||
# Store mappings on self for use in _hook_toolbar
|
||||
# Store mappings on self for use in _hook_toolbar and _apply_toolbar_layout
|
||||
self._ACTION_MAPPINGS = {
|
||||
"menu_plots": plot_actions,
|
||||
"menu_devices": device_actions,
|
||||
"menu_utils": util_actions,
|
||||
}
|
||||
if plugin_actions:
|
||||
self._ACTION_MAPPINGS["menu_plugins"] = plugin_actions
|
||||
|
||||
self._apply_toolbar_layout()
|
||||
|
||||
def _hook_toolbar(self):
|
||||
def _connect_menu(menu_key: str):
|
||||
@@ -486,10 +506,9 @@ 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":
|
||||
toolbar_action = menu.actions[key]
|
||||
act = toolbar_action.action
|
||||
if key == "terminal":
|
||||
act.triggered.connect(
|
||||
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
|
||||
)
|
||||
@@ -499,21 +518,24 @@ class BECDockArea(DockAreaWidget):
|
||||
widget=t, closable=True, show_settings_action=False
|
||||
)
|
||||
)
|
||||
elif menu_key == "menu_plugins":
|
||||
act.triggered.connect(
|
||||
lambda _, t=widget_type, a=toolbar_action: self._new_plugin_widget(t, a)
|
||||
)
|
||||
else:
|
||||
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
|
||||
|
||||
_connect_menu("menu_plots")
|
||||
_connect_menu("menu_devices")
|
||||
_connect_menu("menu_utils")
|
||||
if "menu_plugins" in self._ACTION_MAPPINGS:
|
||||
_connect_menu("menu_plugins")
|
||||
|
||||
def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]):
|
||||
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"])
|
||||
@@ -522,6 +544,10 @@ class BECDockArea(DockAreaWidget):
|
||||
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
|
||||
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
|
||||
|
||||
def _new_plugin_widget(self, widget_type: str, toolbar_action: MaterialIconAction) -> None:
|
||||
# Created as helper method for simple tests
|
||||
self.new(widget=widget_type, dock_icon=toolbar_action.get_icon())
|
||||
|
||||
def _set_editable(self, editable: bool) -> None:
|
||||
self.workspace_is_locked = not editable
|
||||
self._editable = editable
|
||||
@@ -595,13 +621,13 @@ class BECDockArea(DockAreaWidget):
|
||||
|
||||
@property
|
||||
def profile_namespace(self) -> str | None:
|
||||
"""Namespace used to scope user/default profile files for this dock area."""
|
||||
"""Namespace used to scope runtime/baseline profile files for this dock area."""
|
||||
return self._resolve_profile_namespace()
|
||||
|
||||
def _profile_exists(self, name: str, namespace: str | None) -> bool:
|
||||
return any(
|
||||
os.path.exists(path) for path in user_profile_candidates(name, namespace)
|
||||
) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace))
|
||||
os.path.exists(path) for path in runtime_profile_candidates(name, namespace)
|
||||
) or any(os.path.exists(path) for path in baseline_profile_candidates(name, namespace))
|
||||
|
||||
def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None:
|
||||
"""
|
||||
@@ -627,35 +653,34 @@ class BECDockArea(DockAreaWidget):
|
||||
name: str,
|
||||
namespace: str | None,
|
||||
*,
|
||||
write_default: bool = True,
|
||||
write_user: bool = True,
|
||||
write_baseline: bool = True,
|
||||
write_runtime: bool = True,
|
||||
save_preview: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Write profile settings to default and/or user settings files.
|
||||
Write profile settings to baseline and/or runtime settings files.
|
||||
|
||||
Args:
|
||||
name: The profile name.
|
||||
namespace: The profile namespace.
|
||||
write_default: Whether to write to the default settings file.
|
||||
write_user: Whether to write to the user settings file.
|
||||
write_baseline: Whether to write to the baseline settings file.
|
||||
write_runtime: Whether to write to the runtime settings file.
|
||||
save_preview: Whether to save a screenshot preview.
|
||||
"""
|
||||
if write_default:
|
||||
ds = open_default_settings(name, namespace=namespace)
|
||||
self._write_snapshot_to_settings(ds, save_preview=save_preview)
|
||||
if not ds.value(SETTINGS_KEYS["created_at"], ""):
|
||||
ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||
if not ds.value(SETTINGS_KEYS["is_quick_select"], None):
|
||||
ds.setValue(SETTINGS_KEYS["is_quick_select"], True)
|
||||
|
||||
if write_user:
|
||||
us = open_user_settings(name, namespace=namespace)
|
||||
self._write_snapshot_to_settings(us, save_preview=save_preview)
|
||||
if not us.value(SETTINGS_KEYS["created_at"], ""):
|
||||
us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||
if not us.value(SETTINGS_KEYS["is_quick_select"], None):
|
||||
us.setValue(SETTINGS_KEYS["is_quick_select"], True)
|
||||
def _write_settings(open_settings) -> None:
|
||||
settings = open_settings(name, namespace=namespace)
|
||||
self._write_snapshot_to_settings(settings, save_preview=save_preview)
|
||||
if not settings.value(SETTINGS_KEYS["created_at"], ""):
|
||||
settings.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||
if not settings.value(SETTINGS_KEYS["is_quick_select"], None):
|
||||
settings.setValue(SETTINGS_KEYS["is_quick_select"], True)
|
||||
|
||||
if write_baseline:
|
||||
_write_settings(open_baseline_settings)
|
||||
|
||||
if write_runtime:
|
||||
_write_settings(open_runtime_settings)
|
||||
|
||||
def _finalize_profile_change(self, name: str, namespace: str | None) -> None:
|
||||
"""
|
||||
@@ -673,6 +698,14 @@ class BECDockArea(DockAreaWidget):
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
combo.refresh_profiles(active_profile=name)
|
||||
|
||||
def _set_workspace_combo_text_silent(self, text: str) -> None:
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
was_blocked = combo.blockSignals(True)
|
||||
try:
|
||||
combo.setCurrentText(text)
|
||||
finally:
|
||||
combo.blockSignals(was_blocked)
|
||||
|
||||
def _enter_empty_profile_state(self) -> None:
|
||||
"""
|
||||
Switch to the transient empty workspace state.
|
||||
@@ -709,10 +742,10 @@ class BECDockArea(DockAreaWidget):
|
||||
Save the current workspace profile.
|
||||
|
||||
On first save of a given name:
|
||||
- writes a default copy to states/default/<name>.ini with tag=default and created_at
|
||||
- writes a user copy to states/user/<name>.ini with tag=user and created_at
|
||||
On subsequent saves of user-owned profiles:
|
||||
- updates both the default and user copies so restore uses the latest snapshot.
|
||||
- writes a baseline copy to profiles/baseline/<name>.ini with created_at
|
||||
- writes a runtime copy to profiles/runtime/<name>.ini with created_at
|
||||
On subsequent saves:
|
||||
- updates both the baseline and runtime copies so restore uses the latest snapshot.
|
||||
Read-only bundled profiles cannot be overwritten.
|
||||
|
||||
Args:
|
||||
@@ -776,7 +809,7 @@ class BECDockArea(DockAreaWidget):
|
||||
overwrite_existing = origin == "settings"
|
||||
|
||||
origin_before_save = profile_origin(name, namespace=namespace)
|
||||
overwrite_default = overwrite_existing and origin_before_save == "settings"
|
||||
overwrite_baseline = overwrite_existing and origin_before_save == "settings"
|
||||
|
||||
# Display saving placeholder in toolbar
|
||||
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
@@ -785,12 +818,12 @@ class BECDockArea(DockAreaWidget):
|
||||
workspace_combo.setCurrentIndex(0)
|
||||
workspace_combo.blockSignals(False)
|
||||
|
||||
# Write to default and/or user settings
|
||||
should_write_default = overwrite_default or not any(
|
||||
os.path.exists(path) for path in default_profile_candidates(name, namespace)
|
||||
# Write to baseline and/or runtime settings
|
||||
should_write_baseline = overwrite_baseline or not any(
|
||||
os.path.exists(path) for path in baseline_profile_candidates(name, namespace)
|
||||
)
|
||||
self._write_profile_settings(
|
||||
name, namespace, write_default=should_write_default, write_user=True
|
||||
name, namespace, write_baseline=should_write_baseline, write_runtime=True
|
||||
)
|
||||
|
||||
set_quick_select(name, quickselect, namespace=namespace)
|
||||
@@ -800,7 +833,6 @@ class BECDockArea(DockAreaWidget):
|
||||
self._pending_autosave_skip = (current_profile, name)
|
||||
else:
|
||||
self._pending_autosave_skip = None
|
||||
workspace_combo.setCurrentText(name)
|
||||
self._finalize_profile_change(name, namespace)
|
||||
|
||||
@SafeSlot()
|
||||
@@ -820,16 +852,21 @@ class BECDockArea(DockAreaWidget):
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
@SafeSlot(str, bool)
|
||||
@rpc_timeout(None)
|
||||
def load_profile(self, name: str | None = None):
|
||||
def load_profile(self, name: str | None = None, restore_baseline: bool = False):
|
||||
"""
|
||||
Load a workspace profile.
|
||||
|
||||
Before switching, persist the current profile to the user copy.
|
||||
Prefer loading the user copy; fall back to the default copy.
|
||||
Before switching, persist the current profile to the runtime copy.
|
||||
Prefer loading the runtime copy; fall back to the baseline copy. When
|
||||
``restore_baseline`` is True, first overwrite the runtime copy with the
|
||||
baseline profile and then load it.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||
restore_baseline (bool): If True, restore the runtime copy from the
|
||||
baseline before loading. Defaults to False.
|
||||
"""
|
||||
if name == "":
|
||||
return
|
||||
@@ -848,14 +885,17 @@ class BECDockArea(DockAreaWidget):
|
||||
if skip_pair and skip_pair == (prev_name, name):
|
||||
self._pending_autosave_skip = None
|
||||
else:
|
||||
us_prev = open_user_settings(prev_name, namespace=namespace)
|
||||
us_prev = open_runtime_settings(prev_name, namespace=namespace)
|
||||
self._write_snapshot_to_settings(us_prev, save_preview=True)
|
||||
|
||||
if restore_baseline:
|
||||
restore_runtime_from_baseline(name, namespace=namespace)
|
||||
|
||||
settings = None
|
||||
if any(os.path.exists(path) for path in user_profile_candidates(name, namespace)):
|
||||
settings = open_user_settings(name, namespace=namespace)
|
||||
elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)):
|
||||
settings = open_default_settings(name, namespace=namespace)
|
||||
if any(os.path.exists(path) for path in runtime_profile_candidates(name, namespace)):
|
||||
settings = open_runtime_settings(name, namespace=namespace)
|
||||
elif any(os.path.exists(path) for path in baseline_profile_candidates(name, namespace)):
|
||||
settings = open_baseline_settings(name, namespace=namespace)
|
||||
if settings is None:
|
||||
logger.warning(f"Profile '{name}' not found in namespace '{namespace}'. Creating new.")
|
||||
self.delete_all()
|
||||
@@ -897,32 +937,36 @@ class BECDockArea(DockAreaWidget):
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
def restore_user_profile_from_default(self, name: str | None = None):
|
||||
@SafeSlot(str, bool)
|
||||
@rpc_timeout(None)
|
||||
def restore_baseline_profile(self, name: str | None = None, show_dialog: bool = False):
|
||||
"""
|
||||
Overwrite the user copy of *name* with the default baseline.
|
||||
Overwrite the runtime copy of *name* with the baseline.
|
||||
If *name* is None, target the currently active profile.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile to restore. If None, uses the current profile.
|
||||
show_dialog (bool): If True, ask for confirmation before restoring.
|
||||
"""
|
||||
target = name or getattr(self, "_current_profile_name", None)
|
||||
if not target:
|
||||
return
|
||||
namespace = self.profile_namespace
|
||||
|
||||
current_pixmap = None
|
||||
if self.isVisible():
|
||||
current_pixmap = QPixmap()
|
||||
ba = bytes(self.screenshot_bytes())
|
||||
current_pixmap.loadFromData(ba)
|
||||
if current_pixmap is None or current_pixmap.isNull():
|
||||
current_pixmap = load_user_profile_screenshot(target, namespace=namespace)
|
||||
default_pixmap = load_default_profile_screenshot(target, namespace=namespace)
|
||||
if show_dialog:
|
||||
current_pixmap = None
|
||||
if self.isVisible():
|
||||
current_pixmap = QPixmap()
|
||||
ba = bytes(self.screenshot_bytes())
|
||||
current_pixmap.loadFromData(ba)
|
||||
if current_pixmap is None or current_pixmap.isNull():
|
||||
current_pixmap = load_runtime_profile_screenshot(target, namespace=namespace)
|
||||
baseline_pixmap = load_baseline_profile_screenshot(target, namespace=namespace)
|
||||
|
||||
if not RestoreProfileDialog.confirm(self, current_pixmap, default_pixmap):
|
||||
return
|
||||
if not RestoreProfileDialog.confirm(self, current_pixmap, baseline_pixmap):
|
||||
return
|
||||
|
||||
restore_user_from_default(target, namespace=namespace)
|
||||
restore_runtime_from_baseline(target, namespace=namespace)
|
||||
self.delete_all()
|
||||
self.load_profile(target)
|
||||
|
||||
@@ -1057,7 +1101,7 @@ class BECDockArea(DockAreaWidget):
|
||||
manage_action = self.toolbar.components.get_action("manage_workspaces").action
|
||||
if self.manage_dialog is None or not self.manage_dialog.isVisible():
|
||||
self.manage_widget = WorkSpaceManager(
|
||||
self, target_widget=self, default_profile=self._current_profile_name
|
||||
self, target_widget=self, active_profile=self._current_profile_name
|
||||
)
|
||||
self.manage_dialog = QDialog(modal=False)
|
||||
|
||||
@@ -1105,14 +1149,10 @@ class BECDockArea(DockAreaWidget):
|
||||
if mode_key == "user":
|
||||
bundles = ["spacer_bundle", "workspace", "dock_actions"]
|
||||
elif mode_key == "creator":
|
||||
bundles = [
|
||||
"menu_plots",
|
||||
"menu_devices",
|
||||
"menu_utils",
|
||||
"spacer_bundle",
|
||||
"workspace",
|
||||
"dock_actions",
|
||||
]
|
||||
bundles = ["menu_plots", "menu_devices", "menu_utils"]
|
||||
if "menu_plugins" in getattr(self, "_ACTION_MAPPINGS", {}):
|
||||
bundles.append("menu_plugins")
|
||||
bundles += ["spacer_bundle", "workspace", "dock_actions"]
|
||||
elif mode_key == "plot":
|
||||
bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"]
|
||||
elif mode_key == "device":
|
||||
@@ -1156,7 +1196,7 @@ class BECDockArea(DockAreaWidget):
|
||||
return
|
||||
|
||||
namespace = self.profile_namespace
|
||||
settings = open_user_settings(name, namespace=namespace)
|
||||
settings = open_runtime_settings(name, namespace=namespace)
|
||||
self._write_snapshot_to_settings(settings)
|
||||
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
||||
self._exit_snapshot_written = True
|
||||
@@ -1186,6 +1226,8 @@ class BECDockArea(DockAreaWidget):
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
from bec_widgets.applications.views.view import ViewTourSteps
|
||||
|
||||
return ViewTourSteps(view_title="Dock Area Workspace", step_ids=step_ids)
|
||||
|
||||
def cleanup(self):
|
||||
@@ -1206,6 +1248,9 @@ class BECDockArea(DockAreaWidget):
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
dispatcher = BECDispatcher(gui_id="ads")
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
Utilities for managing BECDockArea profiles stored in INI files.
|
||||
|
||||
Policy:
|
||||
- All created/modified profiles are stored under the BEC settings root: <base_path>/profiles/{default,user}
|
||||
- Bundled read-only defaults are discovered in BW core states/default and plugin bec_widgets/profiles but never written to.
|
||||
- Lookup order when reading: user → settings default → app or plugin bundled default.
|
||||
- All created/modified profiles are stored under the BEC settings root:
|
||||
<base_path>/profiles/{baseline,runtime}
|
||||
- Bundled read-only baselines are discovered in BW core profiles and plugin
|
||||
bec_widgets/profiles but never written to.
|
||||
- Lookup order when reading: runtime → settings baseline → app or plugin bundled baseline.
|
||||
- Legacy settings paths profiles/{default,user} are read through a thin segment
|
||||
alias layer and copied to the canonical location on first access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -32,6 +36,12 @@ logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
|
||||
ProfileSegment = Literal["baseline", "runtime"]
|
||||
|
||||
_PROFILE_SEGMENT_ALIASES: dict[ProfileSegment, tuple[str, str]] = {
|
||||
"baseline": ("baseline", "default"),
|
||||
"runtime": ("runtime", "user"),
|
||||
}
|
||||
|
||||
|
||||
def module_profiles_dir() -> str:
|
||||
@@ -130,7 +140,7 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
|
||||
Build (and ensure) the directory that holds profiles for a namespace segment.
|
||||
|
||||
Args:
|
||||
segment (str): Either ``"user"`` or ``"default"``.
|
||||
segment (str): Profile segment directory name.
|
||||
namespace (str | None): Optional namespace label to scope profiles.
|
||||
|
||||
Returns:
|
||||
@@ -143,157 +153,175 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
|
||||
return path
|
||||
|
||||
|
||||
def _user_path_candidates(name: str, namespace: str | None) -> list[str]:
|
||||
"""
|
||||
Generate candidate user-profile paths honoring namespace fallbacks.
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
namespace (str | None): Optional namespace label.
|
||||
|
||||
Returns:
|
||||
list[str]: Ordered list of candidate user profile paths (.ini files).
|
||||
"""
|
||||
def _candidate_namespaces(namespace: str | None) -> list[str | None]:
|
||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
||||
primary = os.path.join(_profiles_dir("user", ns), f"{name}.ini")
|
||||
if not ns:
|
||||
return [primary]
|
||||
legacy = os.path.join(_profiles_dir("user", None), f"{name}.ini")
|
||||
return [primary, legacy] if legacy != primary else [primary]
|
||||
return [None]
|
||||
return [ns, None]
|
||||
|
||||
|
||||
def _default_path_candidates(name: str, namespace: str | None) -> list[str]:
|
||||
def _segment_profile_path(segment_name: str, name: str, namespace: str | None) -> str:
|
||||
return os.path.join(_profiles_dir(segment_name, namespace), f"{name}.ini")
|
||||
|
||||
|
||||
def _canonical_profile_path(segment: ProfileSegment, name: str, namespace: str | None) -> str:
|
||||
return _segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][0], name, namespace)
|
||||
|
||||
|
||||
def _segment_path_candidates(
|
||||
segment: ProfileSegment,
|
||||
name: str,
|
||||
namespace: str | None,
|
||||
*,
|
||||
include_legacy: bool = True,
|
||||
migrate_legacy: bool = True,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Generate candidate default-profile paths honoring namespace fallbacks.
|
||||
Generate profile candidates for a canonical segment.
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
namespace (str | None): Optional namespace label.
|
||||
|
||||
Returns:
|
||||
list[str]: Ordered list of candidate default profile paths (.ini files).
|
||||
Canonical baseline/runtime files are always preferred. Namespace fallback
|
||||
files and legacy default/user files are copied to the primary canonical path
|
||||
when the primary file does not exist.
|
||||
"""
|
||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
||||
primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini")
|
||||
if not ns:
|
||||
return [primary]
|
||||
legacy = os.path.join(_profiles_dir("default", None), f"{name}.ini")
|
||||
return [primary, legacy] if legacy != primary else [primary]
|
||||
canonical = [
|
||||
_segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][0], name, ns)
|
||||
for ns in _candidate_namespaces(namespace)
|
||||
]
|
||||
legacy = []
|
||||
if include_legacy:
|
||||
legacy = [
|
||||
_segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][1], name, ns)
|
||||
for ns in _candidate_namespaces(namespace)
|
||||
]
|
||||
|
||||
primary_canonical = canonical[0]
|
||||
if migrate_legacy and not os.path.exists(primary_canonical):
|
||||
canonical_src = next((path for path in canonical[1:] if os.path.exists(path)), None)
|
||||
if canonical_src:
|
||||
os.makedirs(os.path.dirname(primary_canonical), exist_ok=True)
|
||||
shutil.copy2(canonical_src, primary_canonical)
|
||||
elif include_legacy:
|
||||
legacy_src = next((path for path in legacy if os.path.exists(path)), None)
|
||||
if legacy_src:
|
||||
os.makedirs(os.path.dirname(primary_canonical), exist_ok=True)
|
||||
shutil.copy2(legacy_src, primary_canonical)
|
||||
|
||||
return list(dict.fromkeys(canonical + legacy))
|
||||
|
||||
|
||||
def default_profiles_dir(namespace: str | None = None) -> str:
|
||||
def baseline_profiles_dir(namespace: str | None = None) -> str:
|
||||
"""
|
||||
Return the directory that stores default profiles for the namespace.
|
||||
Return the directory that stores baseline profiles for the namespace.
|
||||
|
||||
Args:
|
||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||
|
||||
Returns:
|
||||
str: Absolute path to the default profile directory.
|
||||
str: Absolute path to the baseline profile directory.
|
||||
"""
|
||||
return _profiles_dir("default", namespace)
|
||||
return _profiles_dir("baseline", namespace)
|
||||
|
||||
|
||||
def user_profiles_dir(namespace: str | None = None) -> str:
|
||||
def runtime_profiles_dir(namespace: str | None = None) -> str:
|
||||
"""
|
||||
Return the directory that stores user profiles for the namespace.
|
||||
Return the directory that stores runtime profiles for the namespace.
|
||||
|
||||
Args:
|
||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||
|
||||
Returns:
|
||||
str: Absolute path to the user profile directory.
|
||||
str: Absolute path to the runtime profile directory.
|
||||
"""
|
||||
return _profiles_dir("user", namespace)
|
||||
return _profiles_dir("runtime", namespace)
|
||||
|
||||
|
||||
def default_profile_path(name: str, namespace: str | None = None) -> str:
|
||||
def baseline_profile_path(name: str, namespace: str | None = None) -> str:
|
||||
"""
|
||||
Compute the canonical default profile path for a profile name.
|
||||
Compute the canonical baseline profile path for a profile name.
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||
|
||||
Returns:
|
||||
str: Absolute path to the default profile file (.ini).
|
||||
str: Absolute path to the baseline profile file (.ini).
|
||||
"""
|
||||
return _default_path_candidates(name, namespace)[0]
|
||||
return _canonical_profile_path("baseline", name, namespace)
|
||||
|
||||
|
||||
def user_profile_path(name: str, namespace: str | None = None) -> str:
|
||||
def runtime_profile_path(name: str, namespace: str | None = None) -> str:
|
||||
"""
|
||||
Compute the canonical user profile path for a profile name.
|
||||
Compute the canonical runtime profile path for a profile name.
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||
|
||||
Returns:
|
||||
str: Absolute path to the user profile file (.ini).
|
||||
str: Absolute path to the runtime profile file (.ini).
|
||||
"""
|
||||
return _user_path_candidates(name, namespace)[0]
|
||||
return _canonical_profile_path("runtime", name, namespace)
|
||||
|
||||
|
||||
def user_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
|
||||
def runtime_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
|
||||
"""
|
||||
List all user profile path candidates for a profile name.
|
||||
List all runtime profile path candidates for a profile name.
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||
|
||||
Returns:
|
||||
list[str]: De-duplicated list of candidate user profile paths.
|
||||
list[str]: De-duplicated list of candidate runtime profile paths.
|
||||
"""
|
||||
return list(dict.fromkeys(_user_path_candidates(name, namespace)))
|
||||
return _segment_path_candidates("runtime", name, namespace)
|
||||
|
||||
|
||||
def default_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
|
||||
def baseline_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
|
||||
"""
|
||||
List all default profile path candidates for a profile name.
|
||||
List all baseline profile path candidates for a profile name.
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||
|
||||
Returns:
|
||||
list[str]: De-duplicated list of candidate default profile paths.
|
||||
list[str]: De-duplicated list of candidate baseline profile paths.
|
||||
"""
|
||||
return list(dict.fromkeys(_default_path_candidates(name, namespace)))
|
||||
return _segment_path_candidates("baseline", name, namespace)
|
||||
|
||||
|
||||
def _existing_user_settings(name: str, namespace: str | None = None) -> QSettings | None:
|
||||
def _existing_runtime_settings(name: str, namespace: str | None = None) -> QSettings | None:
|
||||
"""
|
||||
Resolve the first existing user profile settings object.
|
||||
Resolve the first existing runtime profile settings object.
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
|
||||
|
||||
Returns:
|
||||
QSettings | None: Config for the first existing user profile candidate, or ``None``
|
||||
QSettings | None: Config for the first existing runtime profile candidate, or ``None``
|
||||
when no files are present.
|
||||
"""
|
||||
for path in user_profile_candidates(name, namespace):
|
||||
for path in runtime_profile_candidates(name, namespace):
|
||||
if os.path.exists(path):
|
||||
return QSettings(path, QSettings.IniFormat)
|
||||
return None
|
||||
|
||||
|
||||
def _existing_default_settings(name: str, namespace: str | None = None) -> QSettings | None:
|
||||
def _existing_baseline_settings(name: str, namespace: str | None = None) -> QSettings | None:
|
||||
"""
|
||||
Resolve the first existing default profile settings object.
|
||||
Resolve the first existing baseline profile settings object.
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
|
||||
|
||||
Returns:
|
||||
QSettings | None: Config for the first existing default profile candidate, or ``None``
|
||||
QSettings | None: Config for the first existing baseline profile candidate, or ``None``
|
||||
when no files are present.
|
||||
"""
|
||||
for path in default_profile_candidates(name, namespace):
|
||||
for path in baseline_profile_candidates(name, namespace):
|
||||
if os.path.exists(path):
|
||||
return QSettings(path, QSettings.IniFormat)
|
||||
return None
|
||||
@@ -347,7 +375,7 @@ def profile_origin(name: str, namespace: str | None = None) -> ProfileOrigin:
|
||||
plugin_path = plugin_profile_path(name)
|
||||
if plugin_path and os.path.exists(plugin_path):
|
||||
return "plugin"
|
||||
for path in user_profile_candidates(name, namespace) + default_profile_candidates(
|
||||
for path in runtime_profile_candidates(name, namespace) + baseline_profile_candidates(
|
||||
name, namespace
|
||||
):
|
||||
if os.path.exists(path):
|
||||
@@ -406,8 +434,8 @@ def delete_profile_files(name: str, namespace: str | None = None) -> bool:
|
||||
read_only = is_profile_read_only(name, namespace)
|
||||
|
||||
removed = False
|
||||
# Always allow removing user copies; keep default copies for read-only origins.
|
||||
for path in set(user_profile_candidates(name, namespace)):
|
||||
# Always allow removing runtime copies; keep baseline copies for read-only origins.
|
||||
for path in set(runtime_profile_candidates(name, namespace)):
|
||||
try:
|
||||
os.remove(path)
|
||||
removed = True
|
||||
@@ -415,7 +443,7 @@ def delete_profile_files(name: str, namespace: str | None = None) -> bool:
|
||||
continue
|
||||
|
||||
if not read_only:
|
||||
for path in set(default_profile_candidates(name, namespace)):
|
||||
for path in set(baseline_profile_candidates(name, namespace)):
|
||||
try:
|
||||
os.remove(path)
|
||||
removed = True
|
||||
@@ -443,7 +471,7 @@ SETTINGS_KEYS = {
|
||||
|
||||
def list_profiles(namespace: str | None = None) -> list[str]:
|
||||
"""
|
||||
Enumerate all known profile names, syncing bundled defaults when missing locally.
|
||||
Enumerate all known profile names, syncing bundled baselines when missing locally.
|
||||
|
||||
Args:
|
||||
namespace (str | None, optional): Namespace label scoped to the profile set.
|
||||
@@ -459,16 +487,27 @@ def list_profiles(namespace: str | None = None) -> list[str]:
|
||||
return set()
|
||||
return {os.path.splitext(f)[0] for f in os.listdir(directory) if f.endswith(".ini")}
|
||||
|
||||
settings_dirs = {default_profiles_dir(namespace), user_profiles_dir(namespace)}
|
||||
settings_dirs = {baseline_profiles_dir(namespace), runtime_profiles_dir(namespace)}
|
||||
if ns:
|
||||
settings_dirs.add(default_profiles_dir(None))
|
||||
settings_dirs.add(user_profiles_dir(None))
|
||||
settings_dirs.add(baseline_profiles_dir(None))
|
||||
settings_dirs.add(runtime_profiles_dir(None))
|
||||
|
||||
for segment in ("baseline", "runtime"):
|
||||
for legacy_dir in [
|
||||
_profiles_dir(_PROFILE_SEGMENT_ALIASES[segment][1], item)
|
||||
for item in _candidate_namespaces(namespace)
|
||||
]:
|
||||
settings_dirs.add(legacy_dir)
|
||||
|
||||
settings_names: set[str] = set()
|
||||
for directory in settings_dirs:
|
||||
settings_names |= _collect_from(directory)
|
||||
|
||||
# Also consider read-only defaults from core module and beamline plugin repositories
|
||||
for name in sorted(settings_names):
|
||||
runtime_profile_candidates(name, namespace)
|
||||
baseline_profile_candidates(name, namespace)
|
||||
|
||||
# Also consider read-only baselines from core module and beamline plugin repositories
|
||||
read_only_sources: dict[str, tuple[str, str]] = {}
|
||||
sources: list[tuple[str, str | None]] = [
|
||||
("module", module_profiles_dir()),
|
||||
@@ -484,17 +523,17 @@ def list_profiles(namespace: str | None = None) -> list[str]:
|
||||
read_only_sources.setdefault(name, (origin, os.path.join(directory, filename)))
|
||||
|
||||
for name, (_origin, src) in sorted(read_only_sources.items()):
|
||||
# Ensure a copy in the namespace-specific settings default directory
|
||||
dst_default = default_profile_path(name, namespace)
|
||||
if not os.path.exists(dst_default):
|
||||
os.makedirs(os.path.dirname(dst_default), exist_ok=True)
|
||||
shutil.copyfile(src, dst_default)
|
||||
# Ensure a user copy exists to allow edits in the writable settings area
|
||||
dst_user = user_profile_path(name, namespace)
|
||||
if not os.path.exists(dst_user):
|
||||
os.makedirs(os.path.dirname(dst_user), exist_ok=True)
|
||||
shutil.copyfile(src, dst_user)
|
||||
s = open_user_settings(name, namespace)
|
||||
# Ensure a copy in the namespace-specific settings baseline directory.
|
||||
dst_baseline = baseline_profile_path(name, namespace)
|
||||
if not os.path.exists(dst_baseline):
|
||||
os.makedirs(os.path.dirname(dst_baseline), exist_ok=True)
|
||||
shutil.copy2(src, dst_baseline)
|
||||
# Ensure a runtime copy exists to allow edits in the writable settings area.
|
||||
dst_runtime = runtime_profile_path(name, namespace)
|
||||
if not os.path.exists(dst_runtime):
|
||||
os.makedirs(os.path.dirname(dst_runtime), exist_ok=True)
|
||||
shutil.copy2(src, dst_runtime)
|
||||
s = open_runtime_settings(name, namespace)
|
||||
if s.value(SETTINGS_KEYS["created_at"], "") == "":
|
||||
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||
|
||||
@@ -504,32 +543,34 @@ def list_profiles(namespace: str | None = None) -> list[str]:
|
||||
return sorted(settings_names)
|
||||
|
||||
|
||||
def open_default_settings(name: str, namespace: str | None = None) -> QSettings:
|
||||
def open_baseline_settings(name: str, namespace: str | None = None) -> QSettings:
|
||||
"""
|
||||
Open (and create if necessary) the default profile settings file.
|
||||
Open (and create if necessary) the baseline profile settings file.
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||
|
||||
Returns:
|
||||
QSettings: Settings instance targeting the default profile file.
|
||||
QSettings: Settings instance targeting the baseline profile file.
|
||||
"""
|
||||
return QSettings(default_profile_path(name, namespace), QSettings.IniFormat)
|
||||
baseline_profile_candidates(name, namespace)
|
||||
return QSettings(baseline_profile_path(name, namespace), QSettings.IniFormat)
|
||||
|
||||
|
||||
def open_user_settings(name: str, namespace: str | None = None) -> QSettings:
|
||||
def open_runtime_settings(name: str, namespace: str | None = None) -> QSettings:
|
||||
"""
|
||||
Open (and create if necessary) the user profile settings file.
|
||||
Open (and create if necessary) the runtime profile settings file.
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||
|
||||
Returns:
|
||||
QSettings: Settings instance targeting the user profile file.
|
||||
QSettings: Settings instance targeting the runtime profile file.
|
||||
"""
|
||||
return QSettings(user_profile_path(name, namespace), QSettings.IniFormat)
|
||||
runtime_profile_candidates(name, namespace)
|
||||
return QSettings(runtime_profile_path(name, namespace), QSettings.IniFormat)
|
||||
|
||||
|
||||
def _app_settings() -> QSettings:
|
||||
@@ -759,26 +800,26 @@ def read_manifest(settings: QSettings) -> list[dict]:
|
||||
return items
|
||||
|
||||
|
||||
def restore_user_from_default(name: str, namespace: str | None = None) -> None:
|
||||
def restore_runtime_from_baseline(name: str, namespace: str | None = None) -> None:
|
||||
"""
|
||||
Copy the default profile to the user profile, preserving quick-select flag.
|
||||
Copy the baseline profile to the runtime profile, preserving quick-select flag.
|
||||
|
||||
Args:
|
||||
name(str): Profile name without extension.
|
||||
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
||||
"""
|
||||
src = None
|
||||
for candidate in default_profile_candidates(name, namespace):
|
||||
for candidate in baseline_profile_candidates(name, namespace):
|
||||
if os.path.exists(candidate):
|
||||
src = candidate
|
||||
break
|
||||
if not src:
|
||||
return
|
||||
dst = user_profile_path(name, namespace)
|
||||
dst = runtime_profile_path(name, namespace)
|
||||
preserve_quick_select = is_quick_select(name, namespace)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
shutil.copyfile(src, dst)
|
||||
s = open_user_settings(name, namespace)
|
||||
s = open_runtime_settings(name, namespace)
|
||||
if not s.value(SETTINGS_KEYS["created_at"], ""):
|
||||
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||
if preserve_quick_select:
|
||||
@@ -796,9 +837,9 @@ def is_quick_select(name: str, namespace: str | None = None) -> bool:
|
||||
Returns:
|
||||
bool: True if quick-select is enabled for the profile.
|
||||
"""
|
||||
s = _existing_user_settings(name, namespace)
|
||||
s = _existing_runtime_settings(name, namespace)
|
||||
if s is None:
|
||||
s = _existing_default_settings(name, namespace)
|
||||
s = _existing_baseline_settings(name, namespace)
|
||||
if s is None:
|
||||
return False
|
||||
return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool)
|
||||
@@ -813,13 +854,13 @@ def set_quick_select(name: str, enabled: bool, namespace: str | None = None) ->
|
||||
enabled(bool): True to enable quick-select, False to disable.
|
||||
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
||||
"""
|
||||
s = open_user_settings(name, namespace)
|
||||
s = open_runtime_settings(name, namespace)
|
||||
s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled))
|
||||
|
||||
|
||||
def list_quick_profiles(namespace: str | None = None) -> list[str]:
|
||||
"""
|
||||
List only profiles that have quick-select enabled (user wins over default).
|
||||
List only profiles that have quick-select enabled (runtime wins over baseline).
|
||||
|
||||
Args:
|
||||
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
||||
@@ -909,8 +950,8 @@ class ProfileInfo(BaseModel):
|
||||
is_quick_select: bool = False
|
||||
widget_count: int = 0
|
||||
size_kb: int = 0
|
||||
user_path: str = ""
|
||||
default_path: str = ""
|
||||
runtime_path: str = ""
|
||||
baseline_path: str = ""
|
||||
origin: ProfileOrigin = "unknown"
|
||||
is_read_only: bool = False
|
||||
|
||||
@@ -924,19 +965,19 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
|
||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||
|
||||
Returns:
|
||||
ProfileInfo: Structured profile metadata, preferring the user copy when present.
|
||||
ProfileInfo: Structured profile metadata, preferring the runtime copy when present.
|
||||
"""
|
||||
user_paths = user_profile_candidates(name, namespace)
|
||||
default_paths = default_profile_candidates(name, namespace)
|
||||
u_path = next((p for p in user_paths if os.path.exists(p)), user_paths[0])
|
||||
d_path = next((p for p in default_paths if os.path.exists(p)), default_paths[0])
|
||||
runtime_paths = runtime_profile_candidates(name, namespace)
|
||||
baseline_paths = baseline_profile_candidates(name, namespace)
|
||||
r_path = next((p for p in runtime_paths if os.path.exists(p)), runtime_paths[0])
|
||||
b_path = next((p for p in baseline_paths if os.path.exists(p)), baseline_paths[0])
|
||||
origin = profile_origin(name, namespace)
|
||||
read_only = origin in {"module", "plugin"}
|
||||
prefer_user = os.path.exists(u_path)
|
||||
if prefer_user:
|
||||
s = QSettings(u_path, QSettings.IniFormat)
|
||||
elif os.path.exists(d_path):
|
||||
s = QSettings(d_path, QSettings.IniFormat)
|
||||
prefer_runtime = os.path.exists(r_path)
|
||||
if prefer_runtime:
|
||||
s = QSettings(r_path, QSettings.IniFormat)
|
||||
elif os.path.exists(b_path):
|
||||
s = QSettings(b_path, QSettings.IniFormat)
|
||||
else:
|
||||
s = None
|
||||
if s is None:
|
||||
@@ -957,14 +998,14 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
|
||||
is_quick_select=False,
|
||||
widget_count=0,
|
||||
size_kb=0,
|
||||
user_path=u_path,
|
||||
default_path=d_path,
|
||||
runtime_path=r_path,
|
||||
baseline_path=b_path,
|
||||
origin=origin,
|
||||
is_read_only=read_only,
|
||||
)
|
||||
|
||||
created = s.value(SETTINGS_KEYS["created_at"], "", type=str) or now_iso_utc()
|
||||
src_path = u_path if prefer_user else d_path
|
||||
src_path = r_path if prefer_runtime else b_path
|
||||
modified = _file_modified_iso(src_path)
|
||||
count = _manifest_count(s)
|
||||
try:
|
||||
@@ -990,8 +1031,8 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
|
||||
is_quick_select=is_quick_select(name, namespace),
|
||||
widget_count=count,
|
||||
size_kb=size_kb,
|
||||
user_path=u_path,
|
||||
default_path=d_path,
|
||||
runtime_path=r_path,
|
||||
baseline_path=b_path,
|
||||
origin=origin,
|
||||
is_read_only=read_only,
|
||||
)
|
||||
@@ -999,7 +1040,7 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
|
||||
|
||||
def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
||||
"""
|
||||
Load the stored screenshot pixmap for a profile from settings (user preferred).
|
||||
Load the stored screenshot pixmap for a profile from settings (runtime preferred).
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
@@ -1008,17 +1049,17 @@ def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap
|
||||
Returns:
|
||||
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
||||
"""
|
||||
s = _existing_user_settings(name, namespace)
|
||||
s = _existing_runtime_settings(name, namespace)
|
||||
if s is None:
|
||||
s = _existing_default_settings(name, namespace)
|
||||
s = _existing_baseline_settings(name, namespace)
|
||||
if s is None:
|
||||
return None
|
||||
return _load_screenshot_from_settings(s)
|
||||
|
||||
|
||||
def load_default_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
||||
def load_baseline_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
||||
"""
|
||||
Load the screenshot from the default profile copy, if available.
|
||||
Load the screenshot from the baseline profile copy, if available.
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
@@ -1027,15 +1068,15 @@ def load_default_profile_screenshot(name: str, namespace: str | None = None) ->
|
||||
Returns:
|
||||
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
||||
"""
|
||||
s = _existing_default_settings(name, namespace)
|
||||
s = _existing_baseline_settings(name, namespace)
|
||||
if s is None:
|
||||
return None
|
||||
return _load_screenshot_from_settings(s)
|
||||
|
||||
|
||||
def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
||||
def load_runtime_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
||||
"""
|
||||
Load the screenshot from the user profile copy, if available.
|
||||
Load the screenshot from the runtime profile copy, if available.
|
||||
|
||||
Args:
|
||||
name (str): Profile name without extension.
|
||||
@@ -1044,7 +1085,7 @@ def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPi
|
||||
Returns:
|
||||
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
||||
"""
|
||||
s = _existing_user_settings(name, namespace)
|
||||
s = _existing_runtime_settings(name, namespace)
|
||||
if s is None:
|
||||
return None
|
||||
return _load_screenshot_from_settings(s)
|
||||
|
||||
@@ -160,7 +160,7 @@ class SaveProfileDialog(QDialog):
|
||||
self,
|
||||
"Read-only profile",
|
||||
(
|
||||
f"'{name}' is a default profile provided by {provider} and cannot be overwritten.\n"
|
||||
f"'{name}' is a baseline profile provided by {provider} and cannot be overwritten.\n"
|
||||
"Please choose a different name."
|
||||
),
|
||||
)
|
||||
@@ -179,7 +179,7 @@ class SaveProfileDialog(QDialog):
|
||||
"Overwrite profile",
|
||||
(
|
||||
f"A profile named '{name}' already exists.\n\n"
|
||||
"Overwriting will update both the saved profile and its restore default.\n"
|
||||
"Overwriting will update both the runtime profile and its restore baseline.\n"
|
||||
"Do you want to continue?"
|
||||
),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
@@ -257,21 +257,24 @@ class PreviewPanel(QGroupBox):
|
||||
|
||||
class RestoreProfileDialog(QDialog):
|
||||
"""
|
||||
Confirmation dialog that previews the current profile screenshot against the default baseline.
|
||||
Confirmation dialog that previews the current runtime screenshot against the baseline.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None
|
||||
self,
|
||||
parent: QWidget | None,
|
||||
current_pixmap: QPixmap | None,
|
||||
baseline_pixmap: QPixmap | None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Restore Profile to Default")
|
||||
self.setWindowTitle("Restore Profile to Baseline")
|
||||
self.setModal(True)
|
||||
self.resize(880, 480)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
info_label = QLabel(
|
||||
"Restoring will discard your custom layout and replace it with the default profile."
|
||||
"Restoring will discard your runtime layout and replace it with the baseline profile."
|
||||
)
|
||||
info_label.setWordWrap(True)
|
||||
layout.addWidget(info_label)
|
||||
@@ -280,7 +283,7 @@ class RestoreProfileDialog(QDialog):
|
||||
layout.addLayout(preview_row)
|
||||
|
||||
current_preview = PreviewPanel("Current", current_pixmap, self)
|
||||
default_preview = PreviewPanel("Default", default_pixmap, self)
|
||||
baseline_preview = PreviewPanel("Baseline", baseline_pixmap, self)
|
||||
|
||||
# Equal expansion left/right
|
||||
preview_row.addWidget(current_preview, 1)
|
||||
@@ -292,7 +295,7 @@ class RestoreProfileDialog(QDialog):
|
||||
arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
preview_row.addWidget(arrow_label)
|
||||
|
||||
preview_row.addWidget(default_preview, 1)
|
||||
preview_row.addWidget(baseline_preview, 1)
|
||||
|
||||
# Enforce equal stretch for both previews
|
||||
preview_row.setStretch(0, 1)
|
||||
@@ -300,7 +303,7 @@ class RestoreProfileDialog(QDialog):
|
||||
preview_row.setStretch(2, 1)
|
||||
|
||||
warn_label = QLabel(
|
||||
"This action cannot be undone. Do you want to restore the default layout now?"
|
||||
"This action cannot be undone. Do you want to restore the baseline layout now?"
|
||||
)
|
||||
warn_label.setWordWrap(True)
|
||||
layout.addWidget(warn_label)
|
||||
@@ -324,7 +327,7 @@ class RestoreProfileDialog(QDialog):
|
||||
|
||||
@staticmethod
|
||||
def confirm(
|
||||
parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None
|
||||
parent: QWidget | None, current_pixmap: QPixmap | None, baseline_pixmap: QPixmap | None
|
||||
) -> bool:
|
||||
dialog = RestoreProfileDialog(parent, current_pixmap, default_pixmap)
|
||||
dialog = RestoreProfileDialog(parent, current_pixmap, baseline_pixmap)
|
||||
return dialog.exec() == QDialog.Accepted
|
||||
|
||||
@@ -48,7 +48,7 @@ class WorkSpaceManager(BECWidget, QWidget):
|
||||
HEADERS = ["Actions", "Profile", "Author"]
|
||||
|
||||
def __init__(
|
||||
self, parent=None, target_widget=None, default_profile: str | None = None, **kwargs
|
||||
self, parent=None, target_widget=None, active_profile: str | None = None, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.target_widget = target_widget
|
||||
@@ -59,13 +59,13 @@ class WorkSpaceManager(BECWidget, QWidget):
|
||||
self._init_ui()
|
||||
if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"):
|
||||
self.target_widget.profile_changed.connect(self.on_profile_changed)
|
||||
if default_profile is not None:
|
||||
self._select_by_name(default_profile)
|
||||
self._show_profile_details(default_profile)
|
||||
if active_profile is not None:
|
||||
self._select_by_name(active_profile)
|
||||
self._show_profile_details(active_profile)
|
||||
|
||||
def _init_ui(self):
|
||||
self.root_layout = QHBoxLayout(self)
|
||||
self.splitter = QSplitter(Qt.Horizontal, self)
|
||||
self.splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||
self.root_layout.addWidget(self.splitter)
|
||||
|
||||
# Init components
|
||||
@@ -89,7 +89,9 @@ class WorkSpaceManager(BECWidget, QWidget):
|
||||
left_panel.setMinimumWidth(220)
|
||||
|
||||
# Make the screenshot preview expand to fill remaining space
|
||||
self.screenshot_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.screenshot_label.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
|
||||
self.right_box = QGroupBox("Profile Screenshot Preview", self)
|
||||
right_col = QVBoxLayout(self.right_box)
|
||||
@@ -250,8 +252,8 @@ class WorkSpaceManager(BECWidget, QWidget):
|
||||
("Quick select", "Yes" if info.is_quick_select else "No"),
|
||||
("Widgets", str(info.widget_count)),
|
||||
("Size (KB)", str(info.size_kb)),
|
||||
("User path", info.user_path or ""),
|
||||
("Default path", info.default_path or ""),
|
||||
("Runtime path", info.runtime_path or ""),
|
||||
("Baseline path", info.baseline_path or ""),
|
||||
]
|
||||
for k, v in entries:
|
||||
self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v]))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user