Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/skeleton/base.py: 21%

94 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 15:02 +0000

1import fnmatch 

2import logging 

3import typing as t 

4 

5from deprecated.sphinx import deprecated 

6 

7from .meta import MetaBaseSkel 

8from ..bones.base import BaseBone, ReadFromClientErrorSeverity 

9from ..config import conf 

10 

11if t.TYPE_CHECKING: 11 ↛ 12line 11 didn't jump to line 12 because the condition on line 11 was never true

12 from .instance import SkeletonInstance 

13 

14 

15class BaseSkeleton(object, metaclass=MetaBaseSkel): 

16 """ 

17 This is a container-object holding information about one database entity. 

18 

19 It has to be sub-classed with individual information about the kindName of the entities 

20 and its specific data attributes, the so called bones. 

21 The Skeleton stores its bones in an :class:`OrderedDict`-Instance, so the definition order of the 

22 contained bones remains constant. 

23 

24 :ivar key: This bone stores the current database key of this entity. \ 

25 Assigning to this bones value is dangerous and does *not* affect the actual key its stored in. 

26 

27 :vartype key: server.bones.BaseBone 

28 

29 :ivar creationdate: The date and time where this entity has been created. 

30 :vartype creationdate: server.bones.DateBone 

31 

32 :ivar changedate: The date and time of the last change to this entity. 

33 :vartype changedate: server.bones.DateBone 

34 """ 

35 __viurBaseSkeletonMarker__ = True 

36 boneMap = None 

37 

38 @classmethod 

39 @deprecated( 

40 version="3.7.0", 

41 reason="Function renamed. Use subskel function as alternative implementation.", 

42 ) 

43 def subSkel(cls, *subskel_names, fullClone: bool = False, **kwargs) -> "SkeletonInstance": 

44 return cls.subskel(*subskel_names, clone=fullClone) # FIXME: REMOVE WITH VIUR4 

45 

46 @classmethod 

47 def subskel( 

48 cls, 

49 *names: str, 

50 bones: t.Iterable[str] = (), 

51 clone: bool = False, 

52 ) -> "SkeletonInstance": 

53 """ 

54 Creates a new sub-skeleton from the current skeleton. 

55 

56 A sub-skeleton is a copy of the original skeleton, containing only a subset of its bones. 

57 

58 Sub-skeletons can either be defined using the the subSkels property of the Skeleton object, 

59 or freely by giving patterns for bone names which shall be part of the sub-skeleton. 

60 

61 1. Giving names as parameter merges the bones of all Skeleton.subSkels-configurations together. 

62 This is the usual behavior. By passing multiple sub-skeleton names to this function, a sub-skeleton 

63 with the union of all bones of the specified sub-skeletons is returned. If an entry called "*" 

64 exists in the subSkels-dictionary, the bones listed in this entry will always be part of the 

65 generated sub-skeleton. 

66 2. Given the *bones* parameter allows to freely specify a sub-skeleton; One specialty here is, 

67 that the order of the bones can also be changed in this mode. This mode is the new way of defining 

68 sub-skeletons, and might become the primary way to define sub-skeletons in future. 

69 3. Both modes (1 + 2) can be combined, but then the original order of the bones is kept. 

70 4. The "key" bone is automatically available in each sub-skeleton. 

71 5. An fnmatch-compatible wildcard pattern is allowed both in the subSkels-bone-list and the 

72 free bone list. 

73 

74 Example (TodoSkel is the example skeleton from viur-base): 

75 ```py 

76 # legacy mode (see 1) 

77 subskel = TodoSkel.subskel("add") 

78 # creates subskel: key, firstname, lastname, subject 

79 

80 # free mode (see 2) allows to specify a different order! 

81 subskel = TodoSkel.subskel(bones=("subject", "message", "*stname")) 

82 # creates subskel: key, subject, message, firstname, lastname 

83 

84 # mixed mode (see 3) 

85 subskel = TodoSkel.subskel("add", bones=("message", )) 

86 # creates subskel: key, firstname, lastname, subject, message 

87 ``` 

88 

89 :param bones: Allows to specify an iterator of bone names (more precisely, fnmatch-wildards) which allow 

90 to freely define a subskel. If *only* this parameter is given, the order of the specification also 

91 defines, the order of the list. Otherwise, the original order as defined in the skeleton is kept. 

92 :param clone: If set True, performs a cloning of the used bone map, to be entirely stand-alone. 

93 

94 :return: The sub-skeleton of the specified type. 

95 """ 

96 from_subskel = False 

97 bones = list(bones) 

98 

99 for name in names: 

100 # a str refers to a subskel name from the cls.subSkel dict 

101 if isinstance(name, str): 

102 # add bones from "*" subskel once 

103 if not from_subskel: 

104 bones.extend(cls.subSkels.get("*") or ()) 

105 from_subskel = True 

106 

107 bones.extend(cls.subSkels.get(name) or ()) 

108 

109 else: 

110 raise ValueError(f"Invalid subskel definition: {name!r}") 

111 

112 if from_subskel: 

113 # when from_subskel is True, create bone names based on the order of the bones in the original skeleton 

114 bones = tuple(k for k in cls.__boneMap__.keys() if any(fnmatch.fnmatch(k, n) for n in bones)) 

115 

116 if not bones: 

117 raise ValueError("The given subskel definition doesn't contain any bones!") 

118 

119 return cls(bones=bones, clone=clone) 

120 

121 @classmethod 

122 def setSystemInitialized(cls): 

123 for attrName in dir(cls): 

124 bone = getattr(cls, attrName) 

125 if isinstance(bone, BaseBone): 

126 bone.setSystemInitialized() 

127 

128 @classmethod 

129 def setBoneValue( 

130 cls, 

131 skel: "SkeletonInstance", 

132 boneName: str, 

133 value: t.Any, 

134 append: bool = False, 

135 language: t.Optional[str] = None 

136 ) -> bool: 

137 """ 

138 Allows for setting a bones value without calling fromClient or assigning a value directly. 

139 Sanity-Checks are performed; if the value is invalid, that bone flips back to its original 

140 (default) value and false is returned. 

141 

142 :param boneName: The name of the bone to be modified 

143 :param value: The value that should be assigned. It's type depends on the type of that bone 

144 :param append: If True, the given value is appended to the values of that bone instead of 

145 replacing it. Only supported on bones with multiple=True 

146 :param language: Language to set 

147 

148 :return: Wherever that operation succeeded or not. 

149 """ 

150 bone = getattr(skel, boneName, None) 

151 

152 if not isinstance(bone, BaseBone): 

153 raise ValueError(f"{boneName!r} is no valid bone on this skeleton ({skel!r})") 

154 

155 if language: 

156 if not bone.languages: 

157 raise ValueError("The bone {boneName!r} has no language setting") 

158 elif language not in bone.languages: 

159 raise ValueError("The language {language!r} is not available for bone {boneName!r}") 

160 

161 if value is None: 

162 if append: 

163 raise ValueError("Cannot append None-value to bone {boneName!r}") 

164 

165 if language: 

166 skel[boneName][language] = [] if bone.multiple else None 

167 else: 

168 skel[boneName] = [] if bone.multiple else None 

169 

170 return True 

171 

172 _ = skel[boneName] # ensure the bone is being unserialized first 

173 return bone.setBoneValue(skel, boneName, value, append, language) 

174 

175 @classmethod 

176 def fromClient( 

177 cls, 

178 skel: "SkeletonInstance", 

179 data: dict[str, list[str] | str], 

180 *, 

181 amend: bool = False, 

182 ignore: t.Optional[t.Iterable[str]] = None, 

183 ) -> bool: 

184 """ 

185 Load supplied *data* into Skeleton. 

186 

187 This function works similar to :func:`~viur.core.skeleton.Skeleton.setValues`, except that 

188 the values retrieved from *data* are checked against the bones and their validity checks. 

189 

190 Even if this function returns False, all bones are guaranteed to be in a valid state. 

191 The ones which have been read correctly are set to their valid values; 

192 Bones with invalid values are set back to a safe default (None in most cases). 

193 So its possible to call :func:`~viur.core.skeleton.Skeleton.write` afterwards even if reading 

194 data with this function failed (through this might violates the assumed consistency-model). 

195 

196 :param skel: The skeleton instance to be filled. 

197 :param data: Dictionary from which the data is read. 

198 :param amend: Defines whether content of data may be incomplete to amend the skel, 

199 which is useful for edit-actions. 

200 :param ignore: optional list of bones to be ignored; Defaults to all readonly-bones when set to None. 

201 

202 :returns: True if all data was successfully read and complete. \ 

203 False otherwise (e.g. some required fields where missing or where invalid). 

204 """ 

205 complete = True 

206 skel.errors = [] 

207 

208 for key, bone in skel.items(): 

209 if (ignore is None and bone.readOnly) or key in (ignore or ()): 

210 continue 

211 

212 if errors := bone.fromClient(skel, key, data): 

213 for error in errors: 

214 # insert current bone name into error's fieldPath 

215 error.fieldPath.insert(0, str(key)) 

216 

217 # logging.info(f"{key=} {error=} {skel[key]=} {bone.getEmptyValue()=}") 

218 

219 incomplete = ( 

220 # always when something is invalid 

221 error.severity == ReadFromClientErrorSeverity.Invalid 

222 or ( 

223 # only when path is top-level 

224 len(error.fieldPath) == 1 

225 and ( 

226 # bone is generally required 

227 bool(bone.required) 

228 and ( 

229 # and value is either empty 

230 error.severity == ReadFromClientErrorSeverity.Empty 

231 # or not set, depending on amending mode 

232 or ( 

233 error.severity == ReadFromClientErrorSeverity.NotSet 

234 and (amend and bone.isEmpty(skel[key])) 

235 or not amend 

236 ) 

237 ) 

238 ) 

239 ) 

240 ) 

241 

242 # in case there are language requirements, test additionally 

243 if bone.languages and isinstance(bone.required, (list, tuple)): 

244 incomplete &= any([key, lang] == error.fieldPath for lang in bone.required) 

245 

246 # logging.debug(f"BaseSkel.fromClient {incomplete=} {error.severity=} {bone.required=}") 

247 

248 if incomplete: 

249 complete = False 

250 

251 if conf.debug.skeleton_from_client: 

252 logging.error( 

253 f"""{getattr(cls, "kindName", cls.__name__)}: {".".join(error.fieldPath)}: """ 

254 f"""({error.severity}) {error.errorMessage}""" 

255 ) 

256 else: 

257 errors.clear() 

258 

259 skel.errors += errors 

260 

261 return complete 

262 

263 @classmethod 

264 def refresh(cls, skel: "SkeletonInstance[t.Self]"): 

265 """ 

266 Refresh the bones current content. 

267 

268 This function causes a refresh of all relational bones and their associated 

269 information. 

270 """ 

271 logging.debug(f"""Refreshing {skel["key"]!r} ({skel.get("name")!r})""") 

272 

273 for key, bone in skel.items(): 

274 if not isinstance(bone, BaseBone): 

275 continue 

276 

277 _ = skel[key] # Ensure value gets loaded 

278 bone.refresh(skel, key) 

279 

280 @classmethod 

281 def readonly(cls, skel: "SkeletonInstance"): 

282 """ 

283 Set all bones to readonly in the Skeleton. 

284 """ 

285 for bone in skel.values(): 

286 if not isinstance(bone, BaseBone): 

287 continue 

288 bone.readOnly = True 

289 

290 def __new__(cls, *args, **kwargs) -> "SkeletonInstance": 

291 from .instance import SkeletonInstance 

292 return SkeletonInstance(cls, *args, **kwargs)