Skip to content

Commit 5568681

Browse files
mrjerryjohnspull[bot]
authored andcommitted
Optional/Nullable support to Python Cluster Objects (#11515)
* Optional/Nullable support to Python Cluster Objects This amongst other things, adds support for optional and nullable types to the Python cluster objects. It does so by leveraging typing.Union and typing.Optional to do so. To represent nulls, a new Nullable type has been created to encapsulate that. Consequently, 'None' indicates an optional field that is not present. 'NullValue' indicates a null-value. Consequently, the following representations map to the various combinations: typing.Union[base-type, Types.Nullable] => a nullable base-type. typing.Optional[base-type] => an optional base-type typing.Union[base-type, Types.Nullable, None] => an optional, nullable, base-type. In addition, the generation helpers for Python have been cleaned up to better align with how it's done for the other languages. Finally, a unit-test for the generated objects has been added which was crucial to fix some critical bugs in the implementation. Tests: - Validated using test_generated_clusterobjects.py. - Validated by sending commands to the test cluster to a device. * Review feedback * Fixes the equality comparison in Python for Nones * Re-gen cluster objects * Adding debug mode to the newly added unit test
1 parent d42f042 commit 5568681

File tree

11 files changed

+1336
-1109
lines changed

11 files changed

+1336
-1109
lines changed

src/app/zap-templates/templates/app/helper.js

+48-30
Original file line numberDiff line numberDiff line change
@@ -415,50 +415,50 @@ function zapTypeToDecodableClusterObjectType(type, options)
415415
return zapTypeToClusterObjectType.call(this, type, true, options)
416416
}
417417

418-
function zapTypeToPythonClusterObjectType(type, options)
418+
async function _zapTypeToPythonClusterObjectType(type, options)
419419
{
420-
if (StringHelper.isCharString(type)) {
421-
return 'str';
422-
}
423-
424-
if (StringHelper.isOctetString(type)) {
425-
return 'bytes';
426-
}
427-
428-
if ([ 'single', 'double' ].includes(type.toLowerCase())) {
429-
return 'float';
430-
}
431-
432-
if (type.toLowerCase() == 'boolean') {
433-
return 'bool'
434-
}
435-
436-
// #10748: asUnderlyingZclType will emit wrong types for int{48|56|64}(u), so we process all int values here.
437-
if (type.toLowerCase().match(/^int\d+$/)) {
438-
return 'int'
439-
}
440-
441-
if (type.toLowerCase().match(/^int\d+u$/)) {
442-
return 'uint'
443-
}
444-
445420
async function fn(pkgId)
446421
{
447-
const ns = asUpperCamelCase(options.hash.ns);
422+
const ns = options.hash.ns;
448423
const typeChecker = async (method) => zclHelper[method](this.global.db, type, pkgId).then(zclType => zclType != 'unknown');
449424

450425
if (await typeChecker('isEnum')) {
451426
return ns + '.Enums.' + type;
452427
}
453428

454429
if (await typeChecker('isBitmap')) {
455-
return 'int';
430+
return 'uint';
456431
}
457432

458433
if (await typeChecker('isStruct')) {
459434
return ns + '.Structs.' + type;
460435
}
461436

437+
if (StringHelper.isCharString(type)) {
438+
return 'str';
439+
}
440+
441+
if (StringHelper.isOctetString(type)) {
442+
return 'bytes';
443+
}
444+
445+
if ([ 'single', 'double' ].includes(type.toLowerCase())) {
446+
return 'float';
447+
}
448+
449+
if (type.toLowerCase() == 'boolean') {
450+
return 'bool'
451+
}
452+
453+
// #10748: asUnderlyingZclType will emit wrong types for int{48|56|64}(u), so we process all int values here.
454+
if (type.toLowerCase().match(/^int\d+$/)) {
455+
return 'int'
456+
}
457+
458+
if (type.toLowerCase().match(/^int\d+u$/)) {
459+
return 'uint'
460+
}
461+
462462
resolvedType = await zclHelper.asUnderlyingZclType.call({ global : this.global }, type, options);
463463
{
464464
basicType = ChipTypesHelper.asBasicType(resolvedType);
@@ -469,14 +469,32 @@ function zapTypeToPythonClusterObjectType(type, options)
469469
return 'uint'
470470
}
471471
}
472+
}
472473

473-
throw "Unhandled type " + resolvedType + " (from " + type + ")"
474+
let promise = templateUtil.ensureZclPackageId(this).then(fn.bind(this));
475+
if ((this.isList || this.isArray || this.entryType) && !options.hash.forceNotList) {
476+
promise = promise.then(typeStr => `typing.List[${typeStr}]`);
477+
}
478+
479+
const isNull = (this.isNullable && !options.hash.forceNotNullable);
480+
const isOptional = (this.isOptional && !options.hash.forceNotOptional);
481+
482+
if (isNull && isOptional) {
483+
promise = promise.then(typeStr => `typing.Union[None, Nullable, ${typeStr}]`);
484+
} else if (isNull) {
485+
promise = promise.then(typeStr => `typing.Union[Nullable, ${typeStr}]`);
486+
} else if (isOptional) {
487+
promise = promise.then(typeStr => `typing.Optional[${typeStr}]`);
474488
}
475489

476-
const promise = templateUtil.ensureZclPackageId(this).then(fn.bind(this));
477490
return templateUtil.templatePromise(this.global, promise)
478491
}
479492

493+
function zapTypeToPythonClusterObjectType(type, options)
494+
{
495+
return _zapTypeToPythonClusterObjectType.call(this, type, options)
496+
}
497+
480498
async function getResponseCommandName(responseRef, options)
481499
{
482500
let pkgId = await templateUtil.ensureZclPackageId(this);

src/controller/python/BUILD.gn

+1
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ pw_python_action("python") {
118118
"chip/clusters/Command.py",
119119
"chip/clusters/Objects.py",
120120
"chip/clusters/TestObjects.py",
121+
"chip/clusters/Types.py",
121122
"chip/clusters/__init__.py",
122123
"chip/configuration/__init__.py",
123124
"chip/discovery/__init__.py",

src/controller/python/build-chip-wheel.py

+1
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ def finalize_options(self):
118118
'construct',
119119
'ipython',
120120
'dacite',
121+
'rich',
121122
]
122123

123124
if platform.system() == 'Darwin':

src/controller/python/chip/clusters/ClusterObjects.py

+96-31
Original file line numberDiff line numberDiff line change
@@ -17,44 +17,92 @@
1717

1818
from dataclasses import dataclass, asdict, field, make_dataclass
1919
from typing import ClassVar, List, Dict, Any, Mapping, Type, Union, ClassVar
20+
import enum
21+
import typing
2022
from chip import tlv, ChipUtility
23+
from chip.clusters.Types import Nullable, NullValue
2124
from dacite import from_dict
2225

2326

27+
def GetUnionUnderlyingType(typeToCheck, matchingType=None):
28+
''' This retrieves the underlying types behind a unioned type by appropriately
29+
passing in the required matching type in the matchingType input argument.
30+
31+
If that is 'None' (not to be confused with NoneType), then it will retrieve
32+
the 'real' type behind the union, i.e not Nullable && not None
33+
'''
34+
if (not(typing.get_origin(typeToCheck) == typing.Union)):
35+
return None
36+
37+
for t in typing.get_args(typeToCheck):
38+
if (matchingType is None):
39+
if (t != type(None) and t != Nullable):
40+
return t
41+
else:
42+
if (t == matchingType):
43+
return t
44+
45+
return None
46+
47+
2448
@dataclass
2549
class ClusterObjectFieldDescriptor:
2650
Label: str = ''
2751
Tag: int = None
2852
Type: Type = None
29-
IsArray: bool = False
3053

31-
def _PutSingleElementToTLV(self, tag, val, writer: tlv.TLVWriter, debugPath: str = '?'):
32-
if issubclass(self.Type, ClusterObject):
54+
def _PutSingleElementToTLV(self, tag, val, elementType, writer: tlv.TLVWriter, debugPath: str = '?'):
55+
if issubclass(elementType, ClusterObject):
3356
if not isinstance(val, dict):
3457
raise ValueError(
3558
f"Field {debugPath}.{self.Label} expected a struct, but got {type(val)}")
36-
self.Type.descriptor.DictToTLVWithWriter(
59+
elementType.descriptor.DictToTLVWithWriter(
3760
f'{debugPath}.{self.Label}', tag, val, writer)
3861
return
62+
3963
try:
40-
val = self.Type(val)
64+
val = elementType(val)
4165
except Exception:
4266
raise ValueError(
43-
f"Field {debugPath}.{self.Label} expected {self.Type}, but got {type(val)}")
67+
f"Field {debugPath}.{self.Label} expected {elementType}, but got {type(val)}")
4468
writer.put(tag, val)
4569

4670
def PutFieldToTLV(self, tag, val, writer: tlv.TLVWriter, debugPath: str = '?'):
47-
if not self.IsArray:
48-
self._PutSingleElementToTLV(tag, val, writer, debugPath)
49-
return
50-
if not isinstance(val, List):
51-
raise ValueError(
52-
f"Field {debugPath}.{self.Label} expected List[{self.Type}], but got {type(val)}")
53-
writer.startArray(tag)
54-
for i, v in enumerate(val):
55-
self._PutSingleElementToTLV(
56-
None, v, writer, debugPath + f'[{i}]')
57-
writer.endContainer()
71+
if (val == NullValue):
72+
if (GetUnionUnderlyingType(self.Type, Nullable) is None):
73+
raise ValueError(
74+
f"Field {debugPath}.{self.Label} was not nullable, but got a null")
75+
76+
writer.put(tag, None)
77+
elif (val is None):
78+
if (GetUnionUnderlyingType(self.Type, type(None)) is None):
79+
raise ValueError(
80+
f"Field {debugPath}.{self.Label} was not optional, but encountered None")
81+
else:
82+
#
83+
# If it is an optional or nullable type, it's going to be a union.
84+
# So, let's get at the 'real' type within that union before proceeding,
85+
# since at this point, we're guarenteed to not get None or Null as values.
86+
#
87+
elementType = GetUnionUnderlyingType(self.Type)
88+
if (elementType is None):
89+
elementType = self.Type
90+
91+
if not isinstance(val, List):
92+
self._PutSingleElementToTLV(
93+
tag, val, elementType, writer, debugPath)
94+
return
95+
96+
writer.startArray(tag)
97+
98+
# Get the type of the list. This is a generic, which has its sub-type information of the list element
99+
# inside its type argument.
100+
(elementType, ) = typing.get_args(elementType)
101+
102+
for i, v in enumerate(val):
103+
self._PutSingleElementToTLV(
104+
None, v, elementType, writer, debugPath + f'[{i}]')
105+
writer.endContainer()
58106

59107

60108
@dataclass
@@ -73,16 +121,19 @@ def GetFieldByLabel(self, label: str) -> ClusterObjectFieldDescriptor:
73121
return field
74122
return None
75123

76-
def _ConvertNonArray(self, debugPath: str, descriptor: ClusterObjectFieldDescriptor, value: Any) -> Any:
77-
if not issubclass(descriptor.Type, ClusterObject):
78-
if not isinstance(value, descriptor.Type):
124+
def _ConvertNonArray(self, debugPath: str, elementType, value: Any) -> Any:
125+
if not issubclass(elementType, ClusterObject):
126+
if (issubclass(elementType, enum.Enum)):
127+
value = elementType(value)
128+
129+
if not isinstance(value, elementType):
79130
raise ValueError(
80-
f"Failed to decode field {debugPath}, expected type {descriptor.Type}, got {type(value)}")
131+
f"Failed to decode field {debugPath}, expected type {elementType}, got {type(value)}")
81132
return value
82133
if not isinstance(value, Mapping):
83134
raise ValueError(
84135
f"Failed to decode field {debugPath}, struct expected.")
85-
return descriptor.Type.descriptor.TagDictToLabelDict(debugPath, value)
136+
return elementType.descriptor.TagDictToLabelDict(debugPath, value)
86137

87138
def TagDictToLabelDict(self, debugPath: str, tlvData: Dict[int, Any]) -> Dict[str, Any]:
88139
ret = {}
@@ -92,13 +143,30 @@ def TagDictToLabelDict(self, debugPath: str, tlvData: Dict[int, Any]) -> Dict[st
92143
# We do not have enough infomation for this field.
93144
ret[tag] = value
94145
continue
95-
if descriptor.IsArray:
146+
147+
if (value is None):
148+
ret[descriptor.Label] = NullValue
149+
continue
150+
151+
if (typing.get_origin(descriptor.Type) == typing.Union):
152+
realType = GetUnionUnderlyingType(descriptor.Type)
153+
if (realType is None):
154+
raise ValueError(
155+
f"Field {debugPath}.{self.Label} has no valid underlying data model type")
156+
157+
valueType = realType
158+
else:
159+
valueType = descriptor.Type
160+
161+
if (typing.get_origin(valueType) == list):
162+
listElementType = typing.get_args(valueType)[0]
96163
ret[descriptor.Label] = [
97-
self._ConvertNonArray(f'{debugPath}[{i}]', descriptor, v)
164+
self._ConvertNonArray(
165+
f'{debugPath}[{i}]', listElementType, v)
98166
for i, v in enumerate(value)]
99167
continue
100168
ret[descriptor.Label] = self._ConvertNonArray(
101-
f'{debugPath}.{descriptor.Label}', descriptor, value)
169+
f'{debugPath}.{descriptor.Label}', valueType, value)
102170
return ret
103171

104172
def TLVToDict(self, tlvBuf: bytes) -> Dict[str, Any]:
@@ -109,9 +177,6 @@ def DictToTLVWithWriter(self, debugPath: str, tag, data: Mapping, writer: tlv.TL
109177
writer.startStructure(tag)
110178
for field in self.Fields:
111179
val = data.get(field.Label, None)
112-
if val is None:
113-
raise ValueError(
114-
f"Field {debugPath}.{field.Label} is missing in the given dict")
115180
field.PutFieldToTLV(field.Tag, val, writer,
116181
debugPath + f'.{field.Label}')
117182
writer.endContainer()
@@ -198,13 +263,13 @@ def attribute_type(cls) -> ClusterObjectFieldDescriptor:
198263
def _cluster_object(cls) -> ClusterObject:
199264
return make_dataclass('InternalClass',
200265
[
201-
('Value', List[cls.attribute_type.Type]
202-
if cls.attribute_type.IsArray else cls.attribute_type.Type, field(default=None)),
266+
('Value', cls.attribute_type.Type,
267+
field(default=None)),
203268
('descriptor', ClassVar[ClusterObjectDescriptor],
204269
field(
205270
default=ClusterObjectDescriptor(
206271
Fields=[ClusterObjectFieldDescriptor(
207-
Label='Value', Tag=0, Type=cls.attribute_type.Type, IsArray=cls.attribute_type.IsArray)]
272+
Label='Value', Tag=0, Type=cls.attribute_type.Type)]
208273
)
209274
)
210275
)

0 commit comments

Comments
 (0)