diff --git a/.travis.yml b/.travis.yml index 34288574ae..3d3714c4f7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ services: python: - "3.7" + - "3.8" env: - TESTING_DB=sqlite3 diff --git a/.travis/mysql_settings.py b/.travis/mysql_settings.py index b0abfb8519..e654d5ea89 100644 --- a/.travis/mysql_settings.py +++ b/.travis/mysql_settings.py @@ -39,25 +39,25 @@ SERVERNAME = "testing_mygame" # Testing database types DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'evennia', - 'USER': 'evennia', - 'PASSWORD': 'password', - 'HOST': 'localhost', - 'PORT': '', # use default port - 'OPTIONS': { - 'charset': 'utf8mb4', - 'init_command': 'set collation_connection=utf8mb4_unicode_ci' + "default": { + "ENGINE": "django.db.backends.mysql", + "NAME": "evennia", + "USER": "evennia", + "PASSWORD": "password", + "HOST": "localhost", + "PORT": "", # use default port + "OPTIONS": { + "charset": "utf8mb4", + "init_command": "set collation_connection=utf8mb4_unicode_ci", }, - 'TEST': { - 'NAME': 'default', - 'OPTIONS': { - 'charset': 'utf8mb4', + "TEST": { + "NAME": "default", + "OPTIONS": { + "charset": "utf8mb4", # 'init_command': 'set collation_connection=utf8mb4_unicode_ci' - 'init_command': "SET NAMES 'utf8mb4'" - } - } + "init_command": "SET NAMES 'utf8mb4'", + }, + }, } } diff --git a/.travis/postgresql_settings.py b/.travis/postgresql_settings.py index e65737699e..c12927af3a 100644 --- a/.travis/postgresql_settings.py +++ b/.travis/postgresql_settings.py @@ -39,16 +39,14 @@ SERVERNAME = "testing_mygame" # Testing database types DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'evennia', - 'USER': 'evennia', - 'PASSWORD': 'password', - 'HOST': 'localhost', - 'PORT': '', # use default - 'TEST': { - 'NAME': 'default' - } + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "evennia", + "USER": "evennia", + "PASSWORD": "password", + "HOST": "localhost", + "PORT": "", # use default + "TEST": {"NAME": "default"}, } } diff --git a/CHANGELOG.md b/CHANGELOG.md index a865a4569b..5252f8f67a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ defaults to True for backwards-compatibility in 0.9, will be False in 1.0 ### Already in master - +- `is_typeclass(obj (Object), exact (bool))` now defaults to exact=False - `py` command now reroutes stdout to output results in-game client. `py` without arguments starts a full interactive Python console. - Webclient default to a single input pane instead of two. Now defaults to no help-popup. @@ -27,13 +27,33 @@ without arguments starts a full interactive Python console. - `AttributeHandler.get(return_list=True)` will return `[]` if there are no Attributes instead of `[None]`. - Remove `pillow` requirement (install especially if using imagefield) -- Add Simplified Korean translation (user aceamro) +- Add Simplified Korean translation (aceamro) - Show warning on `start -l` if settings contains values unsafe for production. - Make code auto-formatted with Black. - Make default `set` command able to edit nested structures (PR by Aaron McMillan) - Allow running Evennia test suite from core repo with `make test`. -- Return `store_key` from `TickerHandler.add` and add `store_key` as a kwarg to - the `TickerHandler.remove` method. This makes it easier to manage tickers. +- Return `store_key` from `TickerHandler.add` and add `store_key` as a kwarg to + the `TickerHandler.remove` method. This makes it easier to manage tickers. +- EvMore `text` argument can now also be a list - each entry in the list is run + through str(eval()) and ends up on its own line. Good for paginated object lists. +- EvMore auto-justify now defaults to False since this works better with all types + of texts (such as tables). New `justify` bool. Old `justify_kwargs` remains + but is now only used to pass extra kwargs into the justify function. +- Improve performance of `find` and `objects` commands on large data sets (strikaco) +- New `CHANNEL_HANDLER_CLASS` setting allows for replacing the ChannelHandler entirely. +- Made `py` interactive mode support regular quit() and more verbose. +- Made `Account.options.get` accept `default=None` kwarg to mimic other uses of get. Set + the new `raise_exception` boolean if ranting to raise KeyError on a missing key. +- Moved behavior of unmodified `Command` and `MuxCommand` `.func()` to new + `.get_command_info()` method for easier overloading and access. (Volund) +- Removed unused `CYCLE_LOGFILES` setting. Added `SERVER_LOG_DAY_ROTATION` + and `SERVER_LOG_MAX_SIZE` (and equivalent for PORTAL) to control log rotation. +- Addded `inside_rec` lockfunc - if room is locked, the normal `inside()` lockfunc will + fail e.g. for your inventory objs (since their loc is you), whereas this will pass. +- RPSystem contrib's CmdRecog will now list all recogs if no arg is given. Also multiple + bugfixes. +- Remove `dummy@example.com` as a default account email when unset, a string is no longer + required by Django. ## Evennia 0.9 (2018-2019) diff --git a/INSTALL.md b/INSTALL.md index 8c45cb357e..926785ee5f 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,137 +1,5 @@ # Evennia installation -The latest and more detailed installation instructions can be found -[here](https://github.com/evennia/evennia/wiki/Getting-Started). - -## Installing Python - -First install [Python](https://www.python.org/). Linux users should -have it in their repositories, Windows/Mac users can get it from the -Python homepage. You need the 2.7.x version (Python 3 is not yet -supported). Windows users, make sure to select the option to make -Python available in your path - this is so you can call it everywhere -as `python`. Python 2.7.9 and later also includes the -[pip](https://pypi.python.org/pypi/pip/) installer out of the box, -otherwise install this separately (in linux it's usually found as the -`python-pip` package). - -### installing virtualenv - -This step is optional, but *highly* recommended. For installing -up-to-date Python packages we recommend using -[virtualenv](https://pypi.python.org/pypi/virtualenv), this makes it -easy to keep your Python packages up-to-date without interfering with -the defaults for your system. - -``` -pip install virtualenv -``` - -Go to the place where you want to make your virtual python library -storage. This does not need to be near where you plan to install -Evennia. Then do - -``` -virtualenv vienv -``` - -A new folder `vienv` will be created (you could also name it something -else if you prefer). Activate the virtual environment like this: - -``` -# for Linux/Unix/Mac: -source vienv/bin/activate -# for Windows: -vienv\Scripts\activate.bat -``` - -You should see `(vienv)` next to your prompt to show you the -environment is active. You need to activate it whenever you open a new -terminal, but you *don't* have to be inside the `vienv` folder henceforth. - - -## Get the developer's version of Evennia - -This is currently the only Evennia version available. First download -and install [Git](http://git-scm.com/) from the homepage or via the -package manager in Linux. Next, go to the place where you want the -`evennia` folder to be created and run - -``` -git clone https://github.com/evennia/evennia.git -``` - -If you have a github account and have [set up SSH -keys](https://help.github.com/articles/generating-ssh-keys/), you want -to use this instead: - -``` -git clone git@github.com:evennia/evennia.git -``` - -In the future you just enter the new `evennia` folder and do - -``` -git pull -``` - -to get the latest Evennia updates. - -## Evennia package install - -Stand at the root of your new `evennia` directory and run - -``` -pip install -e . -``` - -(note the period "." at the end, this tells pip to install from the -current directory). This will install Evennia and all its dependencies -(into your virtualenv if you are using that) and make the `evennia` -command available on the command line. You can find Evennia's -dependencies in `evennia/requirements.txt`. - -## Creating your game project - -To create your new game you need to initialize a new game project. -This should be done somewhere *outside* of your `evennia` folder. - - -``` -evennia --init mygame -``` - -This will create a new game project named "mygame" in a folder of the -same name. If you want to change the settings for your project, you -will need to edit `mygame/server/conf/settings.py`. - - -## Starting Evennia - -Enter your new game directory and run - -``` -evennia migrate -evennia start -``` - -Follow the instructions to create your superuser account. A lot of -information will scroll past as the database is created and the server -initializes. After this Evennia will be running. Use - -``` -evennia -h -``` - -for help with starting, stopping and other operations. - -Start up your MUD client of choice and point it to your server and -port *4000*. If you are just running locally the server name is -*localhost*. - -Alternatively, you can find the web interface and webclient by -pointing your web browser to *http://localhost:4001*. - -Finally, login with the superuser account and password you provided -earlier. Welcome to Evennia! +You can find the latest updated installation instructions and +requirements [here](https://github.com/evennia/evennia/wiki/Getting-Started). diff --git a/bin/project_rename.py b/bin/project_rename.py index f4c2d9c7ac..f687a52484 100644 --- a/bin/project_rename.py +++ b/bin/project_rename.py @@ -23,7 +23,7 @@ USE_COLOR = True FAKE_MODE = False # if these words are longer than output word, retain given case -CASE_WORD_EXCEPTIONS = ('an', ) +CASE_WORD_EXCEPTIONS = ("an",) _HELP_TEXT = """This program interactively renames words in all files of your project. It's currently renaming {sources} to {targets}. @@ -80,6 +80,7 @@ def _case_sensitive_replace(string, old, new): `old` has been replaced with `new`, retaining case. """ + def repl(match): current = match.group() # treat multi-word sentences word-by-word @@ -99,15 +100,21 @@ def _case_sensitive_replace(string, old, new): all_upper = False # special cases - keep remaing case) if new_word.lower() in CASE_WORD_EXCEPTIONS: - result.append(new_word[ind + 1:]) + result.append(new_word[ind + 1 :]) # append any remaining characters from new elif all_upper: - result.append(new_word[ind + 1:].upper()) + result.append(new_word[ind + 1 :].upper()) else: - result.append(new_word[ind + 1:].lower()) + result.append(new_word[ind + 1 :].lower()) out.append("".join(result)) # if we have more new words than old ones, just add them verbatim - out.extend([new_word for ind, new_word in enumerate(new_words) if ind >= len(old_words)]) + out.extend( + [ + new_word + for ind, new_word in enumerate(new_words) + if ind >= len(old_words) + ] + ) return " ".join(out) regex = re.compile(re.escape(old), re.I) @@ -147,7 +154,9 @@ def rename_in_tree(path, in_list, out_list, excl_list, fileend_list, is_interact print("%s skipped (excluded)." % full_path) continue - if not fileend_list or any(file.endswith(ending) for ending in fileend_list): + if not fileend_list or any( + file.endswith(ending) for ending in fileend_list + ): rename_in_file(full_path, in_list, out_list, is_interactive) # rename file - always ask @@ -155,8 +164,10 @@ def rename_in_tree(path, in_list, out_list, excl_list, fileend_list, is_interact for src, dst in repl_mapping: new_file = _case_sensitive_replace(new_file, src, dst) if new_file != file: - inp = input(_green("Rename %s\n -> %s\n Y/[N]? > " % (file, new_file))) - if inp.upper() == 'Y': + inp = input( + _green("Rename %s\n -> %s\n Y/[N]? > " % (file, new_file)) + ) + if inp.upper() == "Y": new_full_path = os.path.join(root, new_file) try: os.rename(full_path, new_full_path) @@ -171,8 +182,10 @@ def rename_in_tree(path, in_list, out_list, excl_list, fileend_list, is_interact for src, dst in repl_mapping: new_root = _case_sensitive_replace(new_root, src, dst) if new_root != root: - inp = input(_green("Dir Rename %s\n -> %s\n Y/[N]? > " % (root, new_root))) - if inp.upper() == 'Y': + inp = input( + _green("Dir Rename %s\n -> %s\n Y/[N]? > " % (root, new_root)) + ) + if inp.upper() == "Y": try: os.rename(root, new_root) except OSError as err: @@ -201,7 +214,7 @@ def rename_in_file(path, in_list, out_list, is_interactive): print("%s is a directory. You should use the --recursive option." % path) sys.exit() - with open(path, 'r') as fil: + with open(path, "r") as fil: org_text = fil.read() repl_mapping = list(zip(in_list, out_list)) @@ -215,7 +228,7 @@ def rename_in_file(path, in_list, out_list, is_interactive): if FAKE_MODE: print(" ... Saved changes to %s. (faked)" % path) else: - with open(path, 'w') as fil: + with open(path, "w") as fil: fil.write(new_text) print(" ... Saved changes to %s." % path) else: @@ -239,18 +252,24 @@ def rename_in_file(path, in_list, out_list, is_interactive): while True: - for iline, renamed_line in sorted(list(renamed.items()), key=lambda tup: tup[0]): + for iline, renamed_line in sorted( + list(renamed.items()), key=lambda tup: tup[0] + ): print("%3i orig: %s" % (iline + 1, org_lines[iline])) print(" new : %s" % (_yellow(renamed_line))) print(_green("%s (%i lines changed)" % (path, len(renamed)))) - ret = input(_green("Choose: " - "[q]uit, " - "[h]elp, " - "[s]kip file, " - "[i]gnore lines, " - "[c]lear ignores, " - "[a]ccept/save file: ".lower())) + ret = input( + _green( + "Choose: " + "[q]uit, " + "[h]elp, " + "[s]kip file, " + "[i]gnore lines, " + "[c]lear ignores, " + "[a]ccept/save file: ".lower() + ) + ) if ret == "s": # skip file entirely @@ -267,7 +286,7 @@ def rename_in_file(path, in_list, out_list, is_interactive): if FAKE_MODE: print(" ... Saved file %s (faked)" % path) return - with open(path, 'w') as fil: + with open(path, "w") as fil: fil.writelines("\n".join(org_lines)) print(" ... Saved file %s" % path) return @@ -278,7 +297,11 @@ def rename_in_file(path, in_list, out_list, is_interactive): input(_HELP_TEXT.format(sources=in_list, targets=out_list)) elif ret.startswith("i"): # ignore one or more lines - ignores = [int(ind) - 1 for ind in ret[1:].split(',') if ind.strip().isdigit()] + ignores = [ + int(ind) - 1 + for ind in ret[1:].split(",") + if ind.strip().isdigit() + ] if not ignores: input("Ignore example: i 2,7,34,133\n (return to continue)") continue @@ -291,36 +314,57 @@ if __name__ == "__main__": import argparse parser = argparse.ArgumentParser( - description="Rename text in a source tree, or a single file") + description="Rename text in a source tree, or a single file" + ) - parser.add_argument('-i', '--input', action='append', - help="Source word to rename (quote around multiple words)") - parser.add_argument('-o', '--output', action='append', - help="Word to rename a matching src-word to") - parser.add_argument('-x', '--exc', action='append', - help="File path patterns to exclude") - parser.add_argument('-a', '--auto', action='store_true', - help="Automatic mode, don't ask to rename") - parser.add_argument('-r', '--recursive', action='store_true', - help="Recurse subdirs") - parser.add_argument('-f', '--fileending', action='append', - help="Change which file endings to allow (default .py and .html)") - parser.add_argument('--nocolor', action='store_true', - help="Turn off in-program color") - parser.add_argument('--fake', action='store_true', - help="Simulate run but don't actually save") - parser.add_argument('path', - help="File or directory in which to rename text") + parser.add_argument( + "-i", + "--input", + action="append", + help="Source word to rename (quote around multiple words)", + ) + parser.add_argument( + "-o", "--output", action="append", help="Word to rename a matching src-word to" + ) + parser.add_argument( + "-x", "--exc", action="append", help="File path patterns to exclude" + ) + parser.add_argument( + "-a", "--auto", action="store_true", help="Automatic mode, don't ask to rename" + ) + parser.add_argument( + "-r", "--recursive", action="store_true", help="Recurse subdirs" + ) + parser.add_argument( + "-f", + "--fileending", + action="append", + help="Change which file endings to allow (default .py and .html)", + ) + parser.add_argument( + "--nocolor", action="store_true", help="Turn off in-program color" + ) + parser.add_argument( + "--fake", action="store_true", help="Simulate run but don't actually save" + ) + parser.add_argument("path", help="File or directory in which to rename text") args = parser.parse_args() - in_list, out_list, exc_list, fileend_list = args.input, args.output, args.exc, args.fileending + in_list, out_list, exc_list, fileend_list = ( + args.input, + args.output, + args.exc, + args.fileending, + ) if not (in_list and out_list): - print('At least one source- and destination word must be given.') + print("At least one source- and destination word must be given.") sys.exit() if len(in_list) != len(out_list): - print('Number of sources must be identical to the number of destination arguments.') + print( + "Number of sources must be identical to the number of destination arguments." + ) sys.exit() exc_list = exc_list or [] @@ -332,6 +376,8 @@ if __name__ == "__main__": FAKE_MODE = args.fake if is_recursive: - rename_in_tree(args.path, in_list, out_list, exc_list, fileend_list, is_interactive) + rename_in_tree( + args.path, in_list, out_list, exc_list, fileend_list, is_interactive + ) else: rename_in_file(args.path, in_list, out_list, is_interactive) diff --git a/bin/windows/evennia_launcher.py b/bin/windows/evennia_launcher.py index ba11eb8274..6667aec780 100755 --- a/bin/windows/evennia_launcher.py +++ b/bin/windows/evennia_launcher.py @@ -14,4 +14,5 @@ sys.path.insert(0, os.path.abspath(os.getcwd())) sys.path.insert(0, os.path.join(sys.prefix, "Lib", "site-packages")) from evennia.server.evennia_launcher import main + main() diff --git a/evennia/__init__.py b/evennia/__init__.py index a5ae043d08..6313da9530 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -231,6 +231,7 @@ def _init(): from . import contrib from .utils.evmenu import EvMenu from .utils.evtable import EvTable + from .utils.evmore import EvMore from .utils.evform import EvForm from .utils.eveditor import EvEditor from .utils.ansi import ANSIString diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index d6afe2ca2e..af0050ec9d 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -37,7 +37,7 @@ from evennia.scripts.scripthandler import ScriptHandler from evennia.commands.cmdsethandler import CmdSetHandler from evennia.utils.optionhandler import OptionHandler -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from random import getrandbits __all__ = ("DefaultAccount",) @@ -51,8 +51,12 @@ _CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT _MUDINFO_CHANNEL = None # Create throttles for too many account-creations and login attempts -CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60) -LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60) +CREATION_THROTTLE = Throttle( + limit=settings.CREATION_THROTTLE_LIMIT, timeout=settings.CREATION_THROTTLE_TIMEOUT +) +LOGIN_THROTTLE = Throttle( + limit=settings.LOGIN_THROTTLE_LIMIT, timeout=settings.LOGIN_THROTTLE_TIMEOUT +) class AccountSessionHandler(object): @@ -216,12 +220,16 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): @property def characters(self): # Get playable characters list - objs = self.db._playable_characters + objs = self.db._playable_characters or [] # Rebuild the list if legacy code left null values after deletion - if None in objs: - objs = [x for x in self.db._playable_characters if x] - self.db._playable_characters = objs + try: + if None in objs: + objs = [x for x in self.db._playable_characters if x] + self.db._playable_characters = objs + except Exception as e: + logger.log_trace(e) + logger.log_err(e) return objs @@ -820,7 +828,10 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): server. Args: - text (str, optional): text data to send + text (str or tuple, optional): The message to send. This + is treated internally like any send-command, so its + value can be a tuple if sending multiple arguments to + the `text` oob command. from_obj (Object or Account or list, optional): Object sending. If given, its at_msg_send() hook will be called. If iterable, call on all entities. session (Session or list, optional): Session object or a list of @@ -851,7 +862,13 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): kwargs["options"] = options if text is not None: - kwargs["text"] = to_str(text) + if not (isinstance(text, str) or isinstance(text, tuple)): + # sanitize text before sending across the wire + try: + text = to_str(text) + except Exception: + text = repr(text) + kwargs["text"] = text # session relay sessions = make_iter(session) if session else self.sessions.all() diff --git a/evennia/accounts/models.py b/evennia/accounts/models.py index 06d46d9777..53ba05ea78 100644 --- a/evennia/accounts/models.py +++ b/evennia/accounts/models.py @@ -105,9 +105,9 @@ class AccountDB(TypedObject, AbstractUser): objects = AccountDBManager() # defaults - __settingsclasspath__ = settings.BASE_SCRIPT_TYPECLASS __defaultclasspath__ = "evennia.accounts.accounts.DefaultAccount" __applabel__ = "accounts" + __settingsclasspath__ = settings.BASE_SCRIPT_TYPECLASS class Meta(object): verbose_name = "Account" diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index e091f90468..6f8f247a72 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -48,7 +48,7 @@ from evennia.comms.channelhandler import CHANNELHANDLER from evennia.utils import logger, utils from evennia.utils.utils import string_suggestions -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ _IN_GAME_ERRORS = settings.IN_GAME_ERRORS @@ -733,7 +733,7 @@ def cmdhandler( if len(matches) == 1: # We have a unique command match. But it may still be invalid. match = matches[0] - cmdname, args, cmd, raw_cmdname = match[0], match[1], match[2], match[5] + cmdname, args, cmd, raw_cmdname = (match[0], match[1], match[2], match[5]) if not matches: # No commands match our entered command diff --git a/evennia/commands/cmdparser.py b/evennia/commands/cmdparser.py index 914dbb6df9..56b702d8c1 100644 --- a/evennia/commands/cmdparser.py +++ b/evennia/commands/cmdparser.py @@ -125,7 +125,7 @@ def try_num_prefixes(raw_string): # the user might be trying to identify the command # with a #num-command style syntax. We expect the regex to # contain the groups "number" and "name". - mindex, new_raw_string = num_ref_match.group("number"), num_ref_match.group("name") + mindex, new_raw_string = (num_ref_match.group("number"), num_ref_match.group("name")) return mindex, new_raw_string else: return None, None diff --git a/evennia/commands/cmdset.py b/evennia/commands/cmdset.py index 5bb3ec8d28..f124bbbb06 100644 --- a/evennia/commands/cmdset.py +++ b/evennia/commands/cmdset.py @@ -27,7 +27,7 @@ Set theory. """ from weakref import WeakKeyDictionary -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from evennia.utils.utils import inherits_from, is_iter __all__ = ("CmdSet",) diff --git a/evennia/commands/cmdsethandler.py b/evennia/commands/cmdsethandler.py index e855d551e9..395c9c2ba3 100644 --- a/evennia/commands/cmdsethandler.py +++ b/evennia/commands/cmdsethandler.py @@ -72,7 +72,7 @@ from evennia.utils import logger, utils from evennia.commands.cmdset import CmdSet from evennia.server.models import ServerConfig -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ __all__ = ("import_cmdset", "CmdSetHandler") diff --git a/evennia/commands/command.py b/evennia/commands/command.py index 535ce15dd9..dba035b481 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -6,6 +6,7 @@ All commands in Evennia inherit from the 'Command' class in this module. """ import re import math +import inspect from django.conf import settings @@ -74,6 +75,13 @@ def _init_command(cls, **kwargs): cls.is_exit = False if not hasattr(cls, "help_category"): cls.help_category = "general" + # make sure to pick up the parent's docstring if the child class is + # missing one (important for auto-help) + if cls.__doc__ is None: + for parent_class in inspect.getmro(cls): + if parent_class.__doc__ is not None: + cls.__doc__ = parent_class.__doc__ + break cls.help_category = cls.help_category.lower() @@ -401,12 +409,11 @@ class Command(object, metaclass=CommandMeta): """ pass - def func(self): + def get_command_info(self): """ - This is the actual executing part of the command. It is - called directly after self.parse(). See the docstring of this - module for which object properties are available (beyond those - set in self.parse()) + This is the default output of func() if no func() overload is done. + Provided here as a separate method so that it can be called for debugging + purposes when making commands. """ variables = "\n".join( @@ -416,11 +423,8 @@ class Command(object, metaclass=CommandMeta): Command {self} has no defined `func()` - showing on-command variables: {variables} """ - self.caller.msg(string) - return - # a simple test command to show the available properties - string = "-" * 50 + string += "-" * 50 string += "\n|w%s|n - Command variables from evennia:\n" % self.key string += "-" * 50 string += "\nname of cmd (self.key): |w%s|n\n" % self.key @@ -438,6 +442,16 @@ Command {self} has no defined `func()` - showing on-command variables: self.caller.msg(string) + def func(self): + """ + This is the actual executing part of the command. It is + called directly after self.parse(). See the docstring of this + module for which object properties are available (beyond those + set in self.parse()) + + """ + self.get_command_info() + def get_extra_info(self, caller, **kwargs): """ Display some extra information that may help distinguish this @@ -484,12 +498,14 @@ Command {self} has no defined `func()` - showing on-command variables: Get the client screenwidth for the session using this command. Returns: - client width (int or None): The width (in characters) of the client window. None - if this command is run without a Session (such as by an NPC). + client width (int): The width (in characters) of the client window. """ if self.session: - return self.session.protocol_flags["SCREENWIDTH"][0] + return self.session.protocol_flags.get( + "SCREENWIDTH", {0: settings.CLIENT_DEFAULT_WIDTH} + )[0] + return settings.CLIENT_DEFAULT_WIDTH def styled_table(self, *args, **kwargs): """ diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 92002dcecd..1ab8e8714a 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -3,7 +3,7 @@ Building and world design commands """ import re from django.conf import settings -from django.db.models import Q +from django.db.models import Q, Min, Max from evennia.objects.models import ObjectDB from evennia.locks.lockhandler import LockException from evennia.commands.cmdhandler import get_and_merge_cmdsets @@ -13,11 +13,13 @@ from evennia.utils.utils import ( class_from_module, get_all_typeclasses, variable_from_module, + dbref, ) from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus from evennia.utils.ansi import raw +from evennia.prototypes.menus import _format_diff_text_and_options COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -1911,8 +1913,8 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): Usage: typeclass[/switch] [= typeclass.path] - type '' - parent '' + typeclass/prototype = prototype_key + typeclass/list/show [typeclass.path] swap - this is a shorthand for using /force/reset flags. update - this is a shorthand for using the /force/reload flag. @@ -1929,9 +1931,12 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): list - show available typeclasses. Only typeclasses in modules actually imported or used from somewhere in the code will show up here (those typeclasses are still available if you know the path) + prototype - clean and overwrite the object with the specified + prototype key - effectively making a whole new object. Example: type button = examples.red_button.RedButton + type/prototype button=a red button If the typeclass_path is not given, the current object's typeclass is assumed. @@ -1953,7 +1958,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): key = "typeclass" aliases = ["type", "parent", "swap", "update"] - switch_options = ("show", "examine", "update", "reset", "force", "list") + switch_options = ("show", "examine", "update", "reset", "force", "list", "prototype") locks = "cmd:perm(typeclass) or perm(Builder)" help_category = "Building" @@ -2037,6 +2042,27 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): new_typeclass = self.rhs or obj.path + prototype = None + if "prototype" in self.switches: + key = self.rhs + prototype = protlib.search_prototype(key=key) + if len(prototype) > 1: + caller.msg( + "More than one match for {}:\n{}".format( + key, "\n".join(proto.get("prototype_key", "") for proto in prototype) + ) + ) + return + elif prototype: + # one match + prototype = prototype[0] + else: + # no match + caller.msg("No prototype '{}' was found.".format(key)) + return + new_typeclass = prototype["typeclass"] + self.switches.append("force") + if "show" in self.switches or "examine" in self.switches: string = "%s's current typeclass is %s." % (obj.name, obj.__class__) caller.msg(string) @@ -2069,11 +2095,34 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): hooks = "at_object_creation" if update else "all" old_typeclass_path = obj.typeclass_path + # special prompt for the user in cases where we want + # to confirm changes. + if "prototype" in self.switches: + diff, _ = spawner.prototype_diff_from_object(prototype, obj) + txt, options = _format_diff_text_and_options(diff, objects=[obj]) + prompt = ( + "Applying prototype '%s' over '%s' will cause the follow changes:\n%s\n" + % (prototype["key"], obj.name, "\n".join(txt)) + ) + if not reset: + prompt += "\n|yWARNING:|n Use the /reset switch to apply the prototype over a blank state." + prompt += "\nAre you sure you want to apply these changes [yes]/no?" + answer = yield (prompt) + if answer and answer in ("no", "n"): + caller.msg("Canceled: No changes were applied.") + return + # we let this raise exception if needed obj.swap_typeclass( new_typeclass, clean_attributes=reset, clean_cmdsets=reset, run_start_hooks=hooks ) + if "prototype" in self.switches: + modified = spawner.batch_update_objects_with_prototype(prototype, objects=[obj]) + prototype_success = modified > 0 + if not prototype_success: + caller.msg("Prototype %s failed to apply." % prototype["key"]) + if is_same: string = "%s updated its existing typeclass (%s).\n" % (obj.name, obj.path) else: @@ -2090,6 +2139,11 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): string += " All old attributes where deleted before the swap." else: string += " Attributes set before swap were not removed." + if "prototype" in self.switches and prototype_success: + string += ( + " Prototype '%s' was successfully applied over the object type." + % prototype["key"] + ) caller.msg(string) @@ -2641,7 +2695,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): caller = self.caller switches = self.switches - if not self.args: + if not self.args or (not self.lhs and not self.rhs): caller.msg("Usage: find [= low [-high]]") return @@ -2649,18 +2703,46 @@ class CmdFind(COMMAND_DEFAULT_CLASS): switches.append("loc") searchstring = self.lhs - low, high = 1, ObjectDB.objects.all().order_by("-id")[0].id + + try: + # Try grabbing the actual min/max id values by database aggregation + qs = ObjectDB.objects.values("id").aggregate(low=Min("id"), high=Max("id")) + low, high = sorted(qs.values()) + if not (low and high): + raise ValueError( + f"{self.__class__.__name__}: Min and max ID not returned by aggregation; falling back to queryset slicing." + ) + except Exception as e: + logger.log_trace(e) + # If that doesn't work for some reason (empty DB?), guess the lower + # bound and do a less-efficient query to find the upper. + low, high = 1, ObjectDB.objects.all().order_by("-id").first().id + if self.rhs: - if "-" in self.rhs: - # also support low-high syntax - limlist = [part.lstrip("#").strip() for part in self.rhs.split("-", 1)] - else: - # otherwise split by space - limlist = [part.lstrip("#") for part in self.rhs.split(None, 1)] - if limlist and limlist[0].isdigit(): - low = max(low, int(limlist[0])) - if len(limlist) > 1 and limlist[1].isdigit(): - high = min(high, int(limlist[1])) + try: + # Check that rhs is either a valid dbref or dbref range + bounds = tuple( + sorted(dbref(x, False) for x in re.split("[-\s]+", self.rhs.strip())) + ) + + # dbref() will return either a valid int or None + assert bounds + # None should not exist in the bounds list + assert None not in bounds + + low = bounds[0] + if len(bounds) > 1: + high = bounds[-1] + + except AssertionError: + caller.msg("Invalid dbref range provided (not a number).") + return + except IndexError as e: + logger.log_err( + f"{self.__class__.__name__}: Error parsing upper and lower bounds of query." + ) + logger.log_trace(e) + low = min(low, high) high = max(low, high) @@ -2672,7 +2754,6 @@ class CmdFind(COMMAND_DEFAULT_CLASS): restrictions = ", %s" % (", ".join(self.switches)) if is_dbref or is_account: - if is_dbref: # a dbref search result = caller.search(searchstring, global_search=True, quiet=True) @@ -2703,7 +2784,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): ) else: # Not an account/dbref search but a wider search; build a queryset. - # Searchs for key and aliases + # Searches for key and aliases if "exact" in switches: keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high) aliasquery = Q( @@ -2729,39 +2810,52 @@ class CmdFind(COMMAND_DEFAULT_CLASS): id__lte=high, ) - results = ObjectDB.objects.filter(keyquery | aliasquery).distinct() - nresults = results.count() + # Keep the initial queryset handy for later reuse + result_qs = ObjectDB.objects.filter(keyquery | aliasquery).distinct() + nresults = result_qs.count() - if nresults: - # convert result to typeclasses. - results = [result for result in results] - if "room" in switches: - results = [obj for obj in results if inherits_from(obj, ROOM_TYPECLASS)] - if "exit" in switches: - results = [obj for obj in results if inherits_from(obj, EXIT_TYPECLASS)] - if "char" in switches: - results = [obj for obj in results if inherits_from(obj, CHAR_TYPECLASS)] - nresults = len(results) + # Use iterator to minimize memory ballooning on large result sets + results = result_qs.iterator() + + # Check and see if type filtering was requested; skip it if not + if any(x in switches for x in ("room", "exit", "char")): + obj_ids = set() + for obj in results: + if ( + ("room" in switches and inherits_from(obj, ROOM_TYPECLASS)) + or ("exit" in switches and inherits_from(obj, EXIT_TYPECLASS)) + or ("char" in switches and inherits_from(obj, CHAR_TYPECLASS)) + ): + obj_ids.add(obj.id) + + # Filter previous queryset instead of requesting another + filtered_qs = result_qs.filter(id__in=obj_ids).distinct() + nresults = filtered_qs.count() + + # Use iterator again to minimize memory ballooning + results = filtered_qs.iterator() # still results after type filtering? if nresults: if nresults > 1: - string = "|w%i Matches|n(#%i-#%i%s):" % (nresults, low, high, restrictions) - for res in results: - string += "\n |g%s - %s|n" % (res.get_display_name(caller), res.path) + header = f"{nresults} Matches" else: - string = "|wOne Match|n(#%i-#%i%s):" % (low, high, restrictions) - string += "\n |g%s - %s|n" % ( - results[0].get_display_name(caller), - results[0].path, - ) - if "loc" in self.switches and nresults == 1 and results[0].location: - string += " (|wlocation|n: |g{}|n)".format( - results[0].location.get_display_name(caller) - ) + header = "One Match" + + string = f"|w{header}|n(#{low}-#{high}{restrictions}):" + res = None + for res in results: + string += f"\n |g{res.get_display_name(caller)} - {res.path}|n" + if ( + "loc" in self.switches + and nresults == 1 + and res + and getattr(res, "location", None) + ): + string += f" (|wlocation|n: |g{res.location.get_display_name(caller)}|n)" else: - string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions) - string += "\n |RNo matches found for '%s'|n" % searchstring + string = f"|wNo Matches|n(#{low}-#{high}{restrictions}):" + string += f"\n |RNo matches found for '{searchstring}'|n" # send result caller.msg(string.strip()) @@ -2791,8 +2885,8 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS): reference. A puppeted object cannot be moved to None. loc - teleport object to the target's location instead of its contents - Teleports an object somewhere. If no object is given, you yourself - is teleported to the target location. + Teleports an object somewhere. If no object is given, you yourself are + teleported to the target location. """ key = "tel" @@ -2957,7 +3051,8 @@ class CmdScript(COMMAND_DEFAULT_CLASS): ok = obj.scripts.add(self.rhs, autostart=True) if not ok: result.append( - "\nScript %s could not be added and/or started on %s." + "\nScript %s could not be added and/or started on %s " + "(or it started and immediately shut down)." % (self.rhs, obj.get_display_name(caller)) ) else: @@ -2988,7 +3083,8 @@ class CmdScript(COMMAND_DEFAULT_CLASS): else: result = ["Script started successfully."] break - caller.msg("".join(result).strip()) + + EvMore(caller, "".join(result).strip()) class CmdTag(COMMAND_DEFAULT_CLASS): diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index afcfb4f964..9e1b45c2c6 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -79,7 +79,7 @@ class CmdHelp(Command): evmore.msg(self.caller, text, session=self.session) return - self.msg((text, {"type": "help"})) + self.msg(text=(text, {"type": "help"})) @staticmethod def format_help_entry(title, help_text, aliases=None, suggested=None): @@ -376,7 +376,7 @@ class CmdSetHelp(COMMAND_DEFAULT_CLASS): self.msg("You have to define a topic!") return topicstrlist = topicstr.split(";") - topicstr, aliases = topicstrlist[0], topicstrlist[1:] if len(topicstr) > 1 else [] + topicstr, aliases = (topicstrlist[0], topicstrlist[1:] if len(topicstr) > 1 else []) aliastxt = ("(aliases: %s)" % ", ".join(aliases)) if aliases else "" old_entry = None diff --git a/evennia/commands/default/muxcommand.py b/evennia/commands/default/muxcommand.py index d288f5f980..5e0967a130 100644 --- a/evennia/commands/default/muxcommand.py +++ b/evennia/commands/default/muxcommand.py @@ -202,11 +202,15 @@ class MuxCommand(Command): else: self.character = None - def func(self): + def get_command_info(self): """ - This is the hook function that actually does all the work. It is called - by the cmdhandler right after self.parser() finishes, and so has access - to all the variables defined therein. + Update of parent class's get_command_info() for MuxCommand. + """ + self.get_command_info() + + def get_command_info(self): + """ + Update of parent class's get_command_info() for MuxCommand. """ variables = "\n".join( " |w{}|n ({}): {}".format(key, type(val), val) for key, val in self.__dict__.items() @@ -245,6 +249,14 @@ Command {self} has no defined `func()` - showing on-command variables: No child string += "-" * 50 self.caller.msg(string) + def func(self): + """ + This is the hook function that actually does all the work. It is called + by the cmdhandler right after self.parser() finishes, and so has access + to all the variables defined therein. + """ + self.get_command_info() + class MuxAccountCommand(MuxCommand): """ diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index f89018dce0..b6ff6ba25c 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -23,6 +23,7 @@ from evennia.accounts.models import AccountDB from evennia.utils import logger, utils, gametime, create, search from evennia.utils.eveditor import EvEditor from evennia.utils.evtable import EvTable +from evennia.utils.evmore import EvMore from evennia.utils.utils import crop, class_from_module COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -232,6 +233,10 @@ def _run_code_snippet( if ret is None: return + elif isinstance(ret, tuple): + # we must convert here to allow msg to pass it (a tuple is confused + # with a outputfunc structure) + ret = str(ret) for session in sessions: try: @@ -284,8 +289,6 @@ class EvenniaPythonConsole(code.InteractiveConsole): result = None try: result = super().push(line) - except SystemExit: - pass finally: sys.stdout = old_stdout sys.stderr = old_stderr @@ -301,6 +304,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS): py/edit py/time py/clientraw + py/noecho Switches: time - output an approximate execution time for @@ -308,6 +312,8 @@ class CmdPy(COMMAND_DEFAULT_CLASS): clientraw - turn off all client-specific escaping. Note that this may lead to different output depending on prototocol (such as angular brackets being parsed as HTML in the webclient but not in telnet clients) + noecho - in Python console mode, turn off the input echo (e.g. if your client + does this for you already) Without argument, open a Python console in-game. This is a full console, accepting multi-line Python code for testing and debugging. Type `exit()` to @@ -339,7 +345,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS): key = "py" aliases = ["!"] - switch_options = ("time", "edit", "clientraw") + switch_options = ("time", "edit", "clientraw", "noecho") locks = "cmd:perm(py) or perm(Developer)" help_category = "System" @@ -349,6 +355,8 @@ class CmdPy(COMMAND_DEFAULT_CLASS): caller = self.caller pycode = self.args + noecho = "noecho" in self.switches + if "edit" in self.switches: caller.db._py_measure_time = "time" in self.switches caller.db._py_clientraw = "clientraw" in self.switches @@ -367,15 +375,26 @@ class CmdPy(COMMAND_DEFAULT_CLASS): # Run in interactive mode console = EvenniaPythonConsole(self.caller) banner = ( - f"|gPython {sys.version} on {sys.platform}\n" - "Evennia interactive console mode - type 'exit()' to leave.|n" + "|gEvennia Interactive Python mode{echomode}\n" + "Python {version} on {platform}".format( + echomode=" (no echoing of prompts)" if noecho else "", + version=sys.version, + platform=sys.platform, + ) ) self.msg(banner) line = "" - prompt = ">>>" + main_prompt = "|x[py mode - quit() to exit]|n" + prompt = main_prompt while line.lower() not in ("exit", "exit()"): - line = yield (prompt) - prompt = "..." if console.push(line) else ">>>" + try: + line = yield (prompt) + if noecho: + prompt = "..." if console.push(line) else main_prompt + else: + prompt = line if console.push(line) else f"{line}\n{main_prompt}" + except SystemExit: + break self.msg("|gClosing the Python console.|n") return @@ -409,16 +428,19 @@ def format_script_list(scripts): align="r", border="tablecols", ) + for script in scripts: + nextrep = script.time_until_next_repeat() if nextrep is None: - nextrep = "PAUS" if script.db._paused_time else "--" + nextrep = "PAUSED" if script.db._paused_time else "--" else: nextrep = "%ss" % nextrep maxrepeat = script.repeats + remaining = script.remaining_repeats() or 0 if maxrepeat: - rept = "%i/%i" % (maxrepeat - script.remaining_repeats(), maxrepeat) + rept = "%i/%i" % (maxrepeat - remaining, maxrepeat) else: rept = "-/-" @@ -433,6 +455,7 @@ def format_script_list(scripts): script.typeclass_path.rsplit(".", 1)[-1], crop(script.desc, width=20), ) + return "%s" % table @@ -527,7 +550,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): else: # No stopping or validation. We just want to view things. string = format_script_list(scripts) - caller.msg(string) + EvMore(caller, string) class CmdObjects(COMMAND_DEFAULT_CLASS): @@ -592,9 +615,13 @@ class CmdObjects(COMMAND_DEFAULT_CLASS): "|wtypeclass|n", "|wcount|n", "|w%|n", border="table", align="l" ) typetable.align = "l" - dbtotals = ObjectDB.objects.object_totals() - for path, count in dbtotals.items(): - typetable.add_row(path, count, "%.2f" % ((float(count) / nobjs) * 100)) + dbtotals = ObjectDB.objects.get_typeclass_totals() + for stat in dbtotals: + typetable.add_row( + stat.get("typeclass", ""), + stat.get("count", -1), + "%.2f" % stat.get("percent", -1), + ) # last N table objs = ObjectDB.objects.all().order_by("db_date_created")[max(0, nobjs - nlim) :] diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 38d5646e04..d41a1ca4ca 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -19,7 +19,7 @@ from anything import Anything from django.conf import settings from mock import Mock, mock -from evennia import DefaultRoom, DefaultExit +from evennia import DefaultRoom, DefaultExit, ObjectDB from evennia.commands.default.cmdset_character import CharacterCmdSet from evennia.utils.test_resources import EvenniaTest from evennia.commands.default import ( @@ -991,6 +991,34 @@ class TestBuilding(CommandTest): "All object creation hooks were run. All old attributes where deleted before the swap.", ) + from evennia.prototypes.prototypes import homogenize_prototype + + test_prototype = [ + homogenize_prototype( + { + "prototype_key": "testkey", + "prototype_tags": [], + "typeclass": "typeclasses.objects.Object", + "key": "replaced_obj", + "attrs": [("foo", "bar", None, ""), ("desc", "protdesc", None, "")], + } + ) + ] + with mock.patch( + "evennia.commands.default.building.protlib.search_prototype", + new=mock.MagicMock(return_value=test_prototype), + ) as mprot: + self.call( + building.CmdTypeclass(), + "/prototype Obj=testkey", + "replaced_obj changed typeclass from " + "evennia.objects.objects.DefaultObject to " + "typeclasses.objects.Object.\nAll object creation hooks were " + "run. Attributes set before swap were not removed. Prototype " + "'replaced_obj' was successfully applied over the object type.", + ) + assert self.obj1.db.desc == "protdesc" + def test_lock(self): self.call(building.CmdLock(), "", "Usage: ") self.call(building.CmdLock(), "Obj = test:all()", "Added lock 'test:all()' to Obj.") @@ -1038,11 +1066,41 @@ class TestBuilding(CommandTest): self.call(building.CmdFind(), self.char1.dbref, "Exact dbref match") self.call(building.CmdFind(), "*TestAccount", "Match") - self.call(building.CmdFind(), "/char Obj") - self.call(building.CmdFind(), "/room Obj") - self.call(building.CmdFind(), "/exit Obj") + self.call(building.CmdFind(), "/char Obj", "No Matches") + self.call(building.CmdFind(), "/room Obj", "No Matches") + self.call(building.CmdFind(), "/exit Obj", "No Matches") self.call(building.CmdFind(), "/exact Obj", "One Match") + # Test multitype filtering + with mock.patch( + "evennia.commands.default.building.CHAR_TYPECLASS", + "evennia.objects.objects.DefaultCharacter", + ): + self.call(building.CmdFind(), "/char/room Obj", "No Matches") + self.call(building.CmdFind(), "/char/room/exit Char", "2 Matches") + self.call(building.CmdFind(), "/char/room/exit/startswith Cha", "2 Matches") + + # Test null search + self.call(building.CmdFind(), "=", "Usage: ") + + # Test bogus dbref range with no search term + self.call(building.CmdFind(), "= obj", "Invalid dbref range provided (not a number).") + self.call(building.CmdFind(), "= #1a", "Invalid dbref range provided (not a number).") + + # Test valid dbref ranges with no search term + id1 = self.obj1.id + id2 = self.obj2.id + maxid = ObjectDB.objects.latest("id").id + maxdiff = maxid - id1 + 1 + mdiff = id2 - id1 + 1 + + self.call(building.CmdFind(), f"=#{id1}", f"{maxdiff} Matches(#{id1}-#{maxid}") + self.call(building.CmdFind(), f"={id1}-{id2}", f"{mdiff} Matches(#{id1}-#{id2}):") + self.call(building.CmdFind(), f"={id1} - {id2}", f"{mdiff} Matches(#{id1}-#{id2}):") + self.call(building.CmdFind(), f"={id1}- #{id2}", f"{mdiff} Matches(#{id1}-#{id2}):") + self.call(building.CmdFind(), f"={id1}-#{id2}", f"{mdiff} Matches(#{id1}-#{id2}):") + self.call(building.CmdFind(), f"=#{id1}-{id2}", f"{mdiff} Matches(#{id1}-#{id2}):") + def test_script(self): self.call(building.CmdScript(), "Obj = ", "No scripts defined on Obj") self.call( diff --git a/evennia/comms/channelhandler.py b/evennia/comms/channelhandler.py index e33a95ed5a..98e28786a0 100644 --- a/evennia/comms/channelhandler.py +++ b/evennia/comms/channelhandler.py @@ -27,8 +27,12 @@ from django.conf import settings from evennia.commands import cmdset, command from evennia.utils.logger import tail_log_file from evennia.utils.utils import class_from_module -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ +# we must late-import these since any overloads are likely to +# themselves be using these classes leading to a circular import. + +_CHANNEL_HANDLER_CLASS = None _CHANNEL_COMMAND_CLASS = None _CHANNELDB = None @@ -314,5 +318,6 @@ class ChannelHandler(object): return chan_cmdset -CHANNEL_HANDLER = ChannelHandler() +# set up the singleton +CHANNEL_HANDLER = class_from_module(settings.CHANNEL_HANDLER_CLASS)() CHANNELHANDLER = CHANNEL_HANDLER # legacy diff --git a/evennia/comms/managers.py b/evennia/comms/managers.py index 2a2589dd47..9b950ec129 100644 --- a/evennia/comms/managers.py +++ b/evennia/comms/managers.py @@ -8,6 +8,7 @@ Comm system components. from django.db.models import Q from evennia.typeclasses.managers import TypedObjectManager, TypeclassManager from evennia.utils import logger +from evennia.utils.utils import dbref _GA = object.__getattribute__ _AccountDB = None @@ -31,32 +32,6 @@ class CommError(Exception): # -def dbref(inp, reqhash=True): - """ - Valid forms of dbref (database reference number) are either a - string '#N' or an integer N. - - Args: - inp (int or str): A possible dbref to check syntactically. - reqhash (bool): Require an initial hash `#` to accept. - - Returns: - is_dbref (int or None): The dbref integer part if a valid - dbref, otherwise `None`. - - """ - if reqhash and not (isinstance(inp, str) and inp.startswith("#")): - return None - if isinstance(inp, str): - inp = inp.lstrip("#") - try: - if int(inp) < 0: - return None - except Exception: - return None - return inp - - def identify_object(inp): """ Helper function. Identifies if an object is an account or an object; diff --git a/evennia/comms/models.py b/evennia/comms/models.py index 8026a664ce..e22e3a5670 100644 --- a/evennia/comms/models.py +++ b/evennia/comms/models.py @@ -400,9 +400,11 @@ class Msg(SharedMemoryModel): def __str__(self): "This handles what is shown when e.g. printing the message" - senders = ",".join(obj.key for obj in self.senders) + senders = ",".join(getattr(obj, "key", str(obj)) for obj in self.senders) + receivers = ",".join( - ["[%s]" % obj.key for obj in self.channels] + [obj.key for obj in self.receivers] + ["[%s]" % getattr(obj, "key", str(obj)) for obj in self.channels] + + [getattr(obj, "key", str(obj)) for obj in self.receivers] ) return "%s->%s: %s" % (senders, receivers, crop(self.message, width=40)) diff --git a/evennia/comms/tests.py b/evennia/comms/tests.py index 73e03e6aee..b1ffe4beaf 100644 --- a/evennia/comms/tests.py +++ b/evennia/comms/tests.py @@ -1,5 +1,6 @@ from evennia.utils.test_resources import EvenniaTest from evennia import DefaultChannel +from evennia.utils.create import create_message class ObjectCreationTest(EvenniaTest): @@ -10,3 +11,8 @@ class ObjectCreationTest(EvenniaTest): self.assertTrue(obj, errors) self.assertFalse(errors, errors) self.assertEqual(description, obj.db.desc) + + def test_message_create(self): + msg = create_message("peewee herman", "heh-heh!", header="mail time!") + self.assertTrue(msg) + self.assertEqual(str(msg), "peewee herman->: heh-heh!") diff --git a/evennia/contrib/custom_gametime.py b/evennia/contrib/custom_gametime.py index e1065add38..b94a308ced 100644 --- a/evennia/contrib/custom_gametime.py +++ b/evennia/contrib/custom_gametime.py @@ -298,6 +298,9 @@ class GametimeScript(DefaultScript): def at_repeat(self): """Call the callback and reset interval.""" + + from evennia.utils.utils import calledby + callback = self.db.callback if callback: callback() diff --git a/evennia/contrib/gendersub.py b/evennia/contrib/gendersub.py index 09d8a2a006..627a4e3268 100644 --- a/evennia/contrib/gendersub.py +++ b/evennia/contrib/gendersub.py @@ -8,26 +8,39 @@ insert custom markers in their text to indicate gender-aware messaging. It relies on a modified msg() and is meant as an inspiration and starting point to how to do stuff like this. -When in use, all messages being sent to the character will make use of -the character's gender, for example the echo +An object can have the following genders: + - male (he/his) + - female (her/hers) + - neutral (it/its) + - ambiguous (they/them/their/theirs) + +When in use, messages can contain special tags to indicate pronouns gendered +based on the one being addressed. Capitalization will be retained. + +- `|s`, `|S`: Subjective form: he, she, it, He, She, It, They +- `|o`, `|O`: Objective form: him, her, it, Him, Her, It, Them +- `|p`, `|P`: Possessive form: his, her, its, His, Her, Its, Their +- `|a`, `|A`: Absolute Possessive form: his, hers, its, His, Hers, Its, Theirs + +For example, ``` char.msg("%s falls on |p face with a thud." % char.key) +"Tom falls on his face with a thud" ``` -will result in "Tom falls on his|her|its|their face with a thud" -depending on the gender of the object being messaged. Default gender -is "ambiguous" (they). +The default gender is "ambiguous" (they/them/their/theirs). To use, have DefaultCharacter inherit from this, or change setting.DEFAULT_CHARACTER to point to this class. -The `@gender` command needs to be added to the default cmdset before -it becomes available. +The `@gender` command is used to set the gender. It needs to be added to the +default cmdset before it becomes available. """ import re +from evennia.utils import logger from evennia import DefaultCharacter from evennia import Command @@ -114,7 +127,10 @@ class GenderCharacter(DefaultCharacter): gender-aware markers in output. Args: - text (str, optional): The message to send + text (str or tuple, optional): The message to send. This + is treated internally like any send-command, so its + value can be a tuple if sending multiple arguments to + the `text` oob command. from_obj (obj, optional): object that is sending. If given, at_msg_send will be called session (Session or list, optional): session or list of @@ -125,9 +141,13 @@ class GenderCharacter(DefaultCharacter): All extra kwargs will be passed on to the protocol. """ - # pre-process the text before continuing try: - text = _RE_GENDER_PRONOUN.sub(self._get_pronoun, text) + if text and isinstance(text, tuple): + text = (self._RE_GENDER_PRONOUN.sub(self._get_pronoun, text[0]), *text[1:]) + else: + text = self._RE_GENDER_PRONOUN.sub(self._get_pronoun, text) except TypeError: pass + except Exception as e: + logger.log_trace(e) super().msg(text, from_obj=from_obj, session=session, **kwargs) diff --git a/evennia/contrib/mail.py b/evennia/contrib/mail.py index 490fba81f5..2f458651f0 100644 --- a/evennia/contrib/mail.py +++ b/evennia/contrib/mail.py @@ -238,7 +238,7 @@ class CmdMail(default_cmds.MuxAccountCommand): else: raise IndexError except IndexError: - self.caller.msg("Message does not exixt.") + self.caller.msg("Message does not exist.") except ValueError: self.caller.msg("Usage: @mail/forward =<#>[/]") elif "reply" in self.switches or "rep" in self.switches: diff --git a/evennia/contrib/rplanguage.py b/evennia/contrib/rplanguage.py index 6fae496960..a4dc931674 100644 --- a/evennia/contrib/rplanguage.py +++ b/evennia/contrib/rplanguage.py @@ -331,7 +331,7 @@ class LanguageHandler(DefaultScript): # find out what preceeded this word wpos = match.start() preceeding = match.string[:wpos].strip() - start_sentence = preceeding.endswith(".") or not preceeding + start_sentence = preceeding.endswith((".", "!", "?")) or not preceeding # make up translation on the fly. Length can # vary from un-translated word. diff --git a/evennia/contrib/rpsystem.py b/evennia/contrib/rpsystem.py index e398384411..4f68f0dc44 100644 --- a/evennia/contrib/rpsystem.py +++ b/evennia/contrib/rpsystem.py @@ -798,6 +798,16 @@ class RecogHandler(object): # recog_mask log not passed, disable recog return obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key + def all(self): + """ + Get a mapping of the recogs stored in handler. + + Returns: + recogs (dict): A mapping of {recog: obj} stored in handler. + + """ + return {self.obj2recog[obj]: obj for obj in self.obj2recog.keys()} + def remove(self, obj): """ Clear recog for a given object. @@ -896,10 +906,9 @@ class CmdSay(RPCommand): # replaces standard say caller.msg("Say what?") return - # calling the speech hook on the location - speech = caller.location.at_before_say(self.args) + # calling the speech modifying hook + speech = caller.at_before_say(self.args) # preparing the speech with sdesc/speech parsing. - speech = '/me says, "{speech}"'.format(speech=speech) targets = self.caller.location.contents send_emote(self.caller, targets, speech, anonymous_add=None) @@ -932,6 +941,9 @@ class CmdSdesc(RPCommand): # set/look at own sdesc except SdescError as err: caller.msg(err) return + except AttributeError: + caller.msg(f"Cannot set sdesc on {caller.key}.") + return caller.msg("%s's sdesc was set to '%s'." % (caller.key, sdesc)) @@ -1041,6 +1053,7 @@ class CmdRecog(RPCommand): # assign personal alias to object in room Recognize another person in the same room. Usage: + recog recog sdesc as alias forget alias @@ -1048,8 +1061,8 @@ class CmdRecog(RPCommand): # assign personal alias to object in room recog tall man as Griatch forget griatch - This will assign a personal alias for a person, or - forget said alias. + This will assign a personal alias for a person, or forget said alias. + Using the command without arguments will list all current recogs. """ @@ -1058,6 +1071,7 @@ class CmdRecog(RPCommand): # assign personal alias to object in room def parse(self): "Parse for the sdesc as alias structure" + self.sdesc, self.alias = "", "" if " as " in self.args: self.sdesc, self.alias = [part.strip() for part in self.args.split(" as ", 2)] elif self.args: @@ -1070,22 +1084,47 @@ class CmdRecog(RPCommand): # assign personal alias to object in room def func(self): "Assign the recog" caller = self.caller - if not self.args: - caller.msg("Usage: recog as or forget ") - return - sdesc = self.sdesc alias = self.alias.rstrip(".?!") + sdesc = self.sdesc + + recog_mode = self.cmdstring != "forget" and alias and sdesc + forget_mode = self.cmdstring == "forget" and sdesc + list_mode = not self.args + + if not (recog_mode or forget_mode or list_mode): + caller.msg("Usage: recog, recog as or forget ") + return + + if list_mode: + # list all previously set recogs + all_recogs = caller.recog.all() + if not all_recogs: + caller.msg( + "You recognize no-one. " "(Use 'recog as ' to recognize people." + ) + else: + # note that we don't skip those failing enable_recog lock here, + # because that would actually reveal more than we want. + lst = "\n".join( + " {} ({})".format(key, obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key) + for key, obj in all_recogs.items() + ) + caller.msg( + f"Currently recognized (use 'recog as ' to add " + f"new and 'forget ' to remove):\n{lst}" + ) + return + prefixed_sdesc = sdesc if sdesc.startswith(_PREFIX) else _PREFIX + sdesc candidates = caller.location.contents matches = parse_sdescs_and_recogs(caller, candidates, prefixed_sdesc, search_mode=True) nmatches = len(matches) - # handle 0, 1 and >1 matches + # handle 0 and >1 matches if nmatches == 0: caller.msg(_EMOTE_NOMATCH_ERROR.format(ref=sdesc)) elif nmatches > 1: reflist = [ - "%s%s%s (%s%s)" - % ( + "{}{}{} ({}{})".format( inum + 1, _NUM_SEP, _RE_PREFIX.sub("", sdesc), @@ -1095,17 +1134,20 @@ class CmdRecog(RPCommand): # assign personal alias to object in room for inum, obj in enumerate(matches) ] caller.msg(_EMOTE_MULTIMATCH_ERROR.format(ref=sdesc, reflist="\n ".join(reflist))) + else: + # one single match obj = matches[0] if not obj.access(self.obj, "enable_recog", default=True): # don't apply recog if object doesn't allow it (e.g. by being masked). - caller.msg("Can't recognize someone who is masked.") + caller.msg("It's impossible to recognize them.") return - if self.cmdstring == "forget": + if forget_mode: # remove existing recog caller.recog.remove(obj) - caller.msg("%s will now know only '%s'." % (caller.key, obj.recog.get(obj))) + caller.msg("%s will now know them only as '%s'." % (caller.key, obj.recog.get(obj))) else: + # set recog sdesc = obj.sdesc.get() if hasattr(obj, "sdesc") else obj.key try: alias = caller.recog.add(obj, alias) @@ -1509,6 +1551,20 @@ class ContribRPCharacter(DefaultCharacter, ContribRPObject): # initializing sdesc self.sdesc.add("A normal person") + def at_before_say(self, message, **kwargs): + """ + Called before the object says or whispers anything, return modified message. + + Args: + message (str): The suggested say/whisper text spoken by self. + Kwargs: + whisper (bool): If True, this is a whisper rather than a say. + + """ + if kwargs.get("whisper"): + return f'/me whispers "{message}"' + return f'/me says, "{message}"' + def process_sdesc(self, sdesc, obj, **kwargs): """ Allows to customize how your sdesc is displayed (primarily by diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index ed025b666c..d394cd5a3b 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -169,6 +169,8 @@ class TestRPSystem(EvenniaTest): self.speaker.recog.remove(self.receiver1) self.assertEqual(self.speaker.recog.get(self.receiver1), sdesc1) + self.assertEqual(self.speaker.recog.all(), {"Mr Receiver2": self.receiver2}) + def test_parse_language(self): self.assertEqual( rpsystem.parse_language(self.speaker, emote), @@ -233,6 +235,49 @@ class TestRPSystem(EvenniaTest): self.assertEqual(self.speaker.search("colliding"), self.receiver2) +class TestRPSystemCommands(CommandTest): + def setUp(self): + super().setUp() + self.char1.swap_typeclass(rpsystem.ContribRPCharacter) + self.char2.swap_typeclass(rpsystem.ContribRPCharacter) + + def test_commands(self): + + self.call( + rpsystem.CmdSdesc(), "Foobar Character", "Char's sdesc was set to 'Foobar Character'." + ) + self.call( + rpsystem.CmdSdesc(), + "BarFoo Character", + "Char2's sdesc was set to 'BarFoo Character'.", + caller=self.char2, + ) + self.call(rpsystem.CmdSay(), "Hello!", 'Char says, "Hello!"') + self.call(rpsystem.CmdEmote(), "/me smiles to /barfoo.", "Char smiles to BarFoo Character") + self.call( + rpsystem.CmdPose(), + "stands by the bar", + "Pose will read 'Foobar Character stands by the bar.'.", + ) + self.call( + rpsystem.CmdRecog(), + "barfoo as friend", + "Char will now remember BarFoo Character as friend.", + ) + self.call( + rpsystem.CmdRecog(), + "", + "Currently recognized (use 'recog as ' to add new " + "and 'forget ' to remove):\n friend (BarFoo Character)", + ) + self.call( + rpsystem.CmdRecog(), + "friend", + "Char will now know them only as 'BarFoo Character'", + cmdstring="forget", + ) + + # Testing of ExtendedRoom contrib from django.conf import settings @@ -607,7 +652,7 @@ class TestWilderness(EvenniaTest): "west": (0, 1), "northwest": (0, 2), } - for direction, correct_loc in directions.items(): # Not compatible with Python 3 + for (direction, correct_loc) in directions.items(): # Not compatible with Python 3 new_loc = wilderness.get_new_coordinates(loc, direction) self.assertEqual(new_loc, correct_loc, direction) diff --git a/evennia/contrib/turnbattle/tb_basic.py b/evennia/contrib/turnbattle/tb_basic.py index da8915ec86..35c5bc130f 100644 --- a/evennia/contrib/turnbattle/tb_basic.py +++ b/evennia/contrib/turnbattle/tb_basic.py @@ -441,11 +441,11 @@ class TBBasicTurnHandler(DefaultScript): """ combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. character.db.combat_actionsleft = ( - 0 - ) # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + ) character.db.combat_turnhandler = ( - self - ) # Add a reference to this turn handler script to the character + self # Add a reference to this turn handler script to the character + ) character.db.combat_lastaction = "null" # Track last action taken in combat def start_turn(self, character): diff --git a/evennia/contrib/turnbattle/tb_equip.py b/evennia/contrib/turnbattle/tb_equip.py index e516e64ac1..a789f4ccf1 100644 --- a/evennia/contrib/turnbattle/tb_equip.py +++ b/evennia/contrib/turnbattle/tb_equip.py @@ -218,10 +218,10 @@ def apply_damage(defender, damage): def at_defeat(defeated): """ Announces the defeat of a fighter in combat. - + Args: defeated (obj): Fighter that's been defeated. - + Notes: All this does is announce a defeat message by default, but if you want anything else to happen to defeated fighters (like putting them @@ -438,11 +438,11 @@ class TBEquipTurnHandler(DefaultScript): """ combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. character.db.combat_actionsleft = ( - 0 - ) # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + ) character.db.combat_turnhandler = ( - self - ) # Add a reference to this turn handler script to the character + self # Add a reference to this turn handler script to the character + ) character.db.combat_lastaction = "null" # Track last action taken in combat def start_turn(self, character): @@ -553,8 +553,8 @@ class TBEWeapon(DefaultObject): self.db.damage_range = (15, 25) # Minimum and maximum damage on hit self.db.accuracy_bonus = 0 # Bonus to attack rolls (or penalty if negative) self.db.weapon_type_name = ( - "weapon" - ) # Single word for weapon - I.E. "dagger", "staff", "scimitar" + "weapon" # Single word for weapon - I.E. "dagger", "staff", "scimitar" + ) def at_drop(self, dropper): """ @@ -903,10 +903,10 @@ class CmdCombatHelp(CmdHelp): class CmdWield(Command): """ Wield a weapon you are carrying - + Usage: wield - + Select a weapon you are carrying to wield in combat. If you are already wielding another weapon, you will switch to the weapon you specify instead. Using this command in @@ -933,7 +933,7 @@ class CmdWield(Command): weapon = self.caller.search(self.args, candidates=self.caller.contents) if not weapon: return - if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEWeapon"): + if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEWeapon", exact=True): self.caller.msg("That's not a weapon!") # Remember to update the path to the weapon typeclass if you move this module! return @@ -955,10 +955,10 @@ class CmdWield(Command): class CmdUnwield(Command): """ Stop wielding a weapon. - + Usage: unwield - + After using this command, you will stop wielding any weapon you are currently wielding and become unarmed. """ @@ -986,12 +986,12 @@ class CmdUnwield(Command): class CmdDon(Command): """ Don armor that you are carrying - + Usage: don - + Select armor to wear in combat. You can't use this - command in the middle of a fight. Use the "doff" + command in the middle of a fight. Use the "doff" command to remove any armor you are wearing. """ @@ -1012,7 +1012,7 @@ class CmdDon(Command): armor = self.caller.search(self.args, candidates=self.caller.contents) if not armor: return - if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEArmor"): + if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEArmor", exact=True): self.caller.msg("That's not armor!") # Remember to update the path to the armor typeclass if you move this module! return @@ -1031,10 +1031,10 @@ class CmdDon(Command): class CmdDoff(Command): """ Stop wearing armor. - + Usage: doff - + After using this command, you will stop wearing any armor you are currently using and become unarmored. You can't use this command in combat. diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 512337478a..1e6fce59f7 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -718,11 +718,11 @@ class TBItemsTurnHandler(DefaultScript): """ combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. character.db.combat_actionsleft = ( - 0 - ) # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + ) character.db.combat_turnhandler = ( - self - ) # Add a reference to this turn handler script to the character + self # Add a reference to this turn handler script to the character + ) character.db.combat_lastaction = "null" # Track last action taken in combat def start_turn(self, character): diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index 8788166524..ab9f094d26 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -44,6 +44,12 @@ instead of the default: class Character(TBMagicCharacter): +Note: If your character already existed you need to also make sure +to re-run the creation hooks on it to set the needed Attributes. +Use `update self` to try on yourself or use py to call `at_object_creation()` +on all existing Characters. + + Next, import this module into your default_cmdsets.py module: from evennia.contrib.turnbattle import tb_magic @@ -199,10 +205,10 @@ def apply_damage(defender, damage): def at_defeat(defeated): """ Announces the defeat of a fighter in combat. - + Args: defeated (obj): Fighter that's been defeated. - + Notes: All this does is announce a defeat message by default, but if you want anything else to happen to defeated fighters (like putting them @@ -332,7 +338,7 @@ class TBMagicCharacter(DefaultCharacter): """ Called once, when this object is first created. This is the normal hook to overload for most object types. - + Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. @@ -464,11 +470,11 @@ class TBMagicTurnHandler(DefaultScript): """ combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. character.db.combat_actionsleft = ( - 0 - ) # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + ) character.db.combat_turnhandler = ( - self - ) # Add a reference to this turn handler script to the character + self # Add a reference to this turn handler script to the character + ) character.db.combat_lastaction = "null" # Track last action taken in combat def start_turn(self, character): @@ -731,26 +737,26 @@ class CmdDisengage(Command): class CmdLearnSpell(Command): """ Learn a magic spell. - + Usage: learnspell - + Adds a spell by name to your list of spells known. - + The following spells are provided as examples: - + |wmagic missile|n (3 MP): Fires three missiles that never miss. Can target up to three different enemies. - + |wflame shot|n (3 MP): Shoots a high-damage jet of flame at one target. - + |wcure wounds|n (5 MP): Heals damage on one target. - + |wmass cure wounds|n (10 MP): Like 'cure wounds', but can heal up to 5 targets at once. |wfull heal|n (12 MP): Heals one target back to full HP. - + |wcactus conjuration|n (2 MP): Creates a cactus. """ @@ -803,10 +809,10 @@ class CmdCast(MuxCommand): """ Cast a magic spell that you know, provided you have the MP to spend on its casting. - + Usage: cast [= , , etc...] - + Some spells can be cast on multiple targets, some can be cast on only yourself, and some don't need a target specified at all. Typing 'cast' by itself will give you a list of spells you know. @@ -818,7 +824,7 @@ class CmdCast(MuxCommand): def func(self): """ This performs the actual command. - + Note: This is a quite long command, since it has to cope with all the different circumstances in which you may or may not be able to cast a spell. None of the spell's effects are handled by the @@ -1123,7 +1129,7 @@ in the docstring for each function. def spell_healing(caster, spell_name, targets, cost, **kwargs): """ Spell that restores HP to a target or targets. - + kwargs: healing_range (tuple): Minimum and maximum amount healed to each target. (20, 40) by default. @@ -1156,7 +1162,7 @@ def spell_healing(caster, spell_name, targets, cost, **kwargs): def spell_attack(caster, spell_name, targets, cost, **kwargs): """ Spell that deals damage in combat. Similar to resolve_attack. - + kwargs: attack_name (tuple): Single and plural describing the sort of attack or projectile that strikes each enemy. @@ -1250,12 +1256,12 @@ def spell_attack(caster, spell_name, targets, cost, **kwargs): def spell_conjure(caster, spell_name, targets, cost, **kwargs): """ Spell that creates an object. - + kwargs: obj_key (str): Key of the created object. obj_desc (str): Desc of the created object. obj_typeclass (str): Typeclass path of the object. - + If you want to make more use of this particular spell funciton, you may want to modify it to use the spawner (in evennia.utils.spawner) instead of creating objects directly. @@ -1300,7 +1306,7 @@ parameters, some of which are required and others which are optional. Required values for spells: - cost (int): MP cost of casting the spell + cost (int): MP cost of casting the spell target (str): Valid targets for the spell. Can be any of: "none" - No target needed "self" - Self only @@ -1312,9 +1318,9 @@ Required values for spells: spellfunc (callable): Function that performs the action of the spell. Must take the following arguments: caster (obj), spell_name (str), targets (list), and cost (int), as well as **kwargs. - + Optional values for spells: - + combat_spell (bool): If the spell can be cast in combat. True by default. noncombat_spell (bool): If the spell can be cast out of combat. True by default. max_targets (int): Maximum number of objects that can be targeted by the spell. diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py index 1d0a297a10..c0eca41487 100644 --- a/evennia/contrib/turnbattle/tb_range.py +++ b/evennia/contrib/turnbattle/tb_range.py @@ -674,11 +674,11 @@ class TBRangeTurnHandler(DefaultScript): """ combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. character.db.combat_actionsleft = ( - 0 - ) # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + ) character.db.combat_turnhandler = ( - self - ) # Add a reference to this turn handler script to the character + self # Add a reference to this turn handler script to the character + ) character.db.combat_lastaction = "null" # Track last action taken in combat def start_turn(self, character): diff --git a/evennia/game_template/server/conf/web_plugins.py b/evennia/game_template/server/conf/web_plugins.py index 4050a82664..ec11ad7c6a 100644 --- a/evennia/game_template/server/conf/web_plugins.py +++ b/evennia/game_template/server/conf/web_plugins.py @@ -26,3 +26,16 @@ def at_webserver_root_creation(web_root): """ return web_root + + +def at_webproxy_root_creation(web_root): + """ + This function can modify the portal proxy service. + Args: + web_root (evennia.server.webserver.Website): The Evennia + Website application. Use .putChild() to add new + subdomains that are Portal-accessible over TCP; + primarily for new protocol development, but suitable + for other shenanigans. + """ + return web_root diff --git a/evennia/locks/lockfuncs.py b/evennia/locks/lockfuncs.py index a1f161696b..eda4c2d733 100644 --- a/evennia/locks/lockfuncs.py +++ b/evennia/locks/lockfuncs.py @@ -547,11 +547,39 @@ def inside(accessing_obj, accessed_obj, *args, **kwargs): Usage: inside() - Only true if accessing_obj is "inside" accessed_obj + True if accessing_obj is 'inside' accessing_obj. Note that this only checks + one level down. So if if the lock is on a room, you will pass but not your + inventory (since their location is you, not the locked object). If you + want also nested objects to pass the lock, use the `insiderecursive` + lockfunc. """ return accessing_obj.location == accessed_obj +def inside_rec(accessing_obj, accessed_obj, *args, **kwargs): + """ + Usage: + inside_rec() + + True if accessing_obj is inside the accessed obj, at up to 10 levels + of recursion (so if this lock is on a room, then an object inside a box + in your inventory will also pass the lock). + """ + + def _recursive_inside(obj, accessed_obj, lvl=1): + if obj.location: + if obj.location == accessed_obj: + return True + elif lvl >= 10: + # avoid infinite recursions + return False + else: + return _recursive_inside(obj.location, accessed_obj, lvl + 1) + return False + + return _recursive_inside(accessing_obj, accessed_obj) + + def holds(accessing_obj, accessed_obj, *args, **kwargs): """ Usage: @@ -604,7 +632,7 @@ def holds(accessing_obj, accessed_obj, *args, **kwargs): if len(args) == 1: # command is holds(dbref/key) - check if given objname/dbref is held by accessing_ob return check_holds(args[0]) - elif len(args=2): + elif len(args) > 1: # command is holds(attrname, value) check if any held object has the given attribute and value for obj in contents: if obj.attributes.get(args[0]) == args[1]: diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 22f5844e31..ac8c85abc8 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -107,7 +107,7 @@ to any other identifier you can use. import re from django.conf import settings from evennia.utils import logger, utils -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ __all__ = ("LockHandler", "LockException") diff --git a/evennia/locks/tests.py b/evennia/locks/tests.py index bb02877f06..dce0e28fff 100644 --- a/evennia/locks/tests.py +++ b/evennia/locks/tests.py @@ -17,6 +17,7 @@ except ImportError: from evennia import settings_default from evennia.locks import lockfuncs +from evennia.utils.create import create_object # ------------------------------------------------------------ # Lock testing @@ -179,6 +180,13 @@ class TestLockfuncs(EvenniaTest): self.assertEqual(False, lockfuncs.inside(self.char1, self.room2)) self.assertEqual(True, lockfuncs.holds(self.room1, self.char1)) self.assertEqual(False, lockfuncs.holds(self.room2, self.char1)) + # test recursively + self.assertEqual(True, lockfuncs.inside_rec(self.char1, self.room1)) + self.assertEqual(False, lockfuncs.inside_rec(self.char1, self.room2)) + inventory_item = create_object(key="InsideTester", location=self.char1) + self.assertEqual(True, lockfuncs.inside_rec(inventory_item, self.room1)) + self.assertEqual(False, lockfuncs.inside_rec(inventory_item, self.room2)) + inventory_item.delete() def test_has_account(self): self.assertEqual(True, lockfuncs.has_account(self.char1, None)) diff --git a/evennia/objects/manager.py b/evennia/objects/manager.py index 39b91b3dc2..1217d0a91d 100644 --- a/evennia/objects/manager.py +++ b/evennia/objects/manager.py @@ -575,8 +575,10 @@ class ObjectDBManager(TypedObjectManager): return None # copy over all attributes from old to new. - for attr in original_object.attributes.all(): - new_object.attributes.add(attr.key, attr.value) + attrs = ( + (a.key, a.value, a.category, a.lock_storage) for a in original_object.attributes.all() + ) + new_object.attributes.batch_add(*attrs) # copy over all cmdsets, if any for icmdset, cmdset in enumerate(original_object.cmdset.all()): @@ -590,8 +592,10 @@ class ObjectDBManager(TypedObjectManager): ScriptDB.objects.copy_script(script, new_obj=new_object) # copy over all tags, if any - for tag in original_object.tags.get(return_tagobj=True, return_list=True): - new_object.tags.add(tag=tag.db_key, category=tag.db_category, data=tag.db_data) + tags = ( + (t.db_key, t.db_category, t.db_data) for t in original_object.tags.all(return_objs=True) + ) + new_object.tags.batch_add(*tags) return new_object diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 43c2dcc6fe..de9d09abae 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -31,7 +31,7 @@ from evennia.utils.utils import ( list_to_string, to_str, ) -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ _INFLECT = inflect.engine() _MULTISESSION_MODE = settings.MULTISESSION_MODE @@ -341,8 +341,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): """ key = kwargs.get("key", self.key) key = ansi.ANSIString(key) # this is needed to allow inflection of colored names - plural = _INFLECT.plural(key, 2) - plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural) + try: + plural = _INFLECT.plural(key, 2) + plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural) + except IndexError: + # this is raised by inflect if the input is not a proper noun + plural = key singular = _INFLECT.an(key) if not self.aliases.get(plural, category="plural_key"): # we need to wipe any old plurals/an/a in case key changed in the interrim @@ -2062,9 +2066,6 @@ class DefaultCharacter(DefaultObject): # Set the supplied key as the name of the intended object kwargs["key"] = key - # Get home for character - kwargs["home"] = ObjectDB.objects.get_id(kwargs.get("home", settings.DEFAULT_HOME)) - # Get permissions kwargs["permissions"] = kwargs.get("permissions", settings.PERMISSION_ACCOUNT_DEFAULT) @@ -2076,9 +2077,10 @@ class DefaultCharacter(DefaultObject): try: # Check to make sure account does not have too many chars - if len(account.characters) >= settings.MAX_NR_CHARACTERS: - errors.append("There are too many characters associated with this account.") - return obj, errors + if account: + if len(account.characters) >= settings.MAX_NR_CHARACTERS: + errors.append("There are too many characters associated with this account.") + return obj, errors # Create the Character obj = create.create_object(**kwargs) @@ -2524,10 +2526,10 @@ class DefaultExit(DefaultObject): [ "puppet:false()", # would be weird to puppet an exit ... "traverse:all()", # who can pass through exit by default - "get:false()", + "get:false()", # noone can pick up the exit ] ) - ) # noone can pick up the exit + ) # an exit should have a destination (this is replaced at creation time) if self.location: diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index 592f2cda26..75f91cba36 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -9,23 +9,35 @@ class DefaultObjectTest(EvenniaTest): def test_object_create(self): description = "A home for a grouch." + home = self.room1.dbref + obj, errors = DefaultObject.create( - "trashcan", self.account, description=description, ip=self.ip + "trashcan", self.account, description=description, ip=self.ip, home=home ) self.assertTrue(obj, errors) self.assertFalse(errors, errors) self.assertEqual(description, obj.db.desc) self.assertEqual(obj.db.creator_ip, self.ip) + self.assertEqual(obj.db_home, self.room1) def test_character_create(self): description = "A furry green monster, reeking of garbage." + home = self.room1.dbref + obj, errors = DefaultCharacter.create( - "oscar", self.account, description=description, ip=self.ip + "oscar", self.account, description=description, ip=self.ip, home=home ) self.assertTrue(obj, errors) self.assertFalse(errors, errors) self.assertEqual(description, obj.db.desc) self.assertEqual(obj.db.creator_ip, self.ip) + self.assertEqual(obj.db_home, self.room1) + + def test_character_create_noaccount(self): + obj, errors = DefaultCharacter.create("oscar", None, home=self.room1.dbref) + self.assertTrue(obj, errors) + self.assertFalse(errors, errors) + self.assertEqual(obj.db_home, self.room1) def test_room_create(self): description = "A dimly-lit alley behind the local Chinese restaurant." @@ -101,3 +113,27 @@ class TestObjectManager(EvenniaTest): self.assertEqual(list(query), [self.obj1]) query = ObjectDB.objects.get_objs_with_attr("NotFound", candidates=[self.char1, self.obj1]) self.assertFalse(query) + + def test_copy_object(self): + "Test that all attributes and tags properly copy across objects" + + # Add some tags + self.obj1.tags.add("plugh", category="adventure") + self.obj1.tags.add("xyzzy") + + # Add some attributes + self.obj1.attributes.add("phrase", "plugh", category="adventure") + self.obj1.attributes.add("phrase", "xyzzy") + + # Create object copy + obj2 = self.obj1.copy() + + # Make sure each of the tags were replicated + self.assertTrue("plugh" in obj2.tags.all()) + self.assertTrue("plugh" in obj2.tags.get(category="adventure")) + self.assertTrue("xyzzy" in obj2.tags.all()) + + # Make sure each of the attributes were replicated + self.assertEqual(obj2.attributes.get(key="phrase"), "xyzzy") + self.assertEqual(self.obj1.attributes.get(key="phrase", category="adventure"), "plugh") + self.assertEqual(obj2.attributes.get(key="phrase", category="adventure"), "plugh") diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index c2418d102b..bba55b1eee 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1235,7 +1235,7 @@ def _attr_select(caller, attrstr): attr_tup = _get_tup_by_attrname(caller, attrname) if attr_tup: - return "node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"} + return ("node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"}) else: caller.msg("Attribute not found.") return "node_attrs" @@ -1260,7 +1260,7 @@ def _attrs_actions(caller, raw_inp, **kwargs): if action and attr_tup: if action == "examine": - return "node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"} + return ("node_examine_entity", {"text": _display_attribute(attr_tup), "back": "attrs"}) elif action == "remove": res = _add_attr(caller, attrname, delete=True) caller.msg(res) @@ -1439,7 +1439,7 @@ def _tags_actions(caller, raw_inp, **kwargs): if tag_tup: if action == "examine": - return "node_examine_entity", {"text": _display_tag(tag_tup), "back": "tags"} + return ("node_examine_entity", {"text": _display_tag(tag_tup), "back": "tags"}) elif action == "remove": res = _add_tag(caller, tagname, delete=True) caller.msg(res) @@ -1510,7 +1510,7 @@ def _locks_display(caller, lock): def _lock_select(caller, lockstr): - return "node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"} + return ("node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"}) def _lock_add(caller, lock, **kwargs): @@ -1552,7 +1552,7 @@ def _locks_actions(caller, raw_inp, **kwargs): if lock: if action == "examine": - return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"} + return ("node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"}) elif action == "remove": ret = _lock_add(caller, lock, delete=True) caller.msg(ret) @@ -1645,7 +1645,10 @@ def _display_perm(caller, permission, only_hierarchy=False): def _permission_select(caller, permission, **kwargs): - return "node_examine_entity", {"text": _display_perm(caller, permission), "back": "permissions"} + return ( + "node_examine_entity", + {"text": _display_perm(caller, permission), "back": "permissions"}, + ) def _add_perm(caller, perm, **kwargs): @@ -2051,7 +2054,7 @@ def _prototype_locks_actions(caller, raw_inp, **kwargs): if lock: if action == "examine": - return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"} + return ("node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"}) elif action == "remove": ret = _prototype_lock_add(caller, lock.strip(), delete=True) caller.msg(ret) diff --git a/evennia/scripts/scripthandler.py b/evennia/scripts/scripthandler.py index d2298a7ce2..31775c3eec 100644 --- a/evennia/scripts/scripthandler.py +++ b/evennia/scripts/scripthandler.py @@ -9,7 +9,7 @@ from evennia.scripts.models import ScriptDB from evennia.utils import create from evennia.utils import logger -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ class ScriptHandler(object): @@ -78,11 +78,20 @@ class ScriptHandler(object): scriptclass, key=key, account=self.obj, autostart=autostart ) else: - # the normal - adding to an Object - script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=autostart) + # the normal - adding to an Object. We wait to autostart so we can differentiate + # a failing creation from a script that immediately starts/stops. + script = create.create_script(scriptclass, key=key, obj=self.obj, autostart=False) if not script: - logger.log_err("Script %s could not be created and/or started." % scriptclass) + logger.log_err("Script %s failed to be created/started." % scriptclass) return False + if autostart: + script.start() + if not script.id: + # this can happen if the script has repeats=1 or calls stop() in at_repeat. + logger.log_info( + "Script %s started and then immediately stopped; " + "it could probably be a normal function." % scriptclass + ) return True def start(self, key): diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index 75c9074ad4..2f5f95852b 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -8,7 +8,7 @@ ability to run timers. from twisted.internet.defer import Deferred, maybeDeferred from twisted.internet.task import LoopingCall from django.core.exceptions import ObjectDoesNotExist -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from evennia.typeclasses.models import TypeclassBase from evennia.scripts.models import ScriptDB from evennia.scripts.manager import ScriptManager @@ -69,7 +69,7 @@ class ExtendedLoopingCall(LoopingCall): steps if we want. """ - assert not self.running, "Tried to start an already running " "ExtendedLoopingCall." + assert not self.running, "Tried to start an already running ExtendedLoopingCall." if interval < 0: raise ValueError("interval must be >= 0") self.running = True @@ -107,7 +107,8 @@ class ExtendedLoopingCall(LoopingCall): if self.start_delay: self.start_delay = None self.starttime = self.clock.seconds() - LoopingCall.__call__(self) + if self._deferred: + LoopingCall.__call__(self) def force_repeat(self): """ @@ -118,7 +119,7 @@ class ExtendedLoopingCall(LoopingCall): running. """ - assert self.running, "Tried to fire an ExtendedLoopingCall " "that was not running." + assert self.running, "Tried to fire an ExtendedLoopingCall that was not running." self.call.cancel() self.call = None self.starttime = self.clock.seconds() @@ -135,11 +136,10 @@ class ExtendedLoopingCall(LoopingCall): the task is not running. """ - if self.running: + if self.running and self.interval > 0: total_runtime = self.clock.seconds() - self.starttime interval = self.start_delay or self.interval return interval - (total_runtime % self.interval) - return None class ScriptBase(ScriptDB, metaclass=TypeclassBase): @@ -162,9 +162,8 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase): Start task runner. """ - if self.ndb._task: - return - self.ndb._task = ExtendedLoopingCall(self._step_task) + if not self.ndb._task: + self.ndb._task = ExtendedLoopingCall(self._step_task) if self.db._paused_time: # the script was paused; restarting @@ -174,7 +173,8 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase): ) del self.db._paused_time del self.db._paused_repeats - else: + + elif not self.ndb._task.running: # starting script anew self.ndb._task.start(self.db_interval, now=not self.db_start_delay) @@ -186,6 +186,7 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase): task = self.ndb._task if task and task.running: task.stop() + self.ndb._task = None def _step_errback(self, e): """ @@ -208,6 +209,9 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase): Step task runner. No try..except needed due to defer wrap. """ + if not self.ndb._task: + # if there is no task, we have no business using this method + return if not self.is_valid(): self.stop() @@ -217,10 +221,13 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase): self.at_repeat() # check repeats - callcount = self.ndb._task.callcount - maxcount = self.db_repeats - if maxcount > 0 and maxcount <= callcount: - self.stop() + if self.ndb._task: + # we need to check for the task in case stop() was called + # inside at_repeat() and it already went away. + callcount = self.ndb._task.callcount + maxcount = self.db_repeats + if maxcount > 0 and maxcount <= callcount: + self.stop() def _step_task(self): """ @@ -267,13 +274,13 @@ class ScriptBase(ScriptDB, metaclass=TypeclassBase): self.db_key = cdict["key"] updates.append("db_key") if cdict.get("interval") and self.interval != cdict["interval"]: - self.db_interval = cdict["interval"] + self.db_interval = max(0, cdict["interval"]) updates.append("db_interval") if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]: self.db_start_delay = cdict["start_delay"] updates.append("db_start_delay") if cdict.get("repeats") and self.repeats != cdict["repeats"]: - self.db_repeats = cdict["repeats"] + self.db_repeats = max(0, cdict["repeats"]) updates.append("db_repeats") if cdict.get("persistent") and self.persistent != cdict["persistent"]: self.db_persistent = cdict["persistent"] @@ -338,9 +345,9 @@ class DefaultScript(ScriptBase): try: obj = create.create_script(**kwargs) - except Exception as e: + except Exception: + logger.log_trace() errors.append("The script '%s' encountered errors and could not be created." % key) - logger.log_err(e) return obj, errors @@ -562,11 +569,9 @@ class DefaultScript(ScriptBase): Restarts an already existing/running Script from the beginning, optionally using different settings. This will first call the stop hooks, and then the start hooks again. - Args: interval (int, optional): Allows for changing the interval - of the Script. Given in seconds. if `None`, will use the - already stored interval. + of the Script. Given in seconds. if `None`, will use the already stored interval. repeats (int, optional): The number of repeats. If unset, will use the previous setting. start_delay (bool, optional): If we should wait `interval` seconds @@ -585,6 +590,7 @@ class DefaultScript(ScriptBase): del self.db._paused_callcount # set new flags and start over if interval is not None: + interval = max(0, interval) self.interval = interval if repeats is not None: self.repeats = repeats diff --git a/evennia/server/deprecations.py b/evennia/server/deprecations.py index 3776364ba5..fd6a7fbc85 100644 --- a/evennia/server/deprecations.py +++ b/evennia/server/deprecations.py @@ -96,6 +96,12 @@ def check_errors(settings): "must now be either None or a dict " "specifying the properties of the channel to create." ) + if hasattr(settings, "CYCLE_LOGFILES"): + raise DeprecationWarning( + "settings.CYCLE_LOGFILES is unused and should be removed. " + "Use PORTAL/SERVER_LOG_DAY_ROTATION and PORTAL/SERVER_LOG_MAX_SIZE " + "to control log cycling." + ) def check_warnings(settings): @@ -109,3 +115,10 @@ def check_warnings(settings): print(" [Devel: settings.IN_GAME_ERRORS is True. Turn off in production.]") if settings.ALLOWED_HOSTS == ["*"]: print(" [Devel: settings.ALLOWED_HOSTS set to '*' (all). Limit in production.]") + for dbentry in settings.DATABASES.values(): + if "psycopg" in dbentry.get("ENGINE", ""): + print( + 'Deprecation: postgresql_psycopg2 backend is deprecated". ' + "Switch settings.DATABASES to use " + '"ENGINE": "django.db.backends.postgresql instead"' + ) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 74eb408bcb..e4a1518a75 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -27,6 +27,7 @@ from twisted.protocols import amp from twisted.internet import reactor, endpoints import django from django.core.management import execute_from_command_line +from django.db.utils import ProgrammingError # Signal processing SIG = signal.SIGINT @@ -93,7 +94,7 @@ SRESET = chr(19) # shutdown server in reset mode PYTHON_MIN = "3.7" TWISTED_MIN = "18.0.0" DJANGO_MIN = "2.1" -DJANGO_REC = "2.2.5" +DJANGO_REC = "2.2" try: sys.path[1] = EVENNIA_ROOT @@ -1152,7 +1153,7 @@ def tail_log_files(filename1, filename2, start_lines1=20, start_lines2=20, rate= # this happens if the file was cycled or manually deleted/edited. print( " ** Log file {filename} has cycled or been edited. " - "Restarting log. ".format(filehandle.name) + "Restarting log. ".format(filename=filehandle.name) ) new_linecount = 0 old_linecount = 0 @@ -1280,7 +1281,7 @@ def check_main_evennia_dependencies(): try: dversion = ".".join(str(num) for num in django.VERSION if isinstance(num, int)) # only the main version (1.5, not 1.5.4.0) - dversion_main = ".".join(dversion.split(".")[:3]) + dversion_main = ".".join(dversion.split(".")[:2]) if LooseVersion(dversion) < LooseVersion(DJANGO_MIN): print(ERROR_DJANGO_MIN.format(dversion=dversion_main, django_min=DJANGO_MIN)) error = True @@ -1428,12 +1429,17 @@ def create_superuser(): django.core.management.call_command("createsuperuser", interactive=True) -def check_database(): +def check_database(always_return=False): """ Check so the database exists. + Args: + always_return (bool, optional): If set, will always return True/False + also on critical errors. No output will be printed. Returns: exists (bool): `True` if the database exists, otherwise `False`. + + """ # Check so a database exists and is accessible from django.db import connection @@ -1449,7 +1455,9 @@ def check_database(): try: AccountDB.objects.get(id=1) - except django.db.utils.OperationalError as e: + except (django.db.utils.OperationalError, ProgrammingError) as e: + if always_return: + return False print(ERROR_DATABASE.format(traceback=e)) sys.exit() except AccountDB.DoesNotExist: @@ -1484,7 +1492,7 @@ def check_database(): new.save() else: create_superuser() - check_database() + check_database(always_return=always_return) return True @@ -2246,14 +2254,15 @@ def main(): # pass-through to django manager, but set things up first check_db = False need_gamedir = True - # some commands don't require the presence of a game directory to work - if option in ("makemessages", "compilemessages"): - need_gamedir = False # handle special django commands if option in ("runserver", "testserver"): + # we don't want the django test-webserver print(WARNING_RUNSERVER) - if option in ("shell", "check"): + if option in ("makemessages", "compilemessages"): + # some commands don't require the presence of a game directory to work + need_gamedir = False + if option in ("shell", "check", "makemigrations"): # some django commands requires the database to exist, # or evennia._init to have run before they work right. check_db = True @@ -2263,16 +2272,17 @@ def main(): init_game_directory(CURRENT_DIR, check_db=check_db, need_gamedir=need_gamedir) - if option in ("migrate", "makemigrations"): - # we have to launch migrate within the program to make sure migrations - # run within the scope of the launcher (otherwise missing a db will cause errors) - django.core.management.call_command(*([option] + unknown_args)) - else: - # pass on to the core django manager - re-parse the entire input line - # but keep 'evennia' as the name instead of django-admin. This is - # an exit condition. - sys.argv[0] = re.sub(r"(-script\.pyw?|\.exe)?$", "", sys.argv[0]) - sys.exit(execute_from_command_line()) + if option == "migrate": + # we need to bypass some checks here for the first db creation + if not check_database(always_return=True): + django.core.management.call_command(*([option] + unknown_args)) + sys.exit(0) + + # pass on to the core django manager - re-parse the entire input line + # but keep 'evennia' as the name instead of django-admin. This is + # an exit condition. + sys.argv[0] = re.sub(r"(-script\.pyw?|\.exe)?$", "", sys.argv[0]) + sys.exit(execute_from_command_line()) elif not args.tail_log: # no input; print evennia info (don't pring if we're tailing log) diff --git a/evennia/server/evennia_runner.py b/evennia/server/evennia_runner.py deleted file mode 100644 index eac473efa4..0000000000 --- a/evennia/server/evennia_runner.py +++ /dev/null @@ -1,403 +0,0 @@ -#!/usr/bin/env python -""" - -This runner is controlled by the evennia launcher and should normally -not be launched directly. It manages the two main Evennia processes -(Server and Portal) and most importantly runs a passive, threaded loop -that makes sure to restart Server whenever it shuts down. - -Since twistd does not allow for returning an optional exit code we -need to handle the current reload state for server and portal with -flag-files instead. The files, one each for server and portal either -contains True or False indicating if the process should be restarted -upon returning, or not. A process returning != 0 will always stop, no -matter the value of this file. - -""" - -import os -import sys -from argparse import ArgumentParser -from subprocess import Popen -import queue -import _thread -import evennia - -try: - # check if launched with pypy - import __pypy__ as is_pypy -except ImportError: - is_pypy = False - -SERVER = None -PORTAL = None - -EVENNIA_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -EVENNIA_BIN = os.path.join(EVENNIA_ROOT, "bin") -EVENNIA_LIB = os.path.dirname(evennia.__file__) - -SERVER_PY_FILE = os.path.join(EVENNIA_LIB, "server", "server.py") -PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, "server", "portal", "portal.py") - -GAMEDIR = None -SERVERDIR = "server" -SERVER_PIDFILE = None -PORTAL_PIDFILE = None -SERVER_RESTART = None -PORTAL_RESTART = None -SERVER_LOGFILE = None -PORTAL_LOGFILE = None -HTTP_LOGFILE = None -PPROFILER_LOGFILE = None -SPROFILER_LOGFILE = None - -# messages - -CMDLINE_HELP = """ - This program manages the running Evennia processes. It is called - by evennia and should not be started manually. Its main task is to - sit and watch the Server and restart it whenever the user reloads. - The runner depends on four files for its operation, two PID files - and two RESTART files for Server and Portal respectively; these - are stored in the game's server/ directory. - """ - -PROCESS_ERROR = """ - {component} process error: {traceback}. - """ - -PROCESS_IOERROR = """ - {component} IOError: {traceback} - One possible explanation is that 'twistd' was not found. - """ - -PROCESS_RESTART = "{component} restarting ..." - -PROCESS_DOEXIT = "Deferring to external runner." - -# Functions - - -def set_restart_mode(restart_file, flag="reload"): - """ - This sets a flag file for the restart mode. - """ - with open(restart_file, "w") as f: - f.write(str(flag)) - - -def getenv(): - """ - Get current environment and add PYTHONPATH - """ - sep = ";" if os.name == "nt" else ":" - env = os.environ.copy() - sys.path.insert(0, GAMEDIR) - env["PYTHONPATH"] = sep.join(sys.path) - return env - - -def get_restart_mode(restart_file): - """ - Parse the server/portal restart status - """ - if os.path.exists(restart_file): - with open(restart_file, "r") as f: - return f.read() - return "shutdown" - - -def get_pid(pidfile): - """ - Get the PID (Process ID) by trying to access - an PID file. - """ - pid = None - if os.path.exists(pidfile): - with open(pidfile, "r") as f: - pid = f.read() - return pid - - -def cycle_logfile(logfile): - """ - Rotate the old log files to .old - """ - logfile_old = logfile + ".old" - if os.path.exists(logfile): - # Cycle the old logfiles to *.old - if os.path.exists(logfile_old): - # E.g. Windows don't support rename-replace - os.remove(logfile_old) - os.rename(logfile, logfile_old) - - -# Start program management - - -def start_services(server_argv, portal_argv, doexit=False): - """ - This calls a threaded loop that launches the Portal and Server - and then restarts them when they finish. - """ - global SERVER, PORTAL - processes = queue.Queue() - - def server_waiter(queue): - try: - rc = Popen(server_argv, env=getenv()).wait() - except Exception as e: - print(PROCESS_ERROR.format(component="Server", traceback=e)) - return - # this signals the controller that the program finished - queue.put(("server_stopped", rc)) - - def portal_waiter(queue): - try: - rc = Popen(portal_argv, env=getenv()).wait() - except Exception as e: - print(PROCESS_ERROR.format(component="Portal", traceback=e)) - return - # this signals the controller that the program finished - queue.put(("portal_stopped", rc)) - - if portal_argv: - try: - if not doexit and get_restart_mode(PORTAL_RESTART) == "True": - # start portal as interactive, reloadable thread - PORTAL = _thread.start_new_thread(portal_waiter, (processes,)) - else: - # normal operation: start portal as a daemon; - # we don't care to monitor it for restart - PORTAL = Popen(portal_argv, env=getenv()) - except IOError as e: - print(PROCESS_IOERROR.format(component="Portal", traceback=e)) - return - - try: - if server_argv: - if doexit: - SERVER = Popen(server_argv, env=getenv()) - else: - # start server as a reloadable thread - SERVER = _thread.start_new_thread(server_waiter, (processes,)) - except IOError as e: - print(PROCESS_IOERROR.format(component="Server", traceback=e)) - return - - if doexit: - # Exit immediately - return - - # Reload loop - while True: - - # this blocks until something is actually returned. - from twisted.internet.error import ReactorNotRunning - - try: - try: - message, rc = processes.get() - except KeyboardInterrupt: - # this only matters in interactive mode - break - - # restart only if process stopped cleanly - if ( - message == "server_stopped" - and int(rc) == 0 - and get_restart_mode(SERVER_RESTART) in ("True", "reload", "reset") - ): - print(PROCESS_RESTART.format(component="Server")) - SERVER = _thread.start_new_thread(server_waiter, (processes,)) - continue - - # normally the portal is not reloaded since it's run as a daemon. - if ( - message == "portal_stopped" - and int(rc) == 0 - and get_restart_mode(PORTAL_RESTART) == "True" - ): - print(PROCESS_RESTART.format(component="Portal")) - PORTAL = _thread.start_new_thread(portal_waiter, (processes,)) - continue - break - except ReactorNotRunning: - break - - -def main(): - """ - This handles the command line input of the runner, usually created by - the evennia launcher - """ - - parser = ArgumentParser(description=CMDLINE_HELP) - parser.add_argument( - "--noserver", - action="store_true", - dest="noserver", - default=False, - help="Do not start Server process", - ) - parser.add_argument( - "--noportal", - action="store_true", - dest="noportal", - default=False, - help="Do not start Portal process", - ) - parser.add_argument( - "--logserver", - action="store_true", - dest="logserver", - default=False, - help="Log Server output to logfile", - ) - parser.add_argument( - "--iserver", - action="store_true", - dest="iserver", - default=False, - help="Server in interactive mode", - ) - parser.add_argument( - "--iportal", - action="store_true", - dest="iportal", - default=False, - help="Portal in interactive mode", - ) - parser.add_argument( - "--pserver", action="store_true", dest="pserver", default=False, help="Profile Server" - ) - parser.add_argument( - "--pportal", action="store_true", dest="pportal", default=False, help="Profile Portal" - ) - parser.add_argument( - "--nologcycle", - action="store_false", - dest="nologcycle", - default=True, - help="Do not cycle log files", - ) - parser.add_argument( - "--doexit", - action="store_true", - dest="doexit", - default=False, - help="Immediately exit after processes have started.", - ) - parser.add_argument("gamedir", help="path to game dir") - parser.add_argument("twistdbinary", help="path to twistd binary") - parser.add_argument("slogfile", help="path to server log file") - parser.add_argument("plogfile", help="path to portal log file") - parser.add_argument("hlogfile", help="path to http log file") - - args = parser.parse_args() - - global GAMEDIR - global SERVER_LOGFILE, PORTAL_LOGFILE, HTTP_LOGFILE - global SERVER_PIDFILE, PORTAL_PIDFILE - global SERVER_RESTART, PORTAL_RESTART - global SPROFILER_LOGFILE, PPROFILER_LOGFILE - - GAMEDIR = args.gamedir - sys.path.insert(1, os.path.join(GAMEDIR, SERVERDIR)) - - SERVER_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "server.pid") - PORTAL_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "portal.pid") - SERVER_RESTART = os.path.join(GAMEDIR, SERVERDIR, "server.restart") - PORTAL_RESTART = os.path.join(GAMEDIR, SERVERDIR, "portal.restart") - SERVER_LOGFILE = args.slogfile - PORTAL_LOGFILE = args.plogfile - HTTP_LOGFILE = args.hlogfile - TWISTED_BINARY = args.twistdbinary - SPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "server.prof") - PPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "portal.prof") - - # set up default project calls - server_argv = [ - TWISTED_BINARY, - "--nodaemon", - "--logfile=%s" % SERVER_LOGFILE, - "--pidfile=%s" % SERVER_PIDFILE, - "--python=%s" % SERVER_PY_FILE, - ] - portal_argv = [ - TWISTED_BINARY, - "--logfile=%s" % PORTAL_LOGFILE, - "--pidfile=%s" % PORTAL_PIDFILE, - "--python=%s" % PORTAL_PY_FILE, - ] - - # Profiling settings (read file from python shell e.g with - # p = pstats.Stats('server.prof') - pserver_argv = ["--savestats", "--profiler=cprofile", "--profile=%s" % SPROFILER_LOGFILE] - pportal_argv = ["--savestats", "--profiler=cprofile", "--profile=%s" % PPROFILER_LOGFILE] - - # Server - - pid = get_pid(SERVER_PIDFILE) - if pid and not args.noserver: - print( - "\nEvennia Server is already running as process %(pid)s. Not restarted." % {"pid": pid} - ) - args.noserver = True - if args.noserver: - server_argv = None - else: - set_restart_mode(SERVER_RESTART, "shutdown") - if not args.logserver: - # don't log to server logfile - del server_argv[2] - print("\nStarting Evennia Server (output to stdout).") - else: - if not args.nologcycle: - cycle_logfile(SERVER_LOGFILE) - print("\nStarting Evennia Server (output to server logfile).") - if args.pserver: - server_argv.extend(pserver_argv) - print("\nRunning Evennia Server under cProfile.") - - # Portal - - pid = get_pid(PORTAL_PIDFILE) - if pid and not args.noportal: - print( - "\nEvennia Portal is already running as process %(pid)s. Not restarted." % {"pid": pid} - ) - args.noportal = True - if args.noportal: - portal_argv = None - else: - if args.iportal: - # make portal interactive - portal_argv[1] = "--nodaemon" - set_restart_mode(PORTAL_RESTART, True) - print("\nStarting Evennia Portal in non-Daemon mode (output to stdout).") - else: - if not args.nologcycle: - cycle_logfile(PORTAL_LOGFILE) - cycle_logfile(HTTP_LOGFILE) - set_restart_mode(PORTAL_RESTART, False) - print("\nStarting Evennia Portal in Daemon mode (output to portal logfile).") - if args.pportal: - portal_argv.extend(pportal_argv) - print("\nRunning Evennia Portal under cProfile.") - if args.doexit: - print(PROCESS_DOEXIT) - - # Windows fixes (Windows don't support pidfiles natively) - if os.name == "nt": - if server_argv: - del server_argv[-2] - if portal_argv: - del portal_argv[-2] - - # Start processes - start_services(server_argv, portal_argv, doexit=args.doexit) - - -if __name__ == "__main__": - main() diff --git a/evennia/server/game_index_client/README.md b/evennia/server/game_index_client/README.md index f5077a43e6..5471ba7418 100644 --- a/evennia/server/game_index_client/README.md +++ b/evennia/server/game_index_client/README.md @@ -1,8 +1,8 @@ # Evennia Game Index Client -Greg Taylor 2016 +Greg Taylor 2016, Griatch 2020 -This contrib features a client for the [Evennia Game Index] +This is a client for the [Evennia Game Index] (http://evennia-game-index.appspot.com/), a listing of games built on Evennia. By listing your game on the index, you make it easy for other people in the community to discover your creation. @@ -14,74 +14,24 @@ on remedying this.* ## Listing your Game -To list your game, you'll need to enable the Evennia Game Index client. -Start by `cd`'ing to your game directory. From there, open up -`server/conf/server_services_plugins.py`. It might look something like this -if you don't have any other optional add-ons enabled: +To list your game, go to your game dir and run -```python -""" -Server plugin services + evennia connections -This plugin module can define user-created services for the Server to -start. +Follow the prompts to add details to the listing. Use `evennia reload`. In your log (visible with `evennia --log` +you should see a note that info has been sent to the game index. -This module must handle all imports and setups required to start a -twisted service (see examples in evennia.server.server). It must also -contain a function start_plugin_services(application). Evennia will -call this function with the main Server application (so your services -can be added to it). The function should not return anything. Plugin -services are started last in the Server startup process. -""" +## Detailed settings - -def start_plugin_services(server): - """ - This hook is called by Evennia, last in the Server startup process. - - server - a reference to the main server application. - """ - pass -``` - -To enable the client, import `EvenniaGameIndexService` and fire it up after the -Evennia server has finished starting: - -```python -""" -Server plugin services - -This plugin module can define user-created services for the Server to -start. - -This module must handle all imports and setups required to start a -twisted service (see examples in evennia.server.server). It must also -contain a function start_plugin_services(application). Evennia will -call this function with the main Server application (so your services -can be added to it). The function should not return anything. Plugin -services are started last in the Server startup process. -""" - -from evennia.contrib.egi_client import EvenniaGameIndexService - -def start_plugin_services(server): - """ - This hook is called by Evennia, last in the Server startup process. - - server - a reference to the main server application. - """ - egi_service = EvenniaGameIndexService() - server.services.addService(egi_service) -``` - -Next, configure your game listing by opening up `server/conf/settings.py` and +If you don't want to use the wizard you can configure your game listing by opening up `server/conf/settings.py` and using the following as a starting point: ```python ###################################################################### -# Contrib config +# Game index ###################################################################### +GAME_INDEX_ENABLED = True GAME_INDEX_LISTING = { 'game_status': 'pre-alpha', # Optional, comment out or remove if N/A diff --git a/evennia/server/initial_setup.py b/evennia/server/initial_setup.py index 8f7fd3300f..e0845c0859 100644 --- a/evennia/server/initial_setup.py +++ b/evennia/server/initial_setup.py @@ -9,7 +9,7 @@ Everything starts at handle_setup() import time from django.conf import settings -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from evennia.accounts.models import AccountDB from evennia.server.models import ServerConfig from evennia.utils import create, logger diff --git a/evennia/server/inputfuncs.py b/evennia/server/inputfuncs.py index bdf82c07c2..24f74af19f 100644 --- a/evennia/server/inputfuncs.py +++ b/evennia/server/inputfuncs.py @@ -576,8 +576,7 @@ def msdp_list(session, *args, **kwargs): fieldnames = [tup[1] for tup in monitor_infos] session.msg(reported_variables=(fieldnames, {})) if "sendable_variables" in args_lower: - # no default sendable variables - session.msg(sendable_variables=([], {})) + session.msg(sendable_variables=(_monitorable, {})) def msdp_report(session, *args, **kwargs): @@ -597,6 +596,17 @@ def msdp_unreport(session, *args, **kwargs): unmonitor(session, *args, **kwargs) +def msdp_send(session, *args, **kwargs): + """ + MSDP SEND command + """ + out = {} + for varname in args: + if varname.lower() in _monitorable: + out[varname] = _monitorable[varname.lower()] + session.msg(send=((), out)) + + # client specific diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py index 4a96da9f25..505ea5e3e0 100644 --- a/evennia/server/portal/amp.py +++ b/evennia/server/portal/amp.py @@ -314,7 +314,9 @@ class AMPMultiConnectionProtocol(amp.AMP): try: super(AMPMultiConnectionProtocol, self).dataReceived(data) except KeyError: - _get_logger().log_trace("Discarded incoming partial data: {}".format(to_str(data))) + _get_logger().log_trace( + "Discarded incoming partial (packed) data (len {})".format(len(data)) + ) elif self.multibatches: # invalid AMP, but we have a pending multi-batch that is not yet complete if data[-2:] == NULNUL: @@ -323,7 +325,9 @@ class AMPMultiConnectionProtocol(amp.AMP): try: super(AMPMultiConnectionProtocol, self).dataReceived(data) except KeyError: - _get_logger().log_trace("Discarded incoming multi-batch data:".format(to_str(data))) + _get_logger().log_trace( + "Discarded incoming multi-batch (packed) data (len {})".format(len(data)) + ) else: # not an AMP communication, return warning self.transport.write(_HTTP_WARNING) diff --git a/evennia/server/portal/mccp.py b/evennia/server/portal/mccp.py index 3ca35ff4de..2d00e479b6 100644 --- a/evennia/server/portal/mccp.py +++ b/evennia/server/portal/mccp.py @@ -15,9 +15,10 @@ This protocol is implemented by the telnet protocol importing mccp_compress and calling it from its write methods. """ import zlib +from twisted.python.compat import _bytesChr as chr # negotiations for v1 and v2 of the protocol -MCCP = b"\x56" +MCCP = chr(86) # b"\x56" FLUSH = zlib.Z_SYNC_FLUSH diff --git a/evennia/server/portal/mssp.py b/evennia/server/portal/mssp.py index 20331741ed..d939bcc37b 100644 --- a/evennia/server/portal/mssp.py +++ b/evennia/server/portal/mssp.py @@ -12,10 +12,11 @@ active players and so on. """ from django.conf import settings from evennia.utils import utils +from twisted.python.compat import _bytesChr as bchr -MSSP = b"\x46" -MSSP_VAR = b"\x01" -MSSP_VAL = b"\x02" +MSSP = bchr(70) # b"\x46" +MSSP_VAR = bchr(1) # b"\x01" +MSSP_VAL = bchr(2) # b"\x02" # try to get the customized mssp info, if it exists. MSSPTable_CUSTOM = utils.variable_from_module(settings.MSSP_META_MODULE, "MSSPTable", default={}) @@ -86,7 +87,7 @@ class Mssp(object): "PLAYERS": self.get_player_count, "UPTIME": self.get_uptime, "PORT": list( - reversed(settings.TELNET_PORTS) + str(port) for port in reversed(settings.TELNET_PORTS) ), # most important port should be last in list # Evennia auto-filled "CRAWL DELAY": "-1", @@ -119,10 +120,15 @@ class Mssp(object): if utils.is_iter(value): for partval in value: varlist += ( - MSSP_VAR + bytes(variable, "utf-8") + MSSP_VAL + bytes(partval, "utf-8") + MSSP_VAR + + bytes(str(variable), "utf-8") + + MSSP_VAL + + bytes(str(partval), "utf-8") ) else: - varlist += MSSP_VAR + bytes(variable, "utf-8") + MSSP_VAL + bytes(value, "utf-8") + varlist += ( + MSSP_VAR + bytes(str(variable), "utf-8") + MSSP_VAL + bytes(str(value), "utf-8") + ) # send to crawler by subnegotiation self.protocol.requestNegotiation(MSSP, varlist) diff --git a/evennia/server/portal/mxp.py b/evennia/server/portal/mxp.py index c1d9e7422c..8ff773036d 100644 --- a/evennia/server/portal/mxp.py +++ b/evennia/server/portal/mxp.py @@ -14,11 +14,12 @@ http://www.gammon.com.au/mushclient/addingservermxp.htm """ import re +from twisted.python.compat import _bytesChr as bchr LINKS_SUB = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL) # MXP Telnet option -MXP = b"\x5b" +MXP = bchr(91) # b"\x5b" MXP_TEMPSECURE = "\x1B[4z" MXP_SEND = MXP_TEMPSECURE + '' + "\\2" + MXP_TEMPSECURE + "" diff --git a/evennia/server/portal/naws.py b/evennia/server/portal/naws.py index 737ba74845..caa9f73402 100644 --- a/evennia/server/portal/naws.py +++ b/evennia/server/portal/naws.py @@ -11,9 +11,10 @@ client and update it when the size changes """ from codecs import encode as codecs_encode from django.conf import settings +from twisted.python.compat import _bytesChr as bchr -NAWS = b"\x1f" -IS = b"\x00" +NAWS = bchr(31) # b"\x1f" +IS = bchr(0) # b"\x00" # default taken from telnet specification DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH DEFAULT_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index ebf321a566..633e63bb51 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -95,6 +95,16 @@ INFO_DICT = { "webserver_internal": [], } +try: + WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE) +except ImportError: + WEB_PLUGINS_MODULE = None + INFO_DICT["errors"] = ( + "WARNING: settings.WEB_PLUGINS_MODULE not found - " + "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf." + ) + + # ------------------------------------------------------------- # Portal Service object # ------------------------------------------------------------- @@ -190,7 +200,6 @@ class Portal(object): self.sessions.disconnect_all() if _stop_server: self.amp_protocol.stop_server(mode="shutdown") - if not _reactor_stopping: # shutting down the reactor will trigger another signal. We set # a flag to avoid loops. @@ -213,7 +222,10 @@ application = service.Application("Portal") if "--nodaemon" not in sys.argv: logfile = logger.WeeklyLogFile( - os.path.basename(settings.PORTAL_LOG_FILE), os.path.dirname(settings.PORTAL_LOG_FILE) + os.path.basename(settings.PORTAL_LOG_FILE), + os.path.dirname(settings.PORTAL_LOG_FILE), + day_rotation=settings.PORTAL_LOG_DAY_ROTATION, + max_size=settings.PORTAL_LOG_MAX_SIZE, ) application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit) @@ -376,6 +388,14 @@ if WEBSERVER_ENABLED: webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port) INFO_DICT["webclient"].append(webclientstr) + if WEB_PLUGINS_MODULE: + try: + web_root = WEB_PLUGINS_MODULE.at_webproxy_root_creation(web_root) + except Exception as e: # Legacy user has not added an at_webproxy_root_creation function in existing web plugins file + INFO_DICT["errors"] = ( + "WARNING: WEB_PLUGINS_MODULE is enabled but at_webproxy_root_creation() not found - " + "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf." + ) web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE) web_root.is_portal = True proxy_service = internet.TCPServer(proxyport, web_root, interface=interface) diff --git a/evennia/server/portal/suppress_ga.py b/evennia/server/portal/suppress_ga.py index 2e8f38808a..a295582a26 100644 --- a/evennia/server/portal/suppress_ga.py +++ b/evennia/server/portal/suppress_ga.py @@ -13,7 +13,9 @@ It is set as the NOGOAHEAD protocol_flag option. http://www.faqs.org/rfcs/rfc858.html """ -SUPPRESS_GA = b"\x03" +from twisted.python.compat import _bytesChr as bchr + +SUPPRESS_GA = bchr(3) # b"\x03" # default taken from telnet specification diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index 1d5afcfcbc..4dbee60f7d 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -40,6 +40,21 @@ _RE_SCREENREADER_REGEX = re.compile( ) _IDLE_COMMAND = str.encode(settings.IDLE_COMMAND + "\n") +# identify HTTP indata +_HTTP_REGEX = re.compile( + b"(GET|HEAD|POST|PUT|DELETE|TRACE|OPTIONS|CONNECT|PATCH) (.*? HTTP/[0-9]\.[0-9])", re.I +) + +_HTTP_WARNING = bytes( + """ + This is Evennia's Telnet port and cannot be used for regular HTTP traffic. + Use a telnet client to connect here and point your browser to the server's + dedicated web port instead. + + """.strip(), + "utf-8", +) + class TelnetServerFactory(protocol.ServerFactory): "This is only to name this better in logs" @@ -60,13 +75,21 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): self.protocol_key = "telnet" super().__init__(*args, **kwargs) + def dataReceived(self, data): + """ + Unused by default, but a good place to put debug printouts + of incoming data. + """ + # print(f"telnet dataReceived: {data}") + super().dataReceived(data) + def connectionMade(self): """ This is called when the connection is first established. """ # important in order to work normally with standard telnet - self.do(LINEMODE) + self.do(LINEMODE).addErrback(self._wont_linemode) # initialize the session self.line_buffer = b"" client_address = self.transport.client @@ -111,6 +134,14 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): self.nop_keep_alive = None self.toggle_nop_keepalive() + def _wont_linemode(self, *args): + """ + Client refuses do(linemode). This is common for MUD-specific + clients, but we must ask for the sake of raw telnet. We ignore + this error. + """ + pass + def _send_nop_keepalive(self): """Send NOP keepalive unless flag is set""" if self.protocol_flags.get("NOPKEEPALIVE"): @@ -178,6 +209,16 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): or option == suppress_ga.SUPPRESS_GA ) + def disableRemote(self, option): + return ( + option == LINEMODE + or option == ttype.TTYPE + or option == naws.NAWS + or option == MCCP + or option == mssp.MSSP + or option == suppress_ga.SUPPRESS_GA + ) + def enableLocal(self, option): """ Call to allow the activation of options for this protocol @@ -204,13 +245,20 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): option (char): The telnet option to disable locally. """ + if option == LINEMODE: + return True if option == ECHO: return True if option == MCCP: self.mccp.no_mccp(option) return True else: - return super().disableLocal(option) + try: + return super().disableLocal(option) + except Exception: + from evennia.utils import logger + + logger.log_trace() def connectionLost(self, reason): """ @@ -246,6 +294,14 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): data = [_IDLE_COMMAND] else: data = _RE_LINEBREAK.split(data) + + if len(data) > 2 and _HTTP_REGEX.match(data[0]): + # guard against HTTP request on the Telnet port; we + # block and kill the connection. + self.transport.write(_HTTP_WARNING) + self.transport.loseConnection() + return + if self.line_buffer and len(data) > 1: # buffer exists, it is terminated by the first line feed data[0] = self.line_buffer + data[0] diff --git a/evennia/server/portal/telnet_oob.py b/evennia/server/portal/telnet_oob.py index f6e156f5a4..7931dcb2a4 100644 --- a/evennia/server/portal/telnet_oob.py +++ b/evennia/server/portal/telnet_oob.py @@ -28,22 +28,24 @@ header where applicable. import re import json from evennia.utils.utils import is_iter - -# MSDP-relevant telnet cmd/opt-codes -MSDP = b"\x45" -MSDP_VAR = b"\x01" # ^A -MSDP_VAL = b"\x02" # ^B -MSDP_TABLE_OPEN = b"\x03" # ^C -MSDP_TABLE_CLOSE = b"\x04" # ^D -MSDP_ARRAY_OPEN = b"\x05" # ^E -MSDP_ARRAY_CLOSE = b"\x06" # ^F - -# GMCP -GMCP = b"\xc9" +from twisted.python.compat import _bytesChr as bchr # General Telnet from twisted.conch.telnet import IAC, SB, SE +# MSDP-relevant telnet cmd/opt-codes +MSDP = bchr(69) +MSDP_VAR = bchr(1) +MSDP_VAL = bchr(2) +MSDP_TABLE_OPEN = bchr(3) +MSDP_TABLE_CLOSE = bchr(4) + +MSDP_ARRAY_OPEN = bchr(5) +MSDP_ARRAY_CLOSE = bchr(6) + +# GMCP +GMCP = bchr(201) + # pre-compiled regexes # returns 2-tuple @@ -168,7 +170,7 @@ class TelnetOOB(object): """ msdp_cmdname = "{msdp_var}{msdp_cmdname}{msdp_val}".format( - msdp_var=MSDP_VAR, msdp_cmdname=cmdname, msdp_val=MSDP_VAL + msdp_var=MSDP_VAR.decode(), msdp_cmdname=cmdname, msdp_val=MSDP_VAL.decode() ) if not (args or kwargs): @@ -186,9 +188,9 @@ class TelnetOOB(object): "{msdp_array_open}" "{msdp_args}" "{msdp_array_close}".format( - msdp_array_open=MSDP_ARRAY_OPEN, - msdp_array_close=MSDP_ARRAY_CLOSE, - msdp_args="".join("%s%s" % (MSDP_VAL, json.dumps(val)) for val in args), + msdp_array_open=MSDP_ARRAY_OPEN.decode(), + msdp_array_close=MSDP_ARRAY_CLOSE.decode(), + msdp_args="".join("%s%s" % (MSDP_VAL.decode(), val) for val in args), ) ) @@ -199,10 +201,10 @@ class TelnetOOB(object): "{msdp_table_open}" "{msdp_kwargs}" "{msdp_table_close}".format( - msdp_table_open=MSDP_TABLE_OPEN, - msdp_table_close=MSDP_TABLE_CLOSE, + msdp_table_open=MSDP_TABLE_OPEN.decode(), + msdp_table_close=MSDP_TABLE_CLOSE.decode(), msdp_kwargs="".join( - "%s%s%s%s" % (MSDP_VAR, key, MSDP_VAL, json.dumps(val)) + "%s%s%s%s" % (MSDP_VAR.decode(), key, MSDP_VAL.decode(), val) for key, val in kwargs.items() ), ) diff --git a/evennia/server/portal/telnet_ssl.py b/evennia/server/portal/telnet_ssl.py index 1698d4faac..66fa606def 100644 --- a/evennia/server/portal/telnet_ssl.py +++ b/evennia/server/portal/telnet_ssl.py @@ -100,11 +100,11 @@ def verify_or_create_SSL_key_and_cert(keyfile, certfile): keypair.generate_key(crypto.TYPE_RSA, _PRIVATE_KEY_LENGTH) with open(_PRIVATE_KEY_FILE, "wt") as pfile: - pfile.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, keypair)) + pfile.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, keypair).decode("utf-8")) print("Created SSL private key in '{}'.".format(_PRIVATE_KEY_FILE)) with open(_PUBLIC_KEY_FILE, "wt") as pfile: - pfile.write(crypto.dump_publickey(crypto.FILETYPE_PEM, keypair)) + pfile.write(crypto.dump_publickey(crypto.FILETYPE_PEM, keypair).decode("utf-8")) print("Created SSL public key in '{}'.".format(_PUBLIC_KEY_FILE)) except Exception as err: @@ -128,7 +128,7 @@ def verify_or_create_SSL_key_and_cert(keyfile, certfile): cert.sign(keypair, "sha1") with open(_CERTIFICATE_FILE, "wt") as cfile: - cfile.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) + cfile.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode("utf-8")) print("Created SSL certificate in '{}'.".format(_CERTIFICATE_FILE)) except Exception as err: diff --git a/evennia/server/portal/tests.py b/evennia/server/portal/tests.py index c9b4c1456f..df01a29981 100644 --- a/evennia/server/portal/tests.py +++ b/evennia/server/portal/tests.py @@ -11,6 +11,7 @@ except ImportError: import sys import string import mock +import pickle from mock import Mock, MagicMock from evennia.server.portal import irc @@ -50,11 +51,21 @@ class TestAMPServer(TwistedTestCase): self.proto.makeConnection(self.transport) self.proto.data_to_server(MsgServer2Portal, 1, test=2) - byte_out = ( - b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0b" - b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR" - b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00" - ) + + if pickle.HIGHEST_PROTOCOL == 5: + # Python 3.8+ + byte_out = ( + b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0b" + b"packed_data\x00 x\xdak`\x9d*\xc8\x00\x01\xde\x8c\xb5SzXJR" + b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00VU\x07u\x00\x00" + ) + elif pickle.HIGHEST_PROTOCOL == 4: + # Python 3.7 + byte_out = ( + b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0b" + b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR" + b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00" + ) self.transport.write.assert_called_with(byte_out) with mock.patch("evennia.server.portal.amp.amp.AMP.dataReceived") as mocked_amprecv: self.proto.dataReceived(byte_out) @@ -64,11 +75,20 @@ class TestAMPServer(TwistedTestCase): self.proto.makeConnection(self.transport) self.proto.data_to_server(MsgPortal2Server, 1, test=2) - byte_out = ( - b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgPortal2Server\x00\x0b" - b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR" - b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00" - ) + if pickle.HIGHEST_PROTOCOL == 5: + # Python 3.8+ + byte_out = ( + b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgPortal2Server\x00\x0b" + b"packed_data\x00 x\xdak`\x9d*\xc8\x00\x01\xde\x8c\xb5SzXJR" + b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00VU\x07u\x00\x00" + ) + elif pickle.HIGHEST_PROTOCOL == 4: + # Python 3.7 + byte_out = ( + b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgPortal2Server\x00\x0b" + b"packed_data\x00 x\xdak`\x99*\xc8\x00\x01\xde\x8c\xb5SzXJR" + b"\x8bK\xa6x3\x15\xb7M\xd1\x03\x00V:\x07t\x00\x00" + ) self.transport.write.assert_called_with(byte_out) with mock.patch("evennia.server.portal.amp.amp.AMP.dataReceived") as mocked_amprecv: self.proto.dataReceived(byte_out) @@ -82,28 +102,28 @@ class TestAMPServer(TwistedTestCase): outstr = "test" * AMP_MAXLEN self.proto.data_to_server(MsgServer2Portal, 1, test=outstr) - if sys.version < "3.7": + if pickle.HIGHEST_PROTOCOL == 5: + # Python 3.8+ self.transport.write.assert_called_with( b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0bpacked_data" - b"\x00xx\xda\xed\xc6\xc1\t\x800\x10\x00\xc1\x13\xaf\x01\xeb\xb2\x01\x1bH" - b'\x05\xe6+X\x80\xcf\xd8m@I\x1d\x99\x85\x81\xbd\xf3\xdd"c\xb4/W{' - b"\xb2\x96\xb3\xb6\xa3\x7fk\x8c\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00`\x0e?Pv\x02\x16\x00\r" - b"packed_data.2\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a" - b"\xa3\xd9RUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb" - b"\x03m\xe0\x06\x1d\x00\rpacked_data.3\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0" - b"\xb4&\xf0\xfdg\x10a\xa3fSUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU" - b"UUUUU\xf5\xfb\x03n\x1c\x06\x1e\x00\rpacked_data.4\x00Zx\xda\xed\xc3\x01\t\x00" - b"\x00\x0c\x03\xa0\xb4O\xb0\xf5gA\xae`\xda\x8b\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa" - b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa" - b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa" - b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xdf\x0fnI" - b"\x06,\x00\rpacked_data.5\x00\x14x\xdaK-.)I\xc5\x8e\xa7\x14\xb7M\xd1\x03\x00" - b"\xe7s\x0e\x1c\x00\x00" + b"\x00wx\xda\xed\xc6\xc1\t\x80 \x00@Q#=5Z\x0b\xb8\x80\x13\xe85h\x80\x8e\xbam`Dc\xf4><\xf8g" + b"\x1a[\xf8\xda\x97\xa3_\xb1\x95\xdaz\xbe\xe7\x1a\xde\x03\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe0\x1f\x1eP\x1d\x02\r\x00\rpacked_data.2" + b"\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a\xa3" + b"\xd9RUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb\x03m\xe0\x06" + b"\x1d\x00\rpacked_data.3\x00Zx\xda\xed\xc3\x01\r\x00\x00\x08\xc0\xa0\xb4&\xf0\xfdg\x10a" + b"\xa3fSUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU\xf5\xfb\x03n\x1c" + b"\x06\x1e\x00\rpacked_data.4\x00Zx\xda\xed\xc3\x01\t\x00\x00\x0c\x03\xa0\xb4O\xb0\xf5gA" + b"\xae`\xda\x8b\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa" + b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa" + b"\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa" + b"\xaa\xaa\xaa\xdf\x0fnI\x06,\x00\rpacked_data.5\x00\x18x\xdaK-.)I\xc5\x8e\xa7\xb22@\xc0" + b"\x94\xe2\xb6)z\x00Z\x1e\x0e\xb6\x00\x00" ) - else: + elif pickle.HIGHEST_PROTOCOL == 4: + # Python 3.7 self.transport.write.assert_called_with( b"\x00\x04_ask\x00\x011\x00\x08_command\x00\x10MsgServer2Portal\x00\x0bpacked_data" b"\x00wx\xda\xed\xc6\xc1\t\x80 \x00@Q#o\x8e\xd6\x02-\xe0\x04z\r\x1a\xa0\xa3m+$\xd2" diff --git a/evennia/server/portal/ttype.py b/evennia/server/portal/ttype.py index fb946bf9a2..01e7ebf74a 100644 --- a/evennia/server/portal/ttype.py +++ b/evennia/server/portal/ttype.py @@ -10,11 +10,12 @@ etc. If the client does not support TTYPE, this will be ignored. All data will be stored on the protocol's protocol_flags dictionary, under the 'TTYPE' key. """ +from twisted.python.compat import _bytesChr as bchr # telnet option codes -TTYPE = b"\x18" -IS = b"\x00" -SEND = b"\x01" +TTYPE = bchr(24) # b"\x18" +IS = bchr(0) # b"\x00" +SEND = bchr(1) # b"\x01" # terminal capabilities and their codes MTTS = [ diff --git a/evennia/server/portal/webclient.py b/evennia/server/portal/webclient.py index b501a3df2e..faee4c20f4 100644 --- a/evennia/server/portal/webclient.py +++ b/evennia/server/portal/webclient.py @@ -29,16 +29,26 @@ _RE_SCREENREADER_REGEX = re.compile( r"%s" % settings.SCREENREADER_REGEX_STRIP, re.DOTALL + re.MULTILINE ) _CLIENT_SESSIONS = mod_import(settings.SESSION_ENGINE).SessionStore +_UPSTREAM_IPS = settings.UPSTREAM_IPS - +# Status Code 1000: Normal Closure +# called when the connection was closed through JavaScript CLOSE_NORMAL = WebSocketServerProtocol.CLOSE_STATUS_CODE_NORMAL +# Status Code 1001: Going Away +# called when the browser is navigating away from the page +GOING_AWAY = WebSocketServerProtocol.CLOSE_STATUS_CODE_GOING_AWAY + class WebSocketClient(WebSocketServerProtocol, Session): """ Implements the server-side of the Websocket connection. """ + # nonce value, used to prevent the webclient from erasing the + # webclient_authenticated_uid value of csession on disconnect + nonce = None + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.protocol_key = "webclient/websocket" @@ -75,14 +85,26 @@ class WebSocketClient(WebSocketServerProtocol, Session): """ client_address = self.transport.client client_address = client_address[0] if client_address else None + + if client_address in _UPSTREAM_IPS and "x-forwarded-for" in self.http_headers: + addresses = [x.strip() for x in self.http_headers["x-forwarded-for"].split(",")] + addresses.reverse() + + for addr in addresses: + if addr not in _UPSTREAM_IPS: + client_address = addr + break + self.init_session("websocket", client_address, self.factory.sessionhandler) csession = self.get_client_session() # this sets self.csessid csessid = self.csessid uid = csession and csession.get("webclient_authenticated_uid", None) + nonce = csession and csession.get("webclient_authenticated_nonce", 0) if uid: # the client session is already logged in. self.uid = uid + self.nonce = nonce self.logged_in = True for old_session in self.sessionhandler.sessions_from_csessid(csessid): @@ -111,12 +133,20 @@ class WebSocketClient(WebSocketServerProtocol, Session): csession = self.get_client_session() if csession: - csession["webclient_authenticated_uid"] = None - csession.save() + # if the nonce is different, webclient_authenticated_uid has been + # set *before* this disconnect (disconnect called after a new client + # connects, which occurs in some 'fast' browsers like Google Chrome + # and Mobile Safari) + if csession.get("webclient_authenticated_nonce", None) == self.nonce: + csession["webclient_authenticated_uid"] = None + csession["webclient_authenticated_nonce"] = 0 + csession.save() self.logged_in = False self.sessionhandler.disconnect(self) - # autobahn-python: 1000 for a normal close, 3000-4999 for app. specific, + # autobahn-python: + # 1000 for a normal close, 1001 if the browser window is closed, + # 3000-4999 for app. specific, # in case anyone wants to expose this functionality later. # # sendClose() under autobahn/websocket/interfaces.py @@ -134,7 +164,7 @@ class WebSocketClient(WebSocketServerProtocol, Session): reason (str or None): Close reason as sent by the WebSocket peer. """ - if code == CLOSE_NORMAL: + if code == CLOSE_NORMAL or code == GOING_AWAY: self.disconnect(reason) else: self.websocket_close_code = code diff --git a/evennia/server/server.py b/evennia/server/server.py index 7602435068..2093b5b88c 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -38,7 +38,7 @@ from evennia.utils import logger from evennia.comms import channelhandler from evennia.server.sessionhandler import SESSIONS -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ _SA = object.__setattr__ @@ -148,13 +148,17 @@ def _server_maintenance(): # handle idle timeouts if _IDLE_TIMEOUT > 0: reason = _("idle timeout exceeded") + to_disconnect = [] for session in ( sess for sess in SESSIONS.values() if (now - sess.cmd_last) > _IDLE_TIMEOUT ): if not session.account or not session.account.access( session.account, "noidletimeout", default=False ): - SESSIONS.disconnect(session, reason=reason) + to_disconnect.append(session) + + for session in to_disconnect: + SESSIONS.disconnect(session, reason=reason) # ------------------------------------------------------------ @@ -416,7 +420,7 @@ class Evennia(object): yield [ (s.pause(manual_pause=False), s.at_server_reload()) for s in ScriptDB.get_all_cached_instances() - if s.is_active or s.attributes.has("_manual_pause") + if s.id and (s.is_active or s.attributes.has("_manual_pause")) ] yield self.sessions.all_sessions_portal_sync() self.at_server_reload_stop() @@ -612,7 +616,10 @@ application = service.Application("Evennia") if "--nodaemon" not in sys.argv: # custom logging, but only if we are not running in interactive mode logfile = logger.WeeklyLogFile( - os.path.basename(settings.SERVER_LOG_FILE), os.path.dirname(settings.SERVER_LOG_FILE) + os.path.basename(settings.SERVER_LOG_FILE), + os.path.dirname(settings.SERVER_LOG_FILE), + day_rotation=settings.SERVER_LOG_DAY_ROTATION, + max_size=settings.SERVER_LOG_MAX_SIZE, ) application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit) diff --git a/evennia/server/serversession.py b/evennia/server/serversession.py index 6ae0ec4b63..2443758b12 100644 --- a/evennia/server/serversession.py +++ b/evennia/server/serversession.py @@ -23,7 +23,7 @@ _ObjectDB = None _ANSI = None # i18n -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ # Handlers for Session.db/ndb operation diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index 1bf43746d6..0d099d6d30 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -67,7 +67,7 @@ PSTATUS = chr(18) # ping server or portal status SRESET = chr(19) # server shutdown in reset mode # i18n -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ _SERVERNAME = settings.SERVERNAME _MULTISESSION_MODE = settings.MULTISESSION_MODE diff --git a/evennia/server/webserver.py b/evennia/server/webserver.py index 4dc3f71e35..ff7f61baa0 100644 --- a/evennia/server/webserver.py +++ b/evennia/server/webserver.py @@ -70,14 +70,15 @@ class HTTPChannelWithXForwardedFor(http.HTTPChannel): Check to see if this is a reverse proxied connection. """ - CLIENT = 0 - http.HTTPChannel.allHeadersReceived(self) - req = self.requests[-1] - client_ip, port = self.transport.client - proxy_chain = req.getHeader("X-FORWARDED-FOR") - if proxy_chain and client_ip in _UPSTREAM_IPS: - forwarded = proxy_chain.split(", ", 1)[CLIENT] - self.transport.client = (forwarded, port) + if self.requests: + CLIENT = 0 + http.HTTPChannel.allHeadersReceived(self) + req = self.requests[-1] + client_ip, port = self.transport.client + proxy_chain = req.getHeader("X-FORWARDED-FOR") + if proxy_chain and client_ip in _UPSTREAM_IPS: + forwarded = proxy_chain.split(", ", 1)[CLIENT] + self.transport.client = (forwarded, port) # Monkey-patch Twisted to handle X-Forwarded-For. diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 804df3f479..5ecafb75b5 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -135,17 +135,19 @@ else: break os.chdir(os.pardir) -# Place to put log files +# Place to put log files, how often to rotate the log and how big each log file +# may become before rotating. LOG_DIR = os.path.join(GAME_DIR, "server", "logs") SERVER_LOG_FILE = os.path.join(LOG_DIR, "server.log") +SERVER_LOG_DAY_ROTATION = 7 +SERVER_LOG_MAX_SIZE = 1000000 PORTAL_LOG_FILE = os.path.join(LOG_DIR, "portal.log") +PORTAL_LOG_DAY_ROTATION = 7 +PORTAL_LOG_MAX_SIZE = 1000000 +# The http log is usually only for debugging since it's very spammy HTTP_LOG_FILE = os.path.join(LOG_DIR, "http_requests.log") # if this is set to the empty string, lockwarnings will be turned off. LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, "lockwarnings.log") -# Rotate log files when server and/or portal stops. This will keep log -# file sizes down. Turn off to get ever growing log files and never -# lose log info. -CYCLE_LOGFILES = True # Number of lines to append to rotating channel logs when they rotate CHANNEL_LOG_NUM_TAIL_LINES = 20 # Max size (in bytes) of channel log files before they rotate @@ -243,7 +245,7 @@ IN_GAME_ERRORS = True # ENGINE - path to the the database backend. Possible choices are: # 'django.db.backends.sqlite3', (default) # 'django.db.backends.mysql', -# 'django.db.backends.postgresql_psycopg2', +# 'django.db.backends.postgresql', # 'django.db.backends.oracle' (untested). # NAME - database name, or path to the db file for sqlite3 # USER - db admin (unused in sqlite3) @@ -400,22 +402,23 @@ COLOR_NO_DEFAULT = False ###################################################################### -# Default command sets +# Default command sets and commands ###################################################################### -# Note that with the exception of the unloggedin set (which is not -# stored anywhere in the database), changing these paths will only affect -# NEW created characters/objects, not those already in play. So if you plan to -# change this, it's recommended you do it before having created a lot of objects -# (or simply reset the database after the change for simplicity). # Command set used on session before account has logged in CMDSET_UNLOGGEDIN = "commands.default_cmdsets.UnloggedinCmdSet" +# (Note that changing these three following cmdset paths will only affect NEW +# created characters/objects, not those already in play. So if you want to +# change this and have it apply to every object, it's recommended you do it +# before having created a lot of objects (or simply reset the database after +# the change for simplicity)). # Command set used on the logged-in session CMDSET_SESSION = "commands.default_cmdsets.SessionCmdSet" # Default set for logged in account with characters (fallback) CMDSET_CHARACTER = "commands.default_cmdsets.CharacterCmdSet" # Command set for accounts without a character (ooc) CMDSET_ACCOUNT = "commands.default_cmdsets.AccountCmdSet" + # Location to search for cmdsets if full path not given CMDSET_PATHS = ["commands", "evennia", "evennia.contrib"] # Fallbacks for cmdset paths that fail to load. Note that if you change the path for your @@ -445,10 +448,17 @@ COMMAND_DEFAULT_MSG_ALL_SESSIONS = False COMMAND_DEFAULT_HELP_CATEGORY = "general" # The default lockstring of a command. COMMAND_DEFAULT_LOCKS = "" -# The Channel Handler will create a command to represent each channel, -# creating it with the key of the channel, its aliases, locks etc. The -# default class logs channel messages to a file and allows for /history. -# This setting allows to override the command class used with your own. +# The Channel Handler is responsible for managing all available channels. By +# default it builds the current channels into a channel-cmdset that it feeds +# to the cmdhandler. Overloading this can completely change how Channels +# are identified and called. +CHANNEL_HANDLER_CLASS = "evennia.comms.channelhandler.ChannelHandler" +# The (default) Channel Handler will create a command to represent each +# channel, creating it with the key of the channel, its aliases, locks etc. The +# default class logs channel messages to a file and allows for /history. This +# setting allows to override the command class used with your own. +# If you implement CHANNEL_HANDLER_CLASS, you can change this directly and will +# likely not need this setting. CHANNEL_COMMAND_CLASS = "evennia.comms.channelhandler.ChannelCommand" ###################################################################### @@ -640,6 +650,12 @@ CLIENT_DEFAULT_HEIGHT = 45 # (excluding webclient with separate help popups). If continuous scroll # is preferred, change 'HELP_MORE' to False. EvMORE uses CLIENT_DEFAULT_HEIGHT HELP_MORE = True +# Set rate limits per-IP on account creations and login attempts +CREATION_THROTTLE_LIMIT = 2 +CREATION_THROTTLE_TIMEOUT = 10 * 60 +LOGIN_THROTTLE_LIMIT = 5 +LOGIN_THROTTLE_TIMEOUT = 5 * 60 + ###################################################################### # Guest accounts diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index ef201eb0e2..2cb7ac8843 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -235,17 +235,23 @@ class AttributeHandler(object): # full cache was run on all attributes self._cache_complete = False - def _fullcache(self): - """Cache all attributes of this object""" + def _query_all(self): + "Fetch all Attributes on this object" query = { "%s__id" % self._model: self._objid, "attribute__db_model__iexact": self._model, "attribute__db_attrtype": self._attrtype, } - attrs = [ + return [ conn.attribute for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) ] + + def _fullcache(self): + """Cache all attributes of this object""" + if not _TYPECLASS_AGGRESSIVE_CACHE: + return + attrs = self._query_all() self._cache = dict( ( "%s-%s" @@ -298,7 +304,7 @@ class AttributeHandler(object): attr = None cachefound = False del self._cache[cachekey] - if cachefound: + if cachefound and _TYPECLASS_AGGRESSIVE_CACHE: if attr: return [attr] # return cached entity else: @@ -316,13 +322,15 @@ class AttributeHandler(object): conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) if conn: attr = conn[0].attribute - self._cache[cachekey] = attr + if _TYPECLASS_AGGRESSIVE_CACHE: + self._cache[cachekey] = attr return [attr] if attr.pk else [] else: # There is no such attribute. We will explicitly save that # in our cache to avoid firing another query if we try to # retrieve that (non-existent) attribute again. - self._cache[cachekey] = None + if _TYPECLASS_AGGRESSIVE_CACHE: + self._cache[cachekey] = None return [] else: # only category given (even if it's None) - we can't @@ -345,12 +353,13 @@ class AttributeHandler(object): **query ) ] - for attr in attrs: - if attr.pk: - cachekey = "%s-%s" % (attr.db_key, category) - self._cache[cachekey] = attr - # mark category cache as up-to-date - self._catcache[catkey] = True + if _TYPECLASS_AGGRESSIVE_CACHE: + for attr in attrs: + if attr.pk: + cachekey = "%s-%s" % (attr.db_key, category) + self._cache[cachekey] = attr + # mark category cache as up-to-date + self._catcache[catkey] = True return attrs def _setcache(self, key, category, attr_obj): @@ -363,6 +372,8 @@ class AttributeHandler(object): attr_obj (Attribute): The newly saved attribute """ + if not _TYPECLASS_AGGRESSIVE_CACHE: + return if not key: # don't allow an empty key in cache return cachekey = "%s-%s" % (key, category) @@ -769,9 +780,13 @@ class AttributeHandler(object): their values!) in the handler. """ - if not self._cache_complete: - self._fullcache() - attrs = sorted([attr for attr in self._cache.values() if attr], key=lambda o: o.id) + if _TYPECLASS_AGGRESSIVE_CACHE: + if not self._cache_complete: + self._fullcache() + attrs = sorted([attr for attr in self._cache.values() if attr], key=lambda o: o.id) + else: + attrs = sorted([attr for attr in self._query_all() if attr], key=lambda o: o.id) + if accessing_obj: return [ attr diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index abaf389a1d..e9ad42ed11 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -5,7 +5,8 @@ all Attributes and TypedObjects). """ import shlex -from django.db.models import Q +from django.db.models import F, Q, Count, ExpressionWrapper, FloatField +from django.db.models.functions import Cast from evennia.utils import idmapper from evennia.utils.utils import make_iter, variable_from_module from evennia.typeclasses.attributes import Attribute @@ -236,21 +237,29 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): """ return self.get_tag(key=key, category=category, obj=obj, tagtype="alias") - def get_by_tag(self, key=None, category=None, tagtype=None): + def get_by_tag(self, key=None, category=None, tagtype=None, **kwargs): """ Return objects having tags with a given key or category or combination of the two. Also accepts multiple tags/category/tagtype Args: key (str or list, optional): Tag key or list of keys. Not case sensitive. - category (str or list, optional): Tag category. Not case sensitive. If `key` is - a list, a single category can either apply to all keys in that list or this - must be a list matching the `key` list element by element. If no `key` is given, - all objects with tags of this category are returned. + category (str or list, optional): Tag category. Not case sensitive. + If `key` is a list, a single category can either apply to all + keys in that list or this must be a list matching the `key` + list element by element. If no `key` is given, all objects with + tags of this category are returned. tagtype (str, optional): 'type' of Tag, by default this is either `None` (a normal Tag), `alias` or `permission`. This always apply to all queried tags. + Kwargs: + match (str): "all" (default) or "any"; determines whether the + target object must be tagged with ALL of the provided + tags/categories or ANY single one. ANY will perform a weighted + sort, so objects with more tag matches will outrank those with + fewer tag matches. + Returns: objects (list): Objects with matching tag. @@ -262,10 +271,18 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): if not (key or category): return [] + global _Tag + if not _Tag: + from evennia.typeclasses.models import Tag as _Tag + + match = kwargs.get("match", "all").lower().strip() + keys = make_iter(key) if key else [] categories = make_iter(category) if category else [] n_keys = len(keys) n_categories = len(categories) + unique_categories = sorted(set(categories)) + n_unique_categories = len(unique_categories) dbmodel = self.model.__dbclass__.__name__.lower() query = ( @@ -286,14 +303,30 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): "get_by_tag needs a single category or a list of categories " "the same length as the list of tags." ) + clauses = Q() for ikey, key in enumerate(keys): - query = query.filter( - db_tags__db_key__iexact=key, db_tags__db_category__iexact=categories[ikey] - ) + # Keep each key and category together, grouped by AND + clauses |= Q(db_key__iexact=key, db_category__iexact=categories[ikey]) + else: # only one or more categories given - for category in categories: - query = query.filter(db_tags__db_category__iexact=category) + # import evennia;evennia.set_trace() + clauses = Q() + for category in unique_categories: + clauses |= Q(db_category__iexact=category) + + tags = _Tag.objects.filter(clauses) + query = query.filter(db_tags__in=tags).annotate( + matches=Count("db_tags__pk", filter=Q(db_tags__in=tags), distinct=True) + ) + + # Default ALL: Match all of the tags and optionally more + if match == "all": + n_req_tags = tags.count() if n_keys > 0 else n_unique_categories + query = query.filter(matches__gte=n_req_tags) + # ANY: Match any single tag, ordered by weight + elif match == "any": + query = query.order_by("-matches") return query @@ -452,6 +485,34 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): retval = retval.filter(id__lte=self.dbref(max_dbref, reqhash=False)) return retval + def get_typeclass_totals(self, *args, **kwargs) -> object: + """ + Returns a queryset of typeclass composition statistics. + + Returns: + qs (Queryset): A queryset of dicts containing the typeclass (name), + the count of objects with that typeclass and a float representing + the percentage of objects associated with the typeclass. + + """ + return ( + self.values("db_typeclass_path") + .distinct() + .annotate( + # Get count of how many objects for each typeclass exist + count=Count("db_typeclass_path") + ) + .annotate( + # Rename db_typeclass_path field to something more human + typeclass=F("db_typeclass_path"), + # Calculate this class' percentage of total composition + percent=ExpressionWrapper( + ((F("count") / float(self.count())) * 100.0), output_field=FloatField() + ), + ) + .values("typeclass", "count", "percent") + ) + def object_totals(self): """ Get info about database statistics. @@ -463,11 +524,8 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): object having that typeclass set on themselves). """ - dbtotals = {} - typeclass_paths = set(self.values_list("db_typeclass_path", flat=True)) - for typeclass_path in typeclass_paths: - dbtotals[typeclass_path] = self.filter(db_typeclass_path=typeclass_path).count() - return dbtotals + stats = self.get_typeclass_totals().order_by("typeclass") + return {x.get("typeclass"): x.get("count") for x in stats} def typeclass_search(self, typeclass, include_children=False, include_parents=False): """ diff --git a/evennia/typeclasses/migrations/0010_delete_old_player_tables.py b/evennia/typeclasses/migrations/0010_delete_old_player_tables.py index 0732f0fe99..32e1f80923 100644 --- a/evennia/typeclasses/migrations/0010_delete_old_player_tables.py +++ b/evennia/typeclasses/migrations/0010_delete_old_player_tables.py @@ -27,7 +27,7 @@ def _drop_table(db_cursor, table_name): db_cursor.execute("SET FOREIGN_KEY_CHECKS=0;") db_cursor.execute("DROP TABLE {table};".format(table=table_name)) db_cursor.execute("SET FOREIGN_KEY_CHECKS=1;") - elif _ENGINE == "postgresql_psycopg2": + elif _ENGINE == "postgresql": db_cursor.execute("ALTER TABLE {table} DISABLE TRIGGER ALL;".format(table=table_name)) db_cursor.execute("DROP TABLE {table};".format(table=table_name)) db_cursor.execute("ALTER TABLE {table} ENABLE TRIGGER ALL;".format(table=table_name)) diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index b20f7ca3d4..c6869b339f 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -458,7 +458,7 @@ class TypedObject(SharedMemoryModel): # Object manipulation methods # - def is_typeclass(self, typeclass, exact=True): + def is_typeclass(self, typeclass, exact=False): """ Returns true if this object has this type OR has a typeclass which is an subclass of the given typeclass. This operates on diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index 7dac017777..2cd4ae027d 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -124,17 +124,23 @@ class TagHandler(object): # full cache was run on all tags self._cache_complete = False - def _fullcache(self): - "Cache all tags of this object" + def _query_all(self): + "Get all tags for this objects" query = { "%s__id" % self._model: self._objid, "tag__db_model": self._model, "tag__db_tagtype": self._tagtype, } - tags = [ + return [ conn.tag for conn in getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) ] + + def _fullcache(self): + "Cache all tags of this object" + if not _TYPECLASS_AGGRESSIVE_CACHE: + return + tags = self._query_all() self._cache = dict( ( "%s-%s" @@ -193,7 +199,8 @@ class TagHandler(object): conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query) if conn: tag = conn[0].tag - self._cache[cachekey] = tag + if _TYPECLASS_AGGRESSIVE_CACHE: + self._cache[cachekey] = tag return [tag] else: # only category given (even if it's None) - we can't @@ -216,11 +223,12 @@ class TagHandler(object): **query ) ] - for tag in tags: - cachekey = "%s-%s" % (tag.db_key, category) - self._cache[cachekey] = tag - # mark category cache as up-to-date - self._catcache[catkey] = True + if _TYPECLASS_AGGRESSIVE_CACHE: + for tag in tags: + cachekey = "%s-%s" % (tag.db_key, category) + self._cache[cachekey] = tag + # mark category cache as up-to-date + self._catcache[catkey] = True return tags return [] @@ -234,9 +242,11 @@ class TagHandler(object): tag_obj (tag): The newly saved tag """ + if not _TYPECLASS_AGGRESSIVE_CACHE: + return if not key: # don't allow an empty key in cache return - key, category = key.strip().lower(), category.strip().lower() if category else category + key, category = (key.strip().lower(), category.strip().lower() if category else category) cachekey = "%s-%s" % (key, category) catkey = "-%s" % category self._cache[cachekey] = tag_obj @@ -253,7 +263,7 @@ class TagHandler(object): category (str or None): A cleaned category name """ - key, category = key.strip().lower(), category.strip().lower() if category else category + key, category = (key.strip().lower(), category.strip().lower() if category else category) catkey = "-%s" % category if key: cachekey = "%s-%s" % (key, category) @@ -419,9 +429,13 @@ class TagHandler(object): `return_key_and_category` is set. """ - if not self._cache_complete: - self._fullcache() - tags = sorted(self._cache.values()) + if _TYPECLASS_AGGRESSIVE_CACHE: + if not self._cache_complete: + self._fullcache() + tags = sorted(self._cache.values()) + else: + tags = sorted(self._query_all()) + if return_key_and_category: # return tuple (key, category) return [(to_str(tag.db_key), tag.db_category) for tag in tags] diff --git a/evennia/typeclasses/tests.py b/evennia/typeclasses/tests.py index 2c52fa5c2f..8632db5fc6 100644 --- a/evennia/typeclasses/tests.py +++ b/evennia/typeclasses/tests.py @@ -2,8 +2,9 @@ Unit tests for typeclass base system """ - +from django.test import override_settings from evennia.utils.test_resources import EvenniaTest +from mock import patch # ------------------------------------------------------------ # Manager tests @@ -19,6 +20,19 @@ class TestAttributes(EvenniaTest): self.obj1.db.testattr = value self.assertEqual(self.obj1.db.testattr, value) + @override_settings(TYPECLASS_AGGRESSIVE_CACHE=False) + @patch("evennia.typeclasses.attributes._TYPECLASS_AGGRESSIVE_CACHE", False) + def test_attrhandler_nocache(self): + key = "testattr" + value = "test attr value " + self.obj1.attributes.add(key, value) + self.assertFalse(self.obj1.attributes._cache) + + self.assertEqual(self.obj1.attributes.get(key), value) + self.obj1.db.testattr = value + self.assertEqual(self.obj1.db.testattr, value) + self.assertFalse(self.obj1.attributes._cache) + def test_weird_text_save(self): "test 'weird' text type (different in py2 vs py3)" from django.utils.safestring import SafeText @@ -62,6 +76,9 @@ class TestTypedObjectManager(EvenniaTest): self.obj2.tags.add("tag6", "category3") self.obj2.tags.add("tag7", "category1") self.obj2.tags.add("tag7", "category5") + self.obj1.tags.add("tag8", "category6") + self.obj2.tags.add("tag9", "category6") + self.assertEqual(self._manager("get_by_tag", "tag5", "category1"), [self.obj1, self.obj2]) self.assertEqual(self._manager("get_by_tag", "tag6", "category1"), []) self.assertEqual(self._manager("get_by_tag", "tag6", "category3"), [self.obj1, self.obj2]) @@ -78,6 +95,8 @@ class TestTypedObjectManager(EvenniaTest): self._manager("get_by_tag", category=["category1", "category3"]), [self.obj1, self.obj2] ) self.assertEqual( - self._manager("get_by_tag", category=["category1", "category2"]), [self.obj2] + self._manager("get_by_tag", category=["category1", "category2"]), [self.obj1, self.obj2] ) self.assertEqual(self._manager("get_by_tag", category=["category5", "category4"]), []) + self.assertEqual(self._manager("get_by_tag", category="category1"), [self.obj1, self.obj2]) + self.assertEqual(self._manager("get_by_tag", category="category6"), [self.obj1, self.obj2]) diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index ec2b1b3378..21d1abd180 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -674,6 +674,13 @@ class ANSIString(str, metaclass=ANSIMeta): """ + # A compiled Regex for the format mini-language: https://docs.python.org/3/library/string.html#formatspec + re_format = re.compile( + r"(?i)(?P(?P.)?(?P\<|\>|\=|\^))?(?P\+|\-| )?(?P\#)?" + r"(?P0)?(?P\d+)?(?P\_|\,)?(?:\.(?P\d+))?" + r"(?Pb|c|d|e|E|f|F|g|G|n|o|s|x|X|%)?" + ) + def __new__(cls, *args, **kwargs): """ When creating a new ANSIString, you may use a custom parser that has @@ -733,6 +740,47 @@ class ANSIString(str, metaclass=ANSIMeta): def __str__(self): return self._raw_string + def __format__(self, format_spec): + """ + This magic method covers ANSIString's behavior within a str.format() or f-string. + + Current features supported: fill, align, width. + + Args: + format_spec (str): The format specification passed by f-string or str.format(). This is a string such as + "0<30" which would mean "left justify to 30, filling with zeros". The full specification can be found + at https://docs.python.org/3/library/string.html#formatspec + + Returns: + ansi_str (str): The formatted ANSIString's .raw() form, for display. + """ + # This calls the compiled regex stored on ANSIString's class to analyze the format spec. + # It returns a dictionary. + format_data = self.re_format.match(format_spec).groupdict() + clean = self.clean() + base_output = ANSIString(self.raw()) + align = format_data.get("align", "<") + fill = format_data.get("fill", " ") + + # Need to coerce width into an integer. We can be certain that it's numeric thanks to regex. + width = format_data.get("width", None) + if width is None: + width = len(clean) + else: + width = int(width) + + if align == "<": + base_output = self.ljust(width, fill) + elif align == ">": + base_output = self.rjust(width, fill) + elif align == "^": + base_output = self.center(width, fill) + elif align == "=": + pass + + # Return the raw string with ANSI markup, ready to be displayed. + return base_output.raw() + def __repr__(self): """ Let's make the repr the command that would actually be used to diff --git a/evennia/utils/create.py b/evennia/utils/create.py index 0f6c1f22dd..5076f60ee3 100644 --- a/evennia/utils/create.py +++ b/evennia/utils/create.py @@ -255,11 +255,11 @@ def create_script( if obj: kwarg["db_obj"] = dbid_to_obj(obj, _ObjectDB) if interval: - kwarg["db_interval"] = interval + kwarg["db_interval"] = max(0, interval) if start_delay: kwarg["db_start_delay"] = start_delay if repeats: - kwarg["db_repeats"] = repeats + kwarg["db_repeats"] = max(0, repeats) if persistent: kwarg["db_persistent"] = persistent if desc: @@ -486,9 +486,8 @@ def create_account( Args: key (str): The account's name. This should be unique. - email (str): Email on valid addr@addr.domain form. This is - technically required but if set to `None`, an email of - `dummy@example.com` will be used as a placeholder. + email (str or None): Email on valid addr@addr.domain form. If + the empty string, will be set to None. password (str): Password in cleartext. Kwargs: @@ -532,7 +531,7 @@ def create_account( # correctly when each object is recovered). if not email: - email = "dummy@example.com" + email = None if _AccountDB.objects.filter(username__iexact=key): raise ValueError("An Account with the name '%s' already exists." % key) diff --git a/evennia/utils/dbserialize.py b/evennia/utils/dbserialize.py index b3e55d1ca6..ddfdb765de 100644 --- a/evennia/utils/dbserialize.py +++ b/evennia/utils/dbserialize.py @@ -28,7 +28,7 @@ except ImportError: from pickle import dumps, loads from django.core.exceptions import ObjectDoesNotExist from django.contrib.contenttypes.models import ContentType -from django.utils.safestring import SafeString, SafeBytes +from django.utils.safestring import SafeString from evennia.utils.utils import uses_database, is_iter, to_str, to_bytes from evennia.utils import logger @@ -549,7 +549,7 @@ def to_pickle(data): def process_item(item): """Recursive processor and identification of data""" dtype = type(item) - if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes): + if dtype in (str, int, float, bool, bytes, SafeString): return item elif dtype == tuple: return tuple(process_item(val) for val in item) @@ -577,7 +577,7 @@ def to_pickle(data): except TypeError: return item except Exception: - logger.log_error(f"The object {item} of type {type(item)} could not be stored.") + logger.log_err(f"The object {item} of type {type(item)} could not be stored.") raise return process_item(data) @@ -609,7 +609,7 @@ def from_pickle(data, db_obj=None): def process_item(item): """Recursive processor and identification of data""" dtype = type(item) - if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes): + if dtype in (str, int, float, bool, bytes, SafeString): return item elif _IS_PACKED_DBOBJ(item): # this must be checked before tuple @@ -638,7 +638,7 @@ def from_pickle(data, db_obj=None): def process_tree(item, parent): """Recursive processor, building a parent-tree from iterable data""" dtype = type(item) - if dtype in (str, int, float, bool, bytes, SafeString, SafeBytes): + if dtype in (str, int, float, bool, bytes, SafeString): return item elif _IS_PACKED_DBOBJ(item): # this must be checked before tuple @@ -716,7 +716,7 @@ def do_pickle(data): try: return dumps(data, protocol=PICKLE_PROTOCOL) except Exception: - logger.log_error(f"Could not pickle data for storage: {data}") + logger.log_err(f"Could not pickle data for storage: {data}") raise @@ -725,7 +725,7 @@ def do_unpickle(data): try: return loads(to_bytes(data)) except Exception: - logger.log_error(f"Could not unpickle data from storage: {data}") + logger.log_err(f"Could not unpickle data from storage: {data}") raise diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 0bc5b37475..ccf2840f91 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -187,7 +187,7 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT # Return messages # i18n -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ _ERR_NOT_IMPLEMENTED = _( "Menu node '{nodename}' is either not implemented or " "caused an error. Make another choice." diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index e86715dcdf..62fd47654a 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -30,7 +30,7 @@ caller.msg() construct every time the page is updated. from django.conf import settings from evennia import Command, CmdSet from evennia.commands import cmdhandler -from evennia.utils.utils import justify +from evennia.utils.utils import justify, make_iter _CMD_NOMATCH = cmdhandler.CMD_NOMATCH _CMD_NOINPUT = cmdhandler.CMD_NOINPUT @@ -39,6 +39,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT _SCREEN_WIDTH = settings.CLIENT_DEFAULT_WIDTH _SCREEN_HEIGHT = settings.CLIENT_DEFAULT_HEIGHT +_EVTABLE = None + # text _DISPLAY = """{text} @@ -126,26 +128,38 @@ class EvMore(object): text, always_page=False, session=None, + justify=False, justify_kwargs=None, exit_on_lastpage=False, exit_cmd=None, **kwargs, ): + """ Initialization of the text handler. Args: caller (Object or Account): Entity reading the text. - text (str): The text to put under paging. + text (str, EvTable or iterator): The text or data to put under paging. + - If a string, paginage normally. If this text contains + one or more `\f` format symbol, automatic pagination and justification + are force-disabled and page-breaks will only happen after each `\f`. + - If `EvTable`, the EvTable will be paginated with the same + setting on each page if it is too long. The table + decorations will be considered in the size of the page. + - Otherwise `text` is converted to an iterator, where each step is + expected to be a line in the final display. Each line + will be run through repr() (so one could pass a list of objects). always_page (bool, optional): If `False`, the pager will only kick in if `text` is too big to fit the screen. session (Session, optional): If given, this session will be used to determine the screen width and will receive all output. - justify_kwargs (dict, bool or None, optional): If given, this should - be valid keyword arguments to the utils.justify() function. If False, - no justification will be done (especially important for handling - fixed-width text content, like tables!). + justify (bool, optional): If set, auto-justify long lines. This must be turned + off for fixed-width or formatted output, like tables. It's force-disabled + if `text` is an EvTable. + justify_kwargs (dict, optional): Keywords for the justifiy function. Used only + if `justify` is True. If this is not set, default arguments will be used. exit_on_lastpage (bool, optional): If reaching the last page without the page being completely filled, exit pager immediately. If unset, another move forward is required to exit. If set, the pager @@ -154,18 +168,32 @@ class EvMore(object): the caller when the more page exits. Note that this will be using whatever cmdset the user had *before* the evmore pager was activated (so none of the evmore commands will be available when this is run). - kwargs (any, optional): These will be passed on - to the `caller.msg` method. + kwargs (any, optional): These will be passed on to the `caller.msg` method. + + Examples: + super_long_text = " ... " + EvMore(caller, super_long_text) + + from django.core.paginator import Paginator + query = ObjectDB.objects.all() + pages = Paginator(query, 10) # 10 objs per page + EvMore(caller, pages) # will repr() each object per line, 10 to a page + + multi_page_table = [ [[..],[..]], ...] + EvMore(caller, multi_page_table, use_evtable=True, + evtable_args=("Header1", "Header2"), + evtable_kwargs={"align": "r", "border": "tablecols"}) """ self._caller = caller self._kwargs = kwargs self._pages = [] - self._npages = [] - self._npos = [] + self._npages = 1 + self._npos = 0 self.exit_on_lastpage = exit_on_lastpage self.exit_cmd = exit_cmd self._exit_msg = "Exited |wmore|n pager." + if not session: # if not supplied, use the first session to # determine screen size @@ -179,16 +207,33 @@ class EvMore(object): height = max(4, session.protocol_flags.get("SCREENHEIGHT", {0: _SCREEN_HEIGHT})[0] - 4) width = session.protocol_flags.get("SCREENWIDTH", {0: _SCREEN_WIDTH})[0] + if hasattr(text, "table") and hasattr(text, "get"): + # This is an EvTable. + + table = text + + if table.height: + # enforced height of each paged table, plus space for evmore extras + height = table.height - 4 + + # convert table to string + text = str(text) + justify_kwargs = None # enforce + + if not isinstance(text, str): + # not a string - pre-set pages of some form + text = "\n".join(str(repr(element)) for element in make_iter(text)) + if "\f" in text: + # we use \f to indicate the user wants to enforce their line breaks + # on their own. If so, we do no automatic line-breaking/justification + # at all. self._pages = text.split("\f") self._npages = len(self._pages) - self._npos = 0 else: - if justify_kwargs is False: - # no justification. Simple division by line - lines = text.split("\n") - else: - # we must break very long lines into multiple ones + if justify: + # we must break very long lines into multiple ones. Note that this + # will also remove spurious whitespace. justify_kwargs = justify_kwargs or {} width = justify_kwargs.get("width", width) justify_kwargs["width"] = width @@ -201,17 +246,20 @@ class EvMore(object): lines.extend(justify(line, **justify_kwargs).split("\n")) else: lines.append(line) + else: + # no justification. Simple division by line + lines = text.split("\n") # always limit number of chars to 10 000 per page height = min(10000 // max(1, width), height) + # figure out the pagination self._pages = ["\n".join(lines[i : i + height]) for i in range(0, len(lines), height)] self._npages = len(self._pages) - self._npos = 0 if self._npages <= 1 and not always_page: # no need for paging; just pass-through. - caller.msg(text=text, session=self._session, **kwargs) + caller.msg(text=self._get_page(0), session=self._session, **kwargs) else: # go into paging mode # first pass on the msg kwargs @@ -221,12 +269,15 @@ class EvMore(object): # goto top of the text self.page_top() + def _get_page(self, pos): + return self._pages[pos] + def display(self, show_footer=True): """ Pretty-print the page. """ - pos = self._pos - text = self._pages[pos] + pos = self._npos + text = self._get_page(pos) if show_footer: page = _DISPLAY.format(text=text, pageno=pos + 1, pagemax=self._npages) else: @@ -245,14 +296,14 @@ class EvMore(object): """ Display the top page """ - self._pos = 0 + self._npos = 0 self.display() def page_end(self): """ Display the bottom page. """ - self._pos = self._npages - 1 + self._npos = self._npages - 1 self.display() def page_next(self): @@ -260,12 +311,12 @@ class EvMore(object): Scroll the text to the next page. Quit if already at the end of the page. """ - if self._pos >= self._npages - 1: + if self._npos >= self._npages - 1: # exit if we are already at the end self.page_quit() else: - self._pos += 1 - if self.exit_on_lastpage and self._pos >= (self._npages - 1): + self._npos += 1 + if self.exit_on_lastpage and self._npos >= (self._npages - 1): self.display(show_footer=False) self.page_quit(quiet=True) else: @@ -275,7 +326,7 @@ class EvMore(object): """ Scroll the text back up, at the most to the top. """ - self._pos = max(0, self._pos - 1) + self._npos = max(0, self._npos - 1) self.display() def page_quit(self, quiet=False): @@ -290,30 +341,51 @@ class EvMore(object): self._caller.execute_cmd(self.exit_cmd, session=self._session) +# helper function + + def msg( caller, text="", always_page=False, session=None, + justify=False, justify_kwargs=None, exit_on_lastpage=True, **kwargs, ): """ - More-supported version of msg, mimicking the normal msg method. + EvMore-supported version of msg, mimicking the normal msg method. Args: caller (Object or Account): Entity reading the text. - text (str): The text to put under paging. + text (str, EvTable or iterator): The text or data to put under paging. + - If a string, paginage normally. If this text contains + one or more `\f` format symbol, automatic pagination is disabled + and page-breaks will only happen after each `\f`. + - If `EvTable`, the EvTable will be paginated with the same + setting on each page if it is too long. The table + decorations will be considered in the size of the page. + - Otherwise `text` is converted to an iterator, where each step is + is expected to be a line in the final display, and each line + will be run through repr(). always_page (bool, optional): If `False`, the pager will only kick in if `text` is too big to fit the screen. session (Session, optional): If given, this session will be used to determine the screen width and will receive all output. + justify (bool, optional): If set, justify long lines in output. Disable for + fixed-format output, like tables. justify_kwargs (dict, bool or None, optional): If given, this should be valid keyword arguments to the utils.justify() function. If False, no justification will be done. exit_on_lastpage (bool, optional): Immediately exit pager when reaching the last page. + use_evtable (bool, optional): If True, each page will be rendered as an + EvTable. For this to work, `text` must be an iterable, where each element + is the table (list of list) to render on that page. + evtable_args (tuple, optional): The args to use for EvTable on each page. + evtable_kwargs (dict, optional): The kwargs to use for EvTable on each + page (except `table`, which is supplied by EvMore per-page). kwargs (any, optional): These will be passed on to the `caller.msg` method. @@ -323,6 +395,7 @@ def msg( text, always_page=always_page, session=session, + justify=justify, justify_kwargs=justify_kwargs, exit_on_lastpage=exit_on_lastpage, **kwargs, diff --git a/evennia/utils/evtable.py b/evennia/utils/evtable.py index e59e1d3a3c..e123589d6d 100644 --- a/evennia/utils/evtable.py +++ b/evennia/utils/evtable.py @@ -1107,8 +1107,9 @@ class EvTable(object): Exception: If given erroneous input or width settings for the data. Notes: - Beyond those table-specific keywords, the non-overlapping keywords of `EcCell.__init__` are - also available. These will be passed down to every cell in the table. + Beyond those table-specific keywords, the non-overlapping keywords + of `EcCell.__init__` are also available. These will be passed down + to every cell in the table. """ # at this point table is a 2D grid - a list of columns diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index 98060208da..8785c1319c 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -16,6 +16,7 @@ log_typemsg(). This is for historical, back-compatible reasons. import os import time +import glob from datetime import datetime from traceback import format_exc from twisted.python import log, logfile @@ -76,33 +77,79 @@ def timeformat(when=None): class WeeklyLogFile(logfile.DailyLogFile): """ - Log file that rotates once per week. Overrides key methods to change format + Log file that rotates once per week by default. Overrides key methods to change format. """ - day_rotation = 7 + def __init__(self, name, directory, defaultMode=None, day_rotation=7, max_size=1000000): + """ + Args: + name (str): Name of log file. + directory (str): Directory holding the file. + defaultMode (str): Permissions used to create file. Defaults to + current permissions of this file if it exists. + day_rotation (int): How often to rotate the file. + max_size (int): Max size of log file before rotation (regardless of + time). Defaults to 1M. + + """ + self.day_rotation = day_rotation + self.max_size = max_size + self.size = 0 + logfile.DailyLogFile.__init__(self, name, directory, defaultMode=defaultMode) + + def _openFile(self): + logfile.DailyLogFile._openFile(self) + self.size = self._file.tell() def shouldRotate(self): """Rotate when the date has changed since last write""" # all dates here are tuples (year, month, day) now = self.toDate() then = self.lastDate - return now[0] > then[0] or now[1] > then[1] or now[2] > (then[2] + self.day_rotation) + return ( + now[0] > then[0] + or now[1] > then[1] + or now[2] > (then[2] + self.day_rotation) + or self.size >= self.max_size + ) def suffix(self, tupledate): """Return the suffix given a (year, month, day) tuple or unixtime. - Format changed to have 03 for march instead of 3 etc (retaining unix file order) + Format changed to have 03 for march instead of 3 etc (retaining unix + file order) + + If we get duplicate suffixes in location (due to hitting size limit), + we append __1, __2 etc. + + Examples: + server.log.2020_01_29 + server.log.2020_01_29__1 + server.log.2020_01_29__2 """ - try: - return "_".join(["{:02d}".format(part) for part in tupledate]) - except Exception: - # try taking a float unixtime - return "_".join(["{:02d}".format(part) for part in self.toDate(tupledate)]) + suffix = "" + copy_suffix = 0 + while True: + try: + suffix = "_".join(["{:02d}".format(part) for part in tupledate]) + except Exception: + # try taking a float unixtime + suffix = "_".join(["{:02d}".format(part) for part in self.toDate(tupledate)]) + + suffix += f"__{copy_suffix}" if copy_suffix else "" + + if os.path.exists(f"{self.path}.{suffix}"): + # Append a higher copy_suffix to try to break the tie (starting from 2) + copy_suffix += 1 + else: + break + return suffix def write(self, data): "Write data to log file" logfile.BaseLogFile.write(self, data) self.lastDate = max(self.lastDate, self.toDate()) + self.size += len(data) class PortalLogObserver(log.FileLogObserver): diff --git a/evennia/utils/optionhandler.py b/evennia/utils/optionhandler.py index fa59c9fc8d..507785d1e4 100644 --- a/evennia/utils/optionhandler.py +++ b/evennia/utils/optionhandler.py @@ -24,8 +24,7 @@ class InMemorySaveHandler(object): class OptionHandler(object): """ - This is a generic Option handler. It is commonly used - implements AttributeHandler. Retrieve options eithers as properties on + This is a generic Option handler. Retrieve options either as properties on this handler or by using the .get method. This is used for Account.options but it could be used by Scripts or Objects @@ -54,7 +53,7 @@ class OptionHandler(object): It will be called as `savefunc(key, value, **save_kwargs)`. A common one to pass would be AttributeHandler.add. loadfunc (callable): A callable for all options to call when loading data into - itself. It will be called as `loadfunc(key, default=default, **load_kwargs)`. + itself. It will be called as `loadfunc(key, default=default, **load_kwargs)`. A common one to pass would be AttributeHandler.get. save_kwargs (any): Optional extra kwargs to pass into `savefunc` above. load_kwargs (any): Optional extra kwargs to pass into `loadfunc` above. @@ -116,14 +115,16 @@ class OptionHandler(object): self.options[key] = loaded_option return loaded_option - def get(self, key, return_obj=False): + def get(self, key, default=None, return_obj=False, raise_error=False): """ Retrieves an Option stored in the handler. Will load it if it doesn't exist. Args: key (str): The option key to retrieve. + default (any): What to return if the option is defined. return_obj (bool, optional): If True, returns the actual option object instead of its value. + raise_error (bool, optional): Raise Exception if key is not found in options. Returns: option_value (any or Option): An option value the Option itself. Raises: @@ -131,7 +132,10 @@ class OptionHandler(object): """ if key not in self.options_dict: - raise KeyError("Option not found!") + if raise_error: + raise KeyError("Option not found!") + return default + # get the options or load/recache it op_found = self.options.get(key) or self._load_option(key) return op_found if return_obj else op_found.value diff --git a/evennia/utils/picklefield.py b/evennia/utils/picklefield.py index 0b4cea4532..084fc6d638 100644 --- a/evennia/utils/picklefield.py +++ b/evennia/utils/picklefield.py @@ -43,7 +43,7 @@ from django.forms.fields import CharField from django.forms.widgets import Textarea from pickle import loads, dumps -from django.utils.encoding import force_text +from django.utils.encoding import force_str DEFAULT_PROTOCOL = 4 @@ -133,8 +133,9 @@ class PickledWidget(Textarea): try: # necessary to convert it back after repr(), otherwise validation errors will mutate it value = literal_eval(repr_value) - except ValueError: - pass + except (ValueError, SyntaxError): + # we could not eval it, just show its prepresentation + value = repr_value return super().render(name, value, attrs=attrs, renderer=renderer) def value_from_datadict(self, data, files, name): @@ -209,10 +210,10 @@ class PickledObjectField(models.Field): """ Returns the default value for this field. - The default implementation on models.Field calls force_text + The default implementation on models.Field calls force_str on the default, which means you can't set arbitrary Python objects as the default. To fix this, we just return the value - without calling force_text on it. Note that if you set a + without calling force_str on it. Note that if you set a callable as a default, the field will still call it. It will *not* try to pickle and encode it. @@ -266,13 +267,13 @@ class PickledObjectField(models.Field): """ if value is not None and not isinstance(value, PickledObject): - # We call force_text here explicitly, so that the encoded string - # isn't rejected by the postgresql_psycopg2 backend. Alternatively, + # We call force_str here explicitly, so that the encoded string + # isn't rejected by the postgresql backend. Alternatively, # we could have just registered PickledObject with the psycopg # marshaller (telling it to store it like it would a string), but # since both of these methods result in the same value being stored, # doing things this way is much easier. - value = force_text(dbsafe_encode(value, self.compress, self.protocol)) + value = force_str(dbsafe_encode(value, self.compress, self.protocol)) return value def value_to_string(self, obj): diff --git a/evennia/utils/tests/test_gametime.py b/evennia/utils/tests/test_gametime.py index 7b11f4b043..28291d01a7 100644 --- a/evennia/utils/tests/test_gametime.py +++ b/evennia/utils/tests/test_gametime.py @@ -95,4 +95,4 @@ class TestGametime(TestCase): self.timescripts.append(script) self.assertIsInstance(script, gametime.TimeScript) self.assertAlmostEqual(script.interval, 12) - self.assertEqual(script.repeats, -1) + self.assertEqual(script.repeats, 0) diff --git a/evennia/utils/tests/test_utils.py b/evennia/utils/tests/test_utils.py index c969a988a5..eb01e38a83 100644 --- a/evennia/utils/tests/test_utils.py +++ b/evennia/utils/tests/test_utils.py @@ -98,6 +98,30 @@ class TestMLen(TestCase): self.assertEqual(utils.m_len({"hello": True, "Goodbye": False}), 2) +class TestANSIString(TestCase): + """ + Verifies that ANSIString's string-API works as intended. + """ + + def setUp(self): + self.example_raw = "|relectric |cboogaloo|n" + self.example_ansi = ANSIString(self.example_raw) + self.example_str = "electric boogaloo" + self.example_output = "\x1b[1m\x1b[31melectric \x1b[1m\x1b[36mboogaloo\x1b[0m" + + def test_length(self): + self.assertEqual(len(self.example_ansi), 17) + + def test_clean(self): + self.assertEqual(self.example_ansi.clean(), self.example_str) + + def test_raw(self): + self.assertEqual(self.example_ansi.raw(), self.example_output) + + def test_format(self): + self.assertEqual(f"{self.example_ansi:0<20}", self.example_output + "000") + + class TestTimeformat(TestCase): """ Default function header from utils.py: diff --git a/evennia/utils/tests/test_validatorfuncs.py b/evennia/utils/tests/test_validatorfuncs.py index 54823bc4ee..e4235974a4 100644 --- a/evennia/utils/tests/test_validatorfuncs.py +++ b/evennia/utils/tests/test_validatorfuncs.py @@ -32,6 +32,11 @@ class TestValidatorFuncs(TestCase): self.assertTrue( isinstance(validatorfuncs.datetime(dt, from_tz=pytz.UTC), datetime.datetime) ) + account = mock.MagicMock() + account.options.get = mock.MagicMock(return_value="America/Chicago") + expected = datetime.datetime(1492, 10, 12, 6, 51, tzinfo=pytz.UTC) + self.assertEqual(expected, validatorfuncs.datetime("Oct 12 1:00 1492", account=account)) + account.options.get.assert_called_with("timezone", "UTC") def test_datetime_raises_ValueError(self): for dt in ["", "January 1, 2019", "1/1/2019", "Jan 1 2019"]: @@ -121,6 +126,7 @@ class TestValidatorFuncs(TestCase): validatorfuncs.boolean(b) def test_timezone_ok(self): + for tz in ["America/Chicago", "GMT", "UTC"]: self.assertEqual(tz, validatorfuncs.timezone(tz).zone) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 49c8af1e7b..cc191fdd53 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -27,7 +27,7 @@ from collections import defaultdict, OrderedDict from twisted.internet import threads, reactor from django.conf import settings from django.utils import timezone -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from django.apps import apps from evennia.utils import logger @@ -1036,7 +1036,7 @@ def uses_database(name="sqlite3"): shortcut to having to use the full backend name. Args: - name (str): One of 'sqlite3', 'mysql', 'postgresql_psycopg2' + name (str): One of 'sqlite3', 'mysql', 'postgresql' or 'oracle'. Returns: diff --git a/evennia/utils/validatorfuncs.py b/evennia/utils/validatorfuncs.py index 623680b176..bca25874cb 100644 --- a/evennia/utils/validatorfuncs.py +++ b/evennia/utils/validatorfuncs.py @@ -40,24 +40,36 @@ def color(entry, option_key="Color", **kwargs): def datetime(entry, option_key="Datetime", account=None, from_tz=None, **kwargs): """ - Process a datetime string in standard forms while accounting for the inputter's timezone. + Process a datetime string in standard forms while accounting for the inputer's timezone. Always + returns a result in UTC. Args: entry (str): A date string from a user. option_key (str): Name to display this datetime as. - account (AccountDB): The Account performing this lookup. Unless from_tz is provided, - account's timezone will be used (if found) for local time and convert the results - to UTC. - from_tz (pytz): An instance of pytz from the user. If not provided, defaults to whatever - the Account uses. If neither one is provided, defaults to UTC. - + account (AccountDB): The Account performing this lookup. Unless `from_tz` is provided, + the account's timezone option will be used. + from_tz (pytz.timezone): An instance of a pytz timezone object from the + user. If not provided, tries to use the timezone option of the `account'. + If neither one is provided, defaults to UTC. Returns: - datetime in utc. + datetime in UTC. + Raises: + ValueError: If encountering a malformed timezone, date string or other format error. + """ if not entry: raise ValueError(f"No {option_key} entered!") if not from_tz: from_tz = _pytz.UTC + if account: + acct_tz = account.options.get("timezone", "UTC") + try: + from_tz = _pytz.timezone(acct_tz) + except Exception as err: + raise ValueError(f"Timezone string '{acct_tz}' is not a valid timezone ({err})") + else: + from_tz = _pytz.UTC + utc = _pytz.UTC now = _dt.datetime.utcnow().replace(tzinfo=utc) cur_year = now.strftime("%Y") diff --git a/evennia/web/urls.py b/evennia/web/urls.py index 6d9e28f6df..a617a6289f 100644 --- a/evennia/web/urls.py +++ b/evennia/web/urls.py @@ -6,7 +6,7 @@ # http://diveintopython.org/regular_expressions/street_addresses.html#re.matching.2.3 # -from django.conf.urls import url, include +from django.urls import path, include from django.views.generic import RedirectView # Setup the root url tree from / @@ -14,9 +14,9 @@ from django.views.generic import RedirectView urlpatterns = [ # Front page (note that we shouldn't specify namespace here since we will # not be able to load django-auth/admin stuff (will probably work in Django>1.9) - url(r"^", include("evennia.web.website.urls")), # , namespace='website', app_name='website')), + path("", include("evennia.web.website.urls")), # webclient - url(r"^webclient/", include("evennia.web.webclient.urls", namespace="webclient")), + path("webclient/", include("evennia.web.webclient.urls")), # favicon - url(r"^favicon\.ico$", RedirectView.as_view(url="/media/images/favicon.ico", permanent=False)), + path("favicon.ico", RedirectView.as_view(url="/media/images/favicon.ico", permanent=False)), ] diff --git a/evennia/web/utils/middleware.py b/evennia/web/utils/middleware.py index d9e58256fe..ee1286b705 100644 --- a/evennia/web/utils/middleware.py +++ b/evennia/web/utils/middleware.py @@ -61,3 +61,12 @@ class SharedLoginMiddleware(object): login(request, account) except AttributeError: logger.log_trace() + + if csession.get("webclient_authenticated_uid", None): + # set a nonce to prevent the webclient from erasing the webclient_authenticated_uid value + csession["webclient_authenticated_nonce"] = ( + csession.get("webclient_authenticated_nonce", 0) + 1 + ) + # wrap around to prevent integer overflows + if csession["webclient_authenticated_nonce"] > 32: + csession["webclient_authenticated_nonce"] = 0 diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css index 312f321595..0ce0997c56 100644 --- a/evennia/web/webclient/static/webclient/css/webclient.css +++ b/evennia/web/webclient/static/webclient/css/webclient.css @@ -88,6 +88,10 @@ div {margin:0px;} height: 100%; } +.card { + background-color: #333; +} + /* Container surrounding entire client */ #clientwrapper { height: 100%; diff --git a/evennia/web/webclient/static/webclient/fonts/DejaVuSansMono-webfont.woff b/evennia/web/webclient/static/webclient/fonts/DejaVuSansMono-webfont.woff new file mode 100644 index 0000000000..43b301c564 Binary files /dev/null and b/evennia/web/webclient/static/webclient/fonts/DejaVuSansMono-webfont.woff differ diff --git a/evennia/web/webclient/static/webclient/fonts/DejaVuSansMono.css b/evennia/web/webclient/static/webclient/fonts/DejaVuSansMono.css new file mode 100644 index 0000000000..5484c470fb --- /dev/null +++ b/evennia/web/webclient/static/webclient/fonts/DejaVuSansMono.css @@ -0,0 +1,6 @@ +@font-face { + font-family: 'DejaVu Sans Mono'; + src: url('/static/webclient/fonts/DejaVuSansMono-webfont.woff') format('woff'); + font-weight: normal; + font-style: normal; +} diff --git a/evennia/web/webclient/static/webclient/js/plugins/clienthelp.js b/evennia/web/webclient/static/webclient/js/plugins/clienthelp.js new file mode 100644 index 0000000000..f5f5545ab0 --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/clienthelp.js @@ -0,0 +1,25 @@ +/* + * + * Evennia Webclient help plugin + * + */ +let clienthelp_plugin = (function () { + // + // + // + var onOptionsUI = function (parentdiv) { + var help_text = $( [ + "
", + "Evennia", + " Webclient Settings:", + "
" + ].join("")); + parentdiv.append(help_text); + } + + return { + init: function () {}, + onOptionsUI: onOptionsUI, + } +})(); +window.plugin_handler.add("clienthelp", clienthelp_plugin); diff --git a/evennia/web/webclient/static/webclient/js/plugins/font.js b/evennia/web/webclient/static/webclient/js/plugins/font.js new file mode 100644 index 0000000000..cf940fab40 --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/font.js @@ -0,0 +1,79 @@ +/* + * + * Evennia Webclient default "send-text-on-enter-key" IO plugin + * + */ +let font_plugin = (function () { + + const font_urls = { + 'B612 Mono': 'https://fonts.googleapis.com/css?family=B612+Mono&display=swap', + 'Consolas': 'https://fonts.googleapis.com/css?family=Consolas&display=swap', + 'DejaVu Sans Mono': '/static/webclient/fonts/DejaVuSansMono.css', + 'Fira Mono': 'https://fonts.googleapis.com/css?family=Fira+Mono&display=swap', + 'Inconsolata': 'https://fonts.googleapis.com/css?family=Inconsolata&display=swap', + 'Monospace': '', + 'Roboto Mono': 'https://fonts.googleapis.com/css?family=Roboto+Mono&display=swap', + 'Source Code Pro': 'https://fonts.googleapis.com/css?family=Source+Code+Pro&display=swap', + 'Ubuntu Mono': 'https://fonts.googleapis.com/css?family=Ubuntu+Mono&display=swap', + }; + + // + // + // + var onOptionsUI = function (parentdiv) { + var fontselect = $(''); + + var fonts = Object.keys(font_urls); + for (var x = 0; x < fonts.length; x++) { + var option = $(''); + fontselect.append(option); + } + + for (var x = 4; x < 21; x++) { + var val = (x/10.0); + var option = $(''); + sizeselect.append(option); + } + + fontselect.val('DejaVu Sans Mono'); // default value + sizeselect.val('0.9'); // default scaling factor + + // font-family change callback + fontselect.on('change', function () { + $(document.body).css('font-family', $(this).val()); + }); + + // font size change callback + sizeselect.on('change', function () { + $(document.body).css('font-size', $(this).val()+"em"); + }); + + // add the font selection dialog control to our parentdiv + parentdiv.append('
Font Selection:
'); + parentdiv.append(fontselect); + parentdiv.append(sizeselect); + } + + // + // Font plugin init function (adds the urls for the webfonts to the page) + // + var init = function () { + var head = $(document.head); + + var fonts = Object.keys(font_urls); + for (var x = 0; x < fonts.length; x++) { + if ( fonts[x] != "Monospace" ) { + var url = font_urls[ fonts[x] ]; + var link = $(''); + head.append( link ); + } + } + } + + return { + init: init, + onOptionsUI: onOptionsUI, + } +})(); +window.plugin_handler.add("font", font_plugin); diff --git a/evennia/web/webclient/static/webclient/js/plugins/goldenlayout.js b/evennia/web/webclient/static/webclient/js/plugins/goldenlayout.js index d1409ab3f7..cbf5217730 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/goldenlayout.js +++ b/evennia/web/webclient/static/webclient/js/plugins/goldenlayout.js @@ -383,6 +383,14 @@ let goldenlayout = (function () { // Public // + // + // helper accessor for other plugins to add new known-message types + var addKnownType = function (newtype) { + if( knownTypes.includes(newtype) == false ) { + knownTypes.push(newtype); + } + } + // // @@ -526,7 +534,7 @@ let goldenlayout = (function () { onKeydown: onKeydown, onText: onText, getGL: function () { return myLayout; }, - addKnownType: function (newtype) { knownTypes.push(newtype); }, + addKnownType: addKnownType, } }()); window.plugin_handler.add("goldenlayout", goldenlayout); diff --git a/evennia/web/webclient/static/webclient/js/plugins/iframe.js b/evennia/web/webclient/static/webclient/js/plugins/iframe.js new file mode 100644 index 0000000000..01d19a7993 --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/plugins/iframe.js @@ -0,0 +1,68 @@ +/* + * IFrame plugin + * REQUIRES: goldenlayout.js + */ +let iframe = (function () { + + var url = window.location.origin; + + // + // Create iframe component + var createIframeComponent = function () { + var myLayout = window.plugins["goldenlayout"].getGL(); + + myLayout.registerComponent( "iframe", function (container, componentState) { + // build the iframe + var div = $('