diff --git a/README.md b/README.md index bf01a06..e411c6f 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,21 @@ Desktop applications that consume the Orisys SDK. | App | Status | Qt binding | |-----|--------|-----------| -| `apps/studio` | Scaffold shell (theme / locale / prefs / logging) | PyQt5 | +| `apps/studio` | The customer-facing shell (theme / locale / prefs / logging, acquisition, 3D) | **PySide6** | | `apps/autocal` | Internal 5-axis calibration platform control | **PySide6** | | `apps/internal` | Stub, superseded by `apps/autocal` | — | -The two shipped apps use different Qt bindings on purpose. PyQt5's free option -is **GPLv3-or-commercial**; PySide6 is **LGPLv3**, which is why the internal -closed-source tool uses PySide6 and why `shared/` must import neither binding. -Migrating `apps/studio` to PySide6 is a separate, build-validated change. +Both shipped apps are **PySide6** — Qt's **LGPLv3** binding. PyQt5's free option is +**GPLv3-or-commercial**, which is why the studio was migrated off it: a closed-source +product would otherwise need a licence purchase. The rule is enforced, not just +documented: neither app may import PyQt5/PyQt6/PySide2, and `shared/` (used by both) may +import PySide6 and nothing else. See `apps/studio/tests/test_binding_purity.py` and +`apps/autocal/tests/test_binding_purity.py`. + +One practical consequence: `pyqtgraph` binds to whichever toolkit it imports first, so a +development environment that still has PyQt5 installed can build plots from the wrong +binding. `orisys_studio.main` sets `PYQTGRAPH_QT_LIB=PySide6` before anything imports it, +and the purity test asserts that importing the app pulls in no other binding. ## Layout diff --git a/apps/autocal/README.md b/apps/autocal/README.md index db27691..5bdd0ae 100644 --- a/apps/autocal/README.md +++ b/apps/autocal/README.md @@ -29,7 +29,8 @@ Replaces the vendor example program it was ported from: ## Run it PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no -licence purchase. (`PyQt5`, which the studio app uses, is GPLv3-or-commercial.) +licence purchase. (PyQt5 is GPLv3-or-commercial; the studio app moved to PySide6 +for the same reason and no longer uses it.) ```powershell # with the environment that already has PySide6 (Anaconda py310, for example) @@ -130,8 +131,10 @@ The suite is written for **PySide6**. Where PySide6 is missing, the test modules are skipped rather than failed: a broken environment should not look like a broken application. `tests/test_binding_purity.py` enforces the binding policy statically — no module may import PyQt5/PySide2/PyQt6, no module may use an -unscoped Qt enumerator (removed in PySide6), `shared/` may import no binding at -all, and the controller interface must stay importable headless. +unscoped Qt enumerator (removed in PySide6), `shared/` may import PySide6 and no +other binding, and the controller interface must stay importable headless. The +studio app enforces the same rules for itself in +`apps/studio/tests/test_binding_purity.py`. ## Do not ship this app diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index 7aae0d5..c873204 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -196,6 +196,8 @@ "validation_title": "Checks", "points_title": "Calibration points", "field_mode": "Surface mode", + "field_name": "Sequence name", + "field_note": "Note", "field_pitch": "Pitch (mm)", "field_margin": "Margin (mm)", "field_radius": "Radius (mm)", @@ -221,8 +223,11 @@ "btn_generate": "Generate points", "btn_simulate": "Simulate", "btn_dry_run": "Dry run (no contact)", - "btn_save": "Save sequence…", - "btn_load": "Load sequence…", + "btn_export": "Export sequence…", + "btn_import": "Import sequence…", + "import_export_tip": "Saved as a JSON document (.oryseq), reusable across machines; the format version is checked on import.", + "export_failed": "Export failed: {error}", + "import_failed": "Import failed: {error}", "pending_tooltip": "This belongs to phase {phase} and is not implemented yet.", "col_index": "#", "col_x": "X (mm)", @@ -234,6 +239,16 @@ "col_actions": "Actions", "not_generated": "—", "status_pending": "Pending", + "status_skipped": "Skipped", + "summary": "{count} points ({enabled} enabled) · {spans} · about {duration} (estimate)", + "summary_empty": "No points yet: generate or import to see the count and travel span.", + "dialog_export": "Export sequence", + "dialog_import": "Import sequence", + "filter_sequence": "Calibration sequence (*.oryseq *.json)", + "exported": "Sequence exported: {name}", + "imported": "Sequence imported: {name} ({count} points)", + "import_warnings": "Import reported {count} warning(s)", + "confirm_discard": "The current sequence has not been exported. Importing replaces it. Continue?", "points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run." }, "action": { diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index 2094242..bdfc50a 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -196,6 +196,8 @@ "validation_title": "校验", "points_title": "标定点列表", "field_mode": "曲面模式", + "field_name": "序列名称", + "field_note": "备注", "field_pitch": "点距 (mm)", "field_margin": "边界余量 (mm)", "field_radius": "曲面半径 (mm)", @@ -221,8 +223,11 @@ "btn_generate": "生成标定点", "btn_simulate": "仿真", "btn_dry_run": "干跑(不接触)", - "btn_save": "保存序列…", - "btn_load": "载入序列…", + "btn_export": "导出序列…", + "btn_import": "导入序列…", + "import_export_tip": "序列以 JSON 文档保存(.oryseq),可跨机器复用;导入前会校验格式版本。", + "export_failed": "导出失败: {error}", + "import_failed": "导入失败: {error}", "pending_tooltip": "该功能属于 {phase} 阶段,尚未实现。", "col_index": "#", "col_x": "X (mm)", @@ -234,6 +239,16 @@ "col_actions": "操作", "not_generated": "—", "status_pending": "待测", + "status_skipped": "已跳过", + "summary": "{count} 点({enabled} 启用) · {spans} · 预计 {duration}(估计值)", + "summary_empty": "尚无标定点:生成或导入后显示点数与行程范围。", + "dialog_export": "导出序列", + "dialog_import": "导入序列", + "filter_sequence": "标定序列 (*.oryseq *.json)", + "exported": "已导出序列: {name}", + "imported": "已导入序列: {name}({count} 点)", + "import_warnings": "导入有 {count} 项提示", + "confirm_discard": "当前序列尚未导出,导入将替换它。是否继续?", "points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。" }, "action": { diff --git a/apps/autocal/docs/sequence-format.md b/apps/autocal/docs/sequence-format.md new file mode 100644 index 0000000..65f934d --- /dev/null +++ b/apps/autocal/docs/sequence-format.md @@ -0,0 +1,167 @@ +# Calibration sequence format (`.oryseq`) + +A calibration sequence is a small JSON document: the **profile** that generated +the points, plus the **points** themselves. It is written by +`导出序列…` in the Sequence tab and read by `导入序列…`. + +Why JSON and not a binary container: a sequence is at most a few hundred points +of text. It is read by a human, diffed in review, and emailed to whoever runs the +bench. Binary would buy nothing and cost all three. (The *acquired data* of a run +is a different problem — per-point time series are large and will need a sidecar; +that decision belongs to workflow P8, not here.) + +## Conventions + +The format follows this repository's existing documents rather than inventing a +convention. Where it matches `session.json` and `force_calibration.json`, that is +deliberate: + +| Convention | Value | +|---|---| +| Version field | `"format_version": "1.0"` — a dotted **string**, compared as a tuple | +| Identity | `"kind": "autocal.sequence"` — checked on import | +| Extension | `.oryseq` by convention; import accepts any `.json` | +| Encoding | UTF-8, `indent=2`, `ensure_ascii=False`, trailing newline | +| Write | temp file then `Path.replace` — a half-written sequence that still parses looks like a design | + +## The document + +```json +{ + "format_version": "1.0", + "kind": "autocal.sequence", + "created_at": "2026-09-20T10:31:00+00:00", + "name": "5 点灵敏度扫描", + "note": "", + "versions": { "app": "0.1.0", "shared": "0.1.0", "shared_api": "0.1" }, + "device": { + "sensor_type": "box", + "config_ref": "…\\config\\sensors\\box\\sensor.json", + "geometry_id": "finger-1.0.0", + "probe_tip_set": true + }, + "frame": { "targets": "platform", "length_unit": "mm", "units_per_mm": 10.0 }, + "profile": { + "mode": "flat", "pitch_mm": 2.0, "margin_mm": 1.0, "radius_mm": 0.0, + "clear_height_mm": 5.0, "approach_speed_mm_s": 2.0, "press_speed_mm_s": 0.5, + "press_depth_mm": 0.2, "dwell_s": 0.5, "settle_window_s": 0.5, + "repeats": 3, "order": "serpentine" + }, + "points": [ + { + "id": 1, + "target_mm": { "x": 0.0, "y": 0.0, "z": 12.4 }, + "sensor_mm": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "clearance_mm": 5.0, + "enabled": true, + "note": "" + } + ], + "summary": { + "count": 12, "enabled": 12, + "min_mm": { "x": 0.0, "y": 0.0, "z": 12.4 }, + "max_mm": { "x": 10.0, "y": 4.0, "z": 12.4 }, + "span_mm": { "x": 10.0, "y": 4.0, "z": 0.0 }, + "pitch_mm": 2.0, + "estimated_duration_s": 380.0 + } +} +``` + +## Fields + +### `frame` — required + +| Field | Meaning | +|---|---| +| `targets` | the frame `target_mm` is expressed in; today always `"platform"` | +| `length_unit` | `"mm"`; anything else is refused | +| `units_per_mm` | the pulse equivalent the points were built with | + +A sequence whose numbers do not say whether they are millimetres or user units is +a sequence that will eventually be read wrong. `units_per_mm` is recorded so a +file from a differently-configured axis is detectable rather than silently +believed. + +### `profile` — required + +The twelve fields the Sequence tab edits, and nothing else. A field absent from +the file is **kept at its current value and reported**, never silently defaulted +to another bench's number. + +### `points` — required, may be empty + +| Field | Meaning | +|---|---| +| `id` | 1-based, stable within the file; used for run progress and resume | +| `target_mm` | where the axes go — required, with all three of `x`, `y`, `z` | +| `sensor_mm` | where that point lands on the sensor; optional but written | +| `clearance_mm` | the standoff for this point | +| `enabled` | false = designed but deliberately skipped | +| `note` | free text | + +Both positions are stored on purpose. The platform target is what the run uses; +the sensor position is what makes the point checkable against the surface. +Deriving one from the other on every load would silently change what the points +mean the moment a sensor is re-fixtured — and a re-fixture is exactly when a +stored transform should be *noticed*, not recomputed from. + +### `summary` — derived, not authoritative + +Present so a human reading the file sees the point count and travel span without +counting rows, and so a diff is legible. It is **recomputed on load**; a mismatch +is reported as a warning and the recomputed values win. That is how a hand-edited +document announces itself instead of quietly disagreeing with its own contents. + +`estimated_duration_s` assumes a constant traverse rate +(`sequence_io.ASSUMED_TRAVERSE_MM_S`) and ignores acceleration. It is a planning +figure and is labelled an estimate wherever it is shown. + +### `device` — a reference, not a requirement + +Records which sensor the sequence was designed for. Importing a sequence for +another sensor is **allowed and warned about**, because re-fixturing +deliberately is a normal thing to do. + +## Import rules + +Every rejection names the specific problem — `invalid file` tells an operator +nothing. + +| Condition | Behaviour | +|---|---| +| not JSON | refused, with line and column | +| top level not an object | refused | +| `kind` missing or different | refused, naming what was found | +| `format_version` missing or not a string | refused | +| `format_version` newer than this build | refused, naming both versions | +| `frame` missing, or `length_unit` not `mm` | refused | +| `profile` not an object | refused | +| a profile key absent | imported; the current value is kept and the key is reported | +| `points` absent | imported as profile-only, and reported | +| a point without `target_mm.x/y/z` | refused, naming the point and the axis | +| `summary` disagreeing with `points` | imported; recomputed, and reported | +| current design is unsaved | the operator is asked before it is replaced | + +Version strings are compared as tuples, never as strings: `"1.10"` is newer than +`"1.9"`, and a string comparison would make a newer file look older and load it +as if it were supported. + +## Compatibility rules + +- **Adding an optional key** does not change `format_version`. +- **Adding a required key, or changing a field's meaning or unit**, does. +- A reader must refuse a document whose version it does not support rather than + interpreting the parts it recognises — a sequence half-applied is worse than + one rejected. +- `1.x` readers are expected to accept `1.y` for `y <= x` on the same major + version and report anything they did not use. + +## Where the pieces live + +| Concern | Module | +|---|---| +| The document, validation, the derived summary | `apps/autocal/src/orisys_autocal/sequence_io.py` | +| The design screen and its controls | `apps/autocal/src/orisys_autocal/ui/sequence_panel.py` | +| The file dialogs and the unsaved-work guard | `apps/autocal/src/orisys_autocal/ui/window.py` | +| Why a sequence is an artifact (P8) | `apps/autocal/docs/workflow.md` | diff --git a/apps/autocal/src/orisys_autocal/sequence_io.py b/apps/autocal/src/orisys_autocal/sequence_io.py new file mode 100644 index 0000000..4a00118 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/sequence_io.py @@ -0,0 +1,476 @@ +"""Import and export a calibration sequence (workflow P5/P8). + +A sequence is a small, human-readable document — a profile plus the points it +generated — so it can be reviewed, diffed, emailed, and reproduced on another +bench. The format follows this repository's other documents rather than +inventing a convention: a dotted ``format_version`` string, sorted sections, +``indent=2 / ensure_ascii=False`` JSON, and an atomic temp-then-replace write +(cf. ``record/experiment.py``'s ``session.json`` and ``force_calibration.json``). +The full field-by-field spec is ``apps/autocal/docs/sequence-format.md``. + +Two properties matter more than the field names: + +* **``summary`` is derived.** It exists so a human reading the file sees the + point count and the travel span without counting rows. It is *recomputed* on + load, and a mismatch against the file is reported — that is how a hand-edited + document announces itself instead of quietly disagreeing with its own contents. +* **The file states its frame.** A sequence whose numbers do not say whether they + are millimetres or user units is a sequence that will eventually be read wrong, + so ``frame`` records both the unit and the pulse equivalent it was built with. + +Nothing here imports Qt, so the format can be exercised from a test process and +a future batch tool. +""" + +from __future__ import annotations + +import json +import math +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Mapping + +#: The version this build writes, and the highest it will read. +FORMAT_VERSION = "1.0" + +#: What a sequence document says it is. Checked on import: a force-calibration +#: file and a sequence file are both JSON, and applying one as the other must +#: fail loudly rather than half-work. +DOCUMENT_KIND = "autocal.sequence" + +#: File extension by convention. Import accepts any ``.json``. +FILE_SUFFIX = ".oryseq" + +#: The frame targets are expressed in. +TARGET_FRAME = "platform" +LENGTH_UNIT = "mm" + +#: Platform traverse rate assumed when estimating a run's duration, in mm/s. +#: A first-pass planning figure, not a measurement: the axes' own speed setting +#: is in user units and the real rate depends on acceleration and the path. +#: Clearly labelled as an estimate wherever it is shown. +ASSUMED_TRAVERSE_MM_S = 20.0 + +#: The profile keys a document must carry, matching the panel's fields. +PROFILE_KEYS: tuple[str, ...] = ( + "mode", + "pitch_mm", + "margin_mm", + "radius_mm", + "clear_height_mm", + "approach_speed_mm_s", + "press_speed_mm_s", + "press_depth_mm", + "dwell_s", + "settle_window_s", + "repeats", + "order", +) + + +class SequenceFormatError(ValueError): + """A sequence document could not be read. + + The message is what the operator sees, so it always names the specific + problem (which key, which version, which point) rather than "invalid file". + """ + + +@dataclass +class SequencePoint: + """One generated calibration point. + + ``target_mm`` is where the axes go; ``sensor_mm`` is where that lands on the + sensor. Both are stored: the run needs the first, and the second is what makes + the point checkable against the surface. Re-deriving one from the other on + every load would silently change what the points mean when a sensor is + re-fixtured. + """ + + id: int + target_mm: dict[str, float] + sensor_mm: dict[str, float] | None = None + clearance_mm: float = 0.0 + enabled: bool = True + note: str = "" + + def as_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "id": int(self.id), + "target_mm": {axis: float(self.target_mm.get(axis, 0.0)) for axis in "xyz"}, + "clearance_mm": float(self.clearance_mm), + "enabled": bool(self.enabled), + "note": str(self.note), + } + if self.sensor_mm is not None: + payload["sensor_mm"] = {axis: float(self.sensor_mm.get(axis, 0.0)) for axis in "xyz"} + return payload + + +@dataclass +class SequenceDocument: + """A sequence as it travels between machines.""" + + profile: dict[str, Any] = field(default_factory=dict) + points: list[SequencePoint] = field(default_factory=list) + name: str = "" + note: str = "" + device: dict[str, Any] = field(default_factory=dict) + units_per_mm: float = 0.0 + created_at: str = "" + summary: dict[str, Any] = field(default_factory=dict) + #: Problems found while reading that did not stop the import. Shown to the + #: operator: "it loaded" and "it loaded as written" are different claims. + warnings: list[str] = field(default_factory=list) + + @property + def point_count(self) -> int: + return len(self.points) + + @property + def enabled_count(self) -> int: + return sum(1 for point in self.points if point.enabled) + + +# --------------------------------------------------------------------- derive + + +def summarise( + points: Iterable[SequencePoint], profile: Mapping[str, Any] +) -> dict[str, Any]: + """The derived block: count, travel span, and an estimated duration. + + Recomputed everywhere rather than trusted from a file, so a document cannot + claim a range its points do not have. + """ + ordered = list(points) + span: dict[str, float] = {} + minimum: dict[str, float] = {} + maximum: dict[str, float] = {} + for axis in "xyz": + values = [float(point.target_mm.get(axis, 0.0)) for point in ordered] + if values: + minimum[axis] = min(values) + maximum[axis] = max(values) + span[axis] = maximum[axis] - minimum[axis] + + return { + "count": len(ordered), + "enabled": sum(1 for point in ordered if point.enabled), + "min_mm": minimum, + "max_mm": maximum, + "span_mm": span, + "pitch_mm": _number(profile.get("pitch_mm"), 0.0), + "estimated_duration_s": estimate_duration(ordered, profile), + } + + +def estimate_duration( + points: Iterable[SequencePoint], profile: Mapping[str, Any] +) -> float: + """A planning estimate for one full run, in seconds. + + Per point: the descent and press, the dwell, and the settle window, repeated + ``repeats`` times; plus a traverse between points at + :data:`ASSUMED_TRAVERSE_MM_S`. An estimate, labelled as one wherever it is + shown — it assumes a constant traverse rate and ignores acceleration. + """ + ordered = list(points) + if not ordered: + return 0.0 + repeats = max(1, int(_number(profile.get("repeats"), 1))) + clear_height = max(0.0, _number(profile.get("clear_height_mm"), 0.0)) + approach = max(1e-6, _number(profile.get("approach_speed_mm_s"), 1.0)) + press_speed = max(1e-6, _number(profile.get("press_speed_mm_s"), 1.0)) + press_depth = max(0.0, _number(profile.get("press_depth_mm"), 0.0)) + dwell = max(0.0, _number(profile.get("dwell_s"), 0.0)) + settle = max(0.0, _number(profile.get("settle_window_s"), 0.0)) + + descent = clear_height / approach + press = press_depth / press_speed + per_point = (descent + press + dwell + settle) * repeats + + total = per_point * len(ordered) + pitch = max(0.0, _number(profile.get("pitch_mm"), 0.0)) + if pitch > 0: + total += (len(ordered) - 1) * (pitch / ASSUMED_TRAVERSE_MM_S) * repeats + return float(total) + + +def format_span(low: float | None, high: float | None, *, places: int = 3) -> str: + """``min–max (span)``, or a single number when the travel is nil. + + A zero span means every point shares that coordinate; printing + ``12.400–12.400 (0.000)`` would be noise in a line whose job is to be read at + a glance. + """ + if low is None or high is None: + return "—" + if abs(high - low) < 10 ** (-places) / 2: + return f"{low:.{places}f}" + return f"{low:.{places}f}–{high:.{places}f} ({high - low:.{places}f})" + + +def format_duration(seconds: float | None) -> str: + """``m:ss``, or a dash when no estimate can be made.""" + if seconds is None or not math.isfinite(float(seconds)) or seconds <= 0: + return "—" + total = int(round(float(seconds))) + minutes, rest = divmod(total, 60) + if minutes >= 60: + hours, minutes = divmod(minutes, 60) + return f"{hours:d}:{minutes:02d}:{rest:02d}" + return f"{minutes:d}:{rest:02d}" + + +# ----------------------------------------------------------------- serialize + + +def to_document( + *, + profile: Mapping[str, Any], + points: Iterable[SequencePoint] = (), + name: str = "", + note: str = "", + device: Mapping[str, Any] | None = None, + units_per_mm: float = 0.0, + app_version: str = "", + shared_version: str = "", + shared_api: str = "", +) -> dict[str, Any]: + """Build the sequence document.""" + ordered = list(points) + return { + "format_version": FORMAT_VERSION, + "kind": DOCUMENT_KIND, + "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "name": str(name), + "note": str(note), + "versions": { + "app": str(app_version), + "shared": str(shared_version), + "shared_api": str(shared_api), + }, + "device": dict(device or {}), + "frame": { + "targets": TARGET_FRAME, + "length_unit": LENGTH_UNIT, + "units_per_mm": float(units_per_mm), + }, + "profile": { + key: profile.get(key) + for key in PROFILE_KEYS + }, + "points": [point.as_dict() for point in ordered], + "summary": summarise(ordered, profile), + } + + +def save(document: Mapping[str, Any], path: str | Path) -> Path: + """Write ``document`` atomically, as UTF-8 JSON. + + Temp-then-replace: a half-written sequence file that still parses is worse + than no file, because it looks like a design. + """ + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_suffix(target.suffix + ".tmp") + temporary.write_text( + json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + temporary.replace(target) + return target + + +# ------------------------------------------------------------------- parse + + +def load(path: str | Path, *, current_profile: Mapping[str, Any] | None = None) -> SequenceDocument: + """Read a sequence document, validating it. + + Args: + path: the file to read. + current_profile: the profile in force now. Missing profile keys are + taken from it and reported, rather than silently defaulting to + another bench's numbers. + + Raises: + SequenceFormatError: with a message naming the specific problem. + """ + target = Path(path) + try: + text = target.read_text(encoding="utf-8") + except OSError as exc: + raise SequenceFormatError(f"cannot read {target.name}: {exc}") from exc + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise SequenceFormatError( + f"{target.name} is not valid JSON: line {exc.lineno}, column {exc.colno}" + ) from exc + if not isinstance(data, dict): + raise SequenceFormatError( + f"{target.name}: the top level must be an object, found {type(data).__name__}" + ) + return from_document(data, current_profile=current_profile, source=target.name) + + +def from_document( + data: Mapping[str, Any], + *, + current_profile: Mapping[str, Any] | None = None, + source: str = "document", +) -> SequenceDocument: + """Validate and interpret an already-parsed sequence document.""" + warnings: list[str] = [] + + kind = data.get("kind") + if kind != DOCUMENT_KIND: + raise SequenceFormatError( + f"{source}: not a sequence document " + f"(kind is {kind!r}, expected {DOCUMENT_KIND!r})" + ) + + raw_version = data.get("format_version") + if not isinstance(raw_version, str) or not raw_version.strip(): + raise SequenceFormatError(f"{source}: missing or invalid format_version") + if _version_tuple(raw_version) > _version_tuple(FORMAT_VERSION): + raise SequenceFormatError( + f"{source}: written by a newer AutoCal " + f"(format {raw_version}, this build reads up to {FORMAT_VERSION})" + ) + + frame = data.get("frame") + if not isinstance(frame, dict): + raise SequenceFormatError(f"{source}: missing frame block") + if str(frame.get("length_unit") or "") != LENGTH_UNIT: + raise SequenceFormatError( + f"{source}: length_unit is {frame.get('length_unit')!r}, expected {LENGTH_UNIT!r}" + ) + + profile_in = data.get("profile") + if not isinstance(profile_in, dict): + raise SequenceFormatError(f"{source}: missing or invalid profile block") + profile: dict[str, Any] = dict(current_profile or {}) + missing: list[str] = [] + for key in PROFILE_KEYS: + if key in profile_in: + profile[key] = profile_in[key] + else: + missing.append(key) + if missing: + warnings.append( + "profile keys absent, kept the current value: " + ", ".join(sorted(missing)) + ) + + points = _parse_points(data.get("points"), source=source, warnings=warnings) + + stored_summary = data.get("summary") + recomputed = summarise(points, profile) + if isinstance(stored_summary, dict) and points: + differences = _summary_differences(stored_summary, recomputed) + if differences: + warnings.append( + "the stored summary disagrees with the points and was recomputed: " + + "; ".join(differences) + ) + + device = data.get("device") if isinstance(data.get("device"), dict) else {} + return SequenceDocument( + profile=profile, + points=points, + name=str(data.get("name") or ""), + note=str(data.get("note") or ""), + device=dict(device), + units_per_mm=_number(frame.get("units_per_mm"), 0.0), + created_at=str(data.get("created_at") or ""), + summary=recomputed, + warnings=warnings, + ) + + +def _parse_points( + raw: Any, *, source: str, warnings: list[str] +) -> list[SequencePoint]: + if raw is None: + warnings.append("no points in the file; only the profile was imported") + return [] + if not isinstance(raw, list): + raise SequenceFormatError( + f"{source}: points must be a list, found {type(raw).__name__}" + ) + + points: list[SequencePoint] = [] + for index, entry in enumerate(raw, start=1): + where = f"{source}: point {index}" + if not isinstance(entry, dict): + raise SequenceFormatError(f"{where} is not an object") + target = entry.get("target_mm") + if not isinstance(target, dict): + raise SequenceFormatError(f"{where} has no target_mm object") + for axis in "xyz": + if axis not in target: + raise SequenceFormatError(f"{where}: target_mm is missing '{axis}'") + sensor = entry.get("sensor_mm") + points.append( + SequencePoint( + id=int(_number(entry.get("id"), index)), + target_mm={axis: _number(target.get(axis), 0.0) for axis in "xyz"}, + sensor_mm=( + {axis: _number(sensor.get(axis), 0.0) for axis in "xyz"} + if isinstance(sensor, dict) + else None + ), + clearance_mm=_number(entry.get("clearance_mm"), 0.0), + enabled=bool(entry.get("enabled", True)), + note=str(entry.get("note") or ""), + ) + ) + return points + + +def _summary_differences(stored: Mapping[str, Any], recomputed: Mapping[str, Any]) -> list[str]: + """Where the file's own summary disagrees with its points.""" + differences: list[str] = [] + if int(_number(stored.get("count"), -1)) != recomputed["count"]: + differences.append( + f"count {stored.get('count')} != {recomputed['count']}" + ) + stored_span = stored.get("span_mm") + if isinstance(stored_span, dict): + for axis in "xyz": + if axis in recomputed["span_mm"]: + if not _close(_number(stored_span.get(axis), 0.0), recomputed["span_mm"][axis]): + differences.append( + f"span {axis} {stored_span.get(axis)} != {recomputed['span_mm'][axis]:.3f}" + ) + return differences + + +def _close(first: float, second: float, tolerance: float = 1e-6) -> bool: + return abs(float(first) - float(second)) <= tolerance + + +def _number(value: Any, fallback: float) -> float: + """A finite float, or the fallback. Never NaN, never a string.""" + try: + number = float(value) + except (TypeError, ValueError): + return float(fallback) + if not math.isfinite(number): + return float(fallback) + return number + + +def _version_tuple(text: str) -> tuple[int, ...]: + """``"1.10"`` as ``(1, 10)``. + + Compared as tuples rather than strings: ``"1.10" < "1.9"`` is a real trap that + would make a newer file look older and load it as if it were supported. + """ + parts: list[int] = [] + for chunk in str(text).split("."): + digits = "".join(character for character in chunk if character.isdigit()) + parts.append(int(digits) if digits else 0) + return tuple(parts) diff --git a/apps/autocal/src/orisys_autocal/ui/sequence_panel.py b/apps/autocal/src/orisys_autocal/ui/sequence_panel.py index 1b093d5..cf5e092 100644 --- a/apps/autocal/src/orisys_autocal/ui/sequence_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/sequence_panel.py @@ -47,6 +47,12 @@ from PySide6.QtWidgets import ( from orisys_app_shared.i18n import t from ..preferences import POINT_ORDERS, SURFACE_MODES +from ..sequence_io import ( + SequencePoint, + format_duration, + format_span, + summarise, +) #: Profile fields, in form order: ``(key, locale_key, kind)``. #: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit). @@ -69,6 +75,10 @@ PROFILE_FIELDS: tuple[tuple[str, str, str], ...] = ( PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2 #: Point-table columns: ``(key, locale_key)``. +#: +#: No actions column. It was rendered empty on every row, and an empty action +#: cell reads as a broken control — the per-point actions (re-measure, skip) +#: belong to the run, not to the design screen. POINT_COLUMNS: tuple[tuple[str, str], ...] = ( ("index", "sequence.col_index"), ("x", "sequence.col_x"), @@ -77,7 +87,6 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = ( ("status", "sequence.col_status"), ("sensitivity", "sequence.col_sensitivity"), ("samples", "sequence.col_samples"), - ("actions", "sequence.col_actions"), ) #: Editability of the point table. Positions are *generated*, not typed: a typed @@ -85,20 +94,22 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = ( _EDITABLE_POINT_COLUMNS: tuple[str, ...] = () #: Column widths of the point table, by column index. -_POINT_COLUMN_WIDTHS = (50, 130, 130, 130, 120, 160, 90, 200) +_POINT_COLUMN_WIDTHS = (60, 150, 150, 150, 140, 190, 110) #: Reserved controls and the phase that will fill each one. -PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run", "save", "load") +PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run") #: Which phase each pending control belongs to (see docs/workflow.md). PENDING_PHASES: dict[str, str] = { "generate": "P5", "simulate": "P6", "dry_run": "P6", - "save": "P8", - "load": "P8", } +#: Controls that work now: a sequence is a document, and P8 says it is a +#: versioned artifact. Import and export are the first half of that. +LIVE_CONTROLS: tuple[str, ...] = ("export", "import") + #: The validation checks, with the locale key naming what each one proves. VALIDATION_CHECKS: tuple[tuple[str, str], ...] = ( ("on_surface", "sequence.check_surface"), @@ -140,8 +151,8 @@ class SequencePanel(QGroupBox): generateRequested = Signal() simulateRequested = Signal() dryRunRequested = Signal() - saveRequested = Signal() - loadRequested = Signal() + exportRequested = Signal() + importRequested = Signal() def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None: super().__init__(parent) @@ -151,6 +162,8 @@ class SequencePanel(QGroupBox): self._choices: dict[str, QComboBox] = {} self._pending_buttons: dict[str, QPushButton] = {} self._check_rows: dict[str, QLabel] = {} + self._summary: dict[str, Any] = {} + self._dirty = False outer = QVBoxLayout(self) outer.setContentsMargins(10, 8, 10, 10) @@ -169,16 +182,48 @@ class SequencePanel(QGroupBox): self.set_profile(profile or {}) self.retranslate() + # A profile edit changes the estimate, so the line follows the form. + for widget in self._profile_fields.values(): + if isinstance(widget, QComboBox): + widget.currentIndexChanged.connect(self._mark_dirty) + else: + widget.textChanged.connect(self._mark_dirty) + self.refresh_summary() # ------------------------------------------------------------------ profile def _build_profile(self) -> QWidget: box = QGroupBox(self) box.setObjectName("groupBox") - grid = QGridLayout(box) - grid.setContentsMargins(10, 8, 10, 8) + outer = QVBoxLayout(box) + outer.setContentsMargins(10, 8, 10, 8) + outer.setSpacing(4) + + # The name is what the document is called on disk and in the export, so + # it lives at the top of the block that defines the sequence rather than + # in a dialog. + name_row = QHBoxLayout() + name_row.setSpacing(8) + self._name_label = QLabel(box) + self._name_label.setObjectName("hintLabel") + self.name_edit = QLineEdit(box) + self.name_edit.setFixedHeight(28) + name_row.addWidget(self._name_label) + name_row.addWidget(self.name_edit, 1) + outer.addLayout(name_row) + + self._note_label = QLabel(box) + self._note_label.setObjectName("hintLabel") + self.note_edit = QLineEdit(box) + self.note_edit.setFixedHeight(28) + outer.addWidget(self._note_label) + outer.addWidget(self.note_edit) + + grid = QGridLayout() + grid.setContentsMargins(0, 0, 0, 0) grid.setHorizontalSpacing(10) grid.setVerticalSpacing(4) + outer.addLayout(grid) # Two fields per row: label, field, label, field. Seeing the whole profile # at once is what lets an operator sanity-check a pitch against a depth. @@ -258,13 +303,21 @@ class SequencePanel(QGroupBox): column.addWidget(button) self._pending_buttons[key] = button + # Import/export work today: a sequence is a portable document (P8). + self._live_buttons: dict[str, QPushButton] = {} + for key in LIVE_CONTROLS: + button = QPushButton(box) + button.setProperty("rail", True) + button.setFixedHeight(28) + column.addWidget(button) + self._live_buttons[key] = button + self._pending_buttons["generate"].clicked.connect(self.generateRequested) self._pending_buttons["simulate"].clicked.connect(self.simulateRequested) self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested) - self._pending_buttons["save"].clicked.connect(self.saveRequested) - self._pending_buttons["load"].clicked.connect(self.loadRequested) - # None of these can run: generation, simulation, the runner and the - # dataset writer belong to later phases (see docs/workflow.md). + self._live_buttons["export"].clicked.connect(self.exportRequested) + self._live_buttons["import"].clicked.connect(self.importRequested) + # Generation, simulation and the runner belong to later phases (P5-P7). for button in self._pending_buttons.values(): button.setEnabled(False) @@ -273,6 +326,10 @@ class SequencePanel(QGroupBox): # -------------------------------------------------------------- point table + def _mark_dirty(self, *_args: Any) -> None: + self._dirty = True + self.refresh_summary() + def _build_point_table(self) -> QWidget: box = QGroupBox(self) box.setObjectName("groupBox") @@ -303,7 +360,6 @@ class SequencePanel(QGroupBox): 4: t("sequence.status_pending"), 5: "—", 6: "—", - 7: "", } for column_index, text in values.items(): item = QTableWidgetItem(text) @@ -311,7 +367,15 @@ class SequencePanel(QGroupBox): item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.table.setItem(row, column_index, item) + # The count and span line: the two numbers an operator reasons with, and + # the only place a wrong pitch or margin shows up before a seven-minute + # run does it for them. + self.summary_label = QLabel(box) + self.summary_label.setObjectName("statusValue") + self.summary_label.setWordWrap(True) + column.addWidget(self.table, 1) + column.addWidget(self.summary_label) self.point_hint = QLabel(box) self.point_hint.setObjectName("hintLabel") @@ -400,7 +464,6 @@ class SequencePanel(QGroupBox): 4: status or t("sequence.status_pending"), 5: "—", 6: "—", - 7: "", } for column, text in values.items(): item = QTableWidgetItem(text) @@ -409,6 +472,77 @@ class SequencePanel(QGroupBox): self.table.setItem(row, column, item) return row + # ----------------------------------------------------------- points & state + + def points(self) -> list[SequencePoint]: + """The list as it stands, for export and for a future runner.""" + points: list[SequencePoint] = [] + for row in range(self.table.rowCount()): + cells = self.point_row(row) + try: + target = {axis: float(cells[axis]) for axis in "xyz"} + except (TypeError, ValueError): + continue # a placeholder row that was never generated + points.append( + SequencePoint(id=row + 1, target_mm=target, enabled=cells["status"] != t("sequence.status_skipped")) + ) + return points + + def set_points(self, points: list[SequencePoint]) -> None: + """Replace the list. Used by import and by the future generator.""" + self.table.setRowCount(0) + for point in points: + row = self.table.rowCount() + self.table.insertRow(row) + values = { + 0: str(point.id), + 1: f"{point.target_mm.get('x', 0.0):.3f}", + 2: f"{point.target_mm.get('y', 0.0):.3f}", + 3: f"{point.target_mm.get('z', 0.0):.3f}", + 4: t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"), + 5: "—", + 6: "—", + } + for column, text in values.items(): + item = QTableWidgetItem(text) + if column >= 4: + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.table.setItem(row, column, item) + self._dirty = True + self.refresh_summary() + + @property + def dirty(self) -> bool: + """Whether the design has changed since it was last saved or loaded. + + Import replaces the design outright, and there is no autosave; discarding + a designed sequence without asking would be worse than one extra prompt. + """ + return self._dirty + + def mark_clean(self) -> None: + self._dirty = False + + def name(self) -> str: + return self.name_edit.text().strip() + + def set_name(self, value: str) -> None: + self.name_edit.setText(str(value or "")) + + def note(self) -> str: + return self.note_edit.text().strip() + + def set_note(self, value: str) -> None: + self.note_edit.setText(str(value or "")) + + def refresh_summary(self) -> None: + """Recompute the count, span and duration line from what is on screen.""" + profile = self.profile() + points = self.points() + summary = summarise(points, profile) + self._summary = summary + self.summary_label.setText(_summary_text(summary, profile)) + def planned_steps(self) -> list[SequenceStep]: """The steps the table describes, for the future runner. @@ -432,6 +566,10 @@ class SequencePanel(QGroupBox): for key, combo in self._choices.items(): _fill_choice(combo, key) + self._name_label.setText(t("sequence.field_name")) + self.name_edit.setPlaceholderText(t("sequence.name_hint")) + self._note_label.setText(t("sequence.field_note")) + self.preview_label.setText(t("sequence.preview_placeholder")) self.preview_hint.setText(t("sequence.preview_hint")) for key, _locale_key in VALIDATION_CHECKS: @@ -442,6 +580,9 @@ class SequencePanel(QGroupBox): self._pending_buttons[key].setToolTip( t("sequence.pending_tooltip", phase=PENDING_PHASES[key]) ) + for key in LIVE_CONTROLS: + self._live_buttons[key].setText(t(f"sequence.btn_{key}")) + self._live_buttons[key].setToolTip(t("sequence.import_export_tip")) self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS]) for row in range(self.table.rowCount()): @@ -449,6 +590,33 @@ class SequencePanel(QGroupBox): if item is not None and item.text() in ("", t("sequence.status_pending")): item.setText(t("sequence.status_pending")) self.point_hint.setText(t("sequence.points_hint")) + self.refresh_summary() + + +def _summary_text(summary: dict[str, Any], profile: dict[str, Any]) -> str: + """The count / span / duration line under the point list. + + Spans are ``min–max (span)`` with a nil span printed once; the duration is + labelled an estimate because it is one. + """ + count = int(summary.get("count") or 0) + enabled = int(summary.get("enabled") or count) + if not count: + return t("sequence.summary_empty") + + spans = " · ".join( + f"{axis.upper()} {format_span(summary['min_mm'].get(axis), summary['max_mm'].get(axis))}" + for axis in "xyz" + if axis in summary.get("min_mm", {}) + ) + duration = format_duration(summary.get("estimated_duration_s")) + return t( + "sequence.summary", + count=count, + enabled=enabled, + spans=spans, + duration=duration, + ) def _fill_choice(combo: QComboBox, key: str) -> None: diff --git a/apps/autocal/src/orisys_autocal/ui/status_strip.py b/apps/autocal/src/orisys_autocal/ui/status_strip.py index 4c46e06..2f9db21 100644 --- a/apps/autocal/src/orisys_autocal/ui/status_strip.py +++ b/apps/autocal/src/orisys_autocal/ui/status_strip.py @@ -314,6 +314,11 @@ class RunBar(QFrame): self.step_label.setMinimumWidth(140) layout.addWidget(self.step_label) + # Shown only while idle, in place of the step/progress/timing trio. + self._idle_hint = QLabel(self) + self._idle_hint.setObjectName("hintLabel") + layout.addWidget(self._idle_hint) + self.progress = QProgressBar(self) self.progress.setTextVisible(False) self.progress.setFixedHeight(12) @@ -395,6 +400,14 @@ class RunBar(QFrame): self.abort_button.setEnabled(self._has_run) self.pause_button.setEnabled(self._has_run and busy) self.run_button.setEnabled(not busy) + # With no run, "point 0/0" and "elapsed 0:00" are noise on a strip whose + # job is to be read at a glance. Idle shows the phase and why instead. + live = self._has_run or busy + self.step_label.setVisible(live) + self.progress.setVisible(live) + self.time_label.setVisible(live) + if not live: + self._idle_hint.setText(t("run.no_steps")) @property def phase(self) -> str: @@ -415,6 +428,7 @@ class RunBar(QFrame): else: self.step_label.setText(t("run.no_steps")) self.time_label.setText(t("run.elapsed_only", elapsed=_duration(0.0))) + self._refresh_buttons(self._phase in ("running", "paused")) def _duration(seconds: float) -> str: diff --git a/apps/autocal/src/orisys_autocal/ui/window.py b/apps/autocal/src/orisys_autocal/ui/window.py index 072b69b..ee4324a 100644 --- a/apps/autocal/src/orisys_autocal/ui/window.py +++ b/apps/autocal/src/orisys_autocal/ui/window.py @@ -38,6 +38,14 @@ from PySide6.QtWidgets import ( from .. import __version__ as app_version from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot from ..preferences import load_preferences, save_preferences +from ..sequence_io import ( + FILE_SUFFIX, + SequenceDocument, + SequenceFormatError, + load, + save, + to_document, +) from .axis_panel import AxisPanel from .axis_readout import AxisReadoutPanel from .brand_bar import BrandBar @@ -242,6 +250,8 @@ class AutoCalMainWindow(QMainWindow): self.run_bar.runRequested.connect(self._on_run_requested) self.run_bar.pauseRequested.connect(self._on_run_pause) self.run_bar.abortRequested.connect(self._on_run_abort) + self.sequence_panel.exportRequested.connect(self._on_export_sequence) + self.sequence_panel.importRequested.connect(self._on_import_sequence) self.axis_readout.stopAllRequested.connect(self._on_stop_all) self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms) self.axis_readout.enableAllRequested.connect(self._on_enable_all) @@ -272,6 +282,106 @@ class AutoCalMainWindow(QMainWindow): self.statusBar().showMessage(text) self.status_strip.set_action_result(text, ok) + # ------------------------------------------------------- sequence import/export + + def _default_sequence_path(self) -> str: + """Start the file dialog somewhere useful and named after the profile.""" + stem = self.sequence_panel.name() or "sequence" + return str(Path(outputs_dir()) / "sequences" / f"{stem}{FILE_SUFFIX}") + + def _sequence_device_ref(self) -> dict: + setup = self.prefs.get("sensor_setup") or {} + return { + "sensor_type": str(setup.get("sensor_type") or ""), + "config_ref": str(setup.get("config_ref") or ""), + "geometry_id": str(setup.get("geometry_id") or ""), + "probe_tip_set": bool(setup.get("probe_tip_set")), + } + + def export_sequence(self, path: str) -> str: + """Write the current design. Returns the path written.""" + document = to_document( + profile=self.sequence_panel.profile(), + points=self.sequence_panel.points(), + name=self.sequence_panel.name(), + note=self.sequence_panel.note(), + device=self._sequence_device_ref(), + units_per_mm=float(self.prefs.get("units_per_mm") or 0.0), + app_version=app_version, + shared_version=shared_version, + shared_api=API_VERSION, + ) + written = save(document, path) + self.sequence_panel.mark_clean() + return str(written) + + def import_sequence(self, path: str) -> SequenceDocument: + """Read a design and apply it. Raises SequenceFormatError on a bad file.""" + document = load(path, current_profile=self.sequence_panel.profile()) + self.sequence_panel.set_profile(document.profile) + self.sequence_panel.set_points(document.points) + self.sequence_panel.set_name(document.name) + self.sequence_panel.set_note(document.note) + self.sequence_panel.mark_clean() + return document + + def _on_export_sequence(self) -> None: + path, _selected = QFileDialog.getSaveFileName( + self, + t("sequence.dialog_export"), + self._default_sequence_path(), + t("sequence.filter_sequence"), + ) + if not path: + return + try: + written = self.export_sequence(path) + except (OSError, ValueError) as exc: + self._report_sequence(t("sequence.export_failed", error=str(exc)), ok=False) + return + self._report_sequence( + t("sequence.exported", name=Path(written).name), ok=True + ) + + def _on_import_sequence(self) -> None: + # Import replaces the design and there is no autosave, so an unsaved + # sequence is confirmed before it is discarded. + if self.sequence_panel.dirty: + answer = QMessageBox.question( + self, + t("sequence.dialog_import"), + t("sequence.confirm_discard"), + ) + if answer != QMessageBox.StandardButton.Yes: + return + path, _selected = QFileDialog.getOpenFileName( + self, + t("sequence.dialog_import"), + str(Path(outputs_dir()) / "sequences"), + t("sequence.filter_sequence"), + ) + if not path: + return + try: + document = self.import_sequence(path) + except SequenceFormatError as exc: + self._report_sequence(t("sequence.import_failed", error=str(exc)), ok=False) + return + self._report_sequence( + t("sequence.imported", name=Path(path).name, count=document.point_count), ok=True + ) + if document.warnings: + self.statusBar().showMessage( + t("sequence.import_warnings", count=len(document.warnings)) + ) + for warning in document.warnings: + logger.warning("Sequence import: %s", warning) + + def _report_sequence(self, text: str, *, ok: bool) -> None: + self.statusBar().showMessage(text) + self.status_strip.set_action_result(text, ok) + + def _apply_chrome_colours(self) -> None: """Style the status bar from the theme rather than Qt's default. diff --git a/apps/autocal/tests/conftest.py b/apps/autocal/tests/conftest.py index 878d3a7..fa07387 100644 --- a/apps/autocal/tests/conftest.py +++ b/apps/autocal/tests/conftest.py @@ -66,6 +66,21 @@ def _scratch_dir() -> Path: return path +@pytest.fixture +def tmp_dir(): + """A workspace-local scratch directory, cleared per test. + + Deliberately not ``tmp_path``: this suite has to run inside a sandboxed + shell, and ``tmp_path`` depends on a writable process temp root that such an + environment may not provide. + """ + import shutil + + path = _scratch_dir() + yield path + shutil.rmtree(path, ignore_errors=True) + + @pytest.fixture def make_window(qt_app, monkeypatch): """Build a window whose preference writes stay inside a scratch directory. diff --git a/apps/autocal/tests/test_binding_purity.py b/apps/autocal/tests/test_binding_purity.py index cfdc6f4..2d01cd5 100644 --- a/apps/autocal/tests/test_binding_purity.py +++ b/apps/autocal/tests/test_binding_purity.py @@ -1,18 +1,23 @@ -"""Binding policy for AutoCal. +"""Binding policy for the Orisys apps. -AutoCal is **PySide6 only**, and this file is the guard for that: +Both shipped apps are **PySide6 only**, and this file is AutoCal's guard for that: * no module may import PyQt5, PySide2 or PyQt6 — the app's environment has PySide6 and nothing else, so such an import is a hard start-up failure; * no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6 removed in favour of ``Qt.AlignmentFlag.AlignLeft``; -* ``shared/`` — which serves both this app and the PyQt5-based studio — must - import no Qt binding at all; +* ``shared/`` may import PySide6 and nothing else: it is used by both apps, so it + must not pull in a second binding, and the studio's own guard + (``apps/studio/tests/test_binding_purity.py``) enforces the same rule from its + side. (Until the studio moved to PySide6 this file forbade *any* binding in + ``shared/``; that rule existed for a two-binding workspace, which no longer + exists. ``shared/theme/qss.py`` paints its chevron icons with PySide6 now, and + under the old rule those imports were failing silently instead.) * the controller layer must stay importable from a headless process. The reason the binding matters at all is licensing, not taste: PyQt5's free -option is GPLv3-or-commercial, while PySide6 is LGPLv3, so an internal -closed-source tool needs no licence purchase. See ``README.md``. +option is GPLv3-or-commercial, while PySide6 is LGPLv3, so a closed-source tool +needs no licence purchase. See ``README.md``. """ from __future__ import annotations @@ -23,13 +28,19 @@ from pathlib import Path APP_SRC = Path(__file__).resolve().parents[1] / "src" SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src" -#: Bindings the app must never import. +#: Bindings no module may import. FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"} #: The one binding allowed, and where it may be named. ALLOWED_BINDING = "PySide6" #: Scoped enum holders we care about, with the value names actually used. +#: +#: Hand-maintained, and therefore incomplete by construction: the PySide6 migration +#: found ``QPainter.RenderHint.Antialiasing`` and ``QDialogButtonBox.StandardButton.Ok`` slipping through a list +#: like this one. The studio's guard asks PySide6 instead (``getattr`` + enum check), +#: which is the better rule; this map is kept because AutoCal cannot import the binding +#: in a headless test run. _SCOPED_ENUMS = { "Qt": ( "AlignmentFlag", @@ -41,6 +52,12 @@ _SCOPED_ENUMS = { "ToolButtonStyle", "FocusPolicy", "ScrollBarPolicy", + "CursorShape", + "MouseButton", + "Key", + "PenStyle", + "GlobalColor", + "ItemFlag", ), "QTableWidget": ("SelectionMode", "EditTrigger"), "QSizePolicy": ("Policy",), @@ -48,6 +65,11 @@ _SCOPED_ENUMS = { "QMessageBox": ("StandardButton", "Icon"), "QDialog": ("DialogCode",), "QAbstractItemView": ("SelectionMode", "EditTrigger"), + "QDialogButtonBox": ("StandardButton",), + "QPainter": ("RenderHint", "CompositionMode"), + "QImage": ("Format",), + "QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"), + "QFrame": ("Shape",), } @@ -72,11 +94,13 @@ def _unscoped_enum_references(path: Path) -> list[str]: """Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects. Parsed rather than pattern-matched for two reasons: a regex also hits prose - in a comment or docstring (which is exactly where the scoped spelling gets - *explained*), and ``QMessageBox.information(...)`` looks like an enumerator - but is a static method. The parent map separates the two, and - ``Qt.AlignmentFlag.AlignLeft`` is an inner attribute on an allow-listed enum - class rather than a plain name, so it is never a candidate. + in a comment or docstring (which is exactly where the *unscoped* spelling gets + explained, so a rewrite would destroy the explanation — this file quotes it + three times, which is why the migration scripts skip it), and + ``QMessageBox.information(...)`` looks like an enumerator but is a static + method. The parent map separates the two, and ``Qt.AlignmentFlag.AlignLeft`` is + an inner attribute on an allow-listed enum class rather than a plain name, so it + is never a candidate. """ tree = ast.parse(path.read_text(encoding="utf-8")) called = { @@ -152,15 +176,22 @@ def test_the_controller_interface_is_qt_free(): assert not offenders, offenders -def test_shared_imports_no_qt_binding(): - """Shared code serves both products, so it must not pick a side.""" +def test_shared_imports_no_forbidden_binding(): + """Shared code serves both products, so it must not pull in a second binding. + + PySide6 is allowed and expected (``shared/theme/qss.py`` paints the chevron icons with + it); what must never appear here is PyQt5/PyQt6/PySide2, which would either fail to + import in a PySide6-only environment or — worse — work in one app's environment and not + the other's. Before the studio moved to PySide6 this test forbade PySide6 itself, and + the PyQt5 import in ``qss.py`` was failing silently as a result. + """ offenders = [] for path in _python_files(SHARED_SRC): - qt_imports = _imported_packages(path) & {"PySide2", "PySide6"} + qt_imports = _imported_packages(path) & FORBIDDEN_BINDINGS if qt_imports: offenders.append(f"{path.name}: {sorted(qt_imports)}") assert not offenders, ( - "shared code must not import PySide2/PySide6 (studio is PyQt5): " + "shared code may import PySide6 only; these files import another binding: " f"{offenders}" ) diff --git a/apps/autocal/tests/test_layout_shell.py b/apps/autocal/tests/test_layout_shell.py index 3d5fae7..f1928aa 100644 --- a/apps/autocal/tests/test_layout_shell.py +++ b/apps/autocal/tests/test_layout_shell.py @@ -17,6 +17,7 @@ import pytest from conftest import pump +from orisys_autocal.sequence_io import SequenceFormatError, SequencePoint from orisys_autocal.ui.workspace import TABS @@ -187,13 +188,7 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window): """A disabled control must say why, or it reads as a broken button.""" window = _show(make_window()) panel = window.sequence_panel - assert panel.pending_controls() == [ - "generate", - "simulate", - "dry_run", - "save", - "load", - ] + assert panel.pending_controls() == ["generate", "simulate", "dry_run"] for name, phase in panel.pending_phases().items(): button = panel._pending_buttons[name] assert not button.isEnabled(), f"{name} is usable but not implemented" @@ -201,6 +196,24 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window): assert button.text(), f"{name} has no label" +def test_import_and_export_are_live_controls(make_window): + """A sequence is a document, and P8 says it is a versioned artifact.""" + window = _show(make_window()) + panel = window.sequence_panel + for name in ("export", "import"): + button = panel._live_buttons[name] + assert button.isEnabled(), f"{name} is not usable" + assert button.text(), f"{name} has no label" + assert button.toolTip() + + +def test_the_point_list_has_no_empty_action_column(make_window): + """An empty action cell reads as a broken control.""" + window = _show(make_window()) + panel = window.sequence_panel + assert "actions" not in panel.column_keys() + + def test_the_sequence_tab_hands_out_no_runnable_steps(make_window): """A runner must not be able to start from points nothing has validated.""" window = _show(make_window()) @@ -456,6 +469,110 @@ def test_the_status_bar_uses_its_own_theme_token(make_window): assert "QStatusBar" in window.statusBar().styleSheet() +def test_the_sequence_summary_reports_count_and_span(make_window): + """The two numbers an operator reasons with, before anything runs.""" + window = _show(make_window()) + panel = window.sequence_panel + panel.set_points( + [ + SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}), + SequencePoint(id=2, target_mm={"x": 10.0, "y": 0.0, "z": 12.4}), + ] + ) + text = panel.summary_label.text() + assert "2" in text + # A nil Z span prints once, and the real X span prints both ends. + assert "0.000–10.000" in text + assert "12.400" in text + + +def test_the_sequence_summary_updates_with_the_profile(make_window): + window = _show(make_window()) + panel = window.sequence_panel + panel.set_points([SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 1.0})]) + before = panel.summary_label.text() + panel.field("repeats").setText("10") + assert panel.summary_label.text() != before + + +def test_an_empty_sequence_says_so_rather_than_showing_zeros(make_window): + window = _show(make_window()) + panel = window.sequence_panel + panel.set_points([]) + assert panel.summary_label.text() + assert "0" not in panel.summary_label.text()[:2] + + +def test_the_design_is_dirty_until_it_is_saved_or_loaded(make_window): + """Import replaces the design, and there is no autosave.""" + window = _show(make_window()) + panel = window.sequence_panel + panel.mark_clean() + assert not panel.dirty + panel.field("pitch_mm").setText("9") + assert panel.dirty, "a profile edit did not mark the design dirty" + + +def test_export_then_import_restores_the_design(make_window, tmp_dir): + """The round trip through the real panel, not just the format module.""" + window = _show(make_window()) + panel = window.sequence_panel + panel.set_name("bench sweep") + panel.field("pitch_mm").setText("3.5") + panel.set_points( + [ + SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}), + SequencePoint(id=2, target_mm={"x": 3.5, "y": 0.0, "z": 12.4}), + ] + ) + path = tmp_dir / "sweep.oryseq" + window.export_sequence(str(path)) + assert path.is_file() + assert not panel.dirty, "export did not clear the dirty flag" + + # Change everything, then import it back. + panel.set_name("") + panel.field("pitch_mm").setText("1") + panel.set_points([]) + document = window.import_sequence(str(path)) + + assert document.point_count == 2 + assert panel.name() == "bench sweep" + assert panel.profile()["pitch_mm"] == 3.5 + assert panel.point_row_count() == 2 + assert not panel.dirty + + +def test_importing_a_bad_file_reports_and_changes_nothing(make_window, tmp_dir): + window = _show(make_window()) + panel = window.sequence_panel + panel.field("pitch_mm").setText("4") + path = tmp_dir / "bad.oryseq" + path.write_text("not json at all", encoding="utf-8") + with pytest.raises(SequenceFormatError) as excinfo: + window.import_sequence(str(path)) + assert "not valid JSON" in str(excinfo.value) + # The design is untouched: a refused import is not a partial import. + assert panel.profile()["pitch_mm"] == 4 + + +def test_an_exported_sequence_names_the_sensor_it_was_designed_for(make_window, tmp_dir): + import json + + window = _show(make_window()) + window.prefs["sensor_setup"] = { + "sensor_type": "finger", + "geometry_id": "finger-1.0.0", + "probe_tip_set": True, + } + path = tmp_dir / "named.oryseq" + window.export_sequence(str(path)) + document = json.loads(path.read_text(encoding="utf-8")) + assert document["device"]["sensor_type"] == "finger" + assert document["device"]["geometry_id"] == "finger-1.0.0" + assert document["versions"]["app"] + + def test_language_switch_relabels_the_new_shell(make_window): from orisys_app_shared.i18n import t diff --git a/apps/autocal/tests/test_sequence_io.py b/apps/autocal/tests/test_sequence_io.py new file mode 100644 index 0000000..519053d --- /dev/null +++ b/apps/autocal/tests/test_sequence_io.py @@ -0,0 +1,283 @@ +"""The calibration sequence document: round trip, refusals, derived summary. + +The format is a contract with another machine's bench, so most of these tests +are about what happens when the file is *wrong* — a sequence half-applied is +worse than one rejected. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from orisys_autocal.sequence_io import ( + DOCUMENT_KIND, + FORMAT_VERSION, + SequenceFormatError, + SequencePoint, + _version_tuple, + format_duration, + format_span, + from_document, + load, + save, + summarise, + to_document, +) + +#: Where scratch files go. Deliberately not ``tmp_dir``: this suite has to run +#: in a sandboxed shell, and ``tmp_dir`` needs a writable process temp root that +#: such an environment may not provide (the same reason ``conftest.make_window`` +#: avoids it). +_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_sequence_io" + + +@pytest.fixture +def tmp_dir(): + shutil.rmtree(_SCRATCH, ignore_errors=True) + _SCRATCH.mkdir(parents=True, exist_ok=True) + yield _SCRATCH + shutil.rmtree(_SCRATCH, ignore_errors=True) + +PROFILE = { + "mode": "flat", + "pitch_mm": 2.0, + "margin_mm": 1.0, + "radius_mm": 0.0, + "clear_height_mm": 5.0, + "approach_speed_mm_s": 2.0, + "press_speed_mm_s": 0.5, + "press_depth_mm": 0.2, + "dwell_s": 0.5, + "settle_window_s": 0.5, + "repeats": 3, + "order": "serpentine", +} + + +def _points(count: int = 6) -> list[SequencePoint]: + return [ + SequencePoint( + id=index + 1, + target_mm={"x": 2.0 * index, "y": 0.0, "z": 12.4}, + sensor_mm={"x": 2.0 * index, "y": 0.0, "z": 0.0}, + clearance_mm=5.0, + ) + for index in range(count) + ] + + +def _document(**overrides) -> dict: + payload = to_document( + profile=PROFILE, + points=_points(), + name="sweep", + units_per_mm=10.0, + app_version="0.1.0", + shared_version="0.1.0", + shared_api="0.1", + ) + payload.update(overrides) + return payload + + +# ------------------------------------------------------------------ round trip + + +def test_a_document_round_trips_through_a_file(tmp_dir): + path = tmp_dir / "sweep.oryseq" + save(_document(), path) + back = load(path, current_profile=PROFILE) + assert back.point_count == 6 + assert back.name == "sweep" + assert back.profile["pitch_mm"] == 2.0 + assert back.units_per_mm == 10.0 + assert not back.warnings + # Both frames survive: the platform target and the sensor position. + assert back.points[0].target_mm["z"] == pytest.approx(12.4) + assert back.points[0].sensor_mm is not None + + +def test_the_written_file_is_readable_json_with_the_house_style(tmp_dir): + path = tmp_dir / "sweep.oryseq" + save(_document(), path) + text = path.read_text(encoding="utf-8") + assert text.endswith("\n") + assert "\n " in text, "the document is not indented" + assert "\\u" not in text, "non-ASCII was escaped" + payload = json.loads(text) + assert payload["kind"] == DOCUMENT_KIND + assert payload["format_version"] == FORMAT_VERSION + + +def test_saving_leaves_no_temporary_file_behind(tmp_dir): + path = tmp_dir / "sweep.oryseq" + save(_document(), path) + assert [entry.name for entry in tmp_dir.iterdir()] == ["sweep.oryseq"] + + +def test_saving_over_an_existing_file_replaces_it(tmp_dir): + path = tmp_dir / "sweep.oryseq" + save(_document(), path) + save(_document(name="second"), path) + assert json.loads(path.read_text(encoding="utf-8"))["name"] == "second" + + +# -------------------------------------------------------------------- refusals + + +@pytest.mark.parametrize( + ("case", "mutate", "expected"), + [ + ("wrong kind", lambda d: d.update(kind="something.else"), "not a sequence document"), + ("missing version", lambda d: d.pop("format_version"), "format_version"), + ("version not a string", lambda d: d.update(format_version=1.0), "format_version"), + ("newer version", lambda d: d.update(format_version="2.0"), "newer AutoCal"), + ("missing frame", lambda d: d.pop("frame"), "frame"), + ("wrong unit", lambda d: d.update(frame={"length_unit": "um"}), "length_unit"), + ("missing profile", lambda d: d.pop("profile"), "profile"), + ("points not a list", lambda d: d.update(points={}), "must be a list"), + ("point not an object", lambda d: d.update(points=["nope"]), "not an object"), + ("point missing axis", lambda d: d.update(points=[{"target_mm": {"x": 1, "y": 2}}]), "missing 'z'"), + ("point without target", lambda d: d.update(points=[{}]), "no target_mm"), + ], +) +def test_a_bad_document_is_refused_with_a_specific_message(case, mutate, expected): + document = _document() + mutate(document) + with pytest.raises(SequenceFormatError) as excinfo: + from_document(document, current_profile=PROFILE) + assert expected in str(excinfo.value), case + + +def test_a_non_json_file_is_refused_with_its_position(tmp_dir): + path = tmp_dir / "broken.oryseq" + path.write_text("this is not json", encoding="utf-8") + with pytest.raises(SequenceFormatError) as excinfo: + load(path, current_profile=PROFILE) + assert "not valid JSON" in str(excinfo.value) + assert "line 1" in str(excinfo.value) + + +def test_a_missing_file_is_refused_not_crashed(tmp_dir): + with pytest.raises(SequenceFormatError): + load(tmp_dir / "absent.oryseq", current_profile=PROFILE) + + +def test_a_json_array_is_refused(tmp_dir): + path = tmp_dir / "array.oryseq" + path.write_text("[1, 2, 3]", encoding="utf-8") + with pytest.raises(SequenceFormatError) as excinfo: + load(path, current_profile=PROFILE) + assert "top level" in str(excinfo.value) + + +# ------------------------------------------------------------------- summary + + +def test_the_summary_reports_count_and_travel_span(): + summary = summarise(_points(6), PROFILE) + assert summary["count"] == 6 + assert summary["min_mm"]["x"] == 0.0 + assert summary["max_mm"]["x"] == 10.0 + assert summary["span_mm"]["x"] == pytest.approx(10.0) + # A constant coordinate has a nil span, and says so. + assert summary["span_mm"]["z"] == pytest.approx(0.0) + + +def test_the_summary_is_recomputed_and_wins_over_the_file(): + """A hand-edited summary must not be believed.""" + document = _document() + document["summary"]["count"] = 999 + document["summary"]["span_mm"]["x"] = 999.0 + back = from_document(document, current_profile=PROFILE) + assert back.summary["count"] == 6 + assert back.summary["span_mm"]["x"] == pytest.approx(10.0) + assert back.warnings and "disagrees" in back.warnings[0] + + +def test_an_agreeing_summary_produces_no_warning(): + back = from_document(_document(), current_profile=PROFILE) + assert not any("disagrees" in warning for warning in back.warnings) + + +def test_no_points_imports_the_profile_and_says_so(): + document = _document() + document.pop("points") + back = from_document(document, current_profile=PROFILE) + assert back.point_count == 0 + assert any("no points" in warning for warning in back.warnings) + + +def test_a_missing_profile_key_keeps_the_current_value(): + document = _document() + document["profile"].pop("pitch_mm") + back = from_document(document, current_profile=PROFILE) + assert back.profile["pitch_mm"] == 2.0 + assert any("pitch_mm" in warning for warning in back.warnings) + + +def test_a_disabled_point_is_counted_separately(): + points = _points(4) + points[1] = SequencePoint(id=2, target_mm=points[1].target_mm, enabled=False) + summary = summarise(points, PROFILE) + assert summary["count"] == 4 + assert summary["enabled"] == 3 + + +# ------------------------------------------------------------------- duration + + +def test_the_duration_estimate_scales_with_repeats_and_points(): + one = summarise(_points(1), {**PROFILE, "repeats": 1})["estimated_duration_s"] + three = summarise(_points(1), {**PROFILE, "repeats": 3})["estimated_duration_s"] + assert three > one + six = summarise(_points(6), {**PROFILE, "repeats": 3})["estimated_duration_s"] + assert six > three + + +def test_no_points_means_no_estimate(): + summary = summarise([], PROFILE) + assert summary["estimated_duration_s"] == 0.0 + assert format_duration(0.0) == "—" + + +def test_the_duration_is_formatted_as_a_bench_timer(): + assert format_duration(72.0) == "1:12" + assert format_duration(3600.0) == "1:00:00" + assert format_duration(None) == "—" + assert format_duration(float("nan")) == "—" + + +# ---------------------------------------------------------------------- spans + + +def test_a_zero_span_prints_once_not_twice(): + assert format_span(12.4, 12.4) == "12.400" + assert format_span(0.0, 0.0) == "0.000" + + +def test_a_real_span_shows_both_ends_and_the_length(): + assert format_span(0.0, 10.0) == "0.000–10.000 (10.000)" + + +def test_a_missing_span_is_a_dash(): + assert format_span(None, None) == "—" + + +# --------------------------------------------------------------------- version + + +def test_versions_compare_as_tuples_not_strings(): + """``"1.10" < "1.9"`` as strings would load a newer file as if supported.""" + assert _version_tuple("1.10") > _version_tuple("1.9") + assert _version_tuple("1.0") == (1, 0) + assert _version_tuple("1") == (1,) + # A same-major newer minor is still refused, but is *recognised* as newer. + document = _document() + document["format_version"] = "1.10" + with pytest.raises(SequenceFormatError): + from_document(document, current_profile=PROFILE) diff --git a/apps/autocal/tools/layout_mockup.py b/apps/autocal/tools/layout_mockup.py index 616cbe0..16bb739 100644 --- a/apps/autocal/tools/layout_mockup.py +++ b/apps/autocal/tools/layout_mockup.py @@ -77,8 +77,8 @@ STRINGS = { "stop_all": "全部停止", "run_title": "运行", "run_phase": "空闲", - "run_step": "第 0/0 点", - "run_time": "已用 0:00 · 预计剩余 —", + "run_idle_hint": "尚未生成标定点", + "run_run": "运行", "run_pause": "暂停", "run_abort": "中止", @@ -129,6 +129,9 @@ STRINGS = { "seq_preview": "预览", "seq_validation": "校验", "seq_points": "标定点列表", + "seq_name": "序列名称", + "seq_note": "备注", + "seq_summary": "6 点(6 启用) · X 0.000–10.000 (10.000) · Y 0.000 · Z 12.400–13.400 (1.000) · 预计 2:24(估计值)", "seq_fields": [ ("曲面模式", "平面"), ("点距 (mm)", "2.000"), ("边界余量 (mm)", "1.000"), ("曲面半径 (mm)", "—"), ("安全高度 (mm)", "5.000"), ("进给速度 (mm/s)", "2.000"), @@ -140,12 +143,12 @@ STRINGS = { "seq_preview_hint": "生成后可在此检查点是否落在曲面上、是否在行程内、接触能否被确认。", "seq_checks": ["在曲面范围内", "在行程内", "接触可确认"], "seq_checks_pending": "尚未生成标定点。", - "seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("保存序列…", True), ("载入序列…", True)], - "point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本", "操作"], + "seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("导出序列…", False), ("导入序列…", False)], + "point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本"], "point_rows": [ - ["1", "0.000", "0.000", "12.400", "待测", "—", "—", ""], - ["2", "2.000", "0.000", "12.400", "待测", "—", "—", ""], - ["3", "4.000", "0.000", "12.400", "待测", "—", "—", ""], + ["1", "0.000", "0.000", "12.400", "待测", "—", "—"], + ["2", "2.000", "0.000", "12.400", "待测", "—", "—"], + ["3", "4.000", "0.000", "12.400", "待测", "—", "—"], ], "points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。", "sensor_note": "设备与信号源、实时图像、力曲线", @@ -169,8 +172,8 @@ STRINGS = { "stop_all": "Stop all", "run_title": "Run", "run_phase": "Idle", - "run_step": "Point 0/0", - "run_time": "Elapsed 0:00 · about — left", + "run_idle_hint": "No points generated", + "run_run": "Run", "run_pause": "Pause", "run_abort": "Abort", @@ -221,6 +224,9 @@ STRINGS = { "seq_preview": "Preview", "seq_validation": "Checks", "seq_points": "Calibration points", + "seq_name": "Sequence name", + "seq_note": "Note", + "seq_summary": "6 points (6 enabled) · X 0.000-10.000 (10.000) · Y 0.000 · Z 12.400-13.400 (1.000) · about 2:24 (estimate)", "seq_fields": [ ("Surface mode", "Flat"), ("Pitch (mm)", "2.000"), ("Margin (mm)", "1.000"), ("Radius (mm)", "—"), ("Clear height (mm)", "5.000"), ("Approach (mm/s)", "2.000"), @@ -232,12 +238,12 @@ STRINGS = { "seq_preview_hint": "After generation, check each point is on the surface, inside travel and confirmable by contact.", "seq_checks": ["On the surface", "Within travel", "Contact confirmable"], "seq_checks_pending": "No points generated yet.", - "seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Save sequence…", True), ("Load sequence…", True)], - "point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples", "Actions"], + "seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Export sequence…", False), ("Import sequence…", False)], + "point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples"], "point_rows": [ - ["1", "0.000", "0.000", "12.400", "Pending", "—", "—", ""], - ["2", "2.000", "0.000", "12.400", "Pending", "—", "—", ""], - ["3", "4.000", "0.000", "12.400", "Pending", "—", "—", ""], + ["1", "0.000", "0.000", "12.400", "Pending", "—", "—"], + ["2", "2.000", "0.000", "12.400", "Pending", "—", "—"], + ["3", "4.000", "0.000", "12.400", "Pending", "—", "—"], ], "points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked.", "sensor_note": "Device and source, live image, force curves", @@ -644,13 +650,22 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) profile = QRect(inner.x(), inner.y(), profile_w, top_h) p_inner = _panel(painter, profile, palette) _box_title(painter, profile, s["seq_profile"], palette) + # the name and note the document carries + y = p_inner.y() + 4 + _text(painter, QRect(p_inner.x(), y, 70, 22), s["seq_name"], palette, size=9, muted=True) + _field(painter, QRect(p_inner.x() + 72, y, p_inner.width() - 78, 22), "5 点灵敏度扫描", palette) + y += 26 + _text(painter, QRect(p_inner.x(), y, 70, 22), s["seq_note"], palette, size=9, muted=True) + _field(painter, QRect(p_inner.x() + 72, y, p_inner.width() - 78, 22), "", palette) + y += 28 + column_w = p_inner.width() // 2 for index, (label, value) in enumerate(s["seq_fields"]): row, side = divmod(index, 2) x = p_inner.x() + side * column_w - y = p_inner.y() + 20 + row * 26 - _text(painter, QRect(x, y, 130, 22), label, palette, size=9, muted=True) - _field(painter, QRect(x + 128, y, column_w - 140, 22), value, palette) + row_y = y + row * 26 + _text(painter, QRect(x, row_y, 130, 22), label, palette, size=9, muted=True) + _field(painter, QRect(x + 128, row_y, column_w - 140, 22), value, palette) # --- preview preview = QRect(inner.x() + profile_w + gap, inner.y(), preview_w, top_h) @@ -699,7 +714,9 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) points = QRect(inner.x(), inner.y() + top_h + gap, inner.width(), inner.height() - top_h - gap) t_inner = _panel(painter, points, palette) _box_title(painter, points, s["seq_points"], palette) - widths = (50, 150, 150, 150, 130, 190, 100) + # Seven columns: no empty actions column, and the summary line carries the + # count and span the operator reasons with. + widths = (60, 150, 150, 150, 140, 190, 110) widths = widths + (t_inner.width() - sum(widths),) header_h = 28 header = QRect(t_inner.x(), t_inner.y() + 4, t_inner.width(), header_h) @@ -719,7 +736,9 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) for value, width in zip(row, widths): _text(painter, QRect(x, row_y, width, row_h), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter) x += width - y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 10 + y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 8 + _text(painter, QRect(t_inner.x(), y, t_inner.width(), 22), s["seq_summary"], palette, size=10) + y += 24 _text(painter, QRect(t_inner.x(), y, t_inner.width(), 34), s["points_hint"], palette, size=9, muted=True) @@ -917,13 +936,7 @@ def draw_run_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> None x += painter.fontMetrics().horizontalAdvance(s["run_title"]) + 20 _text(painter, QRect(x, top, 90, RUNBAR_H), s["run_phase"], palette, size=10, muted=True) x += 96 - _text(painter, QRect(x, top, 150, RUNBAR_H), s["run_step"], palette, size=10) - x += 158 - - bar = QRect(x, top + 14, 300, 12) - painter.fillRect(bar, palette.border) - painter.fillRect(QRect(bar.x(), bar.y(), 3 * bar.width() // 4, bar.height()), palette.accent) - _text(painter, QRect(bar.right() + 12, top, 260, RUNBAR_H), s["run_time"], palette, size=10, muted=True) + _text(painter, QRect(x, top, 420, RUNBAR_H), s["run_idle_hint"], palette, size=9, muted=True) _button(painter, QRect(PAGE_W - 330, top + 7, 96, 26), s["run_run"], palette, disabled=True) _button(painter, QRect(PAGE_W - 228, top + 7, 96, 26), s["run_pause"], palette, disabled=True) diff --git a/apps/autocal/tools/panel_check.py b/apps/autocal/tools/panel_check.py index d877ef9..9909cc4 100644 --- a/apps/autocal/tools/panel_check.py +++ b/apps/autocal/tools/panel_check.py @@ -8,8 +8,8 @@ inside parents, controls gated by connection and bus state, locales complete. python apps/autocal/tools/panel_check.py No controller, no DLL and no display are needed. Mirrors -``apps/studio/tools/ui_check.py``, but cannot import it: that module imports -PyQt5, and AutoCal's environment has none. +``apps/studio/tools/ui_check.py``, but cannot import it: that module pulls in +``orisys_studio`` (the acquisition stack), which AutoCal's environment has none of. """ from __future__ import annotations @@ -218,8 +218,8 @@ def main() -> int: "the sequence point list is empty", ) checker.that( - sequence.table.columnCount() == 8, - f"the point list has {sequence.table.columnCount()} columns, expected 8", + sequence.table.columnCount() == len(sequence.column_keys()), + f"the point list has {sequence.table.columnCount()} columns, expected {len(sequence.column_keys())}", ) # The shell: one rail, a run bar, and a health chip. diff --git a/apps/studio/config/compound_eye.png b/apps/studio/config/compound_eye.png new file mode 100644 index 0000000..7ea4e12 Binary files /dev/null and b/apps/studio/config/compound_eye.png differ diff --git a/apps/studio/pyproject.toml b/apps/studio/pyproject.toml index f569edf..bf46865 100644 --- a/apps/studio/pyproject.toml +++ b/apps/studio/pyproject.toml @@ -9,7 +9,10 @@ description = "Orisys Studio desktop app" requires-python = ">=3.10" authors = [{ name = "Orisys-Tech" }] dependencies = [ - "PyQt5>=5.15", + # PySide6 is the LGPLv3 Qt binding: the licence, not the API, is why the studio is not + # on PyQt5 (whose free option is GPLv3-or-commercial). See + # docs/architecture/studio-3d-design.md §11.1 and apps/studio/tests/test_binding_purity.py. + "PySide6>=6.6", "orisys-app-shared>=0.1.0", "orisys>=0.5", "scipy>=1.10.1", diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 7240e54..8bf952c 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -9,11 +9,9 @@ from pathlib import Path from typing import Any import numpy as np -from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtGui import QIcon, QImage -from PyQt5.QtWidgets import ( - QAction, - QActionGroup, +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QAction, QActionGroup, QIcon, QImage +from PySide6.QtWidgets import ( QApplication, QFrame, QHBoxLayout, @@ -240,7 +238,7 @@ class StudioMainWindow(QMainWindow): root.addWidget(self.toolbar) self.status_chip = self.toolbar.status_chip - self.content_splitter = QSplitter(Qt.Horizontal) + self.content_splitter = QSplitter(Qt.Orientation.Horizontal) self.mesh3d_panel = Mesh3dPanel(theme=self._theme) self.arrow_panel = ArrowPanel(theme=self._theme) self.force_plots = ForcePlotsPanel(theme=self._theme) @@ -888,7 +886,7 @@ class StudioMainWindow(QMainWindow): # convention everyone's hands already know. self.act_play = QAction(self) self.act_play.setShortcut("Space") - self.act_play.setShortcutContext(Qt.ApplicationShortcut) + self.act_play.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut) self.addAction(self.act_play) self.view_menu = menu_bar.addMenu("") @@ -1402,7 +1400,7 @@ class StudioMainWindow(QMainWindow): self._persist_prefs() def _show_about(self) -> None: - AboutDialog(self).exec_() + AboutDialog(self).exec() def _on_camera_id_changed(self, value: int) -> None: self.prefs["camera_id"] = str(int(value)) @@ -1507,7 +1505,7 @@ class StudioMainWindow(QMainWindow): filters_locked=bool(self._running or self.recorder.is_recording), sample_rate_hz=self.display_ctl.device_sample_rate(), ) - if dialog.exec_() != PlotSettingsDialog.Accepted: + if dialog.exec() != PlotSettingsDialog.Accepted: return window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values() self.display_ctl.apply_plot_settings( @@ -1589,10 +1587,10 @@ class StudioMainWindow(QMainWindow): sensor_type=sensor_type, config=config_ref, ), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: self.recorder.log_event( "calibration_apply_blocked", "; ".join(reasons) ) @@ -1602,10 +1600,10 @@ class StudioMainWindow(QMainWindow): self, t("cal.apply_title"), t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: self.statusBar().showMessage(t("cal.apply_skipped")) return worker = self._worker_for(sensor_type) @@ -1720,7 +1718,18 @@ class StudioMainWindow(QMainWindow): if getattr(self, "act_run", None) is None: return self.act_run.setEnabled(self.toolbar.btn_run.isEnabled()) - self.act_stop.setEnabled(self._session_active) + # The menu's Stop mirrors the toolbar's stop affordance, not the acquisition + # session alone: a replay or a recompute pass is something to stop. + # + # This is not cosmetic. Qt6 honours ``enabled`` in ``QAction::trigger()`` (Qt5 + # did not) and never delivers the shortcut of a disabled action, so a Stop that + # is disabled while a replay is playing is a keyboard escape hatch that does not + # work — the one thing ``_on_stop_shortcut`` exists to guarantee. + self.act_stop.setEnabled( + self._session_active + or self.replay_ctl.is_playing() + or self._recompute_running + ) self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled()) self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled()) @@ -1740,10 +1749,16 @@ class StudioMainWindow(QMainWindow): In the replay view it is the same escape hatch as the rail's 停止 button: a replay or a recompute pass that will not stop by itself has to be stoppable - from the keyboard. + from the keyboard. The replay branch accepts the same three states + ``_sync_shortcut_actions`` enables the action for, so the action can never be + enabled with nothing behind it. """ if self.toolbar.run_role() == "playback": - if self.replay_ctl.is_playing() or self._session_active: + if ( + self.replay_ctl.is_playing() + or self._session_active + or self._recompute_running + ): self.replay_ctl.stop_activity() return if not self._session_active: @@ -2348,7 +2363,7 @@ class StudioMainWindow(QMainWindow): geo = self.prefs.get("window_geometry") if isinstance(geo, str) and geo: try: - from PyQt5.QtCore import QByteArray + from PySide6.QtCore import QByteArray self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii"))) except Exception: diff --git a/apps/studio/src/orisys_studio/display_prefs.py b/apps/studio/src/orisys_studio/display_prefs.py index 5cb2759..3f10376 100644 --- a/apps/studio/src/orisys_studio/display_prefs.py +++ b/apps/studio/src/orisys_studio/display_prefs.py @@ -21,7 +21,7 @@ from __future__ import annotations import logging from typing import Any, Optional, Protocol -from PyQt5.QtCore import QObject +from PySide6.QtCore import QObject logger = logging.getLogger(__name__) diff --git a/apps/studio/src/orisys_studio/main.py b/apps/studio/src/orisys_studio/main.py index b2f55ee..ea80352 100644 --- a/apps/studio/src/orisys_studio/main.py +++ b/apps/studio/src/orisys_studio/main.py @@ -6,7 +6,12 @@ import logging import os import sys -from PyQt5.QtWidgets import QApplication +# pyqtgraph picks its toolkit from whichever binding is already imported, and in a +# development environment that also has PyQt5 installed (``orisys[gui]``) it can pick the +# wrong one. The application is PySide6-only, so say so before anything imports pyqtgraph. +os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6") + +from PySide6.QtWidgets import QApplication # noqa: E402 from orisys_app_shared import API_VERSION, __version__ as shared_version from orisys_app_shared.compat import check_shared_compat @@ -85,7 +90,7 @@ def main() -> int: language, gl_profile, ) - code = int(app.exec_()) + code = int(app.exec()) # Leave the process immediately. # diff --git a/apps/studio/src/orisys_studio/replay.py b/apps/studio/src/orisys_studio/replay.py index a88bb81..d88e15b 100644 --- a/apps/studio/src/orisys_studio/replay.py +++ b/apps/studio/src/orisys_studio/replay.py @@ -24,7 +24,7 @@ from pathlib import Path from typing import Optional import numpy as np -from PyQt5.QtCore import QObject, pyqtSignal +from PySide6.QtCore import QObject, Signal from orisys_app_shared.i18n import t from orisys_app_shared.record import RecordingReader, VideoOnlySource @@ -47,13 +47,13 @@ class ReplayEngine(QObject): """Open one recording and answer "what is at t?" for every view.""" #: The cursor moved; the chart cursor and the 2D panel follow. - position_changed = pyqtSignal(float) + position_changed = Signal(float) #: The frame under the cursor changed. - frames_changed = pyqtSignal() + frames_changed = Signal() #: The notes about this recording, or the loaded recording, changed. - status_changed = pyqtSignal() + status_changed = Signal() #: A recording was opened (True) or the previous one was dropped (False). - recording_changed = pyqtSignal(bool) + recording_changed = Signal(bool) def __init__(self, parent=None) -> None: super().__init__(parent) diff --git a/apps/studio/src/orisys_studio/replay_controller.py b/apps/studio/src/orisys_studio/replay_controller.py index fcf97c8..20533aa 100644 --- a/apps/studio/src/orisys_studio/replay_controller.py +++ b/apps/studio/src/orisys_studio/replay_controller.py @@ -28,8 +28,8 @@ from pathlib import Path from typing import Any, Optional, Protocol import numpy as np -from PyQt5.QtCore import QObject, QTimer -from PyQt5.QtWidgets import QFileDialog +from PySide6.QtCore import QObject, QTimer +from PySide6.QtWidgets import QFileDialog from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame from orisys_app_shared.i18n import t diff --git a/apps/studio/src/orisys_studio/scene3d/asset_source.py b/apps/studio/src/orisys_studio/scene3d/asset_source.py index c0c2990..65393a8 100644 --- a/apps/studio/src/orisys_studio/scene3d/asset_source.py +++ b/apps/studio/src/orisys_studio/scene3d/asset_source.py @@ -33,7 +33,7 @@ from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Optional -from PyQt5.QtCore import QObject, pyqtSignal +from PySide6.QtCore import QObject, Signal from orisys_app_shared.paths import config_dir from orisys_app_shared.scene3d.contracts import ( @@ -180,8 +180,8 @@ class SceneAssetLoader(QObject): selection the operator has already moved away from. """ - loaded = pyqtSignal(object, str, int, object) # SceneAssets, description, id, device - failed = pyqtSignal(object, int) # Diagnostic, request id + loaded = Signal(object, str, int, object) # SceneAssets, description, id, device + failed = Signal(object, int) # Diagnostic, request id def __init__(self, parent: Optional[QObject] = None) -> None: super().__init__(parent) diff --git a/apps/studio/src/orisys_studio/scene3d/gl_backend.py b/apps/studio/src/orisys_studio/scene3d/gl_backend.py index 9e70077..2e468de 100644 --- a/apps/studio/src/orisys_studio/scene3d/gl_backend.py +++ b/apps/studio/src/orisys_studio/scene3d/gl_backend.py @@ -72,21 +72,21 @@ def configure_surface_format(profile: Profile = "desktop33") -> None: component must not slow down (the acquisition loop feeds the scene through a capacity-one mailbox), and the design document defers anti-aliasing work. """ - from PyQt5.QtGui import QSurfaceFormat + from PySide6.QtGui import QSurfaceFormat fmt = QSurfaceFormat() if profile == "gles30": - fmt.setRenderableType(QSurfaceFormat.OpenGLES) + fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGLES) fmt.setVersion(3, 0) elif profile == "desktop33": - fmt.setRenderableType(QSurfaceFormat.OpenGL) + fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGL) fmt.setVersion(3, 3) - fmt.setProfile(QSurfaceFormat.CoreProfile) + fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile) else: # pragma: no cover - a typo must not reach a board silently raise ValueError(f"unknown GL profile: {profile!r}") fmt.setDepthBufferSize(24) fmt.setStencilBufferSize(8) - fmt.setSwapBehavior(QSurfaceFormat.DoubleBuffer) + fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer) fmt.setSamples(0) QSurfaceFormat.setDefaultFormat(fmt) @@ -442,7 +442,10 @@ class GLDispatch: if context is None or not context.isValid(): raise error("gl_unsupported", "no current OpenGL context") fmt = context.format() - profile = profile_from_renderable_type(int(fmt.renderableType())) + # PySide6 enums are not ints: `int(QSurfaceFormat.OpenGLES)` raises TypeError, so read + # `.value`. (PyQt5 exposed them as ints. Getting this wrong made every GL probe raise, + # the 3D pane report "unsupported", and the GL smoke tests skip instead of running.) + profile = profile_from_renderable_type(int(fmt.renderableType().value)) try: vendor = _gl_string(GL, GL.GL_VENDOR) renderer = _gl_string(GL, GL.GL_RENDERER) @@ -454,7 +457,7 @@ class GLDispatch: "gl_unsupported", f"the context answered no queries: {type(exc).__name__}: {exc}", ) from exc - from PyQt5.QtGui import QGuiApplication + from PySide6.QtGui import QGuiApplication platform_name = "" try: diff --git a/apps/studio/src/orisys_studio/scene3d/panel.py b/apps/studio/src/orisys_studio/scene3d/panel.py index 7df114f..ae80b40 100644 --- a/apps/studio/src/orisys_studio/scene3d/panel.py +++ b/apps/studio/src/orisys_studio/scene3d/panel.py @@ -22,9 +22,9 @@ from typing import TYPE_CHECKING, Optional import numpy as np -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QImage -from PyQt5.QtWidgets import ( +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QImage +from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDoubleSpinBox, @@ -87,7 +87,7 @@ class ColorScaleBar(QLabel): self.setObjectName("colorScaleBar") self.setFixedHeight(12) self.setMinimumWidth(120) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.setVisible(False) self._lut_id = "sequential" @@ -99,14 +99,14 @@ class ColorScaleBar(QLabel): self.update() def paintEvent(self, event) -> None: # noqa: N802 - Qt naming - from PyQt5.QtGui import QPainter + from PySide6.QtGui import QPainter from orisys_studio.scene3d.gl_backend import lut_rgba # (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call, # which is what QImage's borrowed buffer needs. rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 0, :]).tobytes() - image = QImage(rgba, 256, 1, 256 * 4, QImage.Format_RGBA8888) + image = QImage(rgba, 256, 1, 256 * 4, QImage.Format.Format_RGBA8888) painter = QPainter(self) painter.drawImage(self.rect(), image) painter.end() @@ -115,10 +115,10 @@ class ColorScaleBar(QLabel): class ThreeDPanel(QFrame): """Toggle, presets, reset, housing visibility, gain, colour scale, status.""" - enabledChanged = pyqtSignal(bool) - settingsChanged = pyqtSignal(object) # DisplaySettings - resetRequested = pyqtSignal() - presetRequested = pyqtSignal(str) + enabledChanged = Signal(bool) + settingsChanged = Signal(object) # DisplaySettings + resetRequested = Signal() + presetRequested = Signal(str) def __init__( self, @@ -299,13 +299,13 @@ class ThreeDPanel(QFrame): scale_row.setSpacing(6) self.scale_lower_label = QLabel() self.scale_lower_label.setObjectName("panelMeta") - self.scale_lower_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.scale_lower_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) scale_row.addWidget(self.scale_lower_label) self.scale_bar = ColorScaleBar() scale_row.addWidget(self.scale_bar, 1) self.scale_upper_label = QLabel() self.scale_upper_label.setObjectName("panelMeta") - self.scale_upper_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self.scale_upper_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) scale_row.addWidget(self.scale_upper_label) root.addLayout(scale_row) @@ -327,7 +327,7 @@ class ThreeDPanel(QFrame): self.source_label = ShrinkableLabel() self.source_label.setObjectName("panelMeta") self.source_label.setWordWrap(False) - self.source_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.source_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) legend_row.addWidget(self.source_label, 2) root.addLayout(legend_row) diff --git a/apps/studio/src/orisys_studio/scene3d/settings_dialog.py b/apps/studio/src/orisys_studio/scene3d/settings_dialog.py index 149109f..a4302c9 100644 --- a/apps/studio/src/orisys_studio/scene3d/settings_dialog.py +++ b/apps/studio/src/orisys_studio/scene3d/settings_dialog.py @@ -16,8 +16,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, QLabel, @@ -60,7 +60,7 @@ class Scene3dSettingsDialog(QDialog): root.addWidget(panel.controls_widget) panel.controls_widget.setVisible(True) - self.buttons = QDialogButtonBox(QDialogButtonBox.Close) + self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) self.buttons.rejected.connect(self.close) self.buttons.accepted.connect(self.close) root.addWidget(self.buttons) @@ -72,7 +72,7 @@ class Scene3dSettingsDialog(QDialog): self.hint.setText(t("scene3d.settings_hint")) # Qt ships its own translations for a button box, and the application installs # none: without this the dialog ends in an English "Close" among Chinese labels. - close_button = self.buttons.button(QDialogButtonBox.Close) + close_button = self.buttons.button(QDialogButtonBox.StandardButton.Close) if close_button is not None: close_button.setText(t("dialog.close")) @@ -84,7 +84,7 @@ class Scene3dSettingsDialog(QDialog): def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming """Esc closes the dialog, as it does everywhere else in this application.""" - if event.key() == Qt.Key_Escape: + if event.key() == Qt.Key.Key_Escape: self.hide() return super().keyPressEvent(event) diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index e52886b..e16212f 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -28,8 +28,8 @@ import logging from concurrent.futures import ThreadPoolExecutor from typing import Any, Callable, Optional -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QVBoxLayout, QWidget +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QVBoxLayout, QWidget from orisys_app_shared.scene3d.composer import SceneComposer from orisys_app_shared.scene3d.contracts import ( @@ -71,9 +71,9 @@ def gl_available() -> tuple[bool, str]: pane on a machine where PyOpenGL is not installed. """ try: - from PyQt5.QtWidgets import QOpenGLWidget # noqa: F401 + from PySide6.QtOpenGLWidgets import QOpenGLWidget # noqa: F401 except Exception as exc: # noqa: BLE001 - return False, f"PyQt5 OpenGL widgets are unavailable: {exc}" + return False, f"PySide6 OpenGL widgets are unavailable: {exc}" try: import OpenGL # noqa: F401 except Exception as exc: # noqa: BLE001 @@ -84,11 +84,11 @@ def gl_available() -> tuple[bool, str]: class Scene3DView(QWidget): """One 3D pane: the GL widget on top, its controls underneath.""" - stateChanged = pyqtSignal(object) # SceneState - diagnosticRaised = pyqtSignal(object) # Diagnostic - renderStatsChanged = pyqtSignal(object) # RenderStats - descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor] - enabledChanged = pyqtSignal(bool) + stateChanged = Signal(object) # SceneState + diagnosticRaised = Signal(object) # Diagnostic + renderStatsChanged = Signal(object) # RenderStats + descriptorChanged = Signal(object) # Optional[FieldDescriptor] + enabledChanged = Signal(bool) def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) diff --git a/apps/studio/src/orisys_studio/scene3d/widget.py b/apps/studio/src/orisys_studio/scene3d/widget.py index c6d4874..c91e1d6 100644 --- a/apps/studio/src/orisys_studio/scene3d/widget.py +++ b/apps/studio/src/orisys_studio/scene3d/widget.py @@ -19,9 +19,11 @@ from __future__ import annotations import time from typing import Optional -from PyQt5.QtCore import QEvent, Qt, pyqtSignal -from PyQt5.QtGui import QMouseEvent, QWheelEvent -from PyQt5.QtWidgets import QOpenGLWidget, QWidget +from PySide6.QtCore import QEvent, Qt, Signal +from PySide6.QtGui import QMouseEvent, QWheelEvent +# Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows. +from PySide6.QtOpenGLWidgets import QOpenGLWidget +from PySide6.QtWidgets import QWidget from orisys_app_shared.scene3d.contracts import ( Diagnostic, @@ -48,13 +50,13 @@ STATS_INTERVAL_S = 0.5 class Sensor3DWidget(QOpenGLWidget): """Renders one scene. Signals carry state, never per-frame arrays.""" - capabilitiesChanged = pyqtSignal(object) # GLCapabilities - diagnosticRaised = pyqtSignal(object) # Diagnostic - renderStatsChanged = pyqtSignal(object) # RenderStats, rate-limited + capabilitiesChanged = Signal(object) # GLCapabilities + diagnosticRaised = Signal(object) # Diagnostic + renderStatsChanged = Signal(object) # RenderStats, rate-limited #: What the frame on screen claims to be — quantity, unit, quality. The panel #: labels the colour bar from this and nothing else, so an unmeasured surface #: cannot inherit the previous frame's range. - descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor] + descriptorChanged = Signal(object) # Optional[FieldDescriptor] def __init__( self, @@ -259,14 +261,19 @@ class Sensor3DWidget(QOpenGLWidget): When the platform cannot create a GL context (the offscreen plugin, a broken driver, a board without EGL) ``initializeGL`` is simply never called: there is no exception to catch, and the widget would sit there looking like a working - view that has no data yet. Qt does answer the question, though — ``context()`` - stays ``None`` — so the first paint asks it and turns "no context" into the - state the rest of the application already knows how to show. + view that has no data yet. Qt does answer the question, though, so the first + paint asks it and turns "no context" into the state the rest of the application + already knows how to show. + + Under Qt5 that answer was ``context() is None``. Qt6 returns an *invalid* + context object instead, so the check has to be ``isValid()`` as well — comparing + against ``None`` alone silently stops reporting a broken GL stack. """ super().paintEvent(event) if self._unavailable or self._capabilities is not None: return - if self.context() is None: + context = self.context() + if context is None or not context.isValid(): self._fail( diagnostic( "gl_unsupported", @@ -305,7 +312,7 @@ class Sensor3DWidget(QOpenGLWidget): # --------------------------------------------------------------- interaction def mousePressEvent(self, event: QMouseEvent) -> None: # pragma: no cover - self._last_press = (float(event.x()), float(event.y())) + self._last_press = (float(event.position().x()), float(event.position().y())) super().mousePressEvent(event) def mouseReleaseEvent(self, event: QMouseEvent) -> None: # pragma: no cover @@ -316,17 +323,17 @@ class Sensor3DWidget(QOpenGLWidget): if self._last_press is None: super().mouseMoveEvent(event) return - x, y = float(event.x()), float(event.y()) + x, y = float(event.position().x()), float(event.position().y()) delta = (x - self._last_press[0], y - self._last_press[1]) self._last_press = (x, y) viewport = (max(self.width(), 1), max(self.height(), 1)) buttons = event.buttons() - panning = bool(buttons & Qt.MiddleButton) or bool( - buttons & Qt.LeftButton and event.modifiers() & Qt.ShiftModifier + panning = bool(buttons & Qt.MouseButton.MiddleButton) or bool( + buttons & Qt.MouseButton.LeftButton and event.modifiers() & Qt.KeyboardModifier.ShiftModifier ) if panning: self._camera.pan(delta, viewport) - elif buttons & Qt.LeftButton: + elif buttons & Qt.MouseButton.LeftButton: self._camera.orbit(delta, viewport) self.update() diff --git a/apps/studio/src/orisys_studio/session_controller.py b/apps/studio/src/orisys_studio/session_controller.py index 2d16b5c..a4231e0 100644 --- a/apps/studio/src/orisys_studio/session_controller.py +++ b/apps/studio/src/orisys_studio/session_controller.py @@ -26,7 +26,7 @@ import logging import time from typing import Any, Callable, Optional -from PyQt5.QtCore import QObject, QTimer, pyqtSignal +from PySide6.QtCore import QObject, QTimer, Signal from orisys_app_shared.i18n import t from orisys_studio import session_metadata @@ -47,11 +47,11 @@ class SessionController(QObject): """Owns the acquisition session's state and its UI-free mechanics.""" #: The run timer fired: the window's presenter slot does the painting. - tick = pyqtSignal() + tick = Signal() #: The phase was (re)applied, not only changed: the status chip repaints from it, #: and an idempotent repaint is cheaper than a second source of truth. - phaseChanged = pyqtSignal(str) - runningChanged = pyqtSignal(bool) + phaseChanged = Signal(str) + runningChanged = Signal(bool) def __init__( self, diff --git a/apps/studio/src/orisys_studio/ui/about_dialog.py b/apps/studio/src/orisys_studio/ui/about_dialog.py index 9a1869f..d509a05 100644 --- a/apps/studio/src/orisys_studio/ui/about_dialog.py +++ b/apps/studio/src/orisys_studio/ui/about_dialog.py @@ -2,9 +2,9 @@ from __future__ import annotations -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QPixmap -from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout from orisys_app_shared.i18n import t from orisys_app_shared.paths import logo_topbar_path @@ -24,19 +24,19 @@ class AboutDialog(QDialog): logo = QLabel() pix = QPixmap(str(logo_topbar_path())) if not pix.isNull(): - logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation)) + logo.setPixmap(pix.scaledToHeight(48, Qt.TransformationMode.SmoothTransformation)) row.addWidget(logo) row.addStretch(1) root.addLayout(row) self.body = QLabel() self.body.setWordWrap(True) - self.body.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.body.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) root.addWidget(self.body) close_btn = QPushButton("OK") close_btn.clicked.connect(self.accept) - root.addWidget(close_btn, alignment=Qt.AlignRight) + root.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) self.retranslate() def retranslate(self) -> None: diff --git a/apps/studio/src/orisys_studio/ui/arrow_panel.py b/apps/studio/src/orisys_studio/ui/arrow_panel.py index 2e59db7..a114638 100644 --- a/apps/studio/src/orisys_studio/ui/arrow_panel.py +++ b/apps/studio/src/orisys_studio/ui/arrow_panel.py @@ -12,9 +12,9 @@ from __future__ import annotations from typing import Optional -from PyQt5.QtCore import QSize, Qt, pyqtSignal -from PyQt5.QtGui import QImage, QPainter, QPixmap, QTransform -from PyQt5.QtWidgets import ( +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QImage, QPainter, QPixmap, QTransform +from PySide6.QtWidgets import ( QComboBox, QFrame, QHBoxLayout, @@ -48,7 +48,7 @@ def bgr_to_qimage(frame) -> QImage: """ rgb = frame[:, :, ::-1].copy() height, width, channels = rgb.shape - return QImage(rgb.data, width, height, channels * width, QImage.Format_RGB888).copy() + return QImage(rgb.data, width, height, channels * width, QImage.Format.Format_RGB888).copy() _VIEW_LABELS = { @@ -92,7 +92,7 @@ class AspectImageLabel(QLabel): def __init__(self, parent=None) -> None: super().__init__(parent) self._aspect = DEFAULT_FRAME_ASPECT - policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) policy.setHeightForWidth(True) self.setSizePolicy(policy) self.setMinimumSize(200, _IMAGE_MIN_HEIGHT) @@ -132,7 +132,7 @@ class ColorBarLabel(QLabel): self.setObjectName("colorBar") self.setFixedHeight(_COLORBAR_HEIGHT) self.setMinimumWidth(_COLORBAR_WIDTH) - self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) self.setVisible(False) def set_theme(self, theme: str) -> None: @@ -145,14 +145,14 @@ class ColorBarLabel(QLabel): # (256, 1, 3) BGR -> one RGB scanline. The bytes object stays alive for # the whole call, which is what QImage's borrowed buffer needs. rgb = flow_colormap(self._theme)[:, 0, ::-1].copy().tobytes() - image = QImage(rgb, 256, 1, 256 * 3, QImage.Format_RGB888) + image = QImage(rgb, 256, 1, 256 * 3, QImage.Format.Format_RGB888) painter = QPainter(self) painter.drawImage(self.rect(), image) painter.end() class ArrowPanel(QFrame): - view_mode_changed = pyqtSignal(str) + view_mode_changed = Signal(str) def __init__(self, parent=None, *, theme: str = "dark") -> None: super().__init__(parent) @@ -188,7 +188,7 @@ class ArrowPanel(QFrame): layout.addLayout(header) self.image_label = AspectImageLabel() - self.image_label.setAlignment(Qt.AlignCenter) + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.image_label.setScaledContents(False) self.image_label.setWordWrap(True) # The image keeps the frame's shape and is centred in whatever height is @@ -515,11 +515,11 @@ class ArrowPanel(QFrame): deg = self._effective_rotation_deg() if deg: # QTransform.rotate is clockwise on screen pixmaps; negate for CCW. - pix = pix.transformed(QTransform().rotate(-deg), Qt.FastTransformation) + pix = pix.transformed(QTransform().rotate(-deg), Qt.TransformationMode.FastTransformation) scaled = pix.scaled( self.image_label.size(), - Qt.KeepAspectRatio, - Qt.SmoothTransformation, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, ) self.image_label.setPixmap(scaled) self.image_label.setText("") diff --git a/apps/studio/src/orisys_studio/ui/brand_bar.py b/apps/studio/src/orisys_studio/ui/brand_bar.py index a430730..7d8483b 100644 --- a/apps/studio/src/orisys_studio/ui/brand_bar.py +++ b/apps/studio/src/orisys_studio/ui/brand_bar.py @@ -14,9 +14,9 @@ from __future__ import annotations import logging from pathlib import Path -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QColor, QPainter, QPixmap -from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor, QPainter, QPixmap +from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout from orisys_app_shared.i18n import t from orisys_app_shared.paths import logo_topbar_path @@ -39,13 +39,13 @@ def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap if source.isNull(): return QPixmap() scaled = source.scaled( - size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation + size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation ) out = QPixmap(scaled.size()) - out.fill(Qt.transparent) + out.fill(Qt.GlobalColor.transparent) painter = QPainter(out) painter.drawPixmap(0, 0, scaled) - painter.setCompositionMode(QPainter.CompositionMode_SourceIn) + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn) painter.fillRect(out.rect(), QColor(tint)) painter.end() return out @@ -55,11 +55,11 @@ class BrandBar(QFrame): """Thin brand rail: lockup and top-level view switcher, nothing else.""" #: The user asked for the live view. - live_requested = pyqtSignal() + live_requested = Signal() #: The user asked for the history/replay view. - playback_requested = pyqtSignal() + playback_requested = Signal() #: The user asked for the calibration view. - calibration_requested = pyqtSignal() + calibration_requested = Signal() def __init__(self, parent=None, *, theme: str = "dark") -> None: super().__init__(parent) @@ -74,7 +74,7 @@ class BrandBar(QFrame): self.logo = QLabel() self.logo.setObjectName("brandLogo") self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE) - self.logo.setAlignment(Qt.AlignCenter) + self.logo.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.logo) # The lockup keeps its two labels (the product's tagline stays available @@ -113,7 +113,7 @@ class BrandBar(QFrame): self.product = QLabel() self.product.setObjectName("brandProduct") - self.product.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.product.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) layout.addWidget(self.product) self.set_theme(theme) @@ -123,7 +123,7 @@ class BrandBar(QFrame): def _nav_button(self) -> QPushButton: button = QPushButton() button.setObjectName("navButton") - button.setCursor(Qt.PointingHandCursor) + button.setCursor(Qt.CursorShape.PointingHandCursor) button.setFlat(True) button.setCheckable(True) return button diff --git a/apps/studio/src/orisys_studio/ui/cal_panel.py b/apps/studio/src/orisys_studio/ui/cal_panel.py index c4c8970..5a3f6a8 100644 --- a/apps/studio/src/orisys_studio/ui/cal_panel.py +++ b/apps/studio/src/orisys_studio/ui/cal_panel.py @@ -3,9 +3,9 @@ from __future__ import annotations import numpy as np -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QColor, QFont, QFontDatabase -from PyQt5.QtWidgets import ( +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor, QFont, QFontDatabase +from PySide6.QtWidgets import ( QAbstractItemView, QButtonGroup, QDoubleSpinBox, @@ -60,11 +60,11 @@ def _mono_font() -> str: class CalPanel(QFrame): - status_message = pyqtSignal(str) + status_message = Signal(str) #: Coefficients were written to disk. It does **not** mean they were applied. - calibration_exported = pyqtSignal(str) + calibration_exported = Signal(str) #: The operator asked to push the last export into the running device. - apply_requested = pyqtSignal(str) + apply_requested = Signal(str) def __init__(self, parent=None, *, theme: str = "dark") -> None: super().__init__(parent) @@ -97,9 +97,9 @@ class CalPanel(QFrame): outer.setSpacing(0) self.scroll = QScrollArea() self.scroll.setWidgetResizable(True) - self.scroll.setFrameShape(QFrame.NoFrame) - self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.scroll.setFrameShape(QFrame.Shape.NoFrame) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) content = QWidget() self.scroll.setWidget(content) outer.addWidget(self.scroll) @@ -135,7 +135,7 @@ class CalPanel(QFrame): form = QFormLayout() form.setHorizontalSpacing(10) form.setVerticalSpacing(6) - form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self._weight_label = QLabel() self._weight_label.setObjectName("fieldLabel") self.weight_spin = QDoubleSpinBox() @@ -182,16 +182,16 @@ class CalPanel(QFrame): # Diagnostic table: the loaded axis plus the two crosstalk axes, a # stability measure and the point's state. self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS)) - self.table.setSelectionBehavior(QAbstractItemView.SelectRows) - self.table.setSelectionMode(QAbstractItemView.SingleSelection) - self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.table.setAlternatingRowColors(True) self.table.setShowGrid(False) header = self.table.horizontalHeader() # Every column takes the width its own header and cells need: eliding a # diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow # panel scrolls instead. - header.setSectionResizeMode(QHeaderView.ResizeToContents) + header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) header.setMinimumSectionSize(64) self.table.verticalHeader().setVisible(False) self.table.verticalHeader().setDefaultSectionSize(26) @@ -235,7 +235,7 @@ class CalPanel(QFrame): ): # The action row must survive a narrow three-panel layout. button.setProperty("compact", "true") - button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + button.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) self.btn_toggle_excluded.clicked.connect(self._on_toggle_excluded) self.btn_toggle_validation.clicked.connect(self._on_toggle_validation) self.btn_delete.clicked.connect(self._on_delete_row) @@ -523,11 +523,11 @@ class CalPanel(QFrame): ) for column, text in enumerate(values): item = QTableWidgetItem(text) - item.setFlags(item.flags() & ~Qt.ItemIsEditable) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) if column in (0, 1, 2, 3): - item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter)) + item.setTextAlignment(int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)) else: - item.setTextAlignment(int(Qt.AlignCenter)) + item.setTextAlignment(int(Qt.AlignmentFlag.AlignCenter)) if point.excluded: item.setForeground(QColor(color(self._theme, "text_muted"))) elif point.is_validation: @@ -611,7 +611,7 @@ class CalPanel(QFrame): """Double click a row to inspect that point's three-axis curve.""" dialog = self.point_dialog(self._current_point()) if dialog is not None: - dialog.exec_() + dialog.exec() def _on_toggle_excluded(self) -> None: point = self._current_point() diff --git a/apps/studio/src/orisys_studio/ui/cal_point_dialog.py b/apps/studio/src/orisys_studio/ui/cal_point_dialog.py index a73b2c6..34a9140 100644 --- a/apps/studio/src/orisys_studio/ui/cal_point_dialog.py +++ b/apps/studio/src/orisys_studio/ui/cal_point_dialog.py @@ -7,9 +7,9 @@ the three-panel layout, while a single point can still be examined in full. from __future__ import annotations import numpy as np -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QFont, QFontDatabase -from PyQt5.QtWidgets import ( +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont, QFontDatabase +from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, QFormLayout, @@ -103,7 +103,7 @@ class CalibrationPointDialog(QDialog): root.addWidget(self.plot_widget, 1) self._build_traces() - buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) buttons.rejected.connect(self.reject) buttons.accepted.connect(self.accept) root.addWidget(buttons) diff --git a/apps/studio/src/orisys_studio/ui/fit_text.py b/apps/studio/src/orisys_studio/ui/fit_text.py index 9250be5..ef19834 100644 --- a/apps/studio/src/orisys_studio/ui/fit_text.py +++ b/apps/studio/src/orisys_studio/ui/fit_text.py @@ -9,8 +9,8 @@ clips the text and the tooltip remains the way to read it. from __future__ import annotations -from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import QLabel, QSizePolicy +from PySide6.QtCore import QSize +from PySide6.QtWidgets import QLabel, QSizePolicy class ShrinkableLabel(QLabel): @@ -18,7 +18,7 @@ class ShrinkableLabel(QLabel): def __init__(self, text: str = "", parent=None) -> None: super().__init__(text, parent) - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) self.setMinimumWidth(0) def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index ca2564d..e5701f6 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -5,9 +5,9 @@ from __future__ import annotations import time import numpy as np -from PyQt5.QtCore import Qt, QSize, pyqtSignal -from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics -from PyQt5.QtWidgets import ( +from PySide6.QtCore import Qt, QSize, Signal +from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics +from PySide6.QtWidgets import ( QFrame, QHBoxLayout, QLabel, @@ -116,18 +116,18 @@ class _Sync: class ForcePlotsPanel(QFrame): #: The visible time window changed while browsing history. - window_changed = pyqtSignal(float, float) + window_changed = Signal(float, float) #: The shared cursor moved; other views sync their playback position to it. - cursor_moved = pyqtSignal(float) + cursor_moved = Signal(float) #: "follow" (live) or "browse" (user-controlled window). - mode_changed = pyqtSignal(str) + mode_changed = Signal(str) #: The unit chip was clicked: switch between newton and raw. - unit_toggle_requested = pyqtSignal() + unit_toggle_requested = Signal() #: The gear was clicked: open the plot settings (window, range, filters). - plot_settings_requested = pyqtSignal() + plot_settings_requested = Signal() #: The operator took the timeline over (dragged the cursor, box-zoomed, panned #: or moved the overview marker): playback stops where it is. - timeline_taken_over = pyqtSignal() + timeline_taken_over = Signal() def __init__(self, parent=None, *, theme: str = "dark") -> None: super().__init__(parent) @@ -217,7 +217,7 @@ class ForcePlotsPanel(QFrame): self.unit_badge = QPushButton() self.unit_badge.setObjectName("unitBadge") self.unit_badge.setProperty("compact", "true") - self.unit_badge.setCursor(Qt.PointingHandCursor) + self.unit_badge.setCursor(Qt.CursorShape.PointingHandCursor) self.unit_badge.clicked.connect(self.unit_toggle_requested.emit) header.addWidget(self.unit_badge) root.addLayout(header) @@ -329,7 +329,7 @@ class ForcePlotsPanel(QFrame): self.window_label.setObjectName("panelMeta") self.stats_label = QLabel() self.stats_label.setObjectName("panelMeta") - self.stats_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.stats_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) browse_row.addWidget(self.window_caption) browse_row.addWidget(self.window_label) browse_row.addStretch(1) @@ -348,7 +348,7 @@ class ForcePlotsPanel(QFrame): button.setProperty("iconOnly", "true") button.setIcon(icons.icon(icon_name, self._theme)) button.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) - button.setCursor(Qt.PointingHandCursor) + button.setCursor(Qt.CursorShape.PointingHandCursor) button._icon_name = icon_name # noqa: SLF001 - read back by set_theme return button @@ -389,7 +389,7 @@ class ForcePlotsPanel(QFrame): value_row.setSpacing(5) value_label = QLabel() value_label.setObjectName("valueCard") - value_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + value_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) value_label.setFont(_mono_font(16, bold=True)) # Never a fixed height: the assertion that a value is not clipped is # derived from the font it is actually drawn with. diff --git a/apps/studio/src/orisys_studio/ui/icons.py b/apps/studio/src/orisys_studio/ui/icons.py index 2546ccb..e72d52a 100644 --- a/apps/studio/src/orisys_studio/ui/icons.py +++ b/apps/studio/src/orisys_studio/ui/icons.py @@ -14,8 +14,8 @@ from __future__ import annotations from functools import lru_cache -from PyQt5.QtCore import QRectF, Qt -from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap +from PySide6.QtCore import QRectF, Qt +from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap from orisys_app_shared.theme import color @@ -50,7 +50,7 @@ def _paint_zoom_fit(painter: QPainter, size: int) -> None: def _paint_box_zoom(painter: QPainter, size: int) -> None: """A dashed frame: drag a region to zoom into it.""" pen = painter.pen() - pen.setStyle(Qt.DashLine) + pen.setStyle(Qt.PenStyle.DashLine) pen.setDashPattern([2.2, 1.6]) painter.setPen(pen) painter.drawRect(QRectF(2.0, 2.0, size - 4.0, size - 4.0)) @@ -143,19 +143,19 @@ _PAINTERS = { def pixmap(name: str, tint: str, size: int = ICON_SIZE) -> QPixmap: """One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value).""" canvas = QPixmap(size, size) - canvas.fill(Qt.transparent) + canvas.fill(Qt.GlobalColor.transparent) paint = _PAINTERS.get(str(name)) if paint is None: return canvas painter = QPainter(canvas) - painter.setRenderHint(QPainter.Antialiasing, True) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) pen = QPen() pen.setWidthF(_PEN_WIDTH) - pen.setCapStyle(Qt.RoundCap) - pen.setJoinStyle(Qt.RoundJoin) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) pen.setColor(QColor(tint)) painter.setPen(pen) - painter.setBrush(Qt.NoBrush) + painter.setBrush(Qt.BrushStyle.NoBrush) try: paint(painter, size) finally: diff --git a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py index a813b73..540e374 100644 --- a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py +++ b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py @@ -16,8 +16,8 @@ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING, Callable, Optional -from PyQt5.QtCore import QSize, Qt, pyqtSignal -from PyQt5.QtWidgets import ( +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtWidgets import ( QFrame, QHBoxLayout, QLabel, @@ -78,7 +78,7 @@ class Mesh3dPanel(QFrame): #: Emitted when the operator turns the pane on. The window wires the packet sink #: from this, so no packet is adapted while the pane is off. - enabledChanged = pyqtSignal(bool) + enabledChanged = Signal(bool) def __init__( self, parent: Optional[QWidget] = None, *, theme: str = "dark" @@ -128,14 +128,14 @@ class Mesh3dPanel(QFrame): self.btn_reset_view = QPushButton() self.btn_reset_view.setProperty("compact", "true") self.btn_reset_view.setProperty("iconOnly", "true") - self.btn_reset_view.setCursor(Qt.PointingHandCursor) + self.btn_reset_view.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_reset_view.clicked.connect(self.reset_view) header.addWidget(self.btn_reset_view) self.btn_settings = QPushButton() self.btn_settings.setProperty("compact", "true") self.btn_settings.setProperty("iconOnly", "true") - self.btn_settings.setCursor(Qt.PointingHandCursor) + self.btn_settings.setCursor(Qt.CursorShape.PointingHandCursor) self.btn_settings.clicked.connect(self.open_settings) header.addWidget(self.btn_settings) root.addLayout(header) @@ -145,7 +145,7 @@ class Mesh3dPanel(QFrame): self.message = QLabel() self.message.setObjectName("hintText") - self.message.setAlignment(Qt.AlignCenter) + self.message.setAlignment(Qt.AlignmentFlag.AlignCenter) self.message.setWordWrap(True) self._stack.addWidget(self.message) diff --git a/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py b/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py index 2fddb8d..aaaec78 100644 --- a/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py +++ b/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py @@ -9,7 +9,7 @@ reports as done is what the record will say (see ``force/filters.py``). from __future__ import annotations -from PyQt5.QtWidgets import ( +from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDialog, @@ -133,7 +133,7 @@ class PlotSettingsDialog(QDialog): self.btn_reset.clicked.connect(self._reset_defaults) buttons_row.addWidget(self.btn_reset) buttons_row.addStretch(1) - self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) self.button_box.accepted.connect(self.accept) self.button_box.rejected.connect(self.reject) buttons_row.addWidget(self.button_box) @@ -384,8 +384,8 @@ class PlotSettingsDialog(QDialog): self.rate_label.setText(t("dialog.filter_rate")) self.rate_spin.setToolTip(t("dialog.filter_rate_tip")) self._refresh_filter_note() - ok_btn = self.button_box.button(QDialogButtonBox.Ok) - cancel_btn = self.button_box.button(QDialogButtonBox.Cancel) + ok_btn = self.button_box.button(QDialogButtonBox.StandardButton.Ok) + cancel_btn = self.button_box.button(QDialogButtonBox.StandardButton.Cancel) if ok_btn is not None: ok_btn.setText(t("dialog.ok")) if cancel_btn is not None: diff --git a/apps/studio/src/orisys_studio/ui/record_status.py b/apps/studio/src/orisys_studio/ui/record_status.py index 409c709..97c76bb 100644 --- a/apps/studio/src/orisys_studio/ui/record_status.py +++ b/apps/studio/src/orisys_studio/ui/record_status.py @@ -14,7 +14,7 @@ timer without touching the writer thread. from __future__ import annotations -from PyQt5.QtWidgets import QLabel +from PySide6.QtWidgets import QLabel from orisys_app_shared.i18n import t from orisys_app_shared.theme import color diff --git a/apps/studio/src/orisys_studio/ui/sensor_dialog.py b/apps/studio/src/orisys_studio/ui/sensor_dialog.py index d0fabaf..f0c9304 100644 --- a/apps/studio/src/orisys_studio/ui/sensor_dialog.py +++ b/apps/studio/src/orisys_studio/ui/sensor_dialog.py @@ -17,7 +17,7 @@ from __future__ import annotations from datetime import datetime from pathlib import Path -from PyQt5.QtWidgets import ( +from PySide6.QtWidgets import ( QDialog, QFrame, QHBoxLayout, @@ -111,7 +111,7 @@ class SensorDialog(QDialog): def _section_rule() -> QFrame: rule = QFrame() rule.setObjectName("sectionSeparator") - rule.setFrameShape(QFrame.HLine) + rule.setFrameShape(QFrame.Shape.HLine) rule.setFixedHeight(1) return rule diff --git a/apps/studio/src/orisys_studio/ui/sensor_picker.py b/apps/studio/src/orisys_studio/ui/sensor_picker.py index 5433c44..49427e3 100644 --- a/apps/studio/src/orisys_studio/ui/sensor_picker.py +++ b/apps/studio/src/orisys_studio/ui/sensor_picker.py @@ -9,8 +9,8 @@ own labels and tooltips instead of a bare "配置" combo. from __future__ import annotations -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtWidgets import ( +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( QComboBox, QHBoxLayout, QLabel, @@ -29,8 +29,8 @@ BADGE_WIDTH = 46 class SensorPickerWidget(QWidget): - sensor_type_changed = pyqtSignal(str) - config_changed = pyqtSignal(str) + sensor_type_changed = Signal(str) + config_changed = Signal(str) def __init__(self, parent=None) -> None: super().__init__(parent) @@ -42,7 +42,7 @@ class SensorPickerWidget(QWidget): #: attribute: it states which sensor the whole window is configured for. self.thumb_label = QLabel() self.thumb_label.setObjectName("unitBadge") - self.thumb_label.setAlignment(Qt.AlignCenter) + self.thumb_label.setAlignment(Qt.AlignmentFlag.AlignCenter) # A minimum, not a fixed width: "FINGER" must not be truncated to "INGE". self.thumb_label.setMinimumWidth(BADGE_WIDTH) self.thumb_label.setProperty("rail", "true") @@ -55,7 +55,7 @@ class SensorPickerWidget(QWidget): self.type_combo = QComboBox() self.type_combo.setMinimumWidth(TYPE_WIDTH) self.type_combo.setProperty("rail", "true") - self.type_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.type_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) for name in list_sensor_types(): self.type_combo.addItem(name, name) self.type_combo.currentIndexChanged.connect(self._on_type_index_changed) @@ -65,7 +65,7 @@ class SensorPickerWidget(QWidget): self.config_combo = QComboBox() self.config_combo.setMinimumWidth(CONFIG_WIDTH) self.config_combo.setProperty("rail", "true") - self.config_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.config_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) self.config_combo.currentIndexChanged.connect(self._on_config_index_changed) layout.addWidget(self.thumb_label) diff --git a/apps/studio/src/orisys_studio/ui/toolbar.py b/apps/studio/src/orisys_studio/ui/toolbar.py index b44e4bd..9605566 100644 --- a/apps/studio/src/orisys_studio/ui/toolbar.py +++ b/apps/studio/src/orisys_studio/ui/toolbar.py @@ -12,8 +12,8 @@ scrolls sideways instead of squeezing the controls into an unreadable row. from __future__ import annotations -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtWidgets import ( +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( QComboBox, QFrame, QHBoxLayout, @@ -42,9 +42,9 @@ RECORD_MODES = (("video_data", "record.mode_video_data"), ("data_only", "record. def _separator() -> QFrame: line = QFrame() line.setObjectName("sectionSeparator") - line.setFrameShape(QFrame.VLine) + line.setFrameShape(QFrame.Shape.VLine) line.setFixedWidth(1) - line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + line.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) return line @@ -70,23 +70,23 @@ def _group(*widgets: QWidget) -> QWidget: class ToolbarWidget(QWidget): - search_clicked = pyqtSignal() - preview_toggled = pyqtSignal(bool) - camera_id_changed = pyqtSignal(int) + search_clicked = Signal() + preview_toggled = Signal(bool) + camera_id_changed = Signal(int) #: The sensor summary button: opens the sensor and calibration dialog. - sensor_clicked = pyqtSignal() + sensor_clicked = Signal() #: "Back to the camera" while a video file is the run source. - use_camera_clicked = pyqtSignal() + use_camera_clicked = Signal() #: Replay view: pick the recorded video to play. - replay_video_clicked = pyqtSignal() + replay_video_clicked = Signal() #: Replay view: re-run the pipeline over that video (the only processed picture). - recompute_clicked = pyqtSignal() - run_toggled = pyqtSignal(bool) - reset_clicked = pyqtSignal() - units_changed = pyqtSignal(str) - record_toggled = pyqtSignal(bool) + recompute_clicked = Signal() + run_toggled = Signal(bool) + reset_clicked = Signal() + units_changed = Signal(str) + record_toggled = Signal(bool) #: The settings area was expanded or collapsed by the user. - settings_toggled = pyqtSignal(bool) + settings_toggled = Signal(bool) def __init__( self, @@ -124,9 +124,9 @@ class ToolbarWidget(QWidget): # narrow window. self._settings_scroll = QScrollArea() self._settings_scroll.setWidgetResizable(True) - self._settings_scroll.setFrameShape(QFrame.NoFrame) - self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self._settings_scroll.setFrameShape(QFrame.Shape.NoFrame) + self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.settings_page = self._build_settings() self._settings_scroll.setWidget(self.settings_page) outer.addWidget(self._settings_scroll) @@ -157,7 +157,7 @@ class ToolbarWidget(QWidget): self.status_chip = QLabel() self.status_chip.setObjectName("statusChip") - self.status_chip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred) + self.status_chip.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) self.source_summary = ShrinkableLabel() self.source_summary.setObjectName("panelMeta") self.calibration_label = ShrinkableLabel() diff --git a/apps/studio/src/orisys_studio/view_layout.py b/apps/studio/src/orisys_studio/view_layout.py index 72216a7..0742de7 100644 --- a/apps/studio/src/orisys_studio/view_layout.py +++ b/apps/studio/src/orisys_studio/view_layout.py @@ -24,7 +24,7 @@ import logging from dataclasses import dataclass from typing import Any, Optional, Protocol -from PyQt5.QtCore import QObject +from PySide6.QtCore import QObject logger = logging.getLogger(__name__) diff --git a/apps/studio/tests/conftest.py b/apps/studio/tests/conftest.py index 3d2d2b7..73f2c17 100644 --- a/apps/studio/tests/conftest.py +++ b/apps/studio/tests/conftest.py @@ -3,6 +3,10 @@ The tests render real Qt widgets with the offscreen platform plugin, so they need no display and no camera hardware. A single ``QApplication`` is shared by the whole session (Qt forbids creating more than one). + +The suite requires PySide6, the app's only Qt binding. Where it is missing the +modules are skipped rather than failed, so a broken environment cannot look like a +broken application (the same rule ``apps/autocal`` follows). """ from __future__ import annotations @@ -16,11 +20,30 @@ from pathlib import Path import pytest os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +# In a development environment that also has PyQt5 installed, pyqtgraph would otherwise +# bind to it (it takes whichever toolkit it finds first) and the plots would break. +os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6") if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: _fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" if _fonts.is_dir(): os.environ["QT_QPA_FONTDIR"] = str(_fonts) + +def _have_pyside6() -> tuple[bool, str]: + try: + import PySide6 + except ImportError as exc: # pragma: no cover - depends on the machine + return False, str(exc) + return True, getattr(PySide6, "__version__", "unknown") + + +HAVE_PYSIDE6, PYSIDE6_VERSION = _have_pyside6() + +if not HAVE_PYSIDE6: # pragma: no cover - depends on the machine + # Mirrors apps/autocal/tests/conftest.py: nothing is collected, so a machine without + # the binding reports no tests instead of a wall of import errors. + collect_ignore_glob = ["test_*.py"] + # ``tools/ui_check.py`` owns the palette rules; importing it keeps one source of # truth between the pytest suite and the dependency-free checker. _TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools" @@ -30,7 +53,7 @@ if _TOOLS_DIR.is_dir() and str(_TOOLS_DIR) not in sys.path: @pytest.fixture(scope="session") def qt_app(): - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication([]) yield app @@ -83,7 +106,7 @@ def visible_descendants(widget): def show_window(window, size): """Show a window at ``size`` and flush pending Qt events.""" - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication window.resize(*size) window.show() diff --git a/apps/studio/tests/test_binding_purity.py b/apps/studio/tests/test_binding_purity.py new file mode 100644 index 0000000..d8bdb74 --- /dev/null +++ b/apps/studio/tests/test_binding_purity.py @@ -0,0 +1,224 @@ +"""Binding policy for the studio. + +The studio is **PySide6 only** — Qt's LGPLv3 binding, which is why the app was migrated off +PyQt5 (GPLv3-or-commercial). AutoCal has the same guard in +``apps/autocal/tests/test_binding_purity.py``; this file is the studio's half of it: + +* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has PySide6 and + nothing else, so such an import is a hard start-up failure; +* every UI and 3D module must name the binding it uses rather than inheriting one; +* the PyQt5 signal spellings (``pyqtSignal``, ``pyqtBoundSignal``) do not exist in PySide6; +* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``, ``QPainter.Antialiasing``); +* importing the application must not drag another binding into ``sys.modules`` — that is + what breaks ``pyqtgraph``, which binds to whichever toolkit it finds first. + +Two lessons from the migration are baked in here. The unscoped-enum check asks PySide6 +instead of using a hand-written list of enum holders: a curated list missed +``QPainter.RenderHint.Antialiasing``, ``QPainter.CompositionMode.CompositionMode_SourceIn`` and +``QDialogButtonBox.StandardButton.Ok|Cancel|Close``. And the ``sys.modules`` check runs in a **fresh +interpreter**, because in-process the answer depends on what some earlier test imported. +""" + +from __future__ import annotations + +import ast +import enum +import subprocess +import sys +from pathlib import Path + +APP = Path(__file__).resolve().parents[1] # apps/studio +SRC = APP / "src" +PROJECT_ROOT = APP.parents[1] # orisys-app + +#: Everything that ships or drives the app. +SCAN_ROOTS = (SRC, APP / "tools") + +#: Bindings no module may import. +FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"} + +#: The one binding allowed. +ALLOWED_BINDING = "PySide6" + +#: Directories that must name their binding explicitly. +#: +#: ``ui/`` only: every widget there imports PySide6. ``scene3d/`` is deliberately mixed — +#: ``coordinator.py``, ``camera.py``, ``adapters.py``, ``prefs.py`` and ``keys.py`` import no +#: Qt binding at all so they can be tested headless, and requiring one would forbid that +#: design. The modules there that *do* use Qt already name PySide6. +BINDING_DECLARING_DIRS = (SRC / "orisys_studio" / "ui",) + +#: Names that only ever existed in PyQt. Quoted here on purpose — the migration scripts that +#: do the rewriting skip this file, because a text-based rule cannot tell a forbidden spelling +#: in code from one named in the policy that forbids it. +PYQT_ONLY_NAMES = ("pyqtSignal", "pyqtBoundSignal", "pyqtSlot", "pyqtProperty") + +_SKIP_PARTS = ("__pycache__",) + + +def _python_files(root: Path) -> list[Path]: + return sorted( + path + for path in root.rglob("*.py") + if not any(part in path.parts for part in _SKIP_PARTS) + ) + + +def _read(path: Path) -> str: + # One file in the workspace carries a UTF-8 BOM; `utf-8-sig` keeps `ast` happy either way. + return path.read_text(encoding="utf-8-sig", errors="replace") + + +def _imported_packages(path: Path) -> set[str]: + """Every top-level package name imported by ``path``.""" + found: set[str] = set() + for node in ast.walk(ast.parse(_read(path))): + if isinstance(node, ast.Import): + found.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + found.add(node.module.split(".")[0]) + return found + + +def _qt_namespace() -> dict[str, object]: + """The Qt classes the sources could name, resolved through the binding itself.""" + from PySide6 import QtCore, QtGui, QtOpenGLWidgets, QtWidgets + + namespace: dict[str, object] = {"Qt": QtCore.Qt} + return namespace, (QtCore, QtGui, QtWidgets, QtOpenGLWidgets) + + +def _unscoped_enums(path: Path, namespace: dict[str, object], modules: tuple) -> list[str]: + """Attribute reads whose value resolves to an enum instance rather than an enum class. + + ``Qt.AlignmentFlag.AlignLeft`` is an inner attribute of an enum class, so the outer + ``Qt.AlignmentFlag`` resolves to a *class* and the inner one is not a plain name — neither + is a candidate. ``QMessageBox.information`` resolves to a method. ``Qt.AlignmentFlag.AlignLeft`` + resolves to an enum instance, which is the spelling this test exists to reject. + """ + tree = ast.parse(_read(path)) + called = { + id(node.func) + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + } + inner = {id(node.value) for node in ast.walk(tree) if isinstance(node, ast.Attribute)} + found: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name): + continue + if id(node) in called or id(node) in inner: + continue + holder, member = node.value.id, node.attr + target_holder = namespace.get(holder) + if target_holder is None: + for module in modules: + if hasattr(module, holder): + target_holder = namespace[holder] = getattr(module, holder) + break + else: + continue + value = getattr(target_holder, member, None) + if isinstance(value, (enum.Enum, enum.Flag)) and member not in namespace: + found.add(f"{holder}.{member}") + return sorted(found) + + +def test_no_studio_module_imports_a_forbidden_binding(): + offenders = [] + for root in SCAN_ROOTS: + for path in _python_files(root): + bad = _imported_packages(path) & FORBIDDEN_BINDINGS + if bad: + offenders.append(f"{path.relative_to(APP)}: {sorted(bad)}") + assert not offenders, ( + f"the studio is {ALLOWED_BINDING}-only; these modules import another binding: " + f"{offenders}" + ) + + +def test_every_ui_module_imports_the_allowed_binding(): + """A widget module that names no binding would be using one it does not declare.""" + offenders = [] + for directory in BINDING_DECLARING_DIRS: + for path in _python_files(directory): + if path.name == "__init__.py": + continue # a re-export barrel, not a widget module + if ALLOWED_BINDING not in _imported_packages(path): + offenders.append(str(path.relative_to(APP))) + assert not offenders, f"these modules do not import {ALLOWED_BINDING}: {offenders}" + + +def test_no_pyqt_only_spelling_survives(): + offenders = [] + for root in SCAN_ROOTS: + for path in _python_files(root): + text = _read(path) + for name in PYQT_ONLY_NAMES: + if name in text: + offenders.append(f"{path.relative_to(APP)}: {name}") + assert not offenders, f"these PyQt-only names do not exist in PySide6: {offenders}" + + +def test_no_module_uses_an_unscoped_enum(): + try: + import PySide6 # noqa: F401 + except ImportError: # pragma: no cover - depends on the machine + import pytest + + pytest.skip("PySide6 is not installed here, so the enum rule cannot be checked") + namespace, modules = _qt_namespace() + offenders = {} + for root in SCAN_ROOTS: + for path in _python_files(root): + found = _unscoped_enums(path, namespace, modules) + if found: + offenders[str(path.relative_to(APP))] = found + assert not offenders, ( + "unscoped Qt enumerators are the spelling PySide6 is removing; use the scoped " + f"form: {offenders}" + ) + + +def test_importing_the_application_pulls_in_no_other_binding(): + """Fresh interpreter: PySide6 only, whatever this test session imported before.""" + import os + + script = ( + "import sys;" + "import orisys_studio.app_window;" + "import orisys_studio.scene3d.view;" + "import orisys_studio.ui.force_plots;" + "print(','.join(m for m in ('PyQt5', 'PyQt6', 'PySide2') if m in sys.modules))" + ) + env = dict(os.environ) + entries = [str(PROJECT_ROOT / "shared" / "src"), str(SRC)] + if env.get("PYTHONPATH"): + entries.append(env["PYTHONPATH"]) + env["PYTHONPATH"] = os.pathsep.join(entries) + # The app's own start-up contract (``main.py``) and a display-free platform: the point + # is which *binding* comes in, not whether a window can be shown. + env.setdefault("PYQTGRAPH_QT_LIB", "PySide6") + env.setdefault("QT_QPA_PLATFORM", "offscreen") + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=180, + check=False, + env=env, + ) + assert result.returncode == 0, result.stderr + pulled = [name for name in result.stdout.strip().split(",") if name] + assert pulled == [], ( + f"importing the app pulled in {pulled}; pyqtgraph binds to whichever toolkit it " + "finds first, so a second binding in the environment breaks the plots" + ) + + +def test_the_app_declares_its_binding_dependency(): + """The packaging must state PySide6, not leave it to chance.""" + pyproject = (APP / "pyproject.toml").read_text(encoding="utf-8") + assert ALLOWED_BINDING in pyproject + for forbidden in FORBIDDEN_BINDINGS: + assert f'"{forbidden}' not in pyproject, forbidden diff --git a/apps/studio/tests/test_scene3d_gl_smoke.py b/apps/studio/tests/test_scene3d_gl_smoke.py index 8eec96e..dcf30f7 100644 --- a/apps/studio/tests/test_scene3d_gl_smoke.py +++ b/apps/studio/tests/test_scene3d_gl_smoke.py @@ -94,15 +94,16 @@ def _render(widget, qt_app) -> np.ndarray: on a little-endian machine, with a stride that can exceed the width — so the conversion and the stride trim are both load-bearing. """ - from PyQt5.QtGui import QImage + from PySide6.QtGui import QImage widget.repaint() qt_app.processEvents() image = widget.grabFramebuffer() assert not image.isNull(), "Qt returned no framebuffer" - image = image.convertToFormat(QImage.Format_RGB888) + image = image.convertToFormat(QImage.Format.Format_RGB888) width, height = image.width(), image.height() - buffer = image.constBits().asarray(image.byteCount()) + # PySide6 returns a `memoryview` from constBits(), not a QByteArray: wrap it directly. + buffer = np.frombuffer(image.constBits(), dtype=np.uint8) stride = image.bytesPerLine() rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride) return rows[:, : width * 3].reshape(height, width, 3).copy() diff --git a/apps/studio/tests/test_scene3d_pane_ui.py b/apps/studio/tests/test_scene3d_pane_ui.py index 0423731..3ebee68 100644 --- a/apps/studio/tests/test_scene3d_pane_ui.py +++ b/apps/studio/tests/test_scene3d_pane_ui.py @@ -13,8 +13,8 @@ view menu / the pane's header both reach that dialog. from __future__ import annotations import pytest -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QWidget +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QWidget from orisys_app_shared.i18n import t from orisys_studio.ui import icons @@ -117,8 +117,8 @@ def test_the_pane_header_offers_the_two_actions(qt_app): # Reset forwards to the view when there is one. class _View(QWidget): - diagnosticRaised = pyqtSignal(object) - enabledChanged = pyqtSignal(bool) + diagnosticRaised = Signal(object) + enabledChanged = Signal(bool) def __init__(self) -> None: super().__init__() @@ -229,7 +229,7 @@ def test_the_diagnostics_switch_governs_what_the_pane_explains(panel): def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window): """One line under the canvas: what the picture is, not how it was made.""" - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication window = make_window() window.show() @@ -262,7 +262,7 @@ def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window): def test_the_view_menu_opens_the_3d_settings(make_window): """视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header.""" - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication window = make_window(sensor_type="finger", show_finger_3d=True) window.show() diff --git a/apps/studio/tests/test_scene3d_skeleton.py b/apps/studio/tests/test_scene3d_skeleton.py index 526c170..7bcfb7f 100644 --- a/apps/studio/tests/test_scene3d_skeleton.py +++ b/apps/studio/tests/test_scene3d_skeleton.py @@ -19,7 +19,7 @@ import pytest @pytest.fixture def restore_surface_format(): """The default surface format is process-wide; put it back afterwards.""" - from PyQt5.QtGui import QSurfaceFormat + from PySide6.QtGui import QSurfaceFormat previous = QSurfaceFormat.defaultFormat() try: @@ -49,21 +49,21 @@ def test_the_skeleton_does_not_import_a_gl_binding(qt_app): def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_surface_format): - from PyQt5.QtGui import QSurfaceFormat + from PySide6.QtGui import QSurfaceFormat from orisys_studio.scene3d.gl_backend import configure_surface_format configure_surface_format("desktop33") desktop = QSurfaceFormat.defaultFormat() - assert desktop.renderableType() == QSurfaceFormat.OpenGL + assert desktop.renderableType() == QSurfaceFormat.RenderableType.OpenGL assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3) - assert desktop.profile() == QSurfaceFormat.CoreProfile + assert desktop.profile() == QSurfaceFormat.OpenGLContextProfile.CoreProfile assert desktop.depthBufferSize() == 24 assert desktop.samples() == 0, "v1 does not pay for multisampling" configure_surface_format("gles30") embedded = QSurfaceFormat.defaultFormat() - assert embedded.renderableType() == QSurfaceFormat.OpenGLES + assert embedded.renderableType() == QSurfaceFormat.RenderableType.OpenGLES assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0) with pytest.raises(ValueError): diff --git a/apps/studio/tests/test_scene3d_view.py b/apps/studio/tests/test_scene3d_view.py index 495f43a..66cc2cf 100644 --- a/apps/studio/tests/test_scene3d_view.py +++ b/apps/studio/tests/test_scene3d_view.py @@ -29,8 +29,8 @@ import numpy as np import pytest import ui_check -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QWidget +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QWidget from orisys_app_shared.i18n import t from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import ( @@ -276,8 +276,8 @@ class _RecordingView(QWidget): a real ``QWidget`` because the container puts it in its stack. """ - diagnosticRaised = pyqtSignal(object) - enabledChanged = pyqtSignal(bool) + diagnosticRaised = Signal(object) + enabledChanged = Signal(bool) def __init__(self, parent=None) -> None: super().__init__(parent) @@ -559,7 +559,9 @@ def test_a_package_is_loaded_off_the_gui_thread(qt_app, write_poc): loaded: list = [] failed: list = [] loader.loaded.connect(lambda assets, description: loaded.append((assets, description))) - loader.failed.connect(failed.append) + # Same arity caveat as below: this branch should never fire, and under PySide6 a + # builtin slot would fail *silently* if it ever did. + loader.failed.connect(lambda item, request_id: failed.append(item)) try: loader.load(SceneSource(path=path, kind="package", directory=path.parent)) assert _pump(qt_app, lambda: bool(loaded or failed)), "the loader never answered" @@ -679,7 +681,10 @@ def test_a_corrupt_package_is_reported_not_raised(qt_app, tmp_path): broken.write_bytes(b"not a zip archive at all") loader = SceneAssetLoader() failed: list = [] - loader.failed.connect(failed.append) + # ``failed`` carries (diagnostic, request id). PySide6 adapts to a *Python* callable's + # arity but not to a builtin's, so ``failed.append`` raises TypeError inside the emit, + # Qt swallows it, and the failure signal looks like it was never sent. + loader.failed.connect(lambda item, request_id: failed.append(item)) try: loader.load(SceneSource(path=broken, kind="package", directory=tmp_path)) assert _pump(qt_app, lambda: bool(failed)), "a corrupt package produced no answer" diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 095871a..053304e 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -559,7 +559,7 @@ def test_force_plots_render_demo_data(make_window): window = _show(make_window(), (1440, 880)) for i in range(60): window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N") - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication QApplication.processEvents() assert window.force_plots.history.sample_count > 0 @@ -1009,7 +1009,7 @@ def test_top_area_stays_compact(make_window): assert window.toolbar.record_chip.text() == t("record.state_idle") window.toolbar.btn_settings.setChecked(True) - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication QApplication.processEvents() assert window.toolbar.settings_expanded() @@ -1032,7 +1032,7 @@ def test_settings_area_stays_inside_its_container(make_window, size): The settings area is the one part of the top rail that may scroll: its own content is exempt, everything else must fit. """ - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication window = _show(make_window(sensor_type="finger", show_cal_panel=True), size) window._set_settings_expanded(True) @@ -1063,7 +1063,7 @@ def test_idle_run_is_the_only_filled_action(make_window): def test_connecting_cancel_stays_filled(make_window): """Cancel is the way out of a hung device open, so it keeps the weight.""" - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) stuck = ui_check._StuckWorker() @@ -1197,7 +1197,7 @@ def test_overview_region_is_the_timeline_control(make_window): def test_the_charts_start_in_follow_mode(make_window): """Every live entry point resumes following; only browsing is a gesture.""" - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.i18n import t @@ -1229,7 +1229,7 @@ def test_the_charts_start_in_follow_mode(make_window): def test_the_charts_zoom_and_pan_in_every_view(make_window): """Zoom/pan must be reachable from the live view, not only while browsing.""" - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication window, stuck = _running_window(make_window) panel = window.force_plots @@ -1306,7 +1306,7 @@ def test_the_arrow_legend_follows_the_view_mode(make_window): def test_the_sensor_summary_opens_the_dialog(make_window): """The rail states the sensor; the pickers live in the dialog it opens.""" - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.i18n import t @@ -1344,7 +1344,7 @@ def test_the_sensor_summary_opens_the_dialog(make_window): def test_the_source_picker_follows_the_view(make_window): """Live offers a camera, the replay a recorded video — and no residue.""" - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication window, stuck = _running_window(make_window) assert window.toolbar.camera_group.isVisible() @@ -1425,11 +1425,11 @@ def test_the_nav_items_switch_the_main_window_layout(make_window): def test_the_calibration_view_does_not_clip_its_controls(make_window): - from PyQt5.QtWidgets import QHeaderView + from PySide6.QtWidgets import QHeaderView window = _show(make_window(sensor_type="box", show_cal_panel=True), (1440, 880)) window.brand_bar.btn_calibration.click() - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication for _ in range(3): QApplication.processEvents() @@ -1437,7 +1437,7 @@ def test_the_calibration_view_does_not_clip_its_controls(make_window): cal = window.cal_panel assert cal.scroll.isVisible(), "the calibration panel cannot scroll" assert ( - cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeToContents + cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeMode.ResizeToContents ), "the diagnostic columns can still be elided" for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation): assert button.height() >= button.fontMetrics().height(), button.text() @@ -1468,7 +1468,7 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window): window = _show(make_window(), (1440, 880)) window.brand_bar.btn_live.click() - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication QApplication.processEvents() live_sizes = tuple(window.content_splitter.sizes()) @@ -1496,7 +1496,7 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window): def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_recording): """The replay view reads the recording: charts, 2D panel and transport.""" - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.i18n import t @@ -1602,7 +1602,7 @@ def test_the_unit_chip_switches_the_charts_and_the_toolbar(make_window): from orisys_app_shared.i18n import t from orisys_studio.ui.force_plots import unit_display - from PyQt5.QtWidgets import QPushButton + from PySide6.QtWidgets import QPushButton window = _show(make_window(), (1440, 880)) plots = window.force_plots @@ -1734,7 +1734,7 @@ def test_a_replay_keeps_the_2d_pane_from_the_live_camera( While the replay view is up, its frames own the 2D panel; a live packet used to paint over the recording. Leaving the view hands the pane back. """ - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication window, stuck = _running_window(make_window) window.brand_bar.btn_playback.click() @@ -1786,7 +1786,7 @@ def test_the_readout_describes_the_point_the_cursor_is_on( A replay used to state the live acquisition in cards sitting next to a chart of 7.5e5 raw counts, which reads as "the replay never opened". """ - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.i18n import t @@ -1820,7 +1820,7 @@ def test_a_raw_only_recording_is_never_shown_in_newton(make_window, replay_recor for the axes it calibrated. Asking for newton anyway drew three blank charts and a dash in every card, which looks exactly like "the replay did not load". """ - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.i18n import t @@ -1847,7 +1847,7 @@ def test_the_replay_transport_plays_and_pauses(make_window, replay_recording): """Play/pause like an editor: 1× forward, and a manual gesture takes over.""" import time - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) window.brand_bar.btn_playback.click() @@ -1887,7 +1887,7 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video( make_window, replay_recording ): """Off: the recorded CSV. On: the recorded video through the vision chain.""" - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) window.brand_bar.btn_playback.click() @@ -1929,7 +1929,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec wrong. So the rail's second button becomes an unconditional stop in this view, and it covers both things that can run there: plain playback and a recompute pass. """ - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.i18n import t @@ -1989,7 +1989,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec def test_escape_stops_the_replay_too(make_window, replay_recording): """Esc is the documented way out; in the replay view it stops the replay.""" - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) window.brand_bar.btn_playback.click() @@ -2007,7 +2007,7 @@ def test_escape_stops_the_replay_too(make_window, replay_recording): def test_the_live_view_keeps_the_tracker_reset(make_window): - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) window.brand_bar.btn_live.click() @@ -2028,7 +2028,7 @@ def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, recor import cv2 import numpy as np - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.i18n import t @@ -2091,7 +2091,7 @@ def test_the_replay_and_the_live_paths_share_one_image_backend(make_window, reco """ import cv2 import numpy as np - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication import orisys_studio.app_window as app_module from orisys_app_shared.acquisition.image_backend import ( @@ -2186,7 +2186,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_ro """ import cv2 import numpy as np - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.i18n import t @@ -2239,7 +2239,7 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason( make_window, record_root ): """Not a recording *and* not decodable: say what is missing and offer recompute.""" - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.i18n import t @@ -2272,7 +2272,7 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason( def test_a_video_inside_a_recording_opens_the_recording_and_plays( make_window, replay_recording ): - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.i18n import t @@ -2300,7 +2300,7 @@ def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, recor import csv import json - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.i18n import t @@ -2399,7 +2399,7 @@ def test_the_status_bar_is_a_notice_line_not_a_state_line(make_window): """ import time - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication import orisys_studio.app_window as app_module diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index c674de9..38cafce 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -48,7 +48,7 @@ def check(condition: bool, message: str) -> None: def _inside_scroll_area(widget) -> bool: """True when ``widget`` lives inside a QScrollArea's content.""" - from PyQt5.QtWidgets import QScrollArea + from PySide6.QtWidgets import QScrollArea node = widget while node is not None: @@ -538,7 +538,7 @@ def audit_palette(verbose: bool = False) -> list[str]: def main() -> int: - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.force.calibration_io import CalSample from orisys_app_shared.i18n import set_locale, t @@ -1491,7 +1491,7 @@ def main() -> int: window.brand_bar.btn_calibration.click() for _ in range(3): app.processEvents() - from PyQt5.QtWidgets import QHeaderView as _QHeaderView + from PySide6.QtWidgets import QHeaderView as _QHeaderView sizes = window.content_splitter.sizes() check( @@ -2000,7 +2000,7 @@ def main() -> int: "import os, sys\n" "os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')\n" "os.environ.setdefault('QT_QPA_FONTDIR', r'C:\\Windows\\Fonts')\n" - "from PyQt5.QtCore import QTimer\n" + "from PySide6.QtCore import QTimer\n" "import orisys_studio.main as studio_main\n" "show = studio_main.StudioMainWindow.show\n" "def show_and_close(self):\n" @@ -2259,7 +2259,7 @@ def main() -> int: import orisys_studio.ui.cal_panel as _cal_panel_module import orisys_studio.calibration_service as _cal_service_module - from PyQt5.QtWidgets import QMessageBox as _QMessageBox + from PySide6.QtWidgets import QMessageBox as _QMessageBox from orisys_app_shared.force.calibration_io import ( build_force_calibration_v2 as _build_v2b, diff --git a/apps/studio/tools/ui_snapshot.py b/apps/studio/tools/ui_snapshot.py index 86a74ae..89e3e61 100644 --- a/apps/studio/tools/ui_snapshot.py +++ b/apps/studio/tools/ui_snapshot.py @@ -391,7 +391,7 @@ def main() -> int: panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()} - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.i18n import set_locale from orisys_app_shared.theme import build_qss diff --git a/build/README.md b/build/README.md index bff14d8..83ce094 100644 --- a/build/README.md +++ b/build/README.md @@ -24,7 +24,7 @@ dist/ OrisysStudio/ OrisysStudio.exe config/ # lean locales + sensors (no finger visualization/) - _internal/ # Python, PyQt5, opencv-headless, orisys, OpenGL, … + _internal/ # Python, PySide6, opencv-headless, orisys, OpenGL, … ``` ## Why there are two steps @@ -137,11 +137,20 @@ loaded" can only be printed if those modules imported.) ## Dependency notes -- App shared dep uses **`opencv-python-headless`** (PyQt owns the GUI). `setup.ps1` uninstalls - any `opencv-python` and pins `opencv-python-headless>=4.10,<5` (5.x has shipped wheels - without a `cv2` module) and verifies `import cv2`. +- **PySide6** is the Qt binding (LGPLv3). PyQt5 was replaced because its free option is + GPLv3-or-commercial; the apps are PySide6-only and their tests enforce it + (`apps/studio/tests/test_binding_purity.py`, `apps/autocal/tests/test_binding_purity.py`). + `setup.ps1` **uninstalls** PyQt5/PyQt6/PySide2 from the build venv and verifies only one + binding is present: `pyqtgraph` binds to whichever toolkit it imports first, so a leftover + PyQt5 yields a bundle whose plots were built from the wrong binding. The spec also excludes + `PyQt5`/`PyQt6`/`PySide2` outright, and must never exclude `PySide6` itself. +- App shared dep uses **`opencv-python-headless`** (PySide6 owns the GUI). `setup.ps1` + uninstalls any `opencv-python` and pins `opencv-python-headless>=4.10,<5` (5.x has shipped + wheels without a `cv2` module) and verifies `import cv2`. - **PyOpenGL** is the GL binding for the 3D pane and comes from the studio's `three-d` extra. Add the `protected-assets` extra (`cryptography`) if sealed `.oryscene` packages are shipped. +- **`QOpenGLWidget` lives in `PySide6.QtOpenGLWidgets`**, not `QtWidgets` (Qt6 moved it): the + spec names it in `hiddenimports` so the module and `Qt6OpenGLWidgets.dll` are bundled. - **Open3D stays excluded** (spec + build env): it is an offline geometry tool. The 3D pane is Qt `QOpenGLWidget` + PyOpenGL. - `constraints-build.txt` pins the toolchain. Move a pin only together with `setup.bat -Force` diff --git a/build/studio/constraints-build.txt b/build/studio/constraints-build.txt index 5b0c926..cb94a7c 100644 --- a/build/studio/constraints-build.txt +++ b/build/studio/constraints-build.txt @@ -4,7 +4,11 @@ # on the spec (which Qt, which numpy, which hooks-contrib), so a build that "works" on one # machine and produces a non-starting EXE on another is usually a version difference that # nobody wrote down. `-c` constrains whatever else asks for these packages, so the app's -# own loose requirements (PyQt5>=5.15, scipy>=1.10.1, ...) resolve to exactly this set. +# own loose requirements (PySide6>=6.6, scipy>=1.10.1, ...) resolve to exactly this set. +# +# PySide6 rather than PyQt5: the apps are LGPLv3-bound. PyQt5 must not be present in the +# build environment at all — `pyqtgraph` binds to whichever toolkit it finds first, so a +# leftover PyQt5 produces plots built from the wrong binding (setup.ps1 uninstalls it). # # `orisys` is deliberately absent: it is installed from a local checkout (or deliberately # from PyPI with -FromPypi), and pinning it by name would fight the editable install. @@ -15,9 +19,10 @@ PyInstaller==6.22.2 pyinstaller-hooks-contrib==2026.7 PyOpenGL==3.1.10 -PyQt5==5.15.11 -PyQt5-Qt5==5.15.2 -PyQt5-sip==12.19.0 +PySide6==6.11.2 +PySide6-Essentials==6.11.2 +PySide6-Addons==6.11.2 +shiboken6==6.11.2 numpy==2.2.6 scipy==1.15.3 numba==0.67.0 diff --git a/build/studio/orisys-studio.spec b/build/studio/orisys-studio.spec index a555a2f..b48d6e4 100644 --- a/build/studio/orisys-studio.spec +++ b/build/studio/orisys-studio.spec @@ -79,7 +79,12 @@ datas = _branding_datas() binaries: list = _extra_runtime_binaries() hiddenimports = [ "cv2", - "PyQt5.sip", + # PySide6's runtime is shiboken6; the Qt binding itself is found from the imports, but + # QtOpenGLWidgets is unusual enough to name explicitly: it is a separate module and DLL + # (Qt6OpenGLWidgets), and without it the 3D pane reports "no OpenGL widgets" while the + # rest of the app looks fine. + "shiboken6", + "PySide6.QtOpenGLWidgets", "pyqtgraph.graphicsItems", "scipy.signal", "numba", @@ -126,22 +131,50 @@ a = Analysis( "jupyter", "notebook", "pytest", - "PyQt5.QtBluetooth", - "PyQt5.QtNfc", - "PyQt5.QtPositioning", - "PyQt5.QtLocation", - "PyQt5.QtMultimedia", - "PyQt5.QtMultimediaWidgets", - "PyQt5.QtSensors", - "PyQt5.QtSerialPort", - "PyQt5.QtWebChannel", - "PyQt5.QtWebEngine", - "PyQt5.QtWebEngineCore", - "PyQt5.QtWebEngineWidgets", - "PyQt5.QtWebSockets", - "PySide2", - "PySide6", + # The app uses PySide6 only. Qt modules it never imports are excluded to keep the + # bundle small — but `"PySide6"` itself must NOT appear here: excluding the whole + # binding produces an EXE with no Qt at all (it fails at start-up, not at build + # time). `PySide6.QtOpenGLWidgets` is required by the 3D pane and is deliberately + # absent from this list. + "PySide6.Qt3DAnimation", + "PySide6.Qt3DCore", + "PySide6.Qt3DExtras", + "PySide6.Qt3DInput", + "PySide6.Qt3DLogic", + "PySide6.Qt3DRender", + "PySide6.QtBluetooth", + "PySide6.QtCharts", + "PySide6.QtDataVisualization", + "PySide6.QtGraphs", + "PySide6.QtHelp", + "PySide6.QtLocation", + "PySide6.QtMultimedia", + "PySide6.QtMultimediaWidgets", + "PySide6.QtNfc", + "PySide6.QtPdf", + "PySide6.QtPdfWidgets", + "PySide6.QtPositioning", + "PySide6.QtQml", + "PySide6.QtQuick", + "PySide6.QtQuick3D", + "PySide6.QtQuickControls2", + "PySide6.QtQuickWidgets", + "PySide6.QtRemoteObjects", + "PySide6.QtScxml", + "PySide6.QtSensors", + "PySide6.QtSerialBus", + "PySide6.QtSerialPort", + "PySide6.QtSpatialAudio", + "PySide6.QtStateMachine", + "PySide6.QtTextToSpeech", + "PySide6.QtWebChannel", + "PySide6.QtWebEngineCore", + "PySide6.QtWebEngineQuick", + "PySide6.QtWebEngineWidgets", + "PySide6.QtWebSockets", + "PyQt5", "PyQt6", + "PySide2", ], win_no_prefer_redirects=False, win_private_assemblies=False, diff --git a/build/studio/setup.ps1 b/build/studio/setup.ps1 index 0bb6a66..c832e6e 100644 --- a/build/studio/setup.ps1 +++ b/build/studio/setup.ps1 @@ -237,12 +237,19 @@ $install += @($PyInstallerPin, $CvPin) & $VenvPy @install if ($LASTEXITCODE -ne 0) { throw "pip install failed" } -# Upstream `orisys` may declare opencv-python; the app needs the headless wheel (PyQt owns +# Upstream `orisys` may declare opencv-python; the app needs the headless wheel (PySide6 owns # the GUI), and 5.x can arrive without a `cv2` module. & $VenvPy -m pip uninstall -y opencv-python opencv-python-headless 2>$null | Out-Null & $VenvPy -m pip install --disable-pip-version-check -c $Constraints $CvPin if ($LASTEXITCODE -ne 0) { throw "opencv-python-headless install failed" } +# PyQt5 must not be in the build environment, even though the SDK's `gui` extra asks for it +# when someone installs that: `pyqtgraph` binds to whichever toolkit it imports first, and a +# frozen app built where PyQt5 is present can end up with plots built from the wrong binding. +# The apps are PySide6-only (see apps/studio/tests/test_binding_purity.py). +Write-Host "Removing any other Qt binding from the build venv ..." +& $VenvPy -m pip uninstall -y PyQt5 PyQt5-Qt5 PyQt5-sip PyQt6 PySide2 2>$null | Out-Null + # Open3D must not be in the build env: it is an offline tool, and the spec excludes it. $hasOpen3d = & $VenvPy -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)" if ($hasOpen3d -eq "True") { @@ -256,8 +263,12 @@ if (-not $NoVerify) { Write-Host "Verifying the provisioned environment ..." # These are the imports the frozen app makes before it can draw anything. Checking them # here is what turns "the build succeeded" into "the build can start". - & $VenvPy -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PyQt5.QtWidgets, PyInstaller; print('imports OK')" + & $VenvPy -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PySide6.QtWidgets, PySide6.QtOpenGLWidgets, PyInstaller; print('imports OK')" if ($LASTEXITCODE -ne 0) { throw "the venv cannot import what the app imports (see above)" } + # A second binding in the venv is the failure mode the uninstall above exists for: it + # imports fine here and breaks `pyqtgraph` at run time. + & $VenvPy -c "import importlib.util as u; bad=[m for m in ('PyQt5','PyQt6','PySide2') if u.find_spec(m)]; assert not bad, 'another Qt binding is installed: '+str(bad); print('binding OK: PySide6 only')" + if ($LASTEXITCODE -ne 0) { throw "the build venv has more than one Qt binding" } # Theme colours are package data: PyInstaller only bundles them if the package is # importable at build time, so a stale editable install loses them silently. & $VenvPy -c "from PyInstaller.utils.hooks import collect_data_files as c; d=c('orisys_app_shared', includes=['theme/*.json']); assert d, 'theme data not collectable'; print('theme data OK:', len(d), 'files')" diff --git a/docs/architecture/studio-3d-contracts.pyi b/docs/architecture/studio-3d-contracts.pyi index c1e7e4b..87bb9c2 100644 --- a/docs/architecture/studio-3d-contracts.pyi +++ b/docs/architecture/studio-3d-contracts.pyi @@ -13,9 +13,10 @@ from concurrent.futures import Executor import numpy as np from numpy.typing import NDArray -from PyQt5.QtCore import QEvent, pyqtBoundSignal -from PyQt5.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent -from PyQt5.QtWidgets import QOpenGLWidget, QWidget +from PySide6.QtCore import QEvent, SignalInstance +from PySide6.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent +from PySide6.QtOpenGLWidgets import QOpenGLWidget +from PySide6.QtWidgets import QWidget from orisys_app_shared.acquisition.models import SdkPacket from orisys.finger.runtime import FingerRuntime @@ -343,9 +344,9 @@ class GLRenderer(Protocol): class Sensor3DWidget(QOpenGLWidget): - capabilitiesChanged: pyqtBoundSignal # Payload: GLCapabilities. - diagnosticRaised: pyqtBoundSignal # Payload: Diagnostic. - renderStatsChanged: pyqtBoundSignal # Payload: RenderStats; rate-limited. + capabilitiesChanged: SignalInstance # Payload: GLCapabilities. + diagnosticRaised: SignalInstance # Payload: Diagnostic. + renderStatsChanged: SignalInstance # Payload: RenderStats; rate-limited. def __init__(self, renderer: GLRenderer, camera: OrbitCamera, parent: QWidget | None = None) -> None: ... def set_scene(self, assets: SceneAssets) -> None: diff --git a/docs/architecture/studio-3d-design.md b/docs/architecture/studio-3d-design.md index 1541b02..66511fe 100644 --- a/docs/architecture/studio-3d-design.md +++ b/docs/architecture/studio-3d-design.md @@ -6,7 +6,7 @@ ## 1. 决策与实施范围 -**推荐现有 PyQt5 的 QOpenGLWidget + PyOpenGL,使用现代着色器和 GPU 缓冲区;Open3D 保留为离线几何处理和开发验证工具。** 不为三维显示更换整个 Studio UI 技术栈。 +**推荐使用现有 Qt 技术栈的 QOpenGLWidget + PyOpenGL(Qt 绑定为 PySide6,LGPLv3),使用现代着色器和 GPU 缓冲区;Open3D 保留为离线几何处理和开发验证工具。** 不为三维显示更换整个 Studio UI 技术栈。 目标为 Jetson Orin Nano、QCS6490、Ubuntu。目前没有两块实际部署板的 Ubuntu/BSP、Qt 构建方式、窗口系统和驱动版本;这不阻碍接口设计,但禁止承诺“装好 pip 包即可兼容 ARM”。 @@ -14,7 +14,7 @@ |---|---|---| | QOpenGLWidget + PyOpenGL | 嵌入现有 Qt、动态网格、固定色标、分层渲染 | 首选;自己维护相机、shader、资源生命周期,工作量可控 | | Open3D | 点云、配准、网格处理、离线算法验证 | 不作为 Studio 运行时必需依赖;其 GUI 与 ARM 图形栈有额外限制 | -| Qt 自身 OpenGL 封装 | Qt 上下文与资源管理;可作为 GL 调用适配实现 | 若 PyOpenGL 与目标平台 EGL 函数加载不匹配,在 GLDispatch 内替换绑定;需验证实际 PyQt5 暴露的接口 | +| Qt 自身 OpenGL 封装 | Qt 上下文与资源管理;可作为 GL 调用适配实现 | 若 PyOpenGL 与目标平台 EGL 函数加载不匹配,在 GLDispatch 内替换绑定;需验证实际 PySide6 暴露的接口。Qt6 把 `QOpenGLWidget` 移到了 `QtOpenGLWidgets`,打包时该模块与 `Qt6OpenGLWidgets.dll` 都要收进 bundle | | 更换完整场景引擎/Qt 大版本 | 重型场景、复杂材质、动画 | 第一版需求不足以抵消迁移成本,暂不做 | Open3D 官方将 ARM64 支持标为实验性;其支持矩阵区分 Linux 桌面 OpenGL 与 OpenGL ES,后者不支持其 Visualizer/GUI。新渲染模块的文档另要求 OpenGL 4.1。不能把 Jetson 上的运行结果等同于 QCS6490,也不能把 Python 包可安装当作 GUI 可用。[Open3D ARM 支持](https://www.open3d.org/docs/release/arm.html)、[渲染要求](https://www.open3d.org/docs/release/tutorial/visualization/cpu_rendering.html) @@ -366,7 +366,8 @@ Panel 控件:三维开关、预设视角、复位、外壳显隐、显示形 ### 11.1 依赖 -- 继续使用项目验证过的 Python、PyQt5、NumPy;新增 PyOpenGL。先建立兼容版本范围,PoC 通过后分别锁定 Windows/Jetson/QCS6490 的版本与 wheel 哈希,不在文档虚构已验证版本。 +- 继续使用项目验证过的 Python、**PySide6**、NumPy;新增 PyOpenGL。先建立兼容版本范围,PoC 通过后分别锁定 Windows/Jetson/QCS6490 的版本与 wheel 哈希,不在文档虚构已验证版本。 + Qt 绑定选 PySide6(LGPLv3)而不是 PyQt5(GPLv3-or-commercial):两个 app 都只允许 PySide6,由 `apps/studio/tests/test_binding_purity.py` 与 `apps/autocal/tests/test_binding_purity.py` 静态强制;构建环境里出现第二个 binding 会让 `pyqtgraph` 绑错(`setup.ps1` 会卸载 PyQt5/PyQt6/PySide2,`main.py` 在 import pyqtgraph 之前设 `PYQTGRAPH_QT_LIB=PySide6`)。 - PyOpenGL_accelerate 是可选优化,不作为首轮 ARM 安装前提;官方也将它列为可选项。[PyOpenGL 安装](https://pyopengl.sourceforge.net/documentation/installation.html) - 加密发布模式另引入成熟密码库;Blender、GLB 转换工具只在资产构建环境,客户运行时不依赖 Blender。 - PyOpenGL 不自带 GPU 驱动。不要为了满足依赖让 pip/系统包覆盖板卡 BSP 指定的 EGL/GLES 库。 diff --git a/docs/architecture/studio-architecture.md b/docs/architecture/studio-architecture.md index 2f652cc..b8b9d4b 100644 --- a/docs/architecture/studio-architecture.md +++ b/docs/architecture/studio-architecture.md @@ -186,9 +186,9 @@ graph TB | 规则 | 是否成立 | 证据 | |---|---|---| | 模块级无循环依赖 | ✅ | 扫描 **94 个模块**(studio 37 + shared 57)的 import:**环 0 个** | -| 共享层不依赖 Qt | ✅(有一条惰性例外) | `import` 时拉进 PyQt5 的 21 个模块**全部**在 `orisys_studio.*`;`shared/theme/qss.py` 用 PyQt5 **画图标时**才 import(函数内 + try 保护) | +| 共享层不依赖 Qt | ✅(有一条惰性例外) | `import` 时拉进 PySide6 的模块**全部**在 `orisys_studio.*`;`shared/theme/qss.py` 用 PySide6 **画图标时**才 import(函数内 + try 保护)。两个 app 的 `tests/test_binding_purity.py` 现在静态强制"只允许 PySide6"(在此之前 `qss.py` 的 import 因为写的是 PyQt5 而静默失败,AutoCal 的箭头图标一直没有画出来) | | OpenGL 从不在 import 时拉入 | ✅ | 唯一使用者 `scene3d/gl_backend.py` 是函数内惰性 import;`widget.py` 同理 | -| 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PyQt5/OpenGL/SDK | +| 三维调度与相机无 Qt,可脱离 GUI 测试 | ✅ | 新解释器里 `import orisys_studio.scene3d.coordinator` / `.camera` → 不含 PySide6/OpenGL/SDK | | 采集侧可在无 GUI 环境导入 | ✅ | `acquisition.arrows` / `.sinks`、`record.playback`、`scene3d.contracts`、`scene3d.composer` 全部"干净" | | 共享层与 SDK 解耦 | ⚠️ **不成立** | `orisys_app_shared.force.calibration_io` 在 import 时 `import orisys`;于是 `import orisys_app_shared.force.history` 也会把 SDK 拉进来 | | 三维是单向流动 | ✅ | `window → mesh3d_panel → view → {coordinator, composer, widget, gl_backend, camera}`;`coordinator` 不知道 Qt/GL/IO | @@ -300,7 +300,7 @@ graph TB |---|---|---|---|---| | 1 | ~~**拆 `app_window`**~~ **已完成**(会话 → `session_controller`、回放 → `replay_controller`、布局 → `view_layout`、显示偏好 → `display_prefs`、标定规则 → `calibration_service`;窗口 3044 → 2309 行) | 改动热点从 1 个 3000 行文件变成 5 个 200–650 行文件 + 一个装配器;每个控制器可脱离窗口单测(新增 41 项) | ~~中~~(已付:纯搬运 + 一层转发,未改行为) | `test_ui_smoke`/`test_scene3d_view`/`ui_check` 全绿;`git diff` 只应有移动与转发 | | 1b | **删掉过渡转发**:窗口里那约 40 个 `self._x` 转发是给 tests/`ui_check` 用的桥,下一次改动落在哪个控制器,就顺手把该控制器的转发与测试调用点改成 `self..*` | 窗口真正退到"只有装配";转发留着会慢慢变成新的隐式耦合 | 小-中(每次改动顺带;一次性清理会大面积改测试) | 全套 + `ui_check` | -| 2 | **给共享层加"依赖规则测试"**:AST 扫描断言 `shared` 不得 eager import PyQt5/OpenGL(现状成立),以及 `studio.scene3d` 不得 import `acquisition`;把 `force → orisys` 的现状登记为"已知例外"或改为惰性 | 把"现在对"变成"以后不会错";顺手暴露第 4 条风险 | 小(一个测试文件,用现成扫描脚本改) | 新测试 + 现有套件 | +| 2 | **给共享层加"依赖规则测试"**:AST 扫描断言 `shared` 不得 eager import PySide6/OpenGL(现状成立),以及 `studio.scene3d` 不得 import `acquisition`;把 `force → orisys` 的现状登记为"已知例外"或改为惰性 —— *部分已完成*:`apps/studio/tests/test_binding_purity.py` 与 `apps/autocal/tests/test_binding_purity.py` 已经强制"只允许 PySide6"(含无作用域枚举的检查,且用真 binding 内省而不是手写枚举名单);`studio.scene3d` 与 `force → orisys` 两条仍未加 | 把"现在对"变成"以后不会错";顺手暴露第 4 条风险 | 小(一个测试文件,用现成扫描脚本改) | 新测试 + 现有套件 | | 3 | **消歧命名**:`scene3d/panel.py` → `scene3d/controls.py`;`ui/mesh3d_panel.py` → `ui/scene3d_host.py`;`scene3d/replay` 与 `record/playback` 在 docstring 里互相指路 | 新人少走 3 次错门 | 小(改名 + import 更新),但会触及 ui_check 的引用 | 全套 + `ui_check` | | 4 | **把 `force → SDK` 改为惰性**(`calibration_io` 里改成函数内 import,或把 SDK 相关函数拆出去) | 力曲线的纯计算(history/filters/scale)可在无 SDK 环境测试 | 中(要区分哪些函数真的需要 SDK) | 现有力测试 + 无 SDK 的 import 检查 | | 5 | **`ui_check` 拆模块**(按场景拆 `tools/ui_checks/*.py`,保留单一入口与退出码) | 最脆的资产变成可分工、可 diff 的几块 | 中(3401 行搬运,语义不变) | 检查项计数不变(587 项 0 失败) | diff --git a/shared/pyproject.toml b/shared/pyproject.toml index 769410a..fcde39f 100644 --- a/shared/pyproject.toml +++ b/shared/pyproject.toml @@ -9,7 +9,10 @@ description = "Shared paths, prefs, theme, i18n, and logging for orisys-app" requires-python = ">=3.10" authors = [{ name = "Orisys-Tech" }] dependencies = [ - "PyQt5>=5.15", + # Shared by both apps, which are PySide6-only (LGPLv3). `theme/qss.py` paints the chevron + # icons with it; nothing here may import another binding — enforced by + # apps/autocal/tests/test_binding_purity.py and apps/studio/tests/test_binding_purity.py. + "PySide6>=6.6", "opencv-python-headless>=4.10.0.84,<5", "numpy>=1.24.4", "scipy>=1.10.1", diff --git a/shared/src/orisys_app_shared/theme/qss.py b/shared/src/orisys_app_shared/theme/qss.py index e308f74..51166a2 100644 --- a/shared/src/orisys_app_shared/theme/qss.py +++ b/shared/src/orisys_app_shared/theme/qss.py @@ -33,8 +33,8 @@ def _arrow_icon_cache_dir() -> Path: def _paint_arrow(kind: str, fill: str) -> str: """Draw a small chevron PNG on demand; return its path ('' on failure).""" try: - from PyQt5.QtCore import QPointF, Qt - from PyQt5.QtGui import QColor, QImage, QPainter, QPolygonF + from PySide6.QtCore import QPointF, Qt + from PySide6.QtGui import QColor, QImage, QPainter, QPolygonF except Exception: # pragma: no cover - headless import failure return "" try: @@ -43,12 +43,12 @@ def _paint_arrow(kind: str, fill: str) -> str: path = _arrow_icon_cache_dir() / f"chevron_{kind}_{safe}.png" if path.is_file(): return str(path) - image = QImage(size, size, QImage.Format_ARGB32) - image.fill(Qt.transparent) + image = QImage(size, size, QImage.Format.Format_ARGB32) + image.fill(Qt.GlobalColor.transparent) painter = QPainter(image) - painter.setRenderHint(QPainter.Antialiasing, True) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) painter.setBrush(QColor(fill)) - painter.setPen(Qt.NoPen) + painter.setPen(Qt.PenStyle.NoPen) half = size / 2.0 top = size * 0.32 bottom = size * 0.68 diff --git a/shared/tests/test_scene3d_contracts.py b/shared/tests/test_scene3d_contracts.py index a4b4b27..e61f11b 100644 --- a/shared/tests/test_scene3d_contracts.py +++ b/shared/tests/test_scene3d_contracts.py @@ -464,6 +464,8 @@ def test_the_contract_layer_imports_without_qt_or_opengl(): Checked in a **fresh interpreter**: asking ``sys.modules`` in this process only proves what some earlier test happened to import (it failed once the studio tests painted a widget before this file ran, which says nothing about the contract layer). + The Qt name checked is the binding the app uses (PySide6), which is the only one that + could be pulled in — the earlier spelling named PyQt5, before the migration. """ import subprocess import sys @@ -471,7 +473,7 @@ def test_the_contract_layer_imports_without_qt_or_opengl(): script = ( "import sys;" "import orisys_app_shared.scene3d.contracts as c;" - "print(','.join(m for m in ('OpenGL', 'PyQt5', 'cv2') if m in sys.modules))" + "print(','.join(m for m in ('OpenGL', 'PySide6', 'cv2') if m in sys.modules))" ) result = subprocess.run( [sys.executable, "-c", script], diff --git a/tools/scene_assets/bench_scene.py b/tools/scene_assets/bench_scene.py index 90e2327..5cf171a 100644 --- a/tools/scene_assets/bench_scene.py +++ b/tools/scene_assets/bench_scene.py @@ -310,7 +310,7 @@ def measure( ) -> tuple[dict, int]: """Run the assembled view and report what each stage cost.""" import numpy as np - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import DisplaySettings diff --git a/tools/scene_assets/probe_gl.py b/tools/scene_assets/probe_gl.py index 5c8df40..39e1f8f 100644 --- a/tools/scene_assets/probe_gl.py +++ b/tools/scene_assets/probe_gl.py @@ -43,8 +43,9 @@ def _bootstrap_paths() -> None: def collect(profile: str, *, keep_open_s: float) -> dict: """Create a real window, let Qt initialise it, and report what it got.""" - from PyQt5.QtCore import QTimer - from PyQt5.QtWidgets import QApplication, QOpenGLWidget + from PySide6.QtCore import QTimer + from PySide6.QtOpenGLWidgets import QOpenGLWidget + from PySide6.QtWidgets import QApplication from orisys_studio.scene3d.gl_backend import ( GLDispatch, @@ -103,7 +104,7 @@ def collect(profile: str, *, keep_open_s: float) -> dict: self.ready = True def report_caps(self, capabilities) -> None: - from PyQt5.QtGui import QSurfaceFormat + from PySide6.QtGui import QSurfaceFormat fmt: QSurfaceFormat = self.context().format() if self.context() else QSurfaceFormat() ratio = float(self.devicePixelRatioF() or 1.0) @@ -118,8 +119,11 @@ def collect(profile: str, *, keep_open_s: float) -> dict: "max_texture_size": capabilities.max_texture_size, "qt_format": { "version": f"{fmt.majorVersion()}.{fmt.minorVersion()}", - "renderable_type": int(fmt.renderableType()), - "profile": int(fmt.profile()), + # PySide6 enums are not ints: `int(QSurfaceFormat.OpenGLES)` raises + # TypeError, so read `.value`. (PyQt5 exposed them as ints, which is + # why this worked before the migration.) + "renderable_type": int(fmt.renderableType().value), + "profile": int(fmt.profile().value), "depth_buffer": int(fmt.depthBufferSize()), "samples": int(fmt.samples()), }, diff --git a/tools/scene_assets/soak_scene.py b/tools/scene_assets/soak_scene.py index 4e2cbb0..2b7019e 100644 --- a/tools/scene_assets/soak_scene.py +++ b/tools/scene_assets/soak_scene.py @@ -141,7 +141,7 @@ def run( view = None widget = None if use_gui: - from PyQt5.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication from orisys_studio.scene3d.gl_backend import configure_surface_format from orisys_studio.scene3d.view import Scene3DView