diff --git a/CHANGELOG.md b/CHANGELOG.md index b1601ae585..c8ec5ef076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,9 @@ Web/Django standard initiative (@strikaco) - `evennia.MONITOR_HANDLER.all` now takes keyword argument `obj` to only retrieve monitors from that specific Object (rather than all monitors in the entire handler). - Support adding `\f` in command doc strings to force where EvMore puts page breaks. +- Validation Functions now added with standard API to homogenize user input validation. +- Option Classes added to make storing user-options easier and smoother. +- `evennia.VALIDATOR_CONTAINER` and `evennia.OPTION_CONTAINER` added to load these. ### Contribs diff --git a/evennia/__init__.py b/evennia/__init__.py index 9ed25a2669..c88ef8df18 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -111,6 +111,10 @@ MONITOR_HANDLER = None CHANNEL_HANDLER = None GLOBAL_SCRIPTS = None +# Containers +VALIDATOR_FUNCTIONS = None +OPTION_CLASSES = None + def _create_version(): """ @@ -140,6 +144,7 @@ def _create_version(): __version__ = _create_version() del _create_version + def _init(): """ This function is called automatically by the launcher only after @@ -150,12 +155,16 @@ def _init(): global DefaultRoom, DefaultExit, DefaultChannel, DefaultScript global ObjectDB, AccountDB, ScriptDB, ChannelDB, Msg global Command, CmdSet, default_cmds, syscmdkeys, InterruptCommand - global search_object, search_script, search_account, search_channel, search_help, search_tag, search_message - global create_object, create_script, create_account, create_channel, create_message, create_help_entry + global search_object, search_script, search_account, search_channel + global search_help, search_tag, search_message + global create_object, create_script, create_account, create_channel + global create_message, create_help_entry global settings, lockfuncs, logger, utils, gametime, ansi, spawn, managers - global contrib, TICKER_HANDLER, MONITOR_HANDLER, SESSION_HANDLER, CHANNEL_HANDLER, TASK_HANDLER, GLOBAL_SCRIPTS + global contrib, TICKER_HANDLER, MONITOR_HANDLER, SESSION_HANDLER + global CHANNEL_HANDLER, TASK_HANDLER, GLOBAL_SCRIPTS + global VALIDATOR_FUNCS, OPTION_CLASSES global EvMenu, EvTable, EvForm, EvMore, EvEditor - global ANSIString + global ANSIString from .accounts.accounts import DefaultAccount from .accounts.accounts import DefaultGuest @@ -216,6 +225,10 @@ def _init(): from .scripts.monitorhandler import MONITOR_HANDLER from .utils.containers import GLOBAL_SCRIPTS + # containers + from .utils.containers import VALIDATOR_FUNCS + from .utils.containers import OPTION_CLASSES + # initialize the doc string global __doc__ __doc__ = ansi.parse_ansi(DOCSTRING) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 4a17bedf7a..160e2d41de 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -20,6 +20,7 @@ from django.utils.module_loading import import_string from evennia.typeclasses.models import TypeclassBase from evennia.accounts.manager import AccountManager from evennia.accounts.models import AccountDB +from evennia.utils.option import OptionHandler from evennia.objects.models import ObjectDB from evennia.comms.models import ChannelDB from evennia.commands import cmdhandler @@ -197,6 +198,10 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): def sessions(self): return AccountSessionHandler(self) + @lazy_property + def options(self): + return OptionHandler(self, options_dict=settings.OPTIONS_ACCOUNT_DEFAULT, save_category='option') + # Do not make this a lazy property; the web UI will not refresh it! @property def characters(self): diff --git a/evennia/commands/command.py b/evennia/commands/command.py index 69a69ccb93..5fb952db9d 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -7,11 +7,15 @@ All commands in Evennia inherit from the 'Command' class in this module. from builtins import range import re +import math from django.conf import settings from evennia.locks.lockhandler import LockHandler from evennia.utils.utils import is_iter, fill, lazy_property, make_iter +from evennia.utils.evtable import EvTable +from evennia.utils.ansi import ANSIString + from future.utils import with_metaclass @@ -468,6 +472,98 @@ class Command(with_metaclass(CommandMeta, object)): """ return self.__doc__ + def width(self): + return self.session.protocol_flags['SCREENWIDTH'][0] + + def style_table(self, *args, **kwargs): + border_color = self.account.options.get('border_color') + column_color = self.account.options.get('column_names_color') + + colornames = ['|%s%s|n' % (column_color, col) for col in args] + + h_line_char = kwargs.pop('header_line_char', '~') + header_line_char = ANSIString(f'|{border_color}{h_line_char}|n') + + c_char = kwargs.pop('corner_char', '+') + corner_char = ANSIString(f'|{border_color}{c_char}|n') + + b_left_char = kwargs.pop('border_left_char', '||') + border_left_char = ANSIString(f'|{border_color}{b_left_char}|n') + + b_right_char = kwargs.pop('border_right_char', '||') + border_right_char = ANSIString(f'|{border_color}{b_right_char}|n') + + b_bottom_char = kwargs.pop('border_bottom_char', '-') + border_bottom_char = ANSIString(f'|{border_color}{b_bottom_char}|n') + + b_top_char = kwargs.pop('border_top_char', '-') + border_top_char = ANSIString(f'|{border_color}{b_top_char}|n') + + table = EvTable(*colornames, header_line_char=header_line_char, corner_char=corner_char, + border_left_char=border_left_char, border_right_char=border_right_char, + border_top_char=border_top_char, **kwargs) + return table + + def render_header(self, header_text=None, fill_character=None, edge_character=None, + mode='header', color_header=True): + colors = dict() + colors['border'] = self.account.options.get('border_color') + colors['headertext'] = self.account.options.get('%s_text_color' % mode) + colors['headerstar'] = self.account.options.get('%s_star_color' % mode) + + width = self.width() + if edge_character: + width -= 2 + + if header_text: + if color_header: + header_text = ANSIString(header_text).clean() + header_text = ANSIString('|n|%s%s|n' % (colors['headertext'], header_text)) + if mode == 'header': + begin_center = ANSIString("|n|%s<|%s* |n" % (colors['border'], colors['headerstar'])) + end_center = ANSIString("|n |%s*|%s>|n" % (colors['headerstar'], colors['border'])) + center_string = ANSIString(begin_center + header_text + end_center) + else: + center_string = ANSIString('|n |%s%s |n' % (colors['headertext'], header_text)) + else: + center_string = '' + + fill_character = self.account.options.get('%s_fill' % mode) + + remain_fill = width - len(center_string) + if remain_fill % 2 == 0: + right_width = remain_fill / 2 + left_width = remain_fill / 2 + else: + right_width = math.floor(remain_fill / 2) + left_width = math.ceil(remain_fill / 2) + + right_fill = ANSIString('|n|%s%s|n' % (colors['border'], fill_character * int(right_width))) + left_fill = ANSIString('|n|%s%s|n' % (colors['border'], fill_character * int(left_width))) + + if edge_character: + edge_fill = ANSIString('|n|%s%s|n' % (colors['border'], edge_character)) + main_string = ANSIString(center_string) + final_send = ANSIString(edge_fill) + left_fill + main_string + right_fill + ANSIString(edge_fill) + else: + final_send = left_fill + ANSIString(center_string) + right_fill + return final_send + + def style_header(self, *args, **kwargs): + if 'mode' not in kwargs: + kwargs['mode'] = 'header' + return self.render_header(*args, **kwargs) + + def style_separator(self, *args, **kwargs): + if 'mode' not in kwargs: + kwargs['mode'] = 'separator' + return self.render_header(*args, **kwargs) + + def style_footer(self, *args, **kwargs): + if 'mode' not in kwargs: + kwargs['mode'] = 'footer' + return self.render_header(*args, **kwargs) + class InterruptCommand(Exception): diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index c39d588df9..bf9767d893 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -367,7 +367,7 @@ class CmdSessions(COMMAND_DEFAULT_CLASS): """Implement function""" account = self.account sessions = account.sessions.all() - table = evtable.EvTable("|wsessid", + table = self.style_table("|wsessid", "|wprotocol", "|whost", "|wpuppet/character", @@ -418,7 +418,7 @@ class CmdWho(COMMAND_DEFAULT_CLASS): naccounts = (SESSIONS.account_count()) if show_session_data: # privileged info - table = evtable.EvTable("|wAccount Name", + table = self.style_table("|wAccount Name", "|wOn for", "|wIdle", "|wPuppeting", @@ -444,7 +444,7 @@ class CmdWho(COMMAND_DEFAULT_CLASS): isinstance(session.address, tuple) and session.address[0] or session.address) else: # unprivileged - table = evtable.EvTable("|wAccount name", "|wOn for", "|wIdle") + table = self.style_table("|wAccount name", "|wOn for", "|wIdle") for session in session_list: if not session.logged_in: continue @@ -524,7 +524,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): options.pop("TTYPE", None) header = ("Name", "Value", "Saved") if saved_options else ("Name", "Value") - table = evtable.EvTable(*header) + table = self.style_table(*header) for key in sorted(options): row = [key, options[key]] if saved_options: @@ -870,3 +870,30 @@ class CmdQuell(COMMAND_DEFAULT_CLASS): else: self.msg("Quelling Account permissions%s. Use @unquell to get them back." % permstr) self._recache_locks(account) + + +class CmdStyle(COMMAND_DEFAULT_CLASS): + key = "@style" + switch_options = ['clear'] + + def func(self): + if not self.args: + self.list_styles() + return + self.set() + + def list_styles(self): + styles_table = self.style_table('Option', 'Description', 'Type', 'Value', width=78) + for op_key in self.account.options.options_dict.keys(): + op_found = self.account.options.get(op_key, return_obj=True) + styles_table.add_row(op_key, op_found.description, op_found.__class__.__name__, op_found.display()) + self.msg(str(styles_table)) + + def set(self): + try: + result = self.account.options.set(self.lhs, self.rhs) + except ValueError as e: + self.msg(str(e)) + return + self.msg('Success! The new value is: %s' % result) + diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index c260abaa2e..9e04b31cd7 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -112,7 +112,7 @@ def list_bans(banlist): if not banlist: return "No active bans were found." - table = evtable.EvTable("|wid", "|wname/ip", "|wdate", "|wreason") + table = self.style_table("|wid", "|wname/ip", "|wdate", "|wreason") for inum, ban in enumerate(banlist): table.add_row(str(inum + 1), ban[0] and ban[0] or ban[1], diff --git a/evennia/commands/default/cmdset_account.py b/evennia/commands/default/cmdset_account.py index 8173e461c5..c36a659844 100644 --- a/evennia/commands/default/cmdset_account.py +++ b/evennia/commands/default/cmdset_account.py @@ -38,6 +38,7 @@ class AccountCmdSet(CmdSet): self.add(account.CmdPassword()) self.add(account.CmdColorTest()) self.add(account.CmdQuell()) + self.add(account.CmdStyle()) # nicks self.add(general.CmdNick()) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index 2b8a0b21e0..8b031825fa 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -292,7 +292,7 @@ class CmdChannels(COMMAND_DEFAULT_CLASS): if self.cmdstring == "comlist": # just display the subscribed channels with no extra info - comtable = evtable.EvTable("|wchannel|n", "|wmy aliases|n", + comtable = self.style_table("|wchannel|n", "|wmy aliases|n", "|wdescription|n", align="l", maxwidth=_DEFAULT_WIDTH) for chan in subs: clower = chan.key.lower() @@ -306,7 +306,7 @@ class CmdChannels(COMMAND_DEFAULT_CLASS): " |waddcom|n/|wdelcom|n to sub/unsub):|n\n%s" % comtable) else: # full listing (of channels caller is able to listen to) - comtable = evtable.EvTable("|wsub|n", "|wchannel|n", "|wmy aliases|n", + comtable = self.style_table("|wsub|n", "|wchannel|n", "|wmy aliases|n", "|wlocks|n", "|wdescription|n", maxwidth=_DEFAULT_WIDTH) for chan in channels: clower = chan.key.lower() @@ -815,7 +815,7 @@ def _list_bots(): ircbots = [bot for bot in AccountDB.objects.filter(db_is_bot=True, username__startswith="ircbot-")] if ircbots: from evennia.utils.evtable import EvTable - table = EvTable("|w#dbref|n", "|wbotname|n", "|wev-channel|n", + table = self.style_table("|w#dbref|n", "|wbotname|n", "|wev-channel|n", "|wirc-channel|n", "|wSSL|n", maxwidth=_DEFAULT_WIDTH) for ircbot in ircbots: ircinfo = "%s (%s:%s)" % (ircbot.db.irc_channel, ircbot.db.irc_network, ircbot.db.irc_port) @@ -1051,7 +1051,7 @@ class CmdRSS2Chan(COMMAND_DEFAULT_CLASS): rssbots = [bot for bot in AccountDB.objects.filter(db_is_bot=True, username__startswith="rssbot-")] if rssbots: from evennia.utils.evtable import EvTable - table = EvTable("|wdbid|n", "|wupdate rate|n", "|wev-channel", + table = self.style_table("|wdbid|n", "|wupdate rate|n", "|wev-channel", "|wRSS feed URL|n", border="cells", maxwidth=_DEFAULT_WIDTH) for rssbot in rssbots: table.add_row(rssbot.id, rssbot.db.rss_rate, rssbot.db.ev_channel, rssbot.db.rss_url) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 8b15e7e3f7..b714c787a4 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -157,7 +157,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): if not nicklist: string = "|wNo nicks defined.|n" else: - table = evtable.EvTable("#", "Type", "Nick match", "Replacement") + table = self.style_table("#", "Type", "Nick match", "Replacement") for inum, nickobj in enumerate(nicklist): _, _, nickvalue, replacement = nickobj.value table.add_row(str(inum + 1), nickobj.db_category, _cy(nickvalue), _cy(replacement)) @@ -338,7 +338,7 @@ class CmdInventory(COMMAND_DEFAULT_CLASS): if not items: string = "You are not carrying anything." else: - table = evtable.EvTable(border="header") + table = self.style_table(border="header") for item in items: table.add_row("|C%s|n" % item.name, item.db.desc or "") string = "|wYou are carrying:\n%s" % table diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index 94b0b45cd5..81c9f360e7 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -445,7 +445,7 @@ class CmdObjects(COMMAND_DEFAULT_CLASS): nobjs = nobjs or 1 # fix zero-div error with empty database # total object sum table - totaltable = EvTable("|wtype|n", "|wcomment|n", "|wcount|n", "|w%%|n", border="table", align="l") + totaltable = self.style_table("|wtype|n", "|wcomment|n", "|wcount|n", "|w%%|n", border="table", align="l") totaltable.align = 'l' totaltable.add_row("Characters", "(BASE_CHARACTER_TYPECLASS)", nchars, "%.2f" % ((float(nchars) / nobjs) * 100)) totaltable.add_row("Rooms", "(location=None)", nrooms, "%.2f" % ((float(nrooms) / nobjs) * 100)) @@ -453,7 +453,7 @@ class CmdObjects(COMMAND_DEFAULT_CLASS): totaltable.add_row("Other", "", nother, "%.2f" % ((float(nother) / nobjs) * 100)) # typeclass table - typetable = EvTable("|wtypeclass|n", "|wcount|n", "|w%%|n", border="table", align="l") + typetable = self.style_table("|wtypeclass|n", "|wcount|n", "|w%%|n", border="table", align="l") typetable.align = 'l' dbtotals = ObjectDB.objects.object_totals() for path, count in dbtotals.items(): @@ -461,7 +461,7 @@ class CmdObjects(COMMAND_DEFAULT_CLASS): # last N table objs = ObjectDB.objects.all().order_by("db_date_created")[max(0, nobjs - nlim):] - latesttable = EvTable("|wcreated|n", "|wdbref|n", "|wname|n", "|wtypeclass|n", align="l", border="table") + latesttable = self.style_table("|wcreated|n", "|wdbref|n", "|wname|n", "|wtypeclass|n", align="l", border="table") latesttable.align = 'l' for obj in objs: latesttable.add_row(utils.datetime_format(obj.date_created), @@ -557,12 +557,12 @@ class CmdAccounts(COMMAND_DEFAULT_CLASS): # typeclass table dbtotals = AccountDB.objects.object_totals() - typetable = EvTable("|wtypeclass|n", "|wcount|n", "|w%%|n", border="cells", align="l") + typetable = self.style_table("|wtypeclass|n", "|wcount|n", "|w%%|n", border="cells", align="l") for path, count in dbtotals.items(): typetable.add_row(path, count, "%.2f" % ((float(count) / naccounts) * 100)) # last N table plyrs = AccountDB.objects.all().order_by("db_date_created")[max(0, naccounts - nlim):] - latesttable = EvTable("|wcreated|n", "|wdbref|n", "|wname|n", "|wtypeclass|n", border="cells", align="l") + latesttable = self.style_table("|wcreated|n", "|wdbref|n", "|wname|n", "|wtypeclass|n", border="cells", align="l") for ply in plyrs: latesttable.add_row(utils.datetime_format(ply.date_created), ply.dbref, ply.key, ply.path) @@ -613,7 +613,7 @@ class CmdService(COMMAND_DEFAULT_CLASS): if not switches or switches[0] == "list": # Just display the list of installed services and their # status, then exit. - table = EvTable("|wService|n (use @services/start|stop|delete)", "|wstatus", align="l") + table = self.style_table("|wService|n (use @services/start|stop|delete)", "|wstatus", align="l") for service in service_collection.services: table.add_row(service.name, service.running and "|gRunning" or "|rNot Running") caller.msg(str(table)) @@ -723,14 +723,14 @@ class CmdTime(COMMAND_DEFAULT_CLASS): def func(self): """Show server time data in a table.""" - table1 = EvTable("|wServer time", "", align="l", width=78) + table1 = self.style_table("|wServer time", "", align="l", width=78) table1.add_row("Current uptime", utils.time_format(gametime.uptime(), 3)) table1.add_row("Portal uptime", utils.time_format(gametime.portal_uptime(), 3)) table1.add_row("Total runtime", utils.time_format(gametime.runtime(), 2)) table1.add_row("First start", datetime.datetime.fromtimestamp(gametime.server_epoch())) table1.add_row("Current time", datetime.datetime.now()) table1.reformat_column(0, width=30) - table2 = EvTable("|wIn-Game time", "|wReal time x %g" % gametime.TIMEFACTOR, align="l", width=77, border_top=0) + table2 = self.style_table("|wIn-Game time", "|wReal time x %g" % gametime.TIMEFACTOR, align="l", width=77, border_top=0) epochtxt = "Epoch (%s)" % ("from settings" if settings.TIME_GAME_EPOCH else "server start") table2.add_row(epochtxt, datetime.datetime.fromtimestamp(gametime.game_epoch())) table2.add_row("Total time passed:", utils.time_format(gametime.gametime(), 2)) @@ -824,7 +824,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS): self.caller.msg(string % (rmem, pmem)) return # Display table - loadtable = EvTable("property", "statistic", align="l") + loadtable = self.style_table("property", "statistic", align="l") loadtable.add_row("Total CPU load", "%g %%" % loadavg) loadtable.add_row("Total computer memory usage", "%g MB (%g%%)" % (rmem, pmem)) loadtable.add_row("Process ID", "%g" % pid), @@ -850,7 +850,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS): self.caller.msg(string % (rmem, pmem, vmem)) return - loadtable = EvTable("property", "statistic", align="l") + loadtable = self.style_table("property", "statistic", align="l") loadtable.add_row("Server load (1 min)", "%g" % loadavg) loadtable.add_row("Process ID", "%g" % pid), loadtable.add_row("Memory usage", "%g MB (%g%%)" % (rmem, pmem)) @@ -875,7 +875,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS): total_num, cachedict = _IDMAPPER.cache_size() sorted_cache = sorted([(key, num) for key, num in cachedict.items() if num > 0], key=lambda tup: tup[1], reverse=True) - memtable = EvTable("entity name", "number", "idmapper %", align="l") + memtable = self.style_table("entity name", "number", "idmapper %", align="l") for tup in sorted_cache: memtable.add_row(tup[0], "%i" % tup[1], "%.2f" % (float(tup[1]) / total_num * 100)) @@ -907,7 +907,7 @@ class CmdTickers(COMMAND_DEFAULT_CLASS): if not all_subs: self.caller.msg("No tickers are currently active.") return - table = EvTable("interval (s)", "object", "path/methodname", "idstring", "db") + table = self.style_table("interval (s)", "object", "path/methodname", "idstring", "db") for sub in all_subs: table.add_row(sub[3], "%s%s" % (sub[0] or "[None]", diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 0aa0a60526..26f6049805 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -489,6 +489,42 @@ START_LOCATION = "#2" # issues. TYPECLASS_AGGRESSIVE_CACHE = True +###################################################################### +# Options and validators +###################################################################### + +# Replace or add entries in this dictionary to specify options available +# on accounts. An option goes goteth + +# Evennia uses for commands. Or add more entries! Accounts can change +# their own settings with a command, but this sets down defaults. + +# Option tuples are in this format: +# ("Description", 'Option Class', 'Default Value') + +OPTIONS_ACCOUNT_DEFAULT = { + 'border_color': ('Headers, footers, table borders, etc.', 'Color', 'M'), + 'header_star_color': ('* inside Header lines.', 'Color', 'm'), + 'header_text_color': ('Text inside Header lines.', 'Color', 'w'), + 'footer_text_color': ('Text inside Footer Lines.', 'Color', 'w'), + 'column_names_color': ('Table column header text.', 'Color', 'G'), + 'header_fill': ('Fill for Header lines.', 'Text', '='), + 'separator_fill': ('Fill for Separator Lines.', 'Text', '-'), + 'footer_fill': ('Fill for Footer Lines.', 'Text', '='), + 'help_category_color': ('Help category names.', 'Color', 'g'), + 'help_entry_color': ('Help entry names.', 'Color', 'c'), + 'timezone': ('Timezone for dates. @tz for a list.', 'Timezone', 'UTC') +} +# Modules holding Option classes, responsible for serializing the option and +# calling validator functions on it. Same-named functions in modules added +# later in this list will override those added earlier. +OPTION_MODULES = ['evennia.utils.optionclasses', ] +# Module holding validator functions. These are used as a resource for +# validating options, but can also be used as input validators in general.# +# Same-named functions in modules added later in this list will override those +# added earlier. +VALIDATOR_MODULES = ['evennia.utils.validatorfunctions', ] + ###################################################################### # Batch processors ###################################################################### @@ -521,7 +557,7 @@ TIME_GAME_EPOCH = None TIME_IGNORE_DOWNTIMES = False ###################################################################### -# Inlinefunc & PrototypeFuncs +# Inlinefunc, PrototypeFuncs ###################################################################### # Evennia supports inline function preprocessing. This allows users # to supply inline calls on the form $func(arg, arg, ...) to do diff --git a/evennia/utils/containers.py b/evennia/utils/containers.py index 8a286b44eb..7566f7b2cf 100644 --- a/evennia/utils/containers.py +++ b/evennia/utils/containers.py @@ -5,7 +5,7 @@ Containers from django.conf import settings -from evennia.utils.utils import class_from_module +from evennia.utils.utils import class_from_module, callables_from_module from evennia.utils import logger @@ -108,3 +108,48 @@ class GlobalScriptContainer(object): # Create singleton of the GlobalHandler for the API. GLOBAL_SCRIPTS = GlobalScriptContainer() + + +class ValidatorContainer(object): + """ + Loads and stores the final list of VALIDATOR FUNCTIONS. + + Can access these as properties or dictionary-contents. + """ + + def __init__(self): + self.valid_storage = {} + for module in settings.VALIDATOR_FUNC_MODULES: + self.valid_storage.update(callables_from_module(module)) + + def __getitem__(self, item): + return self.valid_storage.get(item, None) + + def __getattr__(self, item): + return self[item] + + +# Ensure that we have a Singleton of ValidHandler that is always loaded... and only needs to be loaded once. +VALIDATOR_FUNCS = ValidatorContainer() + + +class OptionContainer(object): + """ + Loads and stores the final list of OPTION CLASSES. + + Can access these as properties or dictionary-contents. + """ + def __init__(self): + self.option_storage = {} + for module in settings.OPTION_CLASS_MODULES: + self.option_storage.update(callables_from_module(module)) + + def __getitem__(self, item): + return self.option_storage.get(item, None) + + def __getattr__(self, item): + return self[item] + + +# Ensure that we have a Singleton that keeps all loaded Option classes +OPTION_CLASSES = OptionContainer() diff --git a/evennia/utils/option.py b/evennia/utils/option.py new file mode 100644 index 0000000000..2792fa0238 --- /dev/null +++ b/evennia/utils/option.py @@ -0,0 +1,116 @@ +from evennia.utils.utils import string_partial_matching +from evennia.utils.containers import OPTION_CONTAINER + + +class OptionHandler(object): + """ + This is a generic Option handler meant for Typed Objects - anything that implements AttributeHandler. + + It uses a dictionary to store-and-cache frequently used settings such as colors for borders or an + account's timezone. + + This is used for Account.options but it could be used by Scripts or Objects just as easily. All + it needs to be provided is an options_dict. + """ + + def __init__(self, obj, options_dict=None, save_category=None): + """ + Initialize an OptionHandler. + + Args: + obj (TypedObject): The Typed Object this sits on. Obj MUST implement the Evennia AttributeHandler + or this will barf. + options_dict (dict): A dictionary of option keys, where the values are options. The format of those + tuples is: ('key', "Description to show", 'option_type', ) + save_category (str): The Options data will be stored to this Attribute category on obj. + """ + if not options_dict: + options_dict = dict() + self.options_dict = options_dict + self.save_category = save_category + self.obj = obj + + # This dictionary stores the in-memory Options by their key. Values are the Option objects. + self.options = dict() + + # We use lazy-loading of each Option when it's called for, but it's good to have the save data + # on hand. + self.save_data = {s.key: s.value for s in obj.attributes.get(category=save_category, + return_list=True, return_obj=True) if s} + + def __getitem__(self, item): + """ + Shortcut to self.get(item) used as a different syntax. This entire object is + essentially a dictionary of option_key -> value. + + Args: + item (str): The Key of the item to get. + + Returns: + The Option's value. + """ + return self.get(item).value + + def get(self, item, return_obj=False): + """ + Retrieves an Option stored in the handler. Will load it if it doesn't exist. + + Args: + item (str): The key to retrieve. + return_obj (bool): If True, returns the actual option object instead of its value. + + Returns: + An option value (varies) or the Option itself. + """ + if item not in self.options_dict: + raise KeyError("Option not found!") + if item in self.options: + op_found = self.options[item] + else: + op_found = self._load_option(item) + if return_obj: + return op_found + return op_found.value + + def _load_option(self, key): + """ + Loads option on-demand if it has not been loaded yet. + + Args: + key (str): The option being loaded. + + Returns: + + """ + option_def = self.options_dict[key] + save_data = self.save_data.get(key, None) + self.obj.msg(save_data) + loaded_option = OPTION_CONTAINER[option_def[1]](self, key, option_def[0], option_def[2], save_data) + self.options[key] = loaded_option + return loaded_option + + def set(self, option, value, **kwargs): + """ + Change an individual option. + + Args: + option (str): The key of an option that can be changed. Allows partial matching. + value (str): The value that should be checked, coerced, and stored. + + Returns: + New value + """ + if not option: + raise ValueError("Option field blank!") + found = string_partial_matching(list(self.options_dict.keys()), option, ret_index=False) + if not found: + raise ValueError("Option not found!") + if len(found) > 1: + raise ValueError(f"That matched: {', '.join(found)}. Please be more specific.") + found = found[0] + op = self.get(found, return_obj=True) + op.set(value, **kwargs) + return op.display() + + + diff --git a/evennia/utils/optionclasses.py b/evennia/utils/optionclasses.py new file mode 100644 index 0000000000..fe864fd14f --- /dev/null +++ b/evennia/utils/optionclasses.py @@ -0,0 +1,279 @@ +import datetime as _dt +from evennia import logger as _log +from evennia.utils.ansi import ANSIString as _ANSI +from evennia.utils.validatorfunctions import _TZ_DICT +from evennia.utils.containers import VALIDATOR_CONTAINER as _VAL + + +class BaseOption(object): + """ + Abstract Class to deal with encapsulating individual Options. An Option has a name/key, a description + to display in relevant commands and menus, and a default value. It saves to the owner's Attributes using + its Handler's save category. + + Designed to be extremely overloadable as some options can be cantankerous. + + Properties: + valid: Shortcut to the loaded VALID_HANDLER. + validator_key (str): The key of the Validator this uses. + """ + validator_key = '' + + def __str__(self): + return self.key + + def __init__(self, handler, key, description, default, save_data=None): + """ + + Args: + handler (OptionHandler): The OptionHandler that 'owns' this Option. + key (str): The name this will be used for storage in a dictionary. Must be unique per + OptionHandler. + description (str): What this Option's text will show in commands and menus. + default: A default value for this Option. + save_data: Whatever was saved to Attributes. This differs by Option. + """ + self.handler = handler + self.key = key + self.default_value = default + self.description = description + self.save_data = save_data + + # Value Storage contains None until the Option is loaded. + self.value_storage = None + + # And it's not loaded until it's called upon to spit out its contents. + self.loaded = False + + def display(self, **kwargs): + """ + Renders the Option's value as something pretty to look at. + + Returns: + How the stored value should be projected to users. a raw timedelta is pretty ugly, y'know? + """ + return self.value + + def load(self): + """ + Takes the provided save data, validates it, and gets this Option ready to use. + + Returns: + Boolean: Whether loading was successful. + """ + if self.save_data is not None: + try: + self.value_storage = self.deserialize(self.save_data) + self.loaded = True + return True + except Exception as e: + _log.log_trace(e) + return False + + def save(self): + """ + Exports the current value to an Attribute. + + Returns: + None + """ + self.handler.obj.attributes.add(self.key, category=self.handler.save_category, value=self.serialize()) + + def deserialize(self, save_data): + """ + Perform sanity-checking on the save data. This isn't the same as Validators, as Validators deal with + user input. save data might be a timedelta or a list or some other object. isinstance() is probably + very useful here. + + Args: + save_data: The data to check. + + Returns: + Arbitrary: Whatever the Option needs to track, like a string or a datetime. Not the same as what + users are SHOWN. + """ + return save_data + + def serialize(self): + """ + Serializes the save data for Attribute storage if it's something complicated. + + Returns: + Whatever best handles the Attribute. + """ + return self.value_storage + + @property + def changed(self): + return self.value_storage != self.default_value + + @property + def default(self): + return self.default_value + + @property + def value(self): + if not self.loaded and self.save_data is not None: + self.load() + if self.loaded: + return self.value_storage + else: + return self.default + + @value.setter + def value(self, value): + self.set(value) + + def set(self, value, **kwargs): + """ + Takes user input, presumed to be a string, and changes the value if it is a valid input. + + Args: + value: The new value of this Option. + + Returns: + None + """ + final_value = self.validate(value, **kwargs) + self.value_storage = final_value + self.loaded = True + self.save() + + def validate(self, value, **kwargs): + """ + Validate user input, which is presumed to be a string. + + Args: + value (str): User input. + account (AccountDB): The Account that is performing the validation. This is necessary because of + other settings which may affect the check, such as an Account's timezone affecting how their + datetime entries are processed. + + Returns: + The results of a Validator call. Might be any kind of python object. + """ + return _VAL[self.validator_key](value, thing_name=self.key, **kwargs) + + +class Text(BaseOption): + validator_key = 'text' + + def deserialize(self, save_data): + got_data = str(save_data) + if not got_data: + raise ValueError(f"{self.key} expected Text data, got '{save_data}'") + return got_data + + +class Email(BaseOption): + validator_key = 'email' + + def deserialize(self, save_data): + got_data = str(save_data) + if not got_data: + raise ValueError(f"{self.key} expected String data, got '{save_data}'") + return got_data + + +class Boolean(BaseOption): + validator_key = 'boolean' + + def display(self, **kwargs): + if self.value: + return '1 - On/True' + return '0 - Off/False' + + def serialize(self): + return self.value + + def deserialize(self, save_data): + if not isinstance(save_data, bool): + raise ValueError(f"{self.key} expected Boolean, got '{save_data}'") + return save_data + + +class Color(BaseOption): + validator_key = 'color' + + def display(self, **kwargs): + return f'{self.value} - |{self.value}this|n' + + def deserialize(self, save_data): + if not save_data or len(_ANSI(f'|{save_data}|n')) > 0: + raise ValueError(f"{self.key} expected Color Code, got '{save_data}'") + return save_data + + +class Timezone(BaseOption): + validator_key = 'timezone' + + @property + def default(self): + return _TZ_DICT[self.default_value] + + def deserialize(self, save_data): + if save_data not in _TZ_DICT: + raise ValueError(f"{self.key} expected Timezone Data, got '{save_data}'") + return _TZ_DICT[save_data] + + def serialize(self): + return str(self.value_storage) + + +class UnsignedInteger(BaseOption): + validator_key = 'unsigned_integer' + + def deserialize(self, save_data): + if isinstance(save_data, int) and save_data >= 0: + return save_data + raise ValueError(f"{self.key} expected Whole Number 0+, got '{save_data}'") + + +class SignedInteger(BaseOption): + validator_key = 'signed_integer' + + def deserialize(self, save_data): + if isinstance(save_data, int): + return save_data + raise ValueError(f"{self.key} expected Whole Number, got '{save_data}'") + + +class PositiveInteger(BaseOption): + validator_key = 'positive_integer' + + def deserialize(self, save_data): + if isinstance(save_data, int) and save_data > 0: + return save_data + raise ValueError(f"{self.key} expected Whole Number 1+, got '{save_data}'") + + +class Duration(BaseOption): + validator_key = 'duration' + + def deserialize(self, save_data): + if isinstance(save_data, int): + return _dt.timedelta(0, save_data, 0, 0, 0, 0, 0) + raise ValueError(f"{self.key} expected Timedelta in seconds, got '{save_data}'") + + def serialize(self): + return self.value_storage.seconds + + +class Datetime(BaseOption): + validator_key = 'datetime' + + def deserialize(self, save_data): + if isinstance(save_data, int): + return _dt.datetime.utcfromtimestamp(save_data) + raise ValueError(f"{self.key} expected UTC Datetime in EPOCH format, got '{save_data}'") + + def serialize(self): + return int(self.value_storage.strftime('%s')) + + +class Future(Datetime): + validator_key = 'future' + + +class Lock(Text): + validator_key = 'lock' diff --git a/evennia/utils/validatorfunctions.py b/evennia/utils/validatorfunctions.py new file mode 100644 index 0000000000..41e5a8ffaf --- /dev/null +++ b/evennia/utils/validatorfunctions.py @@ -0,0 +1,204 @@ +""" +Contains all the validation functions. + +All validation functions must have a checker (probably a session) and entry arg. + +They can employ more paramters at your leisure. + + +""" + +import re as _re +import pytz as _pytz +import datetime as _dt +from django.core.exceptions import ValidationError as _error +from django.core.validators import validate_email as _val_email +from evennia.utils.ansi import ANSIString as _ansi +from evennia.utils.utils import string_partial_matching as _partial + +_TZ_DICT = {str(tz): _pytz.timezone(tz) for tz in _pytz.common_timezones} + + +def color(entry, thing_name='Color', **kwargs): + if not entry: + raise ValueError(f"Nothing entered for a {thing_name}!") + test_str = _ansi('|%s|n' % entry) + if len(test_str): + raise ValueError(f"'{entry}' is not a valid {thing_name}.") + return entry + + +def datetime(entry, thing_name='Datetime', account=None, from_tz=None, **kwargs): + """ + Process a datetime string in standard forms while accounting for the inputter's timezone. + + Args: + entry (str): A date string from a user. + thing_name (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. + + Returns: + datetime in utc. + """ + if not entry: + raise ValueError(f"No {thing_name} entered!") + if not from_tz: + from_tz = _pytz.UTC + utc = _pytz.UTC + now = _dt.datetime.utcnow().replace(tzinfo=utc) + cur_year = now.strftime('%Y') + split_time = entry.split(' ') + if len(split_time) == 3: + entry = f"{split_time[0]} {split_time[1]} {split_time[2]} {cur_year}" + elif len(split_time) == 4: + entry = f"{split_time[0]} {split_time[1]} {split_time[2]} {split_time[3]}" + else: + raise ValueError(f"{thing_name} must be entered in a 24-hour format such as: {now.strftime('%b %d %H:%H')}") + try: + local = _dt.datetime.strptime(input, '%b %d %H:%M %Y') + except ValueError: + raise ValueError(f"{thing_name} must be entered in a 24-hour format such as: {now.strftime('%b %d %H:%H')}") + local_tz = from_tz.localize(local) + return local_tz.astimezone(utc) + + +def duration(entry, thing_name='Duration', **kwargs): + """ + Take a string and derive a datetime timedelta from it. + + Args: + entry (string): This is a string from user-input. The intended format is, for example: "5d 2w 90s" for + 'five days, two weeks, and ninety seconds.' Invalid sections are ignored. + thing_name (str): Name to display this query as. + + Returns: + timedelta + + """ + time_string = entry.split(" ") + seconds = 0 + minutes = 0 + hours = 0 + days = 0 + weeks = 0 + + for interval in time_string: + if _re.match(r'^[\d]+s$', interval.lower()): + seconds =+ int(interval.lower().rstrip("s")) + elif _re.match(r'^[\d]+m$', interval): + minutes =+ int(interval.lower().rstrip("m")) + elif _re.match(r'^[\d]+h$', interval): + hours =+ int(interval.lower().rstrip("h")) + elif _re.match(r'^[\d]+d$', interval): + days =+ int(interval.lower().rstrip("d")) + elif _re.match(r'^[\d]+w$', interval): + weeks =+ int(interval.lower().rstrip("w")) + elif _re.match(r'^[\d]+y$', interval): + days =+ int(interval.lower().rstrip("y")) * 365 + else: + raise ValueError(f"Could not convert section '{interval}' to a {thing_name}.") + + return _dt.timedelta(days, seconds, 0, 0, minutes, hours, weeks) + + +def future(entry, thing_name="Future Datetime", from_tz=None, **kwargs): + time = datetime(entry, thing_name) + if time < _dt.datetime.utcnow(): + raise ValueError(f"That {thing_name} is in the past! Must give a Future datetime!") + return time + + +def signed_integer(entry, thing_name="Signed Integer", **kwargs): + if not entry: + raise ValueError(f"Must enter a whole number for {thing_name}!") + try: + num = int(entry) + except ValueError: + raise ValueError(f"Could not convert '{entry}' to a whole number for {thing_name}!") + return num + + +def positive_integer(entry, thing_name="Positive Integer", **kwargs): + num = signed_integer(entry, thing_name) + if not num >= 1: + raise ValueError(f"Must enter a whole number greater than 0 for {thing_name}!") + return num + + +def unsigned_integer(entry, thing_name="Unsigned Integer", **kwargs): + num = signed_integer(entry, thing_name) + if not num >= 0: + raise ValueError(f"{thing_name} must be a whole number greater than or equal to 0!") + return num + + +def boolean(entry, thing_name="True/False", **kwargs): + """ + Simplest check in computer logic, right? This will take user input to flick the switch on or off + Args: + entry (str): A value such as True, On, Enabled, Disabled, False, 0, or 1. + thing_name (str): What kind of Boolean we are setting. What Option is this for? + + Returns: + Boolean + """ + entry = entry.upper() + error = f"Must enter 0 (false) or 1 (true) for {thing_name}. Also accepts True, False, On, Off, Yes, No, Enabled, and Disabled" + if not entry: + raise ValueError(error) + if entry in ('1', 'TRUE', 'ON', 'ENABLED', 'ENABLE', 'YES'): + return True + if entry in ('0', 'FALSE', 'OFF', 'DISABLED', 'DISABLE', 'NO'): + return False + raise ValueError(error) + + +def timezone(entry, thing_name="Timezone", **kwargs): + """ + Takes user input as string, and partial matches a Timezone. + + Args: + entry (str): The name of the Timezone. + thing_name (str): What this Timezone is used for. + + Returns: + A PYTZ timezone. + """ + if not entry: + raise ValueError(f"No {thing_name} entered!") + found = _partial(list(_TZ_DICT.keys()), entry, ret_index=False) + if len(found) > 1: + raise ValueError(f"That matched: {', '.join(str(t) for t in found)}. Please be more specific!") + if found: + return _TZ_DICT[found[0]] + raise ValueError(f"Could not find timezone '{entry}' for {thing_name}!") + + +def email(entry, thing_name="Email Address", **kwargs): + if not entry: + raise ValueError("Email address field empty!") + try: + _val_email(entry) # offloading the hard work to Django! + except _error: + raise ValueError(f"That isn't a valid {thing_name}!") + return entry + + +def lock(entry, thing_name='locks', access_options=None, **kwargs): + entry = entry.strip() + if not entry: + raise ValueError(f"No {thing_name} entered to set!") + for locksetting in entry.split(';'): + access_type, lockfunc = locksetting.split(':', 1) + if not access_type: + raise ValueError("Must enter an access type!") + if access_options: + if access_type not in access_options: + raise ValueError(f"Access type must be one of: {', '.join(access_options)}") + if not lockfunc: + raise ValueError("Lock func not entered.") + return entry