Skip to content

Commit 70284db

Browse files
committed
gentl: reset harvester and CTI failure tests
Add a _reset_harvester helper on GenTLCameraBackend and call it where the harvester must be torn down on failure to ensure proper cleanup. Enhance FakeHarvester to support deterministic add_file failures and to record reset/add/update/create calls; keep create_image_acquirer for compatibility. Update test fixtures (conftest) to expose gentl_fail_add_file_for control, inject a dummy CTI only when none are explicitly provided, and expose gb.fail_add_file_for from patch_gentl_sdk. Add test helpers and new tests to isolate GENICAM env vars and verify CTI load diagnostics for all-success, partial-failure, and complete-failure scenarios. Also adjust some discovery tests to explicitly isolate the environment and tighten assertions.
1 parent d21cd3e commit 70284db

3 files changed

Lines changed: 178 additions & 23 deletions

File tree

dlclivegui/cameras/backends/gentl_backend.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ def open(self) -> None:
372372
ns["cti_file"] = str(cti_files[0]) # best effort
373373

374374
if not loaded:
375-
self._harvester = None
375+
self._reset_harvester()
376376
raise RuntimeError(
377377
"No GenTL producer (.cti) could be loaded.\n\n"
378378
f"Resolved CTIs: {cti_files}\n"
@@ -385,7 +385,7 @@ def open(self) -> None:
385385
self._harvester.update()
386386

387387
if not self._harvester.device_info_list:
388-
self._harvester = None
388+
self._reset_harvester()
389389
raise RuntimeError(
390390
"No GenTL cameras detected via Harvesters after loading producers.\n\n"
391391
f"Loaded CTIs: {loaded}\n"
@@ -797,6 +797,7 @@ def rebind_settings(cls, settings):
797797
continue
798798

799799
if not loaded:
800+
cls._reset_select_harvester(harvester)
800801
return settings
801802

802803
harvester.update()
@@ -936,6 +937,20 @@ def stop(self) -> None:
936937
except Exception:
937938
pass
938939

940+
@staticmethod
941+
def _reset_select_harvester(harvester) -> None:
942+
if harvester is not None:
943+
try:
944+
harvester.reset()
945+
except Exception:
946+
pass
947+
948+
def _reset_harvester(self) -> None:
949+
try:
950+
self._reset_select_harvester(self._harvester)
951+
finally:
952+
self._harvester = None
953+
939954
def close(self) -> None:
940955
if self._acquirer is not None:
941956
try:

tests/cameras/backends/conftest.py

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
from __future__ import annotations
33

44
import importlib
5+
import logging
56
import os
67
from dataclasses import dataclass
78
from typing import Any
89

910
import numpy as np
1011
import pytest
1112

13+
logger = logging.getLogger(__name__)
14+
1215

1316
# -----------------------------
1417
# Dependency detection helpers
@@ -720,21 +723,20 @@ class FakeHarvester:
720723
Inventory-driven so tests can control enumeration.
721724
"""
722725

723-
def __init__(self, inventory: list[dict[str, Any]] | None = None):
726+
def __init__(self, inventory: list[dict[str, Any]] | None = None, *, fail_add_file_for: set[str] | None = None):
724727
self._files: list[str] = []
725728
self._inventory: list[dict[str, Any]] = list(inventory or [])
726729
self.device_info_list: list[Any] = []
727730

731+
# NEW: failure control
732+
self._fail_add_file_for = set(fail_add_file_for or [])
733+
728734
# Call tracing
729735
self.add_file_calls: list[str] = []
730736
self.update_calls = 0
731737
self.reset_calls = 0
732738
self.create_calls: list[Any] = []
733739

734-
def add_file(self, file_path: str):
735-
self._files.append(str(file_path))
736-
self.add_file_calls.append(str(file_path))
737-
738740
def update(self):
739741
self.update_calls += 1
740742
# If not provided, default to a single fake device
@@ -788,6 +790,16 @@ def create(self, selector=None, index: int | None = None, *args, **kwargs):
788790
def create_image_acquirer(self, *args, **kwargs):
789791
return self.create(*args, **kwargs)
790792

793+
def add_file(self, file_path: str):
794+
p = str(file_path)
795+
self.add_file_calls.append(p)
796+
797+
# NEW: fail deterministically if requested
798+
if p in self._fail_add_file_for:
799+
raise RuntimeError(f"Simulated CTI load failure for: {p}")
800+
801+
self._files.append(p)
802+
791803

792804
# -----------------------------------------------------------------------------
793805
# GentL fixtures: inventory, patching, settings factory
@@ -817,41 +829,53 @@ def gentl_inventory():
817829

818830

819831
@pytest.fixture()
820-
def fake_harvester_factory(gentl_inventory):
832+
def fake_harvester_factory(gentl_inventory, gentl_fail_add_file_for):
821833
"""
822-
Factory that returns a FakeHarvester bound to the current gentl_inventory.
823-
Allows tests to mutate gentl_inventory before calling backend.open().
834+
Factory that returns a FakeHarvester bound to the current gentl_inventory and
835+
gentl_fail_add_file_for. Tests can mutate both before calling backend.open().
824836
"""
825837

826838
def _make():
827-
return FakeHarvester(inventory=gentl_inventory)
839+
return FakeHarvester(inventory=gentl_inventory, fail_add_file_for=gentl_fail_add_file_for)
828840

829841
return _make
830842

831843

832844
@pytest.fixture()
833-
def patch_gentl_sdk(monkeypatch, fake_harvester_factory, tmp_path):
845+
def gentl_fail_add_file_for():
846+
"""
847+
Mutable set of CTI file paths that FakeHarvester.add_file should fail on.
848+
Tests can add/remove paths to simulate partial/complete CTI load failures.
849+
"""
850+
return set()
851+
852+
853+
@pytest.fixture()
854+
def patch_gentl_sdk(monkeypatch, fake_harvester_factory, gentl_fail_add_file_for, tmp_path):
834855
"""
835856
Patch dlclivegui.cameras.backends.gentl_backend to use FakeHarvester + Fake timeout.
836-
Also ensure CTI discovery succeeds for classmethods (discover_devices/quick_ping)
837-
by creating a real dummy .cti and exposing it via GENICAM_GENTL64_PATH.
857+
Ensure CTI discovery succeeds for classmethods by creating a real dummy .cti and
858+
exposing it via GENICAM_GENTL64_PATH.
838859
"""
839860
import dlclivegui.cameras.backends.gentl_backend as gb
840861

841862
# Patch Harvester symbol (the backend calls Harvester() directly)
842863
monkeypatch.setattr(gb, "Harvester", lambda: fake_harvester_factory(), raising=False)
843864

844-
# Keep your backend timeout contract as-is: it catches HarvesterTimeoutError
865+
# Keep timeout contract
845866
monkeypatch.setattr(gb, "HarvesterTimeoutError", FakeGenTLTimeoutException, raising=False)
846867

847-
# Create a real CTI file and advertise it via env var (cross-platform via os.pathsep)
868+
# Create a real CTI file and advertise it via env var
848869
cti_file = tmp_path / "dummy.cti"
849870
if not cti_file.exists():
850871
cti_file.write_text("fake", encoding="utf-8")
851872

852873
monkeypatch.setenv("GENICAM_GENTL64_PATH", str(tmp_path))
853874
monkeypatch.delenv("GENICAM_GENTL32_PATH", raising=False)
854875

876+
# OPTIONAL: expose failure control so tests can do gb.fail_add_file_for.add(...)
877+
gb.fail_add_file_for = gentl_fail_add_file_for
878+
855879
return gb
856880

857881

@@ -877,10 +901,22 @@ def _make(
877901
cti = tmp_path / "dummy.cti"
878902
if not cti.exists():
879903
cti.write_text("fake", encoding="utf-8")
904+
880905
props = properties if isinstance(properties, dict) else {}
881906
props.setdefault("gentl", {})
882-
props["gentl"] = dict(props["gentl"])
883-
props["gentl"].setdefault("cti_file", str(cti))
907+
props["gentl"] = dict(props["gentl"]) # copy to avoid mutating caller dict unexpectedly
908+
909+
ns = props["gentl"]
910+
911+
# Detect whether CTIs were explicitly provided in *either* namespace or legacy keys
912+
explicit_ns = bool(ns.get("cti_file") or ns.get("cti_files"))
913+
explicit_legacy = bool(props.get("cti_file") or props.get("cti_files"))
914+
915+
# Only inject a default dummy.cti if nothing explicit was provided
916+
if not explicit_ns and not explicit_legacy:
917+
logger.debug("No CTI file(s) explicitly provided in settings; injecting dummy CTI for gentl tests.")
918+
ns.setdefault("cti_file", str(cti))
919+
884920
return CameraSettings(
885921
name=name,
886922
index=index,

tests/cameras/backends/test_gentl_backend.py

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,31 @@
1414
discover_cti_files,
1515
)
1616

17+
# ----------------------------------------------------------------------
18+
# Helper functions
19+
# ----------------------------------------------------------------------
20+
21+
22+
@pytest.fixture
23+
def isolate_gentl_env(monkeypatch):
24+
monkeypatch.delenv("GENICAM_GENTL64_PATH", raising=False)
25+
monkeypatch.delenv("GENICAM_GENTL32_PATH", raising=False)
26+
yield
27+
28+
29+
def _force_only_these_ctis(settings, ctis: list[str]) -> None:
30+
# Ensure namespace exists
31+
props = settings.properties
32+
props.setdefault("gentl", {})
33+
ns = props["gentl"]
34+
35+
# Make sure no default single-cti sneaks in (dummy.cti)
36+
ns.pop("cti_file", None)
37+
props.pop("cti_file", None)
38+
39+
# Explicit list
40+
ns["cti_files"] = ctis
41+
1742

1843
# ---------------------------------------------------------------------
1944
# Core lifecycle + strict transaction model
@@ -582,7 +607,7 @@ def test_discover_glob_patterns(tmp_path):
582607
assert pattern in diag.glob_patterns
583608

584609

585-
def test_discover_env_var_directory(monkeypatch, tmp_path):
610+
def test_discover_env_var_directory(monkeypatch, tmp_path, isolate_gentl_env):
586611
_make_cti(tmp_path, "Env.cti")
587612

588613
monkeypatch.setenv("GENICAM_GENTL64_PATH", str(tmp_path))
@@ -593,18 +618,19 @@ def test_discover_env_var_directory(monkeypatch, tmp_path):
593618
assert "GENICAM_GENTL64_PATH" in diag.env_vars_used
594619

595620

596-
def test_discover_env_var_direct_file(monkeypatch, tmp_path):
621+
def test_discover_env_var_direct_file(monkeypatch, tmp_path, isolate_gentl_env):
597622
cti = _make_cti(tmp_path, "Direct.cti")
598623

599624
monkeypatch.setenv("GENICAM_GENTL64_PATH", str(cti))
600625

601626
candidates, diag = discover_cti_files(include_env=True, must_exist=True)
602627

603-
assert candidates == [str(cti.resolve())] or Path(candidates[0]).name == "Direct.cti"
628+
assert len(candidates) == 1
629+
assert Path(candidates[0]).name == "Direct.cti"
604630
assert diag.env_paths_expanded # should include the raw env entry
605631

606632

607-
def test_discover_env_var_multiple_entries(monkeypatch, tmp_path):
633+
def test_discover_env_var_multiple_entries(monkeypatch, tmp_path, isolate_gentl_env):
608634
d1 = tmp_path / "d1"
609635
d2 = tmp_path / "d2"
610636
d1.mkdir()
@@ -622,7 +648,7 @@ def test_discover_env_var_multiple_entries(monkeypatch, tmp_path):
622648
assert names == ["A.cti", "B.cti"]
623649

624650

625-
def test_discover_deduplicates_same_file_from_multiple_sources(monkeypatch, tmp_path):
651+
def test_discover_deduplicates_same_file_from_multiple_sources(monkeypatch, tmp_path, isolate_gentl_env):
626652
cti = _make_cti(tmp_path, "Dup.cti")
627653

628654
# Discover it twice: explicit + env dir
@@ -664,3 +690,81 @@ def test_choose_cti_files_newest_policy(tmp_path):
664690
selected = choose_cti_files([str(old), str(new)], policy=GenTLDiscoveryPolicy.NEWEST, max_files=1)
665691
assert len(selected) == 1
666692
assert Path(selected[0]).name == "New.cti"
693+
694+
695+
def test_open_persists_cti_load_diagnostics_all_success(patch_gentl_sdk, gentl_settings_factory, tmp_path):
696+
gb = patch_gentl_sdk
697+
698+
c1 = _make_cti(tmp_path, "A.cti")
699+
c2 = _make_cti(tmp_path, "B.cti")
700+
701+
# Provide multiple CTIs (how your backend reads these may vary)
702+
settings = gentl_settings_factory(properties={"gentl": {"cti_files": [str(c1), str(c2)]}})
703+
_force_only_these_ctis(settings, [str(c1), str(c2)])
704+
be = gb.GenTLCameraBackend(settings)
705+
706+
be.open()
707+
708+
ns = settings.properties["gentl"]
709+
assert ns["cti_files"] == [str(c1), str(c2)]
710+
assert ns["cti_files_loaded"] == [str(c1), str(c2)]
711+
# If dict:
712+
assert ns["cti_files_failed"] == []
713+
714+
be.close()
715+
716+
717+
def test_open_persists_cti_load_diagnostics_partial_failure(patch_gentl_sdk, gentl_settings_factory, tmp_path):
718+
gb = patch_gentl_sdk
719+
720+
ok = _make_cti(tmp_path, "OK.cti")
721+
bad = _make_cti(tmp_path, "BAD.cti")
722+
723+
gb.fail_add_file_for.clear()
724+
gb.fail_add_file_for.add(str(bad))
725+
726+
settings = gentl_settings_factory(properties={"gentl": {"cti_files": [str(ok), str(bad)]}})
727+
_force_only_these_ctis(settings, [str(ok), str(bad)])
728+
729+
be = gb.GenTLCameraBackend(settings)
730+
be.open()
731+
732+
ns = settings.properties["gentl"]
733+
assert ns["cti_files"] == [str(ok), str(bad)]
734+
assert ns["cti_files_loaded"] == [str(ok)]
735+
736+
failed = ns["cti_files_failed"]
737+
assert isinstance(failed, list)
738+
assert len(failed) == 1
739+
assert failed[0]["cti"] == str(bad)
740+
assert isinstance(failed[0]["error"], str) and failed[0]["error"]
741+
742+
be.close()
743+
744+
745+
def test_open_persists_cti_load_diagnostics_complete_failure(patch_gentl_sdk, gentl_settings_factory, tmp_path):
746+
gb = patch_gentl_sdk
747+
748+
b1 = _make_cti(tmp_path, "B1.cti")
749+
b2 = _make_cti(tmp_path, "B2.cti")
750+
751+
gb.fail_add_file_for.clear()
752+
gb.fail_add_file_for.update({str(b1), str(b2)})
753+
754+
settings = gentl_settings_factory(properties={"gentl": {"cti_files": [str(b1), str(b2)]}})
755+
_force_only_these_ctis(settings, [str(b1), str(b2)])
756+
be = gb.GenTLCameraBackend(settings)
757+
758+
with pytest.raises(RuntimeError):
759+
be.open()
760+
761+
# Keys should still be persisted for debugging even though open failed.
762+
ns = settings.properties.get("gentl", {})
763+
assert ns.get("cti_files") == [str(b1), str(b2)]
764+
assert ns.get("cti_files_loaded") == []
765+
766+
failed = ns.get("cti_files_failed")
767+
assert isinstance(failed, list)
768+
assert sorted(d["cti"] for d in failed) == sorted([str(b1), str(b2)])
769+
for d in failed:
770+
assert isinstance(d.get("error"), str) and d["error"]

0 commit comments

Comments
 (0)