Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/module.py: 15%
210 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-13 11:04 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-13 11:04 +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
12class Method:
13 """
14 Abstraction wrapper for any public available method.
15 """
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
27 return cls(func)
29 def __init__(self, func: t.Callable):
30 # Content
31 self._func = func
32 self.__name__ = func.__name__
33 self._instance = None
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
44 # Inspection
45 self.signature = inspect.signature(self._func)
47 # Guards
48 self.guards = []
50 def __get__(self, obj, objtype=None):
51 """
52 This binds the Method to an object.
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
61 return self
63 def __call__(self, *args, **kwargs):
64 """
65 Calls the method with given args and kwargs.
67 Prepares and filters argument values from args and kwargs regarding self._func's signature and type annotations,
68 if present.
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.
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 """
80 if trace := conf.debug.trace:
81 logging.debug(f"calling {self._func=} with raw {args=}, {kwargs=}")
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=}")
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}")
104 # complex types
105 origin_type = t.get_origin(annotation)
107 if origin_type is list and len(annotation.__args__) == 1:
108 if not isinstance(value, list):
109 value = [value]
111 return [parse_value_by_annotation(annotation.__args__[0], name, item) for item in value]
113 elif origin_type is tuple and len(annotation.__args__) == 1:
114 if not isinstance(value, tuple):
115 value = (value, )
117 return tuple(parse_value_by_annotation(annotation.__args__[0], name, item) for item in value)
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}")
123 return value
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
133 elif annotation is db.Key:
134 if isinstance(value, db.Key):
135 return value
136 elif isinstance(value, str): # Maybe we have an url encoded Key
137 try:
138 return db.Key.from_legacy_urlsafe(value)
139 except Exception:
140 pass
141 return parse_value_by_annotation(int | str, name, value)
143 elif isinstance(annotation, enum.EnumMeta):
144 try:
145 return annotation(value)
146 except ValueError as exc:
147 for value_, member in annotation._value2member_map_.items():
148 if str(value) == str(value_): # Do a string comparison, it could be a IntEnum
149 return member
150 raise errors.NotAcceptable(f"{' '.join(exc.args)} for {name}") from exc
152 raise errors.NotAcceptable(f"Unhandled type {annotation=} for {name}={value!r}")
154 # examine parameters
155 args_iter = iter(args)
157 parsed_args = []
158 parsed_kwargs = {}
159 varargs = []
160 varkwargs = False
162 for i, (param_name, param) in enumerate(self.signature.parameters.items()):
163 if self._instance and i == 0 and param_name == "self":
164 continue
166 param_type = param.annotation
167 param_required = param.default is param.empty
169 # take positional parameters first
170 if param.kind in (
171 inspect.Parameter.POSITIONAL_OR_KEYWORD,
172 inspect.Parameter.POSITIONAL_ONLY
173 ):
174 try:
175 value = next(args_iter)
177 if param_type is not param.empty:
178 value = parse_value_by_annotation(param_type, param_name, value)
180 parsed_args.append(value)
181 continue
182 except StopIteration:
183 pass
185 # otherwise take kwargs or variadics
186 if (
187 param.kind in (
188 inspect.Parameter.POSITIONAL_OR_KEYWORD,
189 inspect.Parameter.KEYWORD_ONLY
190 )
191 and param_name in kwargs
192 ):
193 value = kwargs.pop(param_name)
195 if param_type is not param.empty:
196 value = parse_value_by_annotation(param_type, param_name, value)
198 parsed_kwargs[param_name] = value
200 elif param.kind == inspect.Parameter.VAR_POSITIONAL:
201 varargs = list(args_iter)
202 elif param.kind == inspect.Parameter.VAR_KEYWORD:
203 varkwargs = True
204 elif param_required:
205 if self.skey and param_name == self.skey["forward_payload"]:
206 continue
208 raise errors.NotAcceptable(f"Missing required parameter {param_name!r}")
210 # Here's a short clarification on the variables used here:
211 #
212 # - parsed_args = tuple of (the type-parsed) arguments that have been assigned based on the signature
213 # - parsed_kwargs = dict of (the type-parsed) keyword arguments that have been assigned based on the signature
214 # - args = either parsed_args, or parsed_args + remaining args if the function accepts *args
215 # - kwargs = either parsed_kwars, or parsed_kwargs | remaining kwargs if the function accepts **kwargs
216 # - varargs = indicator that the args also contain variable args (*args)
217 # - varkwargs = indicator that variable kwargs (**kwargs) are also contained in the kwargs
218 #
220 # Extend args to any varargs, and redefine args
221 args = tuple(parsed_args + varargs)
223 # always take "skey"-parameter name, when configured, as parsed_kwargs
224 if self.skey and self.skey["name"] in kwargs:
225 parsed_kwargs[self.skey["name"]] = kwargs.pop(self.skey["name"])
227 # When varkwargs are accepted, merge parsed_kwargs and kwargs, otherwise just use parsed_kwargs
228 if varkwargs := varkwargs and bool(kwargs):
229 kwargs = parsed_kwargs | kwargs
230 else:
231 kwargs = parsed_kwargs
233 # Trace message for final call configuration
234 if conf.debug.trace:
235 logging.debug(f"calling {self._func=} with cleaned {args=}, {kwargs=}")
236 # call decorators in reversed because they are added in the reversed order
237 for func in reversed(self.guards):
238 func(args=args, kwargs=kwargs, varargs=varargs, varkwargs=varkwargs)
240 # call with instance when provided
241 if self._instance:
242 return self._func(self._instance, *args, **kwargs)
244 return self._func(*args, **kwargs)
246 def describe(self) -> dict:
247 """
248 Describes the Method with a
249 """
250 return_doc = t.get_type_hints(self._func).get("return")
252 return {
253 "args": {
254 param.name: {
255 "type": str(param.annotation) if param.annotation is not inspect.Parameter.empty else None,
256 "default": str(param.default) if param.default is not inspect.Parameter.empty else None,
257 }
258 for param in self.signature.parameters.values()
259 },
260 "returns": str(return_doc).strip() if return_doc else None,
261 "accepts": self.methods,
262 "docs": self._func.__doc__.strip() if self._func.__doc__ else None,
263 "aliases": tuple(self.seo_language_map.keys()) if self.seo_language_map else None,
264 } | self.additional_descr
266 def register(self, target: dict, name: str, language: str | None = None):
267 """
268 Registers the Method under `name` and eventually some customized SEO-name for the provided language
269 """
270 if self.exposed is None:
271 return
273 target[name] = self
275 # reassign for SEO mapping as well
276 if self.seo_language_map:
277 for lang in tuple(self.seo_language_map.keys()) if not language else (language, ):
278 if translated_name := self.seo_language_map.get(lang):
279 target[translated_name] = self
282class Module:
283 """
284 This is the root module prototype that serves a minimal module in the ViUR system without any other bindings.
285 """
287 handler: str | t.Callable = None
288 """
289 This is the module's handler, respectively its type.
290 Use the @property-decorator in specific Modules to construct the handler's value dynamically.
291 A module without a handler setting cannot be described, so cannot be handled by admin-tools.
292 """
294 accessRights: tuple[str] = None
295 """
296 If set, a tuple of access rights (like add, edit, delete) that this module supports.
298 These will be prefixed on instance startup with the actual module name (becoming file-add, file-edit etc)
299 and registered in ``conf.user.access_rights`` so these will be available on the access bone in user/add
300 or user/edit.
301 """
303 roles: dict = {}
304 r"""
305 Allows to specify role settings for a module.
307 Defaults to no role definition, which ignores the module entirely in the role-system.
308 In this case, access rights can still be set individually on the user's access bone.
310 A "*" wildcard can either be used as key or as value to allow for "all roles", or "all rights".
312 .. code-block:: python
314 # Example
315 roles = {
316 "*": "view", # Any role may only "view"
317 "editor": ("add", "edit"), # Role "editor" may "add" or "edit", but not "delete"
318 "admin": "*", # Role "admin" can do everything
319 }
321 """
323 seo_language_map: dict[str: str] = {}
324 r"""
325 The module name is the first part of a URL.
326 SEO-identifiers have to be set as class-attribute ``seoLanguageMap`` of type ``dict[str, str]`` in the module.
327 It maps a *language* to the according *identifier*.
329 .. code-block:: python
330 :name: module seo-map
331 :caption: modules/myorders.py
332 :emphasize-lines: 4-7
334 from viur.core.prototypes import List
336 class MyOrders(List):
337 seo_language_map = {
338 "de": "bestellungen",
339 "en": "orders",
340 }
342 By default the module would be available under */myorders*, the lowercase module name.
343 With the defined :attr:`seoLanguageMap`, it will become available as */de/bestellungen* and */en/orders*.
345 Great, this part is now user and robot friendly :)
346 """
348 adminInfo: dict[str, t.Any] | t.Callable = None
349 """
350 This is a ``dict`` holding the information necessary for the Vi/Admin to handle this module.
352 name: ``str``
353 Human-readable module name that will be shown in the admin tool.
355 handler: ``str`` (``list``, ``tree`` or ``singleton``):
356 Allows to override the handler provided by the module. Set this only when *really* necessary,
357 otherwise it can be left out and is automatically injected by the Module's prototype.
359 icon: ``str``
360 (Optional) Either the Shoelace icon library name or a path relative to the project's deploy folder
361 (e.g. /static/icons/viur.svg) for the icon used in the admin tool for this module.
363 columns: ``List[str]``
364 (Optional) List of columns (bone names) that are displayed by default.
365 Used only by the List handler.
367 filter: ``Dict[str, str]``
368 (Optional) Dictionary of additional parameters that will be send along when
369 fetching entities from the server. Can be used to filter the entities being displayed on the
370 client-side.
372 display: ``str`` ("default", "hidden" or "group")
373 (Optional) "hidden" will hide the module in the admin tool's main bar.
374 (itwill not be accessible directly, however it's registered with the frontend so it can be used in a
375 relational bone). "group" will show this module in the main bar, but it will not be clickable.
376 Clicking it will just try to expand it (assuming there are additional views defined).
378 preview: ``Union[str, Dict[str, str]]``
379 (Optional) A url that will be opened in a new tab and is expected to display
380 the entity selected in the table. Can be “/{{module}}/view/{{key}}", with {{module}} and {{key}} getting
381 replaced as needed. If more than one preview-url is needed, supply a dictionary where the key is
382 the URL and the value the description shown to the user.
384 views: ``List[Dict[str, t.Any]]``
385 (Optional) List of nested adminInfo like dictionaries. Used to define
386 additional views on the module. Useful f.e. for an order module, where you want separate list of
387 "payed orders", "unpayed orders", "orders waiting for shipment", etc. If such views are defined,
388 the top-level entry in the menu bar will expand if clicked, revealing these additional filters.
390 actions: ``List[str]``
391 (Optional) List of actions supported by this modules. Actions can be defined by
392 the frontend (like "add", "edit", "delete" or "preview"); it can be an action defined by a plugin
393 loaded by the frontend; or it can be a so called "server side action" (see "customActions" below)
395 customActions: ``Dict[str, dict]``
396 (Optional) A mapping of names of server-defined actions that can be used
397 in the ``actions`` list above to their definition dictionary. See .... for more details.
399 disabledActions: ``List[str, dict]``
400 (Optional) A list of disabled actions. The frontend will inject default actions like add or edit
401 even if they're not listed in actions. Listing them here will prevent that. It's up to the frontend
402 to decide if that action won't be visible at all or it's button just being disabled.
404 sortIndex: ``int``
405 (Optional) Defines the order in which the modules will appear in the main bar in
406 ascrending order.
408 indexedBones: ``List[str]``
409 (Optional) List of bones, for which an (composite?) index exists in this
410 view. This allows the fronted to signal the user that a given list can be sorted or filtered by this
411 bone. If no additional filters are enforced by the
412 :meth:`listFilter<viur.core.prototypes.list.listFilter>` and ``filter`` is not set, this should be
413 all bones which are marked as indexed.
415 changeInvalidates: ``List[str]``
416 (Optional) A list of module-names which depend on the entities handled
417 from this module. This allows the frontend to invalidate any caches in these depended modules if the
418 data in this module changes. Example: This module may be a list-module handling the file_rootNode
419 entities for the file module, so a edit/add/deletion action on this module should be reflected in the
420 rootNode-selector in the file-module itself. In this case, this property should be set to ``["file"]``.
422 moduleGroup: ``str``
423 (Optional) If set, should be a key of a moduleGroup defined in .... .
425 editViews: ``Dict[str, t.Any]``
426 (Optional) If set, will embed another list-widget in the edit forms for
427 a given entity. See .... for more details.
429 If this is a function, it must take no parameters and return the dictionary as shown above. This
430 can be used to customize the appearance of the Vi/Admin to individual users.
431 """
433 def __init__(self, moduleName: str, modulePath: str, *args, **kwargs):
434 self.render = None # will be set to the appropriate render instance at runtime
435 self._cached_description = None # caching used by describe()
436 self.moduleName = moduleName # Name of this module (usually it's class name, e.g. "file")
437 self.modulePath = modulePath # Path to this module in URL-routing (e.g. "json/file")
439 if self.handler and self.accessRights:
440 for right in self.accessRights:
441 right = f"{self.moduleName}-{right}"
443 # fixme: Turn conf.user.access_rights into a set.
444 if right not in conf.user.access_rights:
445 conf.user.access_rights.append(right)
447 # Collect methods and (sub)modules
448 self._methods = {}
449 self._modules = {}
450 self._update_methods()
452 def _update_methods(self):
453 """
454 Internal function to update methods and submodules.
455 This function should only be called when member attributes are dynamically modified by the module.
456 """
457 self._methods.clear()
458 self._modules.clear()
460 for key in dir(self):
461 if key[0] == "_":
462 continue
463 if isinstance(getattr(self.__class__, key, None), (property, functools.cached_property)):
464 continue
466 prop = getattr(self, key)
468 if isinstance(prop, Method):
469 self._methods[key] = prop
470 elif isinstance(prop, Module):
471 self._modules[key] = prop
473 def describe(self) -> dict | None:
474 """
475 Meta description of this module.
476 """
477 # Use cached description?
478 if isinstance(self._cached_description, dict):
479 return self._cached_description
481 # Retrieve handler
482 if not (handler := self.handler):
483 return None
485 # Default description
486 ret = {
487 "name": self.__class__.__name__,
488 "handler": ".".join((handler, self.__class__.__name__.lower())),
489 "methods": {
490 name: method.describe() for name, method in self._methods.items()
491 },
492 }
494 # Extend indexes, if available
495 # todo: This must be handled by SkelModule
496 if indexes := getattr(self, "indexes", None):
497 ret["indexes"] = indexes
499 # Merge adminInfo if present
500 if admin_info := self.adminInfo() if callable(self.adminInfo) else self.adminInfo:
501 assert isinstance(admin_info, dict), \
502 f"adminInfo can either be a dict or a callable returning a dict, but got {type(admin_info)}"
503 ret |= admin_info
505 # Cache description for later re-use.
506 if self._cached_description is not False:
507 self._cached_description = ret
509 return ret
511 def register(self, target: dict, render: object):
512 """
513 Registers this module's public functions to a given resolver.
514 This function is executed on start-up, and can be sub-classed.
515 """
516 # connect instance to render
517 self.render = render
519 # Map module under SEO-mapped name, if available.
520 if self.seo_language_map:
521 for lang in conf.i18n.available_languages or [conf.i18n.default_language]:
522 # Map the module under each translation
523 if translated_module_name := self.seo_language_map.get(lang):
524 translated_module = target.setdefault(translated_module_name, {})
526 # Map module methods to the previously determined target
527 for name, method in self._methods.items():
528 method.register(translated_module, name, lang)
530 conf.i18n.language_module_map[self.moduleName] = self.seo_language_map
532 # Map the module also under it's original name
533 if self.moduleName != "index":
534 target = target.setdefault(self.moduleName, {})
536 # Map module methods to the previously determined target
537 for name, method in self._methods.items():
538 method.register(target, name)
540 # Register sub modules
541 for name, module in self._modules.items():
542 module.register(target, self.render)