Note

You are reading an old version of the Evennia documentation. The latest version is here

.

# Source code for evennia.commands.cmdsethandler

    """
    CmdSethandler

    The Cmdsethandler tracks an object's 'Current CmdSet', which is the
    current merged sum of all CmdSets added to it.

    A CmdSet constitues a set of commands. The CmdSet works as a special
    intelligent container that, when added to other CmdSet make sure that
    same-name commands are treated correctly (usually so there are no
    doublets).  This temporary but up-to-date merger of CmdSet is jointly
    called the Current Cmset. It is this Current CmdSet that the
    commandhandler looks through whenever an account enters a command (it
    also adds CmdSets from objects in the room in real-time). All account
    objects have a 'default cmdset' containing all the normal in-game mud
    commands (look etc).

    So what is all this cmdset complexity good for?

    In its simplest form, a CmdSet has no commands, only a key name. In
    this case the cmdset's use is up to each individual game - it can be
    used by an AI module for example (mobs in cmdset 'roam' move from room
    to room, in cmdset 'attack' they enter combat with accounts).

    Defining commands in cmdsets offer some further powerful game-design
    consequences however. Here are some examples:

    As mentioned above, all accounts always have at least the Default
    CmdSet.  This contains the set of all normal-use commands in-game,
    stuff like look and @desc etc. Now assume our players end up in a dark
    room. You don't want the player to be able to do much in that dark
    room unless they light a candle. You could handle this by changing all
    your normal commands to check if the player is in a dark room. This
    rapidly goes unwieldly and error prone. Instead you just define a
    cmdset with only those commands you want to be available in the 'dark'
    cmdset - maybe a modified look command and a 'light candle' command -
    and have this completely replace the default cmdset.

    Another example: Say you want your players to be able to go
    fishing. You could implement this as a 'fish' command that fails
    whenever the account has no fishing rod. Easy enough.  But what if you
    want to make fishing more complex - maybe you want four-five different
    commands for throwing your line, reeling in, etc? Most players won't
    (we assume) have fishing gear, and having all those detailed commands
    is cluttering up the command list. And what if you want to use the
    'throw' command also for throwing rocks etc instead of 'using it up'
    for a minor thing like fishing?

    So instead you put all those detailed fishing commands into their own
    CommandSet called 'Fishing'. Whenever the player gives the command
    'fish' (presumably the code checks there is also water nearby), only
    THEN this CommandSet is added to the Cmdhandler of the account. The
    'throw' command (which normally throws rocks) is replaced by the
    custom 'fishing variant' of throw. What has happened is that the
    Fishing CommandSet was merged on top of the Default ones, and due to
    how we defined it, its command overrules the default ones.

    When we are tired of fishing, we give the 'go home' command (or
    whatever) and the Cmdhandler simply removes the fishing CommandSet
    so that we are back at defaults (and can throw rocks again).

    Since any number of CommandSets can be piled on top of each other, you
    can then implement separate sets for different situations. For
    example, you can have a 'On a boat' set, onto which you then tack on
    the 'Fishing' set. Fishing from a boat? No problem!

    """
    import sys
    from importlib import import_module
    from inspect import trace
    from traceback import format_exc

    from django.conf import settings
    from django.utils.translation import gettext as _

    from evennia.commands.cmdset import CmdSet
    from evennia.server.models import ServerConfig
    from evennia.utils import logger, utils

    __all__ = ("import_cmdset", "CmdSetHandler")

    _CACHED_CMDSETS = {}
    _CMDSET_PATHS = utils.make_iter(settings.CMDSET_PATHS)
    _IN_GAME_ERRORS = settings.IN_GAME_ERRORS
    _CMDSET_FALLBACKS = settings.CMDSET_FALLBACKS

    # Output strings

    _ERROR_CMDSET_IMPORT = _(
        """{traceback}
    Error loading cmdset '{path}'
    (Traceback was logged {timestamp})"""
    )

    _ERROR_CMDSET_KEYERROR = _(
        """Error loading cmdset: No cmdset class '{classname}' in '{path}'.
    (Traceback was logged {timestamp})"""
    )

    _ERROR_CMDSET_SYNTAXERROR = _(
        """{traceback}
    SyntaxError encountered when loading cmdset '{path}'.
    (Traceback was logged {timestamp})"""
    )

    _ERROR_CMDSET_EXCEPTION = _(
        """{traceback}
    Compile/Run error when loading cmdset '{path}'.
    (Traceback was logged {timestamp})"""
    )

    _ERROR_CMDSET_FALLBACK = _(
        """
    Error encountered for cmdset at path '{path}'.
    Replacing with fallback '{fallback_path}'.
    """
    )

    _ERROR_CMDSET_NO_FALLBACK = _("""Fallback path '{fallback_path}' failed to generate a cmdset.""")

    class _ErrorCmdSet(CmdSet):
        """
        This is a special cmdset used to report errors.
        """

        key = "_CMDSET_ERROR"
        errmessage = "Error when loading cmdset."

    class _EmptyCmdSet(CmdSet):
        """
        This cmdset represents an empty cmdset
        """

        key = "_EMPTY_CMDSET"
        priority = -101
        mergetype = "Union"

    def import_cmdset(path, cmdsetobj, emit_to_obj=None, no_logging=False):
        """
        This helper function is used by the cmdsethandler to load a cmdset
        instance from a python module, given a python_path. It's usually accessed
        through the cmdsethandler's add() and add_default() methods.
        path - This is the full path to the cmdset object on python dot-form

        Args:
            path (str): The path to the command set to load.
            cmdsetobj (CmdSet): The database object/typeclass on which this cmdset is to be
                assigned (this can be also channels and exits, as well as accounts
                but there will always be such an object)
            emit_to_obj (Object, optional): If given, error is emitted to
                this object (in addition to logging)
            no_logging (bool, optional): Don't log/send error messages.
                This can be useful if import_cmdset is just used to check if
                this is a valid python path or not.
        Returns:
            cmdset (CmdSet): The imported command set. If an error was
                encountered, `commands.cmdsethandler._ErrorCmdSet` is returned
                for the benefit of the handler.

        """
        python_paths = [path] + [
            "%s.%s" % (prefix, path) for prefix in _CMDSET_PATHS if not path.startswith(prefix)
        ]
        errstring = ""
        for python_path in python_paths:
            if "." in path:
                modpath, classname = python_path.rsplit(".", 1)
            else:
                raise ImportError(f"The path '{path}' is not on the form modulepath.ClassName")

            try:
                # first try to get from cache
                cmdsetclass = _CACHED_CMDSETS.get(python_path, None)

                if not cmdsetclass:
                    try:
                        module = import_module(modpath, package="evennia")
                    except ImportError as exc:
                        if len(trace()) > 2:
                            # error in module, make sure to not hide it.
                            dum, dum, tb = sys.exc_info()
                            raise exc.with_traceback(tb)
                        else:
                            # try next suggested path
                            errstring += _("\n(Unsuccessfully tried '{path}').").format(
                                path=python_path
                            )
                            continue
                    try:
                        cmdsetclass = getattr(module, classname)
                    except AttributeError as exc:
                        if len(trace()) > 2:
                            # Attribute error within module, don't hide it
                            dum, dum, tb = sys.exc_info()
                            raise exc.with_traceback(tb)
                        else:
                            errstring += _("\n(Unsuccessfully tried '{path}').").format(
                                path=python_path
                            )
                            continue
                    _CACHED_CMDSETS[python_path] = cmdsetclass

                # instantiate the cmdset (and catch its errors)
                if callable(cmdsetclass):
                    cmdsetclass = cmdsetclass(cmdsetobj)
                return cmdsetclass
            except ImportError as err:
                logger.log_trace()
                errstring += _ERROR_CMDSET_IMPORT
                if _IN_GAME_ERRORS:
                    errstring = errstring.format(
                        path=python_path, traceback=format_exc(), timestamp=logger.timeformat()
                    )
                else:
                    errstring = errstring.format(
                        path=python_path, traceback=err, timestamp=logger.timeformat()
                    )
                break
            except KeyError:
                logger.log_trace()
                errstring += _ERROR_CMDSET_KEYERROR
                errstring = errstring.format(
                    classname=classname, path=python_path, timestamp=logger.timeformat()
                )
                break
            except SyntaxError as err:
                logger.log_trace()
                errstring += _ERROR_CMDSET_SYNTAXERROR
                if _IN_GAME_ERRORS:
                    errstring = errstring.format(
                        path=python_path, traceback=format_exc(), timestamp=logger.timeformat()
                    )
                else:
                    errstring = errstring.format(
                        path=python_path, traceback=err, timestamp=logger.timeformat()
                    )
                break
            except Exception as err:
                logger.log_trace()
                errstring += _ERROR_CMDSET_EXCEPTION
                if _IN_GAME_ERRORS:
                    errstring = errstring.format(
                        path=python_path, traceback=format_exc(), timestamp=logger.timeformat()
                    )
                else:
                    errstring = errstring.format(
                        path=python_path, traceback=err, timestamp=logger.timeformat()
                    )
                break

        if errstring:
            # returning an empty error cmdset
            errstring = errstring.strip()
            if not no_logging:
                logger.log_err(errstring)
                if emit_to_obj and not ServerConfig.objects.conf("server_starting_mode"):
                    emit_to_obj.msg(errstring)
            err_cmdset = _ErrorCmdSet()
            err_cmdset.errmessage = errstring
            return err_cmdset
        return None  # undefined error

    # classes

    class CmdSetHandler(object):
        """
        The CmdSetHandler is always stored on an object, this object is supplied
        as an argument.

        The 'current' cmdset is the merged set currently active for this object.
        This is the set the game engine will retrieve when determining which
        commands are available to the object. The cmdset_stack holds a history of
        all CmdSets to allow the handler to remove/add cmdsets at will. Doing so
        will re-calculate the 'current' cmdset.
        """

        def __init__(self, obj, init_true=True):
            """
            This method is called whenever an object is recreated.

            Args:
                obj (Object): An reference to the game object this handler
                    belongs to.
                init_true (bool, optional): Set when the handler is initializing
                    and loads the current cmdset.

            """
            self.obj = obj

            # the id of the "merged" current cmdset for easy access.
            self.key = None
            # this holds the "merged" current command set. Note that while the .update
            # method updates this field in order to have it synced when operating on
            # cmdsets in-code, when the game runs, this field is kept up-to-date by
            # the cmdsethandler's get_and_merge_cmdsets!
            self.current = None
            # this holds a history of CommandSets
            self.cmdset_stack = [_EmptyCmdSet(cmdsetobj=self.obj)]
            # this tracks which mergetypes are actually in play in the stack
            self.mergetype_stack = ["Union"]

            # the subset of the cmdset_paths that are to be stored in the database
            self.persistent_paths = [""]

            if init_true:
                self.update(init_mode=True)  # is then called from the object __init__.

        def __str__(self):
            """
            Display current commands

            """

            strings = ["<CmdSetHandler> stack:"]
            mergelist = []
            if len(self.cmdset_stack) > 1:
                # We have more than one cmdset in stack; list them all
                for snum, cmdset in enumerate(self.cmdset_stack):
                    mergelist.append(str(snum + 1))
                    strings.append(f" {snum + 1}: {cmdset}")

            # Display the currently active cmdset, limited by self.obj's permissions
            mergetype = self.mergetype_stack[-1]
            if mergetype != self.current.mergetype:
                merged_on = self.cmdset_stack[-2].key
                mergetype = _("custom {mergetype} on cmdset '{cmdset}'")
                mergetype = mergetype.format(mergetype=mergetype, cmdset=merged_on)

            if mergelist:
                # current is a result of mergers
                mergelist = "+".join(mergelist)
                strings.append(f" <Merged {mergelist}>: {self.current}")
            else:
                # current is a single cmdset
                strings.append(" " + str(self.current))
            return "\n".join(strings).rstrip()

        def _import_cmdset(self, cmdset_path, emit_to_obj=None):
            """
            Method wrapper for import_cmdset; Loads a cmdset from a
            module.

            Args:
                cmdset_path (str): The python path to an cmdset object.
                emit_to_obj (Object): The object to send error messages to

            Returns:
                cmdset (Cmdset): The imported cmdset.

            """
            if not emit_to_obj:
                emit_to_obj = self.obj
            return import_cmdset(cmdset_path, self.obj, emit_to_obj)

        def update(self, init_mode=False):
            """
            Re-adds all sets in the handler to have an updated current

            Args:
                init_mode (bool, optional): Used automatically right after
                    this handler was created; it imports all persistent cmdsets
                    from the database.

            Notes:
                This method is necessary in order to always have a `.current`
                cmdset when working with the cmdsethandler in code. But the
                CmdSetHandler doesn't (cannot) consider external cmdsets and game
                state. This means that the .current calculated from this method
                will likely not match the true current cmdset as determined at
                run-time by `cmdhandler.get_and_merge_cmdsets()`. So in a running
                game the responsibility of keeping `.current` upt-to-date belongs
                to the central `cmdhandler.get_and_merge_cmdsets()`!

            """
            if init_mode:
                # reimport all persistent cmdsets
                storage = self.obj.cmdset_storage
                if storage:
                    self.cmdset_stack = []
                    for pos, path in enumerate(storage):
                        if pos == 0 and not path:
                            self.cmdset_stack = [_EmptyCmdSet(cmdsetobj=self.obj)]
                        elif path:
                            cmdset = self._import_cmdset(path)
                            if cmdset:
                                if cmdset.key == "_CMDSET_ERROR":
                                    # If a cmdset fails to load, check if we have a fallback path to use
                                    fallback_path = _CMDSET_FALLBACKS.get(path, None)
                                    if fallback_path:
                                        err = _ERROR_CMDSET_FALLBACK.format(
                                            path=path, fallback_path=fallback_path
                                        )
                                        logger.log_err(err)
                                        if _IN_GAME_ERRORS:
                                            self.obj.msg(err)
                                        cmdset = self._import_cmdset(fallback_path)
                                    # If no cmdset is returned from the fallback, we can't go further
                                    if not cmdset:
                                        err = _ERROR_CMDSET_NO_FALLBACK.format(
                                            fallback_path=fallback_path
                                        )
                                        logger.log_err(err)
                                        if _IN_GAME_ERRORS:
                                            self.obj.msg(err)
                                        continue
                                cmdset.persistent = cmdset.key != "_CMDSET_ERROR"
                                self.cmdset_stack.append(cmdset)

            # merge the stack into a new merged cmdset
            new_current = None
            self.mergetype_stack = []
            for cmdset in self.cmdset_stack:
                try:
                    # for cmdset's '+' operator, order matters.
                    new_current = cmdset + new_current
                except TypeError:
                    continue
                self.mergetype_stack.append(new_current.actual_mergetype)
            self.current = new_current

        def add(self, cmdset, emit_to_obj=None, persistent=False, default_cmdset=False, **kwargs):
            """
            Add a cmdset to the handler, on top of the old ones, unless it
            is set as the default one (it will then end up at the bottom of the stack)

            Args:
                cmdset (CmdSet or str): Can be a cmdset object or the python path
                    to such an object.
                emit_to_obj (Object, optional): An object to receive error messages.
                persistent (bool, optional): Let cmdset remain across server reload.
                default_cmdset (Cmdset, optional): Insert this to replace the
                    default cmdset position (there is only one such position,
                    always at the bottom of the stack).

            Notes:
              An interesting feature of this method is if you were to send
              it an *already instantiated cmdset* (i.e. not a class), the
              current cmdsethandler's obj attribute will then *not* be
              transferred over to this already instantiated set (this is
              because it might be used elsewhere and can cause strange
              effects).  This means you could in principle have the
              handler launch command sets tied to a *different* object
              than the handler. Not sure when this would be useful, but
              it's a 'quirk' that has to be documented.

            """
            if "permanent" in kwargs:
                logger.log_dep("obj.cmdset.add() kwarg 'permanent' has changed name to 'persistent'.")
                persistent = kwargs["permanent"] if persistent is False else persistent

            if not (isinstance(cmdset, str) or utils.inherits_from(cmdset, CmdSet)):
                string = _("Only CmdSets can be added to the cmdsethandler!")
                raise Exception(string)

            if callable(cmdset):
                cmdset = cmdset(self.obj)
            elif isinstance(cmdset, str):
                # this is (maybe) a python path. Try to import from cache.
                cmdset = self._import_cmdset(cmdset)
            if cmdset and cmdset.key != "_CMDSET_ERROR":
                cmdset.persistent = persistent
                if persistent and cmdset.key != "_CMDSET_ERROR":
                    # store the path permanently
                    storage = self.obj.cmdset_storage or [""]
                    if default_cmdset:
                        storage[0] = cmdset.path
                    else:
                        storage.append(cmdset.path)
                    self.obj.cmdset_storage = storage
                if default_cmdset:
                    self.cmdset_stack[0] = cmdset
                else:
                    self.cmdset_stack.append(cmdset)
                self.update()

        def add_default(self, cmdset, emit_to_obj=None, persistent=True, **kwargs):
            """
            Shortcut for adding a default cmdset.

            Args:
                cmdset (Cmdset): The Cmdset to add.
                emit_to_obj (Object, optional): Gets error messages
                persistent (bool, optional): The new Cmdset should survive a server reboot.

            """
            if "permanent" in kwargs:
                logger.log_dep(
                    "obj.cmdset.add_default() kwarg 'permanent' has changed name to 'persistent'."
                )
                persistent = kwargs["permanent"] if persistent is None else persistent
            self.add(cmdset, emit_to_obj=emit_to_obj, persistent=persistent, default_cmdset=True)

        def remove(self, cmdset=None, default_cmdset=False):
            """
            Remove a cmdset from the  handler.

            Args:
                cmdset (CommandSet or str, optional): This can can be supplied either as a cmdset-key,
                    an instance of the CmdSet or a python path to the cmdset.
                    If no key is given, the last cmdset in the stack is
                    removed. Whenever the cmdset_stack changes, the cmdset is
                    updated. If default_cmdset is set, this argument is ignored.
                default_cmdset (bool, optional): If set, this will remove the
                    default cmdset (at the bottom of the stack).

            """
            if default_cmdset:
                # remove the default cmdset only
                if self.cmdset_stack:
                    cmdset = self.cmdset_stack[0]
                    if cmdset.persistent:
                        storage = self.obj.cmdset_storage or [""]
                        storage[0] = ""
                        self.obj.cmdset_storage = storage
                    self.cmdset_stack[0] = _EmptyCmdSet(cmdsetobj=self.obj)
                else:
                    self.cmdset_stack = [_EmptyCmdSet(cmdsetobj=self.obj)]
                self.update()
                return

            if len(self.cmdset_stack) < 2:
                # don't allow deleting default cmdsets here.
                return

            if not cmdset:
                # remove the last one in the stack
                cmdset = self.cmdset_stack.pop()
                if cmdset.persistent:
                    storage = self.obj.cmdset_storage
                    storage.pop()
                    self.obj.cmdset_storage = storage
            else:
                # try it as a callable
                if callable(cmdset) and hasattr(cmdset, "path"):
                    delcmdsets = [cset for cset in self.cmdset_stack[1:] if cset.path == cmdset.path]
                else:
                    # try it as a path or key
                    delcmdsets = [
                        cset
                        for cset in self.cmdset_stack[1:]
                        if cset.path == cmdset or cset.key == cmdset
                    ]
                storage = []

                if any(cset.persistent for cset in delcmdsets):
                    # only hit database if there's need to
                    storage = self.obj.cmdset_storage
                    updated = False
                    for cset in delcmdsets:
                        if cset.persistent:
                            try:
                                storage.remove(cset.path)
                                updated = True
                            except ValueError:
                                # nothing to remove
                                pass
                    if updated:
                        self.obj.cmdset_storage = storage
                for cset in delcmdsets:
                    # clean the in-memory stack
                    try:
                        self.cmdset_stack.remove(cset)
                    except ValueError:
                        # nothing to remove
                        pass
            # re-sync the cmdsethandler.
            self.update()

        # legacy alias
        delete = remove

        def remove_default(self):
            """
            This explicitly deletes only the default cmdset.

            """
            self.remove(default_cmdset=True)

        # legacy alias
        delete_default = remove_default

        def get(self):
            """
            Get all cmdsets.

            Returns:
                cmdsets (list): All the command sets currently in the handler.

            """
            return self.cmdset_stack

        # backwards-compatible alias
        all = get

        def clear(self):
            """
            Removes all Command Sets from the handler except the default one
            (use `self.remove_default` to remove that).

            """
            self.cmdset_stack = [self.cmdset_stack[0]]
            storage = self.obj.cmdset_storage
            if storage:
                storage = storage[0]
                self.obj.cmdset_storage = storage
            self.update()

        def has(self, cmdset, must_be_default=False):
            """
            checks so the cmdsethandler contains a given cmdset

            Args:
                cmdset (str or Cmdset): Cmdset key, pythonpath or
                    Cmdset to check the existence for.
                must_be_default (bool, optional): Only return True if
                    the checked cmdset is the default one.

            Returns:
                has_cmdset (bool): Whether or not the cmdset is in the handler.

            """
            if callable(cmdset) and hasattr(cmdset, "path"):
                # try it as a callable
                if must_be_default:
                    return self.cmdset_stack and (self.cmdset_stack[0].path == cmdset.path)
                else:
                    return any([cset for cset in self.cmdset_stack if cset.path == cmdset.path])
            else:
                # try it as a path or key
                if must_be_default:
                    return self.cmdset_stack and (
                        self.cmdset_stack[0].key == cmdset or self.cmdset_stack[0].path == cmdset
                    )
                else:
                    return any(
                        [
                            cset
                            for cset in self.cmdset_stack
                            if cset.path == cmdset or cset.key == cmdset
                        ]
                    )

        # backwards-compatability alias
        has_cmdset = has

        def reset(self):
            """
            Force reload of all cmdsets in handler. This should be called
            after _CACHED_CMDSETS have been cleared (normally this is
            handled automatically by @reload).

            """
            new_cmdset_stack = []
            for cmdset in self.cmdset_stack:
                if cmdset.key == "_EMPTY_CMDSET":
                    new_cmdset_stack.append(cmdset)
                else:
                    new_cmdset_stack.append(self._import_cmdset(cmdset.path))
            self.cmdset_stack = new_cmdset_stack
            self.update()

Note

You are reading an old version of the Evennia documentation. The latest version is here

.