Skip to content

Commit e2cdc35

Browse files
committed
Fix States/CommandDefinitions container semantics
__getitem__ now raises KeyError on missing keys (standard Python mapping protocol). get() returns None for missing keys. __contains__ uses explicit name comparison instead of delegating to __getitem__.
1 parent f330a08 commit e2cdc35

2 files changed

Lines changed: 90 additions & 14 deletions

File tree

pyoverkiz/models.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -454,19 +454,24 @@ def __iter__(self) -> Iterator[CommandDefinition]:
454454
"""Iterate over defined commands."""
455455
return self._commands.__iter__()
456456

457-
def __contains__(self, name: str) -> bool:
457+
def __contains__(self, name: object) -> bool:
458458
"""Return True if a command with `name` exists."""
459-
return self.__getitem__(name) is not None
459+
return any(cd.command_name == name for cd in self._commands)
460460

461-
def __getitem__(self, command: str) -> CommandDefinition | None:
462-
"""Return the command definition or None if missing."""
463-
return next((cd for cd in self._commands if cd.command_name == command), None)
461+
def __getitem__(self, command: str) -> CommandDefinition:
462+
"""Return the command definition or raise KeyError if missing."""
463+
result = next((cd for cd in self._commands if cd.command_name == command), None)
464+
if result is None:
465+
raise KeyError(command)
466+
return result
464467

465468
def __len__(self) -> int:
466469
"""Return number of command definitions."""
467470
return len(self._commands)
468471

469-
get = __getitem__
472+
def get(self, command: str) -> CommandDefinition | None:
473+
"""Return the command definition or None if missing."""
474+
return next((cd for cd in self._commands if cd.command_name == command), None)
470475

471476
def select(self, commands: list[str | OverkizCommand]) -> str | None:
472477
"""Return the first command name that exists in this definition, or None."""
@@ -591,17 +596,20 @@ def __iter__(self) -> Iterator[State]:
591596
"""Return an iterator over contained State objects."""
592597
return self._states.__iter__()
593598

594-
def __contains__(self, name: str) -> bool:
599+
def __contains__(self, name: object) -> bool:
595600
"""Return True if a state with the given name exists in the container."""
596-
return self.__getitem__(name) is not None
601+
return any(state.name == name for state in self._states)
597602

598-
def __getitem__(self, name: str) -> State | None:
599-
"""Return the State with the given name or None if missing."""
600-
return next((state for state in self._states if state.name == name), None)
603+
def __getitem__(self, name: str) -> State:
604+
"""Return the State with the given name or raise KeyError if missing."""
605+
result = next((state for state in self._states if state.name == name), None)
606+
if result is None:
607+
raise KeyError(name)
608+
return result
601609

602610
def __setitem__(self, name: str, state: State) -> None:
603611
"""Set or append a State identified by name."""
604-
found = self.__getitem__(name)
612+
found = self.get(name)
605613
if found is None:
606614
self._states.append(state)
607615
else:
@@ -611,12 +619,15 @@ def __len__(self) -> int:
611619
"""Return number of states in the container."""
612620
return len(self._states)
613621

614-
get = __getitem__
622+
def get(self, name: str) -> State | None:
623+
"""Return the State with the given name or None if missing."""
624+
return next((state for state in self._states if state.name == name), None)
615625

616626
def select(self, names: list[str]) -> State | None:
617627
"""Return the first State that exists and has a non-None value, or None."""
618628
for name in names:
619-
if (state := self[name]) and state.value is not None:
629+
state = self.get(name)
630+
if state is not None and state.value is not None:
620631
return state
621632
return None
622633

tests/test_models.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,44 @@ def test_has_any_false(self):
442442
states = States(RAW_STATES)
443443
assert not states.has_any(["nonexistent", "also_nonexistent"])
444444

445+
def test_getitem_raises_keyerror_on_missing(self):
446+
"""Subscript access raises KeyError for missing states."""
447+
states = States(RAW_STATES)
448+
with pytest.raises(KeyError, match="nonexistent"):
449+
states["nonexistent"]
450+
451+
def test_getitem_returns_state_on_hit(self):
452+
"""Subscript access returns the State for a known name."""
453+
states = States(RAW_STATES)
454+
state = states[STATE]
455+
assert state.name == STATE
456+
457+
def test_contains_existing(self):
458+
"""'in' operator returns True for existing state names."""
459+
states = States(RAW_STATES)
460+
assert STATE in states
461+
462+
def test_contains_missing(self):
463+
"""'in' operator returns False for missing state names."""
464+
states = States(RAW_STATES)
465+
assert "nonexistent" not in states
466+
467+
def test_setitem_replaces_existing(self):
468+
"""Setting an existing state replaces it."""
469+
states = States(RAW_STATES)
470+
new_state = State(name=STATE, type=DataType.INTEGER, value=42)
471+
states[STATE] = new_state
472+
assert states.get(STATE).value == 42
473+
474+
def test_setitem_appends_new(self):
475+
"""Setting a new state appends it."""
476+
states = States(RAW_STATES)
477+
initial_len = len(states)
478+
new_state = State(name="new:State", type=DataType.INTEGER, value=1)
479+
states["new:State"] = new_state
480+
assert len(states) == initial_len + 1
481+
assert states.get("new:State").value == 1
482+
445483

446484
class TestCommandDefinitions:
447485
"""Tests for CommandDefinitions container and helper methods."""
@@ -472,6 +510,33 @@ def test_has_any_false(self):
472510
cmds = CommandDefinitions([{"command_name": "close", "nparams": 0}])
473511
assert not cmds.has_any(["nonexistent", "also_nonexistent"])
474512

513+
def test_getitem_raises_keyerror_on_missing(self):
514+
"""Subscript access raises KeyError for missing commands."""
515+
cmds = CommandDefinitions([{"command_name": "close", "nparams": 0}])
516+
with pytest.raises(KeyError, match="nonexistent"):
517+
cmds["nonexistent"]
518+
519+
def test_getitem_returns_command_on_hit(self):
520+
"""Subscript access returns the CommandDefinition for a known command."""
521+
cmds = CommandDefinitions([{"command_name": "close", "nparams": 0}])
522+
cmd = cmds["close"]
523+
assert cmd.command_name == "close"
524+
525+
def test_get_returns_none_on_missing(self):
526+
"""get() returns None for missing commands."""
527+
cmds = CommandDefinitions([{"command_name": "close", "nparams": 0}])
528+
assert cmds.get("nonexistent") is None
529+
530+
def test_contains_existing(self):
531+
"""'in' operator returns True for existing command names."""
532+
cmds = CommandDefinitions([{"command_name": "close", "nparams": 0}])
533+
assert "close" in cmds
534+
535+
def test_contains_missing(self):
536+
"""'in' operator returns False for missing command names."""
537+
cmds = CommandDefinitions([{"command_name": "close", "nparams": 0}])
538+
assert "nonexistent" not in cmds
539+
475540

476541
class TestDefinition:
477542
"""Tests for Definition model and its helper methods."""

0 commit comments

Comments
 (0)