1"""
2Helpful decorators for module writing
3"""
4
5
6import errno
7import inspect
8import logging
9import subprocess
10import sys
11import time
12from collections import defaultdict
13from functools import wraps
14
15import salt.utils.args
16import salt.utils.data
17import salt.utils.versions
18from salt.exceptions import (
19    CommandExecutionError,
20    SaltConfigurationError,
21    SaltInvocationError,
22)
23from salt.log import LOG_LEVELS
24
25IS_WINDOWS = False
26if getattr(sys, "getwindowsversion", False):
27    IS_WINDOWS = True
28
29log = logging.getLogger(__name__)
30
31
32class Depends:
33    """
34    This decorator will check the module when it is loaded and check that the
35    dependencies passed in are in the globals of the module. If not, it will
36    cause the function to be unloaded (or replaced).
37    """
38
39    # kind -> Dependency -> list of things that depend on it
40    dependency_dict = defaultdict(lambda: defaultdict(dict))
41
42    def __init__(self, *dependencies, **kwargs):
43        """
44        The decorator is instantiated with a list of dependencies (string of
45        global name)
46
47        An example use of this would be:
48
49        .. code-block:: python
50
51            @depends('modulename')
52            def test():
53                return 'foo'
54
55            OR
56
57            @depends('modulename', fallback_function=function)
58            def test():
59                return 'foo'
60
61        .. code-block:: python
62
63        This can also be done with the retcode of a command, using the
64        ``retcode`` argument:
65
66            @depends('/opt/bin/check_cmd', retcode=0)
67            def test():
68                return 'foo'
69
70        It is also possible to check for any nonzero retcode using the
71        ``nonzero_retcode`` argument:
72
73            @depends('/opt/bin/check_cmd', nonzero_retcode=True)
74            def test():
75                return 'foo'
76
77        .. note::
78            The command must be formatted as a string, not a list of args.
79            Additionally, I/O redirection and other shell-specific syntax are
80            not supported since this uses shell=False when calling
81            subprocess.Popen().
82
83        """
84        log.trace(
85            "Depends decorator instantiated with dep list of %s and kwargs %s",
86            dependencies,
87            kwargs,
88        )
89        self.dependencies = dependencies
90        self.params = kwargs
91
92    def __call__(self, function):
93        """
94        The decorator is "__call__"d with the function, we take that function
95        and determine which module and function name it is to store in the
96        class wide dependency_dict
97        """
98        try:
99            # This inspect call may fail under certain conditions in the loader.
100            # Possibly related to a Python bug here:
101            # http://bugs.python.org/issue17735
102            frame = inspect.currentframe().f_back
103            # due to missing *.py files under esky we cannot use inspect.getmodule
104            # module name is something like salt.loaded.int.modules.test
105            _, kind, mod_name = frame.f_globals["__name__"].rsplit(".", 2)
106            fun_name = function.__name__
107            for dep in self.dependencies:
108                self.dependency_dict[kind][dep][(mod_name, fun_name)] = (
109                    frame,
110                    self.params,
111                )
112        except Exception as exc:  # pylint: disable=broad-except
113            log.exception(
114                "Exception encountered when attempting to inspect frame in "
115                "dependency decorator"
116            )
117        return function
118
119    @staticmethod
120    def run_command(dependency, mod_name, func_name):
121        full_name = "{}.{}".format(mod_name, func_name)
122        log.trace("Running '%s' for '%s'", dependency, full_name)
123        if IS_WINDOWS:
124            args = salt.utils.args.shlex_split(dependency, posix=False)
125        else:
126            args = salt.utils.args.shlex_split(dependency)
127        log.trace("Command after shlex_split: %s", args)
128        proc = subprocess.Popen(
129            args, shell=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
130        )
131        output = proc.communicate()[0]
132        retcode = proc.returncode
133        log.trace("Output from '%s': %s", dependency, output)
134        log.trace("Retcode from '%s': %d", dependency, retcode)
135        return retcode
136
137    @classmethod
138    def enforce_dependencies(cls, functions, kind, tgt_mod):
139        """
140        This is a class global method to enforce the dependencies that you
141        currently know about.
142        It will modify the "functions" dict and remove/replace modules that
143        are missing dependencies.
144        """
145        for dependency, dependent_dict in cls.dependency_dict[kind].items():
146            for (mod_name, func_name), (frame, params) in dependent_dict.items():
147                if mod_name != tgt_mod:
148                    continue
149                # Imports from local context take presedence over those from the global context.
150                dep_found = frame.f_locals.get(dependency) or frame.f_globals.get(
151                    dependency
152                )
153                # Default to version ``None`` if not found, which will be less than anything.
154                dep_version = getattr(dep_found, "__version__", None)
155                if "retcode" in params or "nonzero_retcode" in params:
156                    try:
157                        retcode = cls.run_command(dependency, mod_name, func_name)
158                    except OSError as exc:
159                        if exc.errno == errno.ENOENT:
160                            log.trace(
161                                "Failed to run command %s, %s not found",
162                                dependency,
163                                exc.filename,
164                            )
165                        else:
166                            log.trace("Failed to run command '%s': %s", dependency, exc)
167                        retcode = -1
168
169                    if "retcode" in params:
170                        if params["retcode"] == retcode:
171                            continue
172
173                    elif "nonzero_retcode" in params:
174                        if params["nonzero_retcode"]:
175                            if retcode != 0:
176                                continue
177                        else:
178                            if retcode == 0:
179                                continue
180
181                # check if dependency is loaded
182                elif dependency is True:
183                    log.trace(
184                        "Dependency for %s.%s exists, not unloading",
185                        mod_name,
186                        func_name,
187                    )
188                    continue
189
190                # check if you have the dependency
191                elif dep_found:
192                    if "version" in params:
193                        if (
194                            salt.utils.versions.version_cmp(
195                                dep_version, params["version"]
196                            )
197                            >= 0
198                        ):
199                            log.trace(
200                                "Dependency (%s) already loaded inside %s with "
201                                "version (%s), required (%s), skipping",
202                                dependency,
203                                mod_name,
204                                dep_version,
205                                params["version"],
206                            )
207                            continue
208                    else:
209                        log.trace(
210                            "Dependency (%s) already loaded inside %s, skipping",
211                            dependency,
212                            mod_name,
213                        )
214                        continue
215
216                log.trace(
217                    "Unloading %s.%s because dependency (%s%s) is not met",
218                    mod_name,
219                    func_name,
220                    dependency,
221                    " version {}".format(params["version"])
222                    if "version" in params
223                    else "",
224                )
225                # if not, unload the function
226                if frame:
227                    try:
228                        func_name = frame.f_globals["__func_alias__"][func_name]
229                    except (AttributeError, KeyError):
230                        pass
231
232                    mod_key = "{}.{}".format(mod_name, func_name)
233
234                    # if we don't have this module loaded, skip it!
235                    if mod_key not in functions:
236                        continue
237
238                    try:
239                        fallback_function = params.get("fallback_function")
240                        if fallback_function is not None:
241                            functions[mod_key] = fallback_function
242                        else:
243                            del functions[mod_key]
244                    except AttributeError:
245                        # we already did???
246                        log.trace("%s already removed, skipping", mod_key)
247                        continue
248
249
250depends = Depends
251
252
253def timing(function):
254    """
255    Decorator wrapper to log execution time, for profiling purposes
256    """
257
258    @wraps(function)
259    def wrapped(*args, **kwargs):
260        start_time = time.time()
261        ret = function(*args, **salt.utils.args.clean_kwargs(**kwargs))
262        end_time = time.time()
263        if function.__module__.startswith("salt.loaded.int."):
264            mod_name = function.__module__[16:]
265        else:
266            mod_name = function.__module__
267        fstr = "Function %s.%s took %.{}f seconds to execute".format(sys.float_info.dig)
268        log.profile(fstr, mod_name, function.__name__, end_time - start_time)
269        return ret
270
271    return wrapped
272
273
274def memoize(func):
275    """
276    Memoize aka cache the return output of a function
277    given a specific set of arguments
278
279    .. versionedited:: 2016.3.4
280
281    Added **kwargs support.
282    """
283    cache = {}
284
285    @wraps(func)
286    def _memoize(*args, **kwargs):
287        str_args = []
288        for arg in args:
289            if not isinstance(arg, str):
290                str_args.append(str(arg))
291            else:
292                str_args.append(arg)
293
294        args_ = ",".join(
295            list(str_args) + ["{}={}".format(k, kwargs[k]) for k in sorted(kwargs)]
296        )
297        if args_ not in cache:
298            cache[args_] = func(*args, **kwargs)
299        return cache[args_]
300
301    return _memoize
302
303
304class _DeprecationDecorator:
305    """
306    Base mix-in class for the deprecation decorator.
307    Takes care of a common functionality, used in its derivatives.
308    """
309
310    OPT_IN = 1
311    OPT_OUT = 2
312
313    def __init__(self, globals, version):
314        """
315        Constructor.
316
317        :param globals: Module globals. Important for finding out replacement functions
318        :param version: Expiration version
319        :return:
320        """
321        from salt.version import SaltStackVersion, __saltstack_version__
322
323        self._globals = globals
324        self._exp_version_name = version
325        self._exp_version = SaltStackVersion.from_name(self._exp_version_name)
326        self._curr_version = __saltstack_version__.info
327        self._raise_later = None
328        self._function = None
329        self._orig_f_name = None
330
331    def _get_args(self, kwargs):
332        """
333        Discard all keywords which aren't function-specific from the kwargs.
334
335        :param kwargs:
336        :return:
337        """
338        _args = list()
339        _kwargs = salt.utils.args.clean_kwargs(**kwargs)
340
341        return _args, _kwargs
342
343    def _call_function(self, kwargs):
344        """
345        Call target function that has been decorated.
346
347        :return:
348        """
349        if self._raise_later:
350            raise self._raise_later  # pylint: disable=E0702
351
352        if self._function:
353            args, kwargs = self._get_args(kwargs)
354            try:
355                return self._function(*args, **kwargs)
356            except TypeError as error:
357                error = str(error).replace(
358                    self._function, self._orig_f_name
359                )  # Hide hidden functions
360                log.error(
361                    'Function "%s" was not properly called: %s',
362                    self._orig_f_name,
363                    error,
364                )
365                return self._function.__doc__
366            except Exception as error:  # pylint: disable=broad-except
367                log.error(
368                    'Unhandled exception occurred in function "%s: %s',
369                    self._function.__name__,
370                    error,
371                )
372                raise
373        else:
374            raise CommandExecutionError(
375                "Function is deprecated, but the successor function was not found."
376            )
377
378    def __call__(self, function):
379        """
380        Callable method of the decorator object when
381        the decorated function is gets called.
382
383        :param function:
384        :return:
385        """
386        self._function = function
387        self._orig_f_name = self._function.__name__
388
389
390class _IsDeprecated(_DeprecationDecorator):
391    """
392    This decorator should be used only with the deprecated functions
393    to mark them as deprecated and alter its behavior a corresponding way.
394    The usage is only suitable if deprecation process is renaming
395    the function from one to another. In case function name or even function
396    signature stays the same, please use 'with_deprecated' decorator instead.
397
398    It has the following functionality:
399
400    1. Put a warning level message to the log, informing that
401       the deprecated function has been in use.
402
403    2. Raise an exception, if deprecated function is being called,
404       but the lifetime of it already expired.
405
406    3. Point to the successor of the deprecated function in the
407       log messages as well during the blocking it, once expired.
408
409    Usage of this decorator as follows. In this example no successor
410    is mentioned, hence the function "foo()" will be logged with the
411    warning each time is called and blocked completely, once EOF of
412    it is reached:
413
414        from salt.util.decorators import is_deprecated
415
416        @is_deprecated(globals(), "Beryllium")
417        def foo():
418            pass
419
420    In the following example a successor function is mentioned, hence
421    every time the function "bar()" is called, message will suggest
422    to use function "baz()" instead. Once EOF is reached of the function
423    "bar()", an exception will ask to use function "baz()", in order
424    to continue:
425
426        from salt.util.decorators import is_deprecated
427
428        @is_deprecated(globals(), "Beryllium", with_successor="baz")
429        def bar():
430            pass
431
432        def baz():
433            pass
434    """
435
436    def __init__(self, globals, version, with_successor=None):
437        """
438        Constructor of the decorator 'is_deprecated'.
439
440        :param globals: Module globals
441        :param version: Version to be deprecated
442        :param with_successor: Successor function (optional)
443        :return:
444        """
445        _DeprecationDecorator.__init__(self, globals, version)
446        self._successor = with_successor
447
448    def __call__(self, function):
449        """
450        Callable method of the decorator object when
451        the decorated function is gets called.
452
453        :param function:
454        :return:
455        """
456        _DeprecationDecorator.__call__(self, function)
457
458        @wraps(function)
459        def _decorate(*args, **kwargs):
460            """
461            Decorator function.
462
463            :param args:
464            :param kwargs:
465            :return:
466            """
467            if self._curr_version < self._exp_version:
468                msg = [
469                    'The function "{f_name}" is deprecated and will '
470                    'expire in version "{version_name}".'.format(
471                        f_name=self._function.__name__,
472                        version_name=self._exp_version_name,
473                    )
474                ]
475                if self._successor:
476                    msg.append(
477                        'Use successor "{successor}" instead.'.format(
478                            successor=self._successor
479                        )
480                    )
481                log.warning(" ".join(msg))
482            else:
483                msg = [
484                    'The lifetime of the function "{f_name}" expired.'.format(
485                        f_name=self._function.__name__
486                    )
487                ]
488                if self._successor:
489                    msg.append(
490                        'Please use its successor "{successor}" instead.'.format(
491                            successor=self._successor
492                        )
493                    )
494                log.warning(" ".join(msg))
495                raise CommandExecutionError(" ".join(msg))
496            return self._call_function(kwargs)
497
498        return _decorate
499
500
501is_deprecated = _IsDeprecated
502
503
504class _WithDeprecated(_DeprecationDecorator):
505    """
506    This decorator should be used with the successor functions
507    to mark them as a new and alter its behavior in a corresponding way.
508    It is used alone if a function content or function signature
509    needs to be replaced, leaving the name of the function same.
510    In case function needs to be renamed or just dropped, it has
511    to be used in pair with 'is_deprecated' decorator.
512
513    It has the following functionality:
514
515    1. Put a warning level message to the log, in case a component
516       is using its deprecated version.
517
518    2. Switch between old and new function in case an older version
519       is configured for the desired use.
520
521    3. Raise an exception, if deprecated version reached EOL and
522       point out for the new version.
523
524    Usage of this decorator as follows. If 'with_name' is not specified,
525    then the name of the deprecated function is assumed with the "_" prefix.
526    In this case, in order to deprecate a function, it is required:
527
528    - Add a prefix "_" to an existing function. E.g.: "foo()" to "_foo()".
529
530    - Implement a new function with exactly the same name, just without
531      the prefix "_".
532
533    Example:
534
535        from salt.util.decorators import with_deprecated
536
537        @with_deprecated(globals(), "Beryllium")
538        def foo():
539            "This is a new function"
540
541        def _foo():
542            "This is a deprecated function"
543
544
545    In case there is a need to deprecate a function and rename it,
546    the decorator should be used with the 'with_name' parameter. This
547    parameter is pointing to the existing deprecated function. In this
548    case deprecation process as follows:
549
550    - Leave a deprecated function without changes, as is.
551
552    - Implement a new function and decorate it with this decorator.
553
554    - Set a parameter 'with_name' to the deprecated function.
555
556    - If a new function has a different name than a deprecated,
557      decorate a deprecated function with the  'is_deprecated' decorator
558      in order to let the function have a deprecated behavior.
559
560    Example:
561
562        from salt.util.decorators import with_deprecated
563
564        @with_deprecated(globals(), "Beryllium", with_name="an_old_function")
565        def a_new_function():
566            "This is a new function"
567
568        @is_deprecated(globals(), "Beryllium", with_successor="a_new_function")
569        def an_old_function():
570            "This is a deprecated function"
571
572    """
573
574    MODULE_NAME = "__virtualname__"
575    CFG_USE_DEPRECATED = "use_deprecated"
576    CFG_USE_SUPERSEDED = "use_superseded"
577
578    def __init__(
579        self, globals, version, with_name=None, policy=_DeprecationDecorator.OPT_OUT
580    ):
581        """
582        Constructor of the decorator 'with_deprecated'
583
584        :param globals:
585        :param version:
586        :param with_name:
587        :param policy:
588        :return:
589        """
590        _DeprecationDecorator.__init__(self, globals, version)
591        self._with_name = with_name
592        self._policy = policy
593
594    def _set_function(self, function):
595        """
596        Based on the configuration, set to execute an old or a new function.
597        :return:
598        """
599        full_name = "{m_name}.{f_name}".format(
600            m_name=self._globals.get(self.MODULE_NAME, "")
601            or self._globals["__name__"].split(".")[-1],
602            f_name=function.__name__,
603        )
604        if full_name.startswith("."):
605            self._raise_later = CommandExecutionError(
606                'Module not found for function "{f_name}"'.format(
607                    f_name=function.__name__
608                )
609            )
610
611        opts = self._globals.get("__opts__", "{}")
612        pillar = self._globals.get("__pillar__", "{}")
613
614        use_deprecated = full_name in opts.get(
615            self.CFG_USE_DEPRECATED, list()
616        ) or full_name in pillar.get(self.CFG_USE_DEPRECATED, list())
617
618        use_superseded = full_name in opts.get(
619            self.CFG_USE_SUPERSEDED, list()
620        ) or full_name in pillar.get(self.CFG_USE_SUPERSEDED, list())
621
622        if use_deprecated and use_superseded:
623            raise SaltConfigurationError(
624                "Function '{}' is mentioned both in deprecated "
625                "and superseded sections. Please remove any of that.".format(full_name)
626            )
627        old_function = self._globals.get(
628            self._with_name or "_{}".format(function.__name__)
629        )
630        if self._policy == self.OPT_IN:
631            self._function = function if use_superseded else old_function
632        else:
633            self._function = old_function if use_deprecated else function
634
635    def _is_used_deprecated(self):
636        """
637        Returns True, if a component configuration explicitly is
638        asking to use an old version of the deprecated function.
639
640        :return:
641        """
642        func_path = "{m_name}.{f_name}".format(
643            m_name=self._globals.get(self.MODULE_NAME, "")
644            or self._globals["__name__"].split(".")[-1],
645            f_name=self._orig_f_name,
646        )
647
648        return (
649            func_path
650            in self._globals.get("__opts__").get(self.CFG_USE_DEPRECATED, list())
651            or func_path
652            in self._globals.get("__pillar__").get(self.CFG_USE_DEPRECATED, list())
653            or (
654                self._policy == self.OPT_IN
655                and not (
656                    func_path
657                    in self._globals.get("__opts__", {}).get(
658                        self.CFG_USE_SUPERSEDED, list()
659                    )
660                )
661                and not (
662                    func_path
663                    in self._globals.get("__pillar__", {}).get(
664                        self.CFG_USE_SUPERSEDED, list()
665                    )
666                )
667            ),
668            func_path,
669        )
670
671    def __call__(self, function):
672        """
673        Callable method of the decorator object when
674        the decorated function is gets called.
675
676        :param function:
677        :return:
678        """
679        _DeprecationDecorator.__call__(self, function)
680
681        @wraps(function)
682        def _decorate(*args, **kwargs):
683            """
684            Decorator function.
685
686            :param args:
687            :param kwargs:
688            :return:
689            """
690            self._set_function(function)
691            is_deprecated, func_path = self._is_used_deprecated()
692            if is_deprecated:
693                if self._curr_version < self._exp_version:
694                    msg = list()
695                    if self._with_name:
696                        msg.append(
697                            'The function "{f_name}" is deprecated and will '
698                            'expire in version "{version_name}".'.format(
699                                f_name=self._with_name.startswith("_")
700                                and self._orig_f_name
701                                or self._with_name,
702                                version_name=self._exp_version_name,
703                            )
704                        )
705                        msg.append(
706                            'Use its successor "{successor}" instead.'.format(
707                                successor=self._orig_f_name
708                            )
709                        )
710                    else:
711                        msg.append(
712                            'The function "{f_name}" is using its deprecated version'
713                            ' and will expire in version "{version_name}".'.format(
714                                f_name=func_path, version_name=self._exp_version_name
715                            )
716                        )
717                    log.warning(" ".join(msg))
718                else:
719                    msg_patt = 'The lifetime of the function "{f_name}" expired.'
720                    if "_" + self._orig_f_name == self._function.__name__:
721                        msg = [
722                            msg_patt.format(f_name=self._orig_f_name),
723                            "Please turn off its deprecated version in the"
724                            " configuration",
725                        ]
726                    else:
727                        msg = [
728                            'Although function "{f_name}" is called, an alias'
729                            ' "{f_alias}" is configured as its deprecated version.'.format(
730                                f_name=self._orig_f_name,
731                                f_alias=self._with_name or self._orig_f_name,
732                            ),
733                            msg_patt.format(
734                                f_name=self._with_name or self._orig_f_name
735                            ),
736                            'Please use its successor "{successor}" instead.'.format(
737                                successor=self._orig_f_name
738                            ),
739                        ]
740                    log.error(" ".join(msg))
741                    raise CommandExecutionError(" ".join(msg))
742            return self._call_function(kwargs)
743
744        _decorate.__doc__ = self._function.__doc__
745        _decorate.__wrapped__ = self._function
746        return _decorate
747
748
749with_deprecated = _WithDeprecated
750
751
752def require_one_of(*kwarg_names):
753    """
754    Decorator to filter out exclusive arguments from the call.
755
756    kwarg_names:
757        Limit which combination of arguments may be passed to the call.
758
759    Example:
760
761
762        # Require one of the following arguments to be supplied to foo()
763        @require_one_of('arg1', 'arg2', 'arg3')
764        def foo(arg1, arg2, arg3):
765
766    """
767
768    def wrapper(f):
769        @wraps(f)
770        def func(*args, **kwargs):
771            names = [key for key in kwargs if kwargs[key] and key in kwarg_names]
772            names.extend(
773                [
774                    args[i]
775                    for i, arg in enumerate(args)
776                    if args[i] and f.__code__.co_varnames[i] in kwarg_names
777                ]
778            )
779            if len(names) > 1:
780                raise SaltInvocationError(
781                    "Only one of the following is allowed: {}".format(
782                        ", ".join(kwarg_names)
783                    )
784                )
785            if not names:
786                raise SaltInvocationError(
787                    "One of the following must be provided: {}".format(
788                        ", ".join(kwarg_names)
789                    )
790                )
791            return f(*args, **kwargs)
792
793        return func
794
795    return wrapper
796
797
798def allow_one_of(*kwarg_names):
799    """
800    Decorator to filter out exclusive arguments from the call.
801
802    kwarg_names:
803        Limit which combination of arguments may be passed to the call.
804
805    Example:
806
807
808        # Allow only one of the following arguments to be supplied to foo()
809        @allow_one_of('arg1', 'arg2', 'arg3')
810        def foo(arg1, arg2, arg3):
811
812    """
813
814    def wrapper(f):
815        @wraps(f)
816        def func(*args, **kwargs):
817            names = [key for key in kwargs if kwargs[key] and key in kwarg_names]
818            names.extend(
819                [
820                    args[i]
821                    for i, arg in enumerate(args)
822                    if args[i] and f.__code__.co_varnames[i] in kwarg_names
823                ]
824            )
825            if len(names) > 1:
826                raise SaltInvocationError(
827                    "Only of the following is allowed: {}".format(
828                        ", ".join(kwarg_names)
829                    )
830                )
831            return f(*args, **kwargs)
832
833        return func
834
835    return wrapper
836
837
838def ignores_kwargs(*kwarg_names):
839    """
840    Decorator to filter out unexpected keyword arguments from the call
841
842    kwarg_names:
843        List of argument names to ignore
844    """
845
846    def _ignores_kwargs(fn):
847        @wraps(fn)
848        def __ignores_kwargs(*args, **kwargs):
849            kwargs_filtered = kwargs.copy()
850            for name in kwarg_names:
851                if name in kwargs_filtered:
852                    del kwargs_filtered[name]
853            return fn(*args, **kwargs_filtered)
854
855        return __ignores_kwargs
856
857    return _ignores_kwargs
858
859
860def ensure_unicode_args(function):
861    """
862    Decodes all arguments passed to the wrapped function
863    """
864
865    @wraps(function)
866    def wrapped(*args, **kwargs):
867        return function(*args, **kwargs)
868
869    return wrapped
870