Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/module.py: 15%

205 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-27 07:59 +0000

1import copy 

2import enum 

3import functools 

4import inspect 

5import types 

6import typing as t 

7import logging 

8from viur.core import db, errors, current, utils 

9from viur.core.config import conf 

10 

11 

12class Method: 

13 """ 

14 Abstraction wrapper for any public available method. 

15 """ 

16 

17 @classmethod 

18 def ensure(cls, func: t.Callable | "Method") -> "Method": 

19 """ 

20 Ensures the provided `func` parameter is either a Method already, or turns it 

21 into a Method. This is done to avoid stacking Method objects, which may create 

22 unwanted results. 

23 """ 

24 if isinstance(func, Method): 

25 return func 

26 

27 return cls(func) 

28 

29 def __init__(self, func: t.Callable): 

30 # Content 

31 self._func = func 

32 self.__name__ = func.__name__ 

33 self._instance = None 

34 

35 # Attributes 

36 self.exposed = None # None = unexposed, True = exposed, False = internal exposed 

37 self.ssl = False 

38 self.methods = ("GET", "POST", "HEAD", "OPTIONS") 

39 self.seo_language_map = None 

40 self.cors_allow_headers = None 

41 self.additional_descr = {} 

42 self.skey = None 

43 

44 # Inspection 

45 self.signature = inspect.signature(self._func) 

46 

47 # Guards 

48 self.guards = [] 

49 

50 def __get__(self, obj, objtype=None): 

51 """ 

52 This binds the Method to an object. 

53 

54 To do it, the Method instance is copied and equipped with the individual _instance member. 

55 """ 

56 if obj: 

57 bound = copy.copy(self) 

58 bound._instance = obj 

59 return bound 

60 

61 return self 

62 

63 def __call__(self, *args, **kwargs): 

64 """ 

65 Calls the method with given args and kwargs. 

66 

67 Prepares and filters argument values from args and kwargs regarding self._func's signature and type annotations, 

68 if present. 

69 

70 Method objects normally wrap functions which are externally exposed. Therefore, any arguments passed from the 

71 client are str-values, and are automatically parsed when equipped with type-annotations. 

72 

73 This preparation of arguments therefore inspects the target function as follows 

74 - incoming values are parsed to their particular type, if type annotations are present 

75 - parameters in *args and **kwargs are being checked against their signature; only relevant values are being 

76 passed, anything else is thrown away. 

77 - execution of guard configurations from @skey and @access, if present 

78 """ 

79 

80 if trace := conf.debug.trace: 

81 logging.debug(f"calling {self._func=} with raw {args=}, {kwargs=}") 

82 

83 def parse_value_by_annotation(annotation: type, name: str, value: str | list | tuple) -> t.Any: 

84 """ 

85 Tries to parse a value according to a given type. 

86 May be called recursively to handle unions, lists and tuples as well. 

87 """ 

88 # logging.debug(f"{annotation=} | {name=} | {value=}") 

89 

90 # simple types 

91 if annotation is str: 

92 return str(value) 

93 elif annotation is int: 

94 return int(value) 

95 elif annotation is float: 

96 return float(value) 

97 elif annotation is bool: 

98 return utils.parse.bool(value) 

99 elif annotation is types.NoneType or annotation is None: 

100 if value in (None, "None", "null"): 

101 return None 

102 raise ValueError(f"Expected None for parameter {name}. Got: {value!r}") 

103 

104 # complex types 

105 origin_type = t.get_origin(annotation) 

106 

107 if origin_type is list and len(annotation.__args__) == 1: 

108 if not isinstance(value, list): 

109 value = [value] 

110 

111 return [parse_value_by_annotation(annotation.__args__[0], name, item) for item in value] 

112 

113 elif origin_type is tuple and len(annotation.__args__) == 1: 

114 if not isinstance(value, tuple): 

115 value = (value, ) 

116 

117 return tuple(parse_value_by_annotation(annotation.__args__[0], name, item) for item in value) 

118 

119 elif origin_type is t.Literal: 

120 if not any(value == str(literal) for literal in annotation.__args__): 

121 raise errors.NotAcceptable(f"Expecting any of {annotation.__args__} for {name}") 

122 

123 return value 

124 

125 elif origin_type is t.Union or isinstance(annotation, types.UnionType): 

126 for i, sub_annotation in enumerate(annotation.__args__): 

127 try: 

128 return parse_value_by_annotation(sub_annotation, name, value) 

129 except ValueError: 

130 if i == len(annotation.__args__) - 1: 

131 raise 

132 

133 elif annotation is db.Key: 

134 if isinstance(value, db.Key): 

135 return value 

136 

137 return parse_value_by_annotation(int | str, name, value) 

138 

139 elif isinstance(annotation, enum.EnumMeta): 

140 try: 

141 return annotation(value) 

142 except ValueError as exc: 

143 for value_, member in annotation._value2member_map_.items(): 

144 if str(value) == str(value_): # Do a string comparison, it could be a IntEnum 

145 return member 

146 raise errors.NotAcceptable(f"{' '.join(exc.args)} for {name}") from exc 

147 

148 raise errors.NotAcceptable(f"Unhandled type {annotation=} for {name}={value!r}") 

149 

150 # examine parameters 

151 args_iter = iter(args) 

152 

153 parsed_args = [] 

154 parsed_kwargs = {} 

155 varargs = [] 

156 varkwargs = False 

157 

158 for i, (param_name, param) in enumerate(self.signature.parameters.items()): 

159 if self._instance and i == 0 and param_name == "self": 

160 continue 

161 

162 param_type = param.annotation 

163 param_required = param.default is param.empty 

164 

165 # take positional parameters first 

166 if param.kind in ( 

167 inspect.Parameter.POSITIONAL_OR_KEYWORD, 

168 inspect.Parameter.POSITIONAL_ONLY 

169 ): 

170 try: 

171 value = next(args_iter) 

172 

173 if param_type is not param.empty: 

174 value = parse_value_by_annotation(param_type, param_name, value) 

175 

176 parsed_args.append(value) 

177 continue 

178 except StopIteration: 

179 pass 

180 

181 # otherwise take kwargs or variadics 

182 if ( 

183 param.kind in ( 

184 inspect.Parameter.POSITIONAL_OR_KEYWORD, 

185 inspect.Parameter.KEYWORD_ONLY 

186 ) 

187 and param_name in kwargs 

188 ): 

189 value = kwargs.pop(param_name) 

190 

191 if param_type is not param.empty: 

192 value = parse_value_by_annotation(param_type, param_name, value) 

193 

194 parsed_kwargs[param_name] = value 

195 

196 elif param.kind == inspect.Parameter.VAR_POSITIONAL: 

197 varargs = list(args_iter) 

198 elif param.kind == inspect.Parameter.VAR_KEYWORD: 

199 varkwargs = True 

200 elif param_required: 

201 if self.skey and param_name == self.skey["forward_payload"]: 

202 continue 

203 

204 raise errors.NotAcceptable(f"Missing required parameter {param_name!r}") 

205 

206 # Here's a short clarification on the variables used here: 

207 # 

208 # - parsed_args = tuple of (the type-parsed) arguments that have been assigned based on the signature 

209 # - parsed_kwargs = dict of (the type-parsed) keyword arguments that have been assigned based on the signature 

210 # - args = either parsed_args, or parsed_args + remaining args if the function accepts *args 

211 # - kwargs = either parsed_kwars, or parsed_kwargs | remaining kwargs if the function accepts **kwargs 

212 # - varargs = indicator that the args also contain variable args (*args) 

213 # - varkwargs = indicator that variable kwargs (**kwargs) are also contained in the kwargs 

214 # 

215 

216 # Extend args to any varargs, and redefine args 

217 args = tuple(parsed_args + varargs) 

218 

219 # always take "skey"-parameter name, when configured, as parsed_kwargs 

220 if self.skey and self.skey["name"] in kwargs: 

221 parsed_kwargs[self.skey["name"]] = kwargs.pop(self.skey["name"]) 

222 

223 # When varkwargs are accepted, merge parsed_kwargs and kwargs, otherwise just use parsed_kwargs 

224 if varkwargs := varkwargs and bool(kwargs): 

225 kwargs = parsed_kwargs | kwargs 

226 else: 

227 kwargs = parsed_kwargs 

228 

229 # Trace message for final call configuration 

230 if conf.debug.trace: 

231 logging.debug(f"calling {self._func=} with cleaned {args=}, {kwargs=}") 

232 # call decorators in reversed because they are added in the reversed order 

233 for func in reversed(self.guards): 

234 func(args=args, kwargs=kwargs, varargs=varargs, varkwargs=varkwargs) 

235 

236 # call with instance when provided 

237 if self._instance: 

238 return self._func(self._instance, *args, **kwargs) 

239 

240 return self._func(*args, **kwargs) 

241 

242 def describe(self) -> dict: 

243 """ 

244 Describes the Method with a 

245 """ 

246 return_doc = t.get_type_hints(self._func).get("return") 

247 

248 return { 

249 "args": { 

250 param.name: { 

251 "type": str(param.annotation) if param.annotation is not inspect.Parameter.empty else None, 

252 "default": str(param.default) if param.default is not inspect.Parameter.empty else None, 

253 } 

254 for param in self.signature.parameters.values() 

255 }, 

256 "returns": str(return_doc).strip() if return_doc else None, 

257 "accepts": self.methods, 

258 "docs": self._func.__doc__.strip() if self._func.__doc__ else None, 

259 "aliases": tuple(self.seo_language_map.keys()) if self.seo_language_map else None, 

260 } | self.additional_descr 

261 

262 def register(self, target: dict, name: str, language: str | None = None): 

263 """ 

264 Registers the Method under `name` and eventually some customized SEO-name for the provided language 

265 """ 

266 if self.exposed is None: 

267 return 

268 

269 target[name] = self 

270 

271 # reassign for SEO mapping as well 

272 if self.seo_language_map: 

273 for lang in tuple(self.seo_language_map.keys()) if not language else (language, ): 

274 if translated_name := self.seo_language_map.get(lang): 

275 target[translated_name] = self 

276 

277 

278class Module: 

279 """ 

280 This is the root module prototype that serves a minimal module in the ViUR system without any other bindings. 

281 """ 

282 

283 handler: str | t.Callable = None 

284 """ 

285 This is the module's handler, respectively its type. 

286 Use the @property-decorator in specific Modules to construct the handler's value dynamically. 

287 A module without a handler setting cannot be described, so cannot be handled by admin-tools. 

288 """ 

289 

290 accessRights: tuple[str] = None 

291 """ 

292 If set, a tuple of access rights (like add, edit, delete) that this module supports. 

293 

294 These will be prefixed on instance startup with the actual module name (becoming file-add, file-edit etc) 

295 and registered in ``conf.user.access_rights`` so these will be available on the access bone in user/add 

296 or user/edit. 

297 """ 

298 

299 roles: dict = {} 

300 r""" 

301 Allows to specify role settings for a module. 

302 

303 Defaults to no role definition, which ignores the module entirely in the role-system. 

304 In this case, access rights can still be set individually on the user's access bone. 

305 

306 A "*" wildcard can either be used as key or as value to allow for "all roles", or "all rights". 

307 

308 .. code-block:: python 

309 

310 # Example 

311 roles = { 

312 "*": "view", # Any role may only "view" 

313 "editor": ("add", "edit"), # Role "editor" may "add" or "edit", but not "delete" 

314 "admin": "*", # Role "admin" can do everything 

315 } 

316 

317 """ 

318 

319 seo_language_map: dict[str: str] = {} 

320 r""" 

321 The module name is the first part of a URL. 

322 SEO-identifiers have to be set as class-attribute ``seoLanguageMap`` of type ``dict[str, str]`` in the module. 

323 It maps a *language* to the according *identifier*. 

324 

325 .. code-block:: python 

326 :name: module seo-map 

327 :caption: modules/myorders.py 

328 :emphasize-lines: 4-7 

329 

330 from viur.core.prototypes import List 

331 

332 class MyOrders(List): 

333 seo_language_map = { 

334 "de": "bestellungen", 

335 "en": "orders", 

336 } 

337 

338 By default the module would be available under */myorders*, the lowercase module name. 

339 With the defined :attr:`seoLanguageMap`, it will become available as */de/bestellungen* and */en/orders*. 

340 

341 Great, this part is now user and robot friendly :) 

342 """ 

343 

344 adminInfo: dict[str, t.Any] | t.Callable = None 

345 """ 

346 This is a ``dict`` holding the information necessary for the Vi/Admin to handle this module. 

347 

348 name: ``str`` 

349 Human-readable module name that will be shown in the admin tool. 

350 

351 handler: ``str`` (``list``, ``tree`` or ``singleton``): 

352 Allows to override the handler provided by the module. Set this only when *really* necessary, 

353 otherwise it can be left out and is automatically injected by the Module's prototype. 

354 

355 icon: ``str`` 

356 (Optional) Either the Shoelace icon library name or a path relative to the project's deploy folder 

357 (e.g. /static/icons/viur.svg) for the icon used in the admin tool for this module. 

358 

359 columns: ``List[str]`` 

360 (Optional) List of columns (bone names) that are displayed by default. 

361 Used only by the List handler. 

362 

363 filter: ``Dict[str, str]`` 

364 (Optional) Dictionary of additional parameters that will be send along when 

365 fetching entities from the server. Can be used to filter the entities being displayed on the 

366 client-side. 

367 

368 display: ``str`` ("default", "hidden" or "group") 

369 (Optional) "hidden" will hide the module in the admin tool's main bar. 

370 (itwill not be accessible directly, however it's registered with the frontend so it can be used in a 

371 relational bone). "group" will show this module in the main bar, but it will not be clickable. 

372 Clicking it will just try to expand it (assuming there are additional views defined). 

373 

374 preview: ``Union[str, Dict[str, str]]`` 

375 (Optional) A url that will be opened in a new tab and is expected to display 

376 the entity selected in the table. Can be “/{{module}}/view/{{key}}", with {{module}} and {{key}} getting 

377 replaced as needed. If more than one preview-url is needed, supply a dictionary where the key is 

378 the URL and the value the description shown to the user. 

379 

380 views: ``List[Dict[str, t.Any]]`` 

381 (Optional) List of nested adminInfo like dictionaries. Used to define 

382 additional views on the module. Useful f.e. for an order module, where you want separate list of 

383 "payed orders", "unpayed orders", "orders waiting for shipment", etc. If such views are defined, 

384 the top-level entry in the menu bar will expand if clicked, revealing these additional filters. 

385 

386 actions: ``List[str]`` 

387 (Optional) List of actions supported by this modules. Actions can be defined by 

388 the frontend (like "add", "edit", "delete" or "preview"); it can be an action defined by a plugin 

389 loaded by the frontend; or it can be a so called "server side action" (see "customActions" below) 

390 

391 customActions: ``Dict[str, dict]`` 

392 (Optional) A mapping of names of server-defined actions that can be used 

393 in the ``actions`` list above to their definition dictionary. See .... for more details. 

394 

395 disabledActions: ``List[str, dict]`` 

396 (Optional) A list of disabled actions. The frontend will inject default actions like add or edit 

397 even if they're not listed in actions. Listing them here will prevent that. It's up to the frontend 

398 to decide if that action won't be visible at all or it's button just being disabled. 

399 

400 sortIndex: ``int`` 

401 (Optional) Defines the order in which the modules will appear in the main bar in 

402 ascrending order. 

403 

404 indexedBones: ``List[str]`` 

405 (Optional) List of bones, for which an (composite?) index exists in this 

406 view. This allows the fronted to signal the user that a given list can be sorted or filtered by this 

407 bone. If no additional filters are enforced by the 

408 :meth:`listFilter<viur.core.prototypes.list.listFilter>` and ``filter`` is not set, this should be 

409 all bones which are marked as indexed. 

410 

411 changeInvalidates: ``List[str]`` 

412 (Optional) A list of module-names which depend on the entities handled 

413 from this module. This allows the frontend to invalidate any caches in these depended modules if the 

414 data in this module changes. Example: This module may be a list-module handling the file_rootNode 

415 entities for the file module, so a edit/add/deletion action on this module should be reflected in the 

416 rootNode-selector in the file-module itself. In this case, this property should be set to ``["file"]``. 

417 

418 moduleGroup: ``str`` 

419 (Optional) If set, should be a key of a moduleGroup defined in .... . 

420 

421 editViews: ``Dict[str, t.Any]`` 

422 (Optional) If set, will embed another list-widget in the edit forms for 

423 a given entity. See .... for more details. 

424 

425 If this is a function, it must take no parameters and return the dictionary as shown above. This 

426 can be used to customize the appearance of the Vi/Admin to individual users. 

427 """ 

428 

429 def __init__(self, moduleName: str, modulePath: str, *args, **kwargs): 

430 self.render = None # will be set to the appropriate render instance at runtime 

431 self._cached_description = None # caching used by describe() 

432 self.moduleName = moduleName # Name of this module (usually it's class name, e.g. "file") 

433 self.modulePath = modulePath # Path to this module in URL-routing (e.g. "json/file") 

434 

435 if self.handler and self.accessRights: 

436 for right in self.accessRights: 

437 right = f"{self.moduleName}-{right}" 

438 

439 # fixme: Turn conf.user.access_rights into a set. 

440 if right not in conf.user.access_rights: 

441 conf.user.access_rights.append(right) 

442 

443 # Collect methods and (sub)modules 

444 self._methods = {} 

445 self._modules = {} 

446 self._update_methods() 

447 

448 def _update_methods(self): 

449 """ 

450 Internal function to update methods and submodules. 

451 This function should only be called when member attributes are dynamically modified by the module. 

452 """ 

453 self._methods.clear() 

454 self._modules.clear() 

455 

456 for key in dir(self): 

457 if key[0] == "_": 

458 continue 

459 if isinstance(getattr(self.__class__, key, None), (property, functools.cached_property)): 

460 continue 

461 

462 prop = getattr(self, key) 

463 

464 if isinstance(prop, Method): 

465 self._methods[key] = prop 

466 elif isinstance(prop, Module): 

467 self._modules[key] = prop 

468 

469 def describe(self) -> dict | None: 

470 """ 

471 Meta description of this module. 

472 """ 

473 # Use cached description? 

474 if isinstance(self._cached_description, dict): 

475 return self._cached_description 

476 

477 # Retrieve handler 

478 if not (handler := self.handler): 

479 return None 

480 

481 # Default description 

482 ret = { 

483 "name": self.__class__.__name__, 

484 "handler": ".".join((handler, self.__class__.__name__.lower())), 

485 "methods": { 

486 name: method.describe() for name, method in self._methods.items() 

487 }, 

488 } 

489 

490 # Extend indexes, if available 

491 # todo: This must be handled by SkelModule 

492 if indexes := getattr(self, "indexes", None): 

493 ret["indexes"] = indexes 

494 

495 # Merge adminInfo if present 

496 if admin_info := self.adminInfo() if callable(self.adminInfo) else self.adminInfo: 

497 assert isinstance(admin_info, dict), \ 

498 f"adminInfo can either be a dict or a callable returning a dict, but got {type(admin_info)}" 

499 ret |= admin_info 

500 

501 # Cache description for later re-use. 

502 if self._cached_description is not False: 

503 self._cached_description = ret 

504 

505 return ret 

506 

507 def register(self, target: dict, render: object): 

508 """ 

509 Registers this module's public functions to a given resolver. 

510 This function is executed on start-up, and can be sub-classed. 

511 """ 

512 # connect instance to render 

513 self.render = render 

514 

515 # Map module under SEO-mapped name, if available. 

516 if self.seo_language_map: 

517 for lang in conf.i18n.available_languages or [conf.i18n.default_language]: 

518 # Map the module under each translation 

519 if translated_module_name := self.seo_language_map.get(lang): 

520 translated_module = target.setdefault(translated_module_name, {}) 

521 

522 # Map module methods to the previously determined target 

523 for name, method in self._methods.items(): 

524 method.register(translated_module, name, lang) 

525 

526 conf.i18n.language_module_map[self.moduleName] = self.seo_language_map 

527 

528 # Map the module also under it's original name 

529 if self.moduleName != "index": 

530 target = target.setdefault(self.moduleName, {}) 

531 

532 # Map module methods to the previously determined target 

533 for name, method in self._methods.items(): 

534 method.register(target, name) 

535 

536 # Register sub modules 

537 for name, module in self._modules.items(): 

538 module.register(target, self.render)