Skip to content

Commit 4f20fbe

Browse files
committed
fix: add new collections.py file
1 parent 1765105 commit 4f20fbe

File tree

1 file changed

+388
-0
lines changed

1 file changed

+388
-0
lines changed

pyomnilogic_local/collections.py

Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
1+
"""Custom collection types for OmniLogic equipment management."""
2+
3+
import logging
4+
from collections import Counter
5+
from collections.abc import Iterator
6+
from typing import Any, Generic, TypeVar, overload
7+
8+
from pyomnilogic_local._base import OmniEquipment
9+
10+
_LOGGER = logging.getLogger(__name__)
11+
12+
# Track which duplicate names we've already warned about to avoid log spam
13+
_WARNED_DUPLICATE_NAMES: set[str] = set()
14+
15+
# Type variable for equipment types
16+
T = TypeVar("T", bound=OmniEquipment[Any, Any])
17+
18+
19+
class EquipmentDict(Generic[T]):
20+
"""A dictionary-like collection that supports lookup by both name and system_id.
21+
22+
This collection allows accessing equipment using either their name (str) or
23+
system_id (int), providing flexible and intuitive access patterns.
24+
25+
Type Safety:
26+
The lookup key type determines the lookup method:
27+
- str keys lookup by equipment name
28+
- int keys lookup by equipment system_id
29+
30+
Examples:
31+
>>> # Create collection from list of equipment
32+
>>> bows = EquipmentDict([pool_bow, spa_bow])
33+
>>>
34+
>>> # Access by name (string key)
35+
>>> pool = bows["Pool"]
36+
>>>
37+
>>> # Access by system_id (integer key)
38+
>>> pool = bows[3]
39+
>>>
40+
>>> # Explicit methods for clarity
41+
>>> pool = bows.get_by_name("Pool")
42+
>>> pool = bows.get_by_id(3)
43+
>>>
44+
>>> # Standard dict operations
45+
>>> for bow in bows:
46+
... print(bow.name)
47+
>>> len(bows)
48+
>>> if "Pool" in bows:
49+
... print("Pool exists")
50+
51+
Note:
52+
If an equipment item has a name that looks like a number (e.g., "123"),
53+
you must use an actual int type to lookup by system_id, as string keys
54+
always lookup by name. This type-based differentiation prevents ambiguity.
55+
"""
56+
57+
def __init__(self, items: list[T] | None = None) -> None:
58+
"""Initialize the equipment collection.
59+
60+
Args:
61+
items: Optional list of equipment items to populate the collection.
62+
63+
Raises:
64+
ValueError: If any item has neither a system_id nor a name.
65+
"""
66+
self._items: list[T] = items if items is not None else []
67+
self._validate()
68+
69+
def _validate(self) -> None:
70+
"""Validate the equipment collection.
71+
72+
Checks for:
73+
1. Items without both system_id and name (raises ValueError)
74+
2. Duplicate names (logs warning once per unique duplicate)
75+
76+
Raises:
77+
ValueError: If any item has neither a system_id nor a name.
78+
"""
79+
# Check for items with no system_id AND no name
80+
if invalid_items := [item for item in self._items if item.system_id is None and item.name is None]:
81+
raise ValueError(
82+
f"Equipment collection contains {len(invalid_items)} item(s) "
83+
"with neither a system_id nor a name. All equipment must have "
84+
"at least one identifier for addressing."
85+
)
86+
87+
# Find duplicate names that we haven't warned about yet
88+
name_counts = Counter(item.name for item in self._items if item.name is not None)
89+
duplicate_names = {name for name, count in name_counts.items() if count > 1}
90+
unwarned_duplicates = duplicate_names.difference(_WARNED_DUPLICATE_NAMES)
91+
92+
# Log warnings for new duplicates
93+
for name in unwarned_duplicates:
94+
_LOGGER.warning(
95+
"Equipment collection contains %d items with the same name '%s'. "
96+
"Name-based lookups will return the first match. "
97+
"Consider using system_id-based lookups for reliability "
98+
"or renaming equipment to avoid duplicates.",
99+
name_counts[name],
100+
name,
101+
)
102+
_WARNED_DUPLICATE_NAMES.add(name)
103+
104+
@property
105+
def _by_name(self) -> dict[str, T]:
106+
"""Dynamically build name-to-equipment mapping."""
107+
return {item.name: item for item in self._items if item.name is not None}
108+
109+
@property
110+
def _by_id(self) -> dict[int, T]:
111+
"""Dynamically build system_id-to-equipment mapping."""
112+
return {item.system_id: item for item in self._items if item.system_id is not None}
113+
114+
@overload
115+
def __getitem__(self, key: str) -> T: ...
116+
117+
@overload
118+
def __getitem__(self, key: int) -> T: ...
119+
120+
def __getitem__(self, key: str | int) -> T:
121+
"""Get equipment by name (str) or system_id (int).
122+
123+
Args:
124+
key: Equipment name (str) or system_id (int)
125+
126+
Returns:
127+
The equipment item matching the key
128+
129+
Raises:
130+
KeyError: If no equipment matches the key
131+
TypeError: If key is not str or int
132+
133+
Examples:
134+
>>> bows["Pool"] # Lookup by name
135+
>>> bows[3] # Lookup by system_id
136+
"""
137+
if isinstance(key, str):
138+
return self._by_name[key]
139+
if isinstance(key, int):
140+
return self._by_id[key]
141+
142+
raise TypeError(f"Key must be str or int, got {type(key).__name__}")
143+
144+
def __setitem__(self, key: str | int, value: T) -> None:
145+
"""Add or update equipment in the collection.
146+
147+
The key is only used to determine the operation type (add vs update).
148+
The actual name and system_id are taken from the equipment object itself.
149+
150+
Args:
151+
key: Equipment name (str) or system_id (int) - must match the equipment's values
152+
value: Equipment item to add or update
153+
154+
Raises:
155+
TypeError: If key is not str or int
156+
ValueError: If key doesn't match the equipment's name or system_id
157+
158+
Examples:
159+
>>> # Add by name
160+
>>> bows["Pool"] = new_pool_bow
161+
>>> # Add by system_id
162+
>>> bows[3] = new_pool_bow
163+
"""
164+
if isinstance(key, str):
165+
if value.name != key:
166+
raise ValueError(f"Equipment name '{value.name}' does not match key '{key}'")
167+
elif isinstance(key, int):
168+
if value.system_id != key:
169+
raise ValueError(f"Equipment system_id {value.system_id} does not match key {key}")
170+
else:
171+
raise TypeError(f"Key must be str or int, got {type(key).__name__}")
172+
173+
# Check if we're updating an existing item (prioritize system_id)
174+
existing_item = None
175+
if value.system_id and value.system_id in self._by_id:
176+
existing_item = self._by_id[value.system_id]
177+
elif value.name and value.name in self._by_name:
178+
existing_item = self._by_name[value.name]
179+
180+
if existing_item:
181+
# Replace existing item in place
182+
idx = self._items.index(existing_item)
183+
self._items[idx] = value
184+
else:
185+
# Add new item
186+
self._items.append(value)
187+
188+
# Validate after modification
189+
self._validate()
190+
191+
def __delitem__(self, key: str | int) -> None:
192+
"""Remove equipment from the collection.
193+
194+
Args:
195+
key: Equipment name (str) or system_id (int)
196+
197+
Raises:
198+
KeyError: If no equipment matches the key
199+
TypeError: If key is not str or int
200+
201+
Examples:
202+
>>> del bows["Pool"] # Remove by name
203+
>>> del bows[3] # Remove by system_id
204+
"""
205+
# First, get the item to remove
206+
item = self[key] # This will raise KeyError if not found
207+
208+
# Remove from the list (indexes rebuild automatically via properties)
209+
self._items.remove(item)
210+
211+
def __contains__(self, key: str | int) -> bool:
212+
"""Check if equipment exists by name (str) or system_id (int).
213+
214+
Args:
215+
key: Equipment name (str) or system_id (int)
216+
217+
Returns:
218+
True if equipment exists, False otherwise
219+
220+
Examples:
221+
>>> if "Pool" in bows:
222+
... print("Pool exists")
223+
>>> if 3 in bows:
224+
... print("System ID 3 exists")
225+
"""
226+
if isinstance(key, str):
227+
return key in self._by_name
228+
if isinstance(key, int):
229+
return key in self._by_id
230+
231+
return False
232+
233+
def __iter__(self) -> Iterator[T]:
234+
"""Iterate over all equipment items in the collection.
235+
236+
Returns:
237+
Iterator over equipment items
238+
239+
Examples:
240+
>>> for bow in bows:
241+
... print(bow.name)
242+
"""
243+
return iter(self._items)
244+
245+
def __len__(self) -> int:
246+
"""Get the number of equipment items in the collection.
247+
248+
Returns:
249+
Number of items
250+
251+
Examples:
252+
>>> len(bows)
253+
2
254+
"""
255+
return len(self._items)
256+
257+
def __repr__(self) -> str:
258+
"""Get string representation of the collection.
259+
260+
Returns:
261+
String representation showing item count and names
262+
"""
263+
names = [f"<ID:{item.system_id},NAME:{item.name}>" for item in self._items]
264+
# names = [item.name or f"<ID:{item.system_id}>" for item in self._items]
265+
return f"EquipmentDict({names})"
266+
267+
def append(self, item: T) -> None:
268+
"""Add or update equipment in the collection (list-like interface).
269+
270+
If equipment with the same system_id or name already exists, it will be
271+
replaced. System_id is checked first as it's the more reliable unique identifier.
272+
273+
Args:
274+
item: Equipment item to add or update
275+
276+
Examples:
277+
>>> # Add new equipment
278+
>>> bows.append(new_pool_bow)
279+
>>>
280+
>>> # Update existing equipment (replaces if system_id or name matches)
281+
>>> bows.append(updated_pool_bow)
282+
"""
283+
# Check if we're updating an existing item (prioritize system_id as it's guaranteed unique)
284+
existing_item = None
285+
if item.system_id and item.system_id in self._by_id:
286+
existing_item = self._by_id[item.system_id]
287+
elif item.name and item.name in self._by_name:
288+
existing_item = self._by_name[item.name]
289+
290+
if existing_item:
291+
# Replace existing item in place
292+
idx = self._items.index(existing_item)
293+
self._items[idx] = item
294+
else:
295+
# Add new item
296+
self._items.append(item)
297+
298+
# Validate after modification
299+
self._validate()
300+
301+
def get_by_name(self, name: str) -> T | None:
302+
"""Get equipment by name with explicit method (returns None if not found).
303+
304+
Args:
305+
name: Equipment name
306+
307+
Returns:
308+
Equipment item or None if not found
309+
310+
Examples:
311+
>>> pool = bows.get_by_name("Pool")
312+
>>> if pool is not None:
313+
... await pool.filters[0].turn_on()
314+
"""
315+
return self._by_name.get(name)
316+
317+
def get_by_id(self, system_id: int) -> T | None:
318+
"""Get equipment by system_id with explicit method (returns None if not found).
319+
320+
Args:
321+
system_id: Equipment system_id
322+
323+
Returns:
324+
Equipment item or None if not found
325+
326+
Examples:
327+
>>> pool = bows.get_by_id(3)
328+
>>> if pool is not None:
329+
... print(pool.name)
330+
"""
331+
return self._by_id.get(system_id)
332+
333+
def get(self, key: str | int, default: T | None = None) -> T | None:
334+
"""Get equipment by name or system_id with optional default.
335+
336+
Args:
337+
key: Equipment name (str) or system_id (int)
338+
default: Default value to return if key not found
339+
340+
Returns:
341+
Equipment item or default if not found
342+
343+
Examples:
344+
>>> pool = bows.get("Pool")
345+
>>> pool = bows.get(3)
346+
>>> pool = bows.get("NonExistent", default=None)
347+
"""
348+
try:
349+
return self[key]
350+
except KeyError:
351+
return default
352+
353+
def keys(self) -> list[str]:
354+
"""Get list of all equipment names.
355+
356+
Returns:
357+
List of equipment names (excluding items without names)
358+
359+
Examples:
360+
>>> bows.keys()
361+
['Pool', 'Spa']
362+
"""
363+
return list(self._by_name.keys())
364+
365+
def values(self) -> list[T]:
366+
"""Get list of all equipment items.
367+
368+
Returns:
369+
List of equipment items
370+
371+
Examples:
372+
>>> for equipment in bows.values():
373+
... print(equipment.name)
374+
"""
375+
return self._items.copy()
376+
377+
def items(self) -> list[tuple[int | None, str | None, T]]:
378+
"""Get list of (system_id, name, equipment) tuples.
379+
380+
Returns:
381+
List of (system_id, name, equipment) tuples where both system_id
382+
and name can be None (though at least one must be set per validation).
383+
384+
Examples:
385+
>>> for system_id, name, bow in bows.items():
386+
... print(f"ID: {system_id}, Name: {name}")
387+
"""
388+
return [(item.system_id, item.name, item) for item in self._items]

0 commit comments

Comments
 (0)