Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/skeleton/meta.py: 37%

160 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-13 11:04 +0000

1import fnmatch 

2import inspect 

3import logging 

4import os 

5import string 

6import sys 

7import typing as t 

8from deprecated.sphinx import deprecated 

9from .adapter import ViurTagsSearchAdapter 

10from ..bones.base import BaseBone, ReadFromClientErrorSeverity, getSystemInitialized 

11from .. import db, utils 

12from ..config import conf 

13 

14 

15_UNDEFINED_KINDNAME = object() 

16ABSTRACT_SKEL_CLS_SUFFIX = "AbstractSkel" 

17KeyType: t.TypeAlias = db.Key | str | int 

18 

19 

20class MetaBaseSkel(type): 

21 """ 

22 This is the metaclass for Skeletons. 

23 It is used to enforce several restrictions on bone names, etc. 

24 """ 

25 _skelCache = {} # Mapping kindName -> SkelCls 

26 _allSkelClasses = set() # list of all known skeleton classes (including Ref and Mail-Skels) 

27 

28 # List of reserved keywords and function names 

29 __reserved_keywords = { 

30 "all", 

31 "bounce", 

32 "clone", 

33 "cursor", 

34 "delete", 

35 "errors", 

36 "fromClient", 

37 "fromDB", 

38 "get", 

39 "getCurrentSEOKeys", 

40 "items", 

41 "keys", 

42 "limit", 

43 "orderby", 

44 "orderdir", 

45 "patch", 

46 "postDeletedHandler", 

47 "postSavedHandler", 

48 "preProcessBlobLocks", 

49 "preProcessSerializedData", 

50 "read", 

51 "readonly", 

52 "refresh", 

53 "self", 

54 "serialize", 

55 "setBoneValue", 

56 "structure", 

57 "style", 

58 "toDB", 

59 "unserialize", 

60 "values", 

61 "write", 

62 } 

63 

64 __allowed_chars = string.ascii_letters + string.digits + "_" 

65 

66 def __init__(cls, name, bases, dct, **kwargs): 

67 cls.__boneMap__ = MetaBaseSkel.generate_bonemap(cls) 

68 

69 if not getSystemInitialized() and not cls.__name__.endswith(ABSTRACT_SKEL_CLS_SUFFIX): 69 ↛ 72line 69 didn't jump to line 72 because the condition on line 69 was always true

70 MetaBaseSkel._allSkelClasses.add(cls) 

71 

72 super().__init__(name, bases, dct) 

73 

74 @staticmethod 

75 def generate_bonemap(cls): 

76 """ 

77 Recursively constructs a dict of bones from 

78 """ 

79 map = {} 

80 

81 for base in cls.__bases__: 

82 if "__viurBaseSkeletonMarker__" in dir(base): 

83 map |= MetaBaseSkel.generate_bonemap(base) 

84 

85 for key in cls.__dict__: 

86 prop = getattr(cls, key) 

87 

88 if isinstance(prop, BaseBone): 

89 if not all([c in MetaBaseSkel.__allowed_chars for c in key]): 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true

90 raise AttributeError(f"Invalid bone name: {key!r} contains invalid characters") 

91 elif key in MetaBaseSkel.__reserved_keywords: 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true

92 raise AttributeError(f"Invalid bone name: {key!r} is reserved and cannot be used") 

93 

94 map[key] = prop 

95 

96 elif prop is None and key in map: # Allow removing a bone in a subclass by setting it to None 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true

97 del map[key] 

98 

99 return map 

100 

101 def __setattr__(self, key, value): 

102 super().__setattr__(key, value) 

103 if isinstance(value, BaseBone): 103 ↛ 105line 103 didn't jump to line 105 because the condition on line 103 was never true

104 # Call BaseBone.__set_name__ manually for bones that are assigned at runtime 

105 value.__set_name__(self, key) 

106 

107 

108class MetaSkel(MetaBaseSkel): 

109 

110 def __init__(cls, name, bases, dct, **kwargs): 

111 super().__init__(name, bases, dct, **kwargs) 

112 

113 relNewFileName = inspect.getfile(cls) \ 

114 .replace(str(conf.instance.project_base_path), "") \ 

115 .replace(str(conf.instance.core_base_path), "") 

116 

117 # Check if we have an abstract skeleton 

118 if cls.__name__.endswith(ABSTRACT_SKEL_CLS_SUFFIX): 118 ↛ 120line 118 didn't jump to line 120 because the condition on line 118 was never true

119 # Ensure that it doesn't have a kindName 

120 assert cls.kindName is _UNDEFINED_KINDNAME or cls.kindName is None, \ 

121 "Abstract Skeletons can't have a kindName" 

122 # Prevent any further processing by this class; it has to be sub-classed before it can be used 

123 return 

124 

125 # Automatic determination of the kindName, if the class is not part of viur.core. 

126 if ( 126 ↛ 131line 126 didn't jump to line 131 because the condition on line 126 was never true

127 cls.kindName is _UNDEFINED_KINDNAME 

128 and not relNewFileName.strip(os.path.sep).startswith("viur") 

129 and "viur_doc_build" not in dir(sys) # do not check during documentation build 

130 ): 

131 if cls.__name__.endswith("Skel"): 

132 cls.kindName = cls.__name__.lower()[:-4] 

133 else: 

134 cls.kindName = cls.__name__.lower() 

135 

136 # Try to determine which skeleton definition takes precedence 

137 if cls.kindName and cls.kindName is not _UNDEFINED_KINDNAME and cls.kindName in MetaBaseSkel._skelCache: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 relOldFileName = inspect.getfile(MetaBaseSkel._skelCache[cls.kindName]) \ 

139 .replace(str(conf.instance.project_base_path), "") \ 

140 .replace(str(conf.instance.core_base_path), "") 

141 idxOld = min( 

142 [x for (x, y) in enumerate(conf.skeleton_search_path) if relOldFileName.startswith(y)] + [999]) 

143 idxNew = min( 

144 [x for (x, y) in enumerate(conf.skeleton_search_path) if relNewFileName.startswith(y)] + [999]) 

145 if idxNew == 999: 

146 # We could not determine a priority for this class as its from a path not listed in the config 

147 raise NotImplementedError( 

148 "Skeletons must be defined in a folder listed in conf.skeleton_search_path") 

149 elif idxOld < idxNew: # Lower index takes precedence 

150 # The currently processed skeleton has a lower priority than the one we already saw - just ignore it 

151 return 

152 elif idxOld > idxNew: 

153 # The currently processed skeleton has a higher priority, use that from now 

154 MetaBaseSkel._skelCache[cls.kindName] = cls 

155 else: # They seem to be from the same Package - raise as something is messed up 

156 raise ValueError(f"Duplicate definition for {cls.kindName} in {relNewFileName} and {relOldFileName}") 

157 

158 # Ensure that all skeletons are defined in folders listed in conf.skeleton_search_path 

159 if ( 159 ↛ 163line 159 didn't jump to line 163 because the condition on line 159 was never true

160 not any([relNewFileName.startswith(path) for path in conf.skeleton_search_path]) 

161 and "viur_doc_build" not in dir(sys) # do not check during documentation build 

162 ): 

163 raise NotImplementedError( 

164 f"""{relNewFileName} must be defined in a folder listed in {conf.skeleton_search_path}""") 

165 

166 if cls.kindName and cls.kindName is not _UNDEFINED_KINDNAME: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true

167 MetaBaseSkel._skelCache[cls.kindName] = cls 

168 

169 # Auto-Add ViUR Search Tags Adapter if the skeleton has no adapter attached 

170 if cls.database_adapters is _UNDEFINED_KINDNAME: 170 ↛ 174line 170 didn't jump to line 174 because the condition on line 170 was always true

171 cls.database_adapters = ViurTagsSearchAdapter() 

172 

173 # Always ensure that skel.database_adapters is an iterable 

174 cls.database_adapters = utils.ensure_iterable(cls.database_adapters) 

175 

176 

177class BaseSkeleton(object, metaclass=MetaBaseSkel): 

178 """ 

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

180 

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

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

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

184 contained bones remains constant. 

185 

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

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

188 

189 :vartype key: server.bones.BaseBone 

190 

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

192 :vartype creationdate: server.bones.DateBone 

193 

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

195 :vartype changedate: server.bones.DateBone 

196 """ 

197 __viurBaseSkeletonMarker__ = True 

198 boneMap = None 

199 

200 @classmethod 

201 @deprecated( 

202 version="3.7.0", 

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

204 ) 

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

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

207 

208 @classmethod 

209 def subskel( 

210 cls, 

211 *names: str, 

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

213 clone: bool = False, 

214 ) -> "SkeletonInstance": 

215 """ 

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

217 

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

219 

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

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

222 

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

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

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

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

227 generated sub-skeleton. 

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

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

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

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

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

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

234 free bone list. 

235 

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

237 ```py 

238 # legacy mode (see 1) 

239 subskel = TodoSkel.subskel("add") 

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

241 

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

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

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

245 

246 # mixed mode (see 3) 

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

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

249 ``` 

250 

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

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

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

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

255 

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

257 """ 

258 from_subskel = False 

259 bones = list(bones) 

260 

261 for name in names: 

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

263 if isinstance(name, str): 

264 # add bones from "*" subskel once 

265 if not from_subskel: 

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

267 from_subskel = True 

268 

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

270 

271 else: 

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

273 

274 if from_subskel: 

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

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

277 

278 if not bones: 

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

280 

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

282 

283 @classmethod 

284 def setSystemInitialized(cls): 

285 for attrName in dir(cls): 

286 bone = getattr(cls, attrName) 

287 if isinstance(bone, BaseBone): 

288 bone.setSystemInitialized() 

289 

290 @classmethod 

291 def setBoneValue( 

292 cls, 

293 skel: "SkeletonInstance", 

294 boneName: str, 

295 value: t.Any, 

296 append: bool = False, 

297 language: t.Optional[str] = None 

298 ) -> bool: 

299 """ 

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

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

302 (default) value and false is returned. 

303 

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

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

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

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

308 :param language: Language to set 

309 

310 :return: Wherever that operation succeeded or not. 

311 """ 

312 bone = getattr(skel, boneName, None) 

313 

314 if not isinstance(bone, BaseBone): 

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

316 

317 if language: 

318 if not bone.languages: 

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

320 elif language not in bone.languages: 

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

322 

323 if value is None: 

324 if append: 

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

326 

327 if language: 

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

329 else: 

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

331 

332 return True 

333 

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

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

336 

337 @classmethod 

338 def fromClient( 

339 cls, 

340 skel: "SkeletonInstance", 

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

342 *, 

343 amend: bool = False, 

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

345 ) -> bool: 

346 """ 

347 Load supplied *data* into Skeleton. 

348 

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

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

351 

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

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

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

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

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

357 

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

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

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

361 which is useful for edit-actions. 

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

363 

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

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

366 """ 

367 complete = True 

368 skel.errors = [] 

369 

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

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

372 continue 

373 

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

375 for error in errors: 

376 # insert current bone name into error's fieldPath 

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

378 

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

380 

381 incomplete = ( 

382 # always when something is invalid 

383 error.severity == ReadFromClientErrorSeverity.Invalid 

384 or ( 

385 # only when path is top-level 

386 len(error.fieldPath) == 1 

387 and ( 

388 # bone is generally required 

389 bool(bone.required) 

390 and ( 

391 # and value is either empty 

392 error.severity == ReadFromClientErrorSeverity.Empty 

393 # or not set, depending on amending mode 

394 or ( 

395 error.severity == ReadFromClientErrorSeverity.NotSet 

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

397 or not amend 

398 ) 

399 ) 

400 ) 

401 ) 

402 ) 

403 

404 # in case there are language requirements, test additionally 

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

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

407 

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

409 

410 if incomplete: 

411 complete = False 

412 

413 if conf.debug.skeleton_from_client: 

414 logging.error( 

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

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

417 ) 

418 else: 

419 errors.clear() 

420 

421 skel.errors += errors 

422 

423 return complete 

424 

425 @classmethod 

426 def refresh(cls, skel: "SkeletonInstance"): 

427 """ 

428 Refresh the bones current content. 

429 

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

431 information. 

432 """ 

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

434 

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

436 if not isinstance(bone, BaseBone): 

437 continue 

438 

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

440 bone.refresh(skel, key) 

441 

442 @classmethod 

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

444 """ 

445 Set all bones to readonly in the Skeleton. 

446 """ 

447 for bone in skel.values(): 

448 if not isinstance(bone, BaseBone): 

449 continue 

450 bone.readOnly = True 

451 

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

453 from .instance import SkeletonInstance 

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