Skip to content

Commit 1f2bf29

Browse files
committed
fix: allow ModelCollection to have null elements if specified
1 parent dd65857 commit 1f2bf29

9 files changed

Lines changed: 172 additions & 189 deletions

File tree

src/dsf/object_model/heat/heat.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class Heat(ModelObject):
3030
cold_retract_temperature = model_prop("cold_retract_temperature", float, 90)
3131

3232
# List of configured Heaters
33-
heaters = model_prop("heaters", ModelCollection[Optional[Heater]], ModelCollection(Heater))
33+
heaters = model_prop("heaters", ModelCollection[Optional[Heater]], ModelCollection(Optional[Heater]))
3434

3535
def __init__(self):
3636
super().__init__()

src/dsf/object_model/job/build_object.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ class BuildObject(ModelObject):
1515
name = nullable_model_prop("name", str)
1616

1717
# X coordinates of the build object (in mm or null if not found)
18-
x = model_prop("x", ModelCollection[Optional[float]], ModelCollection(float))
18+
x = model_prop("x", ModelCollection[Optional[float]], ModelCollection(Optional[float]))
1919

2020
# Y coordinates of the build object (in mm or null if not found)
21-
y = model_prop("y", ModelCollection[Optional[float]], ModelCollection(float))
21+
y = model_prop("y", ModelCollection[Optional[float]], ModelCollection(Optional[float]))
2222

2323
def __init__(self):
2424
super().__init__()

src/dsf/object_model/job/layer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class Layer(ModelObject):
2121
height = model_prop("height", float, 0.0)
2222

2323
# Last heater temperatures during this layer (in C or null if unknown)
24-
temperatures = model_prop("temperatures", ModelCollection[Optional[float]], ModelCollection(float))
24+
temperatures = model_prop("temperatures", ModelCollection[Optional[float]], ModelCollection(Optional[float]))
2525

2626
def __init__(self):
2727
super().__init__()
Lines changed: 74 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from enum import Enum
2-
from typing import Generic, TypeVar, List, Dict, Any, Union, Optional, get_origin, cast
2+
from typing import Generic, TypeVar, List, Dict, Any, Union, Optional, get_origin, get_args, cast
33
from ..utils import JSONElement
44
from .model_type import ModelType
55
from .model_dictionary import ModelDictionary
@@ -15,21 +15,46 @@ class ModelCollection(ModelType[list[JSONElement]], Generic[T], list[T]):
1515
Useful for updating model object items from JSON data (patches)
1616
"""
1717

18-
def __init__(self, item_constructor: type[T], value: Optional[List[T]] = None) -> None:
18+
def __init__(self, item_constructor: type[T] | object, value: Optional[List[T]] = None, allow_none: bool = False) -> None:
1919
"""
2020
:param item_constructor: Item constructor type that items must derive from
2121
:param value: Value used to initialize the list from
22+
:param allow_none: Whether list items may be None
2223
"""
2324
from .utils import is_model_object
2425

2526
super().__init__()
26-
self._item_constructor: type[T] = item_constructor
27-
self._runtime_model_type = cast(type[object], get_origin(item_constructor) or item_constructor)
27+
self._declared_item_constructor = item_constructor
28+
item_origin = get_origin(item_constructor)
29+
item_args = get_args(item_constructor)
30+
self._allow_none = allow_none or (item_origin in (Union, getattr(__import__('types'), 'UnionType', Union)) and type(None) in item_args)
31+
32+
resolved_constructor: object = item_constructor
33+
if item_origin in (Union, getattr(__import__('types'), 'UnionType', Union)):
34+
non_none_args = [arg for arg in item_args if arg is not type(None)]
35+
if len(non_none_args) == 1:
36+
resolved_constructor = non_none_args[0]
37+
self._runtime_model_type = cast(type[object], get_origin(non_none_args[0]) or non_none_args[0])
38+
else:
39+
self._runtime_model_type = object
40+
else:
41+
self._runtime_model_type = cast(type[object], item_origin or item_constructor)
42+
43+
if isinstance(resolved_constructor, type):
44+
self._item_constructor: type[T] = cast(type[T], resolved_constructor)
45+
elif isinstance(self._runtime_model_type, type):
46+
self._item_constructor = cast(type[T], self._runtime_model_type)
47+
else:
48+
self._item_constructor = cast(type[T], object)
2849

2950
if value is not None:
3051
self[:] = []
3152
for (_, item) in enumerate(value):
32-
if isinstance(item, self._item_constructor):
53+
if item is None:
54+
self.append(self._coerce_item_value(item))
55+
continue
56+
57+
if isinstance(item, self._runtime_model_type):
3358
self.append(item)
3459
else:
3560
ref_item = self._item_constructor()
@@ -45,6 +70,24 @@ def __init__(self, item_constructor: type[T], value: Optional[List[T]] = None) -
4570
def from_json(cls, data: list[JSONElement]):
4671
raise RuntimeError("from_json is not supported for ModelCollection. Use the constructor instead.")
4772

73+
def _coerce_item_value(self, value: JSONElement) -> T:
74+
"""Coerce scalar/enum values using the declared item constructor when possible."""
75+
if value is None:
76+
if self._allow_none:
77+
return cast(T, None)
78+
raise TypeError(f"None is not allowed for collection of type {self._declared_item_constructor}")
79+
80+
if issubclass(self._runtime_model_type, Enum):
81+
try:
82+
return self._item_constructor(value)
83+
except (TypeError, ValueError, KeyError):
84+
raise ValueError(f"Invalid enum value {value} for collection of type {self._runtime_model_type.__name__}")
85+
86+
try:
87+
return self._item_constructor(value)
88+
except (TypeError, ValueError):
89+
return cast(T, value)
90+
4891
def update_from_json(self, data: list[JSONElement]) -> 'ModelCollection[T]':
4992
"""
5093
Update this instance from the given data
@@ -65,50 +108,49 @@ def update_from_json(self, data: list[JSONElement]) -> 'ModelCollection[T]':
65108
new_item_data = data[i]
66109

67110
# If the new item data is null, set the current item to null (even if it was a model object before)
68-
if new_item_data is None:
111+
if new_item_data is None and self._allow_none:
69112
self[i] = None
70113
continue
71114

72115
# If the current item is null then we need to create a new item
73116
if current_item is None:
74-
if isinstance(new_item_data, self._item_constructor):
75-
self[i] = new_item_data
76-
elif issubclass(self._item_constructor, Enum):
77-
try:
78-
enum_value = self._item_constructor(new_item_data)
79-
self.append(enum_value)
80-
except KeyError:
81-
raise ValueError(f"Invalid enum value {new_item_data} for collection of type {self._item_constructor.__name__}")
117+
if isinstance(new_item_data, self._runtime_model_type):
118+
self[i] = cast(T, new_item_data)
82119
else:
83-
ref_item = self._item_constructor()
84-
if not is_model_object(ref_item):
85-
raise TypeError(f"Item constructor for ModelCollection must inherit from type ModelType to update from a dict."
86-
f" Got {type(ref_item).__name__}: {ref_item}")
87-
self[i] = cast(ModelType[JSONElement], ref_item).update_from_json(new_item_data)
120+
ref_item: Optional[T] = None
121+
try:
122+
ref_item = self._item_constructor()
123+
except TypeError:
124+
ref_item = None
125+
126+
if ref_item is not None and is_model_object(ref_item):
127+
self[i] = cast(T, cast(ModelType[JSONElement], ref_item).update_from_json(new_item_data))
128+
else:
129+
self[i] = self._coerce_item_value(new_item_data)
88130
# Use the `update_from_json` method of the current item if it's a model object, otherwise replace it with the new data
89131
elif is_model_object(current_item):
90-
self[i] = cast(ModelType[JSONElement], current_item).update_from_json(new_item_data)
132+
self[i] = cast(T, cast(ModelType[JSONElement], current_item).update_from_json(new_item_data))
91133
else:
92-
self[i] = new_item_data
134+
self[i] = self._coerce_item_value(new_item_data)
93135

94136
# Add new items
95137
for i in range(len(self), len(data)):
96138
item_to_add = data[i]
97139
if item_to_add is None:
98-
self.append(item_to_add)
99-
elif issubclass(self._item_constructor, Enum):
100-
try:
101-
enum_value = self._item_constructor(item_to_add)
102-
self.append(enum_value)
103-
except KeyError:
104-
raise ValueError(f"Invalid enum value {item_to_add} for collection of type {self._item_constructor.__name__}")
140+
self.append(self._coerce_item_value(item_to_add))
141+
elif isinstance(item_to_add, self._runtime_model_type):
142+
self.append(cast(T, item_to_add))
105143
else:
106-
107-
ref_item = self._item_constructor()
108-
if is_model_object(ref_item):
144+
ref_item: Optional[T] = None
145+
try:
146+
ref_item = self._item_constructor()
147+
except TypeError:
148+
ref_item = None
149+
150+
if ref_item is not None and is_model_object(ref_item):
109151
self.append(cast(ModelType[JSONElement], ref_item).update_from_json(item_to_add))
110152
else:
111-
self.append(item_to_add)
153+
self.append(self._coerce_item_value(item_to_add))
112154

113155

114156
return self

src/dsf/object_model/model_type.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
from typing import Self, TypeVar, Protocol, TypeAlias
1+
from typing import Self, TypeVar, Protocol, TypeAlias, runtime_checkable
22

33
from ..utils import JSONElement
44

55
T = TypeVar("T", bound=JSONElement)
66
TModelValue: TypeAlias = "ModelType[T]"
77

8+
@runtime_checkable
89
class ModelType(Protocol[T]):
910
@classmethod
1011
def from_json(cls: type[T], data: T) -> T:
Lines changed: 21 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import List
1+
from typing import List, Optional
22

33
from .model_collection import ModelCollection
44
from .model_dictionary import ModelDictionary
@@ -29,138 +29,34 @@ class ObjectModel(ModelObject):
2929

3030
# Information about the SBC which Duet Software Framework is running on.
3131
# This is None if the system is operating in standalone mode
32-
boards = model_prop('boards', ModelCollection[Board])
32+
boards = model_prop('boards', ModelCollection[Board], ModelCollection(Board))
33+
directories = model_prop('directories', Directories)
34+
fans = model_prop('fans', ModelCollection[Optional[Fan]], ModelCollection(Optional[Fan]))
35+
globals = model_prop('globals', ModelDictionary, ModelDictionary(False))
36+
heat = model_prop('heat', Heat)
37+
inputs = model_prop('inputs', Inputs)
38+
job = model_prop('job', Job)
39+
led_strips = model_prop('led_strips', ModelCollection[LedStrip], ModelCollection(LedStrip))
40+
limits = model_prop('limits', Limits)
41+
messages = model_prop('messages', ModelCollection[Message], ModelCollection(Message))
42+
move = model_prop('move', Move)
43+
network = model_prop('network', Network)
44+
plugins = model_prop('plugins', ModelDictionary, ModelDictionary(True, Plugin))
3345
sbc = nullable_model_prop('sbc', SBC)
46+
sensors = model_prop('sensors', Sensors)
47+
spindles = model_prop('spindles', ModelCollection[Optional[Spindle]], ModelCollection(Optional[Spindle]))
48+
state = model_prop('state', State)
49+
tools = model_prop('tools', ModelCollection[Optional[Tool]], ModelCollection(Optional[Tool]))
50+
volumes = model_prop('volumes', ModelCollection[Volume], ModelCollection(Volume))
51+
3452

3553
def __init__(self):
3654
super(ObjectModel, self).__init__()
37-
self._boards = ModelCollection(Board)
38-
self._directories = Directories()
39-
self._fans = ModelCollection(Fan)
40-
self._globals = ModelDictionary(False)
41-
self._heat = Heat()
42-
self._inputs = Inputs()
43-
self._job = Job()
44-
self._led_strips = ModelCollection(LedStrip)
45-
self._limits = Limits()
46-
self._messages = ModelCollection(Message)
47-
self._move = Move()
48-
self._network = Network()
49-
self._plugins = ModelDictionary(True, Plugin)
50-
self._sbc = None
51-
self._sensors = Sensors()
52-
self._spindles = ModelCollection(Spindle)
53-
self._state = State()
54-
self._tools = ModelCollection(Tool)
55-
self._volumes = ModelCollection(Volume)
56-
57-
# @property
58-
# def boards(self) -> List[Board]:
59-
# """List of connected boards
60-
# The first item represents the main board"""
61-
# return self._boards
62-
63-
@property
64-
def directories(self) -> Directories:
65-
"""Information about the individual directories
66-
This may not be available in RepRapFirmware if no mass storages are available"""
67-
return self._directories
68-
69-
@property
70-
def fans(self) -> List[Fan]:
71-
"""List of configured fans
72-
See also Fan()"""
73-
return self._fans
74-
75-
@property
76-
def globals(self) -> dict:
77-
"""Dictionary of global variables vs JSON values
78-
When DSF attempts to reconnect to RRF, this may be set to null to clear the contents
79-
NB: RRF uses 'global' as name but as it is a reserved keyword in Python, 'globals' is used here."""
80-
return self._globals
81-
82-
@property
83-
def heat(self) -> Heat:
84-
"""Information about the heat subsystem"""
85-
return self._heat
86-
87-
@property
88-
def inputs(self) -> Inputs:
89-
"""Information about every available G/M/T-code channel"""
90-
return self._inputs
91-
92-
@property
93-
def job(self) -> Job:
94-
"""Information about the current job"""
95-
return self._job
96-
97-
@property
98-
def led_strips(self) -> List[LedStrip]:
99-
"""List of configured LED strips"""
100-
return self._led_strips
101-
102-
@property
103-
def limits(self) -> Limits:
104-
"""Machine configuration limits"""
105-
return self._limits
106-
107-
@property
108-
def messages(self) -> List[Message]:
109-
"""Generic messages that do not belong explicitly to codes being executed.
110-
This includes status messages, generic errors and outputs generated by M118
111-
See also Message()"""
112-
return self._messages
113-
114-
@property
115-
def move(self) -> Move:
116-
"""Information about the move subsystem"""
117-
return self._move
118-
119-
@property
120-
def network(self) -> Network:
121-
"""Information about connected network adapters"""
122-
return self._network
123-
124-
@property
125-
def plugins(self) -> dict:
126-
"""Dictionary of loaded plugins where each key is the plugin identifier
127-
This is only populated by DSF in SBC mode, however it may be populated manually as well in standalone mode.
128-
Values in this dictionary cannot become None.
129-
If a value is changed to None, the corresponding item is deleted"""
130-
return self._plugins
131-
132-
@property
133-
def sensors(self) -> Sensors:
134-
"""Information about connected sensors including Z-probes and endstops"""
135-
return self._sensors
136-
137-
@property
138-
def spindles(self) -> List[Spindle]:
139-
"""List of configured CNC spindles
140-
See also Spindle()"""
141-
return self._spindles
142-
143-
@property
144-
def state(self) -> State:
145-
"""Information about the machine state"""
146-
return self._state
147-
148-
@property
149-
def tools(self) -> List[Tool]:
150-
"""List of configured tools
151-
See also Tool()"""
152-
return self._tools
153-
154-
@property
155-
def volumes(self) -> List[Volume]:
156-
"""List of available mass storages
157-
See also Volume()"""
158-
return self._volumes
15955

16056
def _update_from_json(self, **kwargs) -> 'ObjectModel':
16157
super(ObjectModel, self)._update_from_json(**kwargs)
16258

16359
# "global" is a reserved keyword in Python, so it is converted to "globals"
16460
if 'global_' in kwargs:
165-
self._globals.update_from_json(kwargs.get('global_'))
61+
self.globals.update_from_json(kwargs.get('global_'))
16662
return self

src/dsf/object_model/sensors/sensors.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,19 @@ class Sensors(ModelObject):
1414
"""Information about sensors"""
1515

1616
# List of analog sensors
17-
analog = model_prop("analog", ModelCollection[Optional[AnalogSensor]], ModelCollection(AnalogSensor))
17+
analog = model_prop("analog", ModelCollection[Optional[AnalogSensor]], ModelCollection(Optional[AnalogSensor]))
1818

1919
# List of configured endstops
20-
endstops = model_prop("endstops", ModelCollection[Optional[Endstop]], ModelCollection(Endstop))
20+
endstops = model_prop("endstops", ModelCollection[Optional[Endstop]], ModelCollection(Optional[Endstop]))
2121

2222
# List of configured filament monitors
23-
filament_monitors = model_prop("filament_monitors", ModelCollection[Optional[FilamentMonitor]], ModelCollection(FilamentMonitor))
23+
filament_monitors = model_prop("filament_monitors", ModelCollection[Optional[FilamentMonitor]], ModelCollection(Optional[FilamentMonitor]))
2424

2525
# List of general-purpose input ports
26-
gp_in = model_prop("gp_in", ModelCollection[Optional[GpInputPort]], ModelCollection(GpInputPort))
26+
gp_in = model_prop("gp_in", ModelCollection[Optional[GpInputPort]], ModelCollection(Optional[GpInputPort]))
2727

2828
# List of probes
29-
probes = model_prop("probes", ModelCollection[Optional[Probe]], ModelCollection(Probe))
29+
probes = model_prop("probes", ModelCollection[Optional[Probe]], ModelCollection(Optional[Probe]))
3030

3131
def __init__(self):
3232
super(Sensors, self).__init__()

0 commit comments

Comments
 (0)