diff --git a/evennia/__init__.py b/evennia/__init__.py index 68dc41c386..20a7a16a7e 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -109,7 +109,6 @@ TASK_HANDLER = None TICKER_HANDLER = None MONITOR_HANDLER = None CHANNEL_HANDLER = None -VALID_HANDLER = None @@ -154,7 +153,7 @@ def _init(): 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 settings, lockfuncs, logger, utils, gametime, ansi, spawn, managers - global contrib, TICKER_HANDLER, MONITOR_HANDLER, SESSION_HANDLER, CHANNEL_HANDLER, TASK_HANDLER, VALID_HANDLER + global contrib, TICKER_HANDLER, MONITOR_HANDLER, SESSION_HANDLER, CHANNEL_HANDLER, TASK_HANDLER global EvMenu, EvTable, EvForm, EvMore, EvEditor global ANSIString @@ -215,7 +214,6 @@ def _init(): from .server.sessionhandler import SESSION_HANDLER from .comms.channelhandler import CHANNEL_HANDLER from .scripts.monitorhandler import MONITOR_HANDLER - from .utils.valid import VALID_HANDLER # initialize the doc string global __doc__ diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 68c39b22c7..35a6e76c53 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -1385,7 +1385,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): return look_string @lazy_property - def option(self): + def options(self): return OptionHandler(self, options_dict=settings.ACCOUNT_OPTIONS, save_category='option') diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index d552d05fc9..151961ab7a 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -883,14 +883,15 @@ class CmdStyle(COMMAND_DEFAULT_CLASS): self.set() def list_styles(self): - styles_table = self.style_table('Option', 'Description', 'Type', 'Value') - for k, v in self.account.option.options_dict.items(): - styles_table.add_row(k, v[0], v[1], v[2]) + 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.expect_type, op_found.display()) self.msg(str(styles_table)) def set(self): try: - result = self.account.option.set(self.lhs, self.rhs) + result = self.account.options.set(self.lhs, self.rhs, account=self.account) except ValueError as e: self.msg(str(e)) return diff --git a/evennia/commands/default/muxcommand.py b/evennia/commands/default/muxcommand.py index e83e7f1033..2dc9c18b60 100644 --- a/evennia/commands/default/muxcommand.py +++ b/evennia/commands/default/muxcommand.py @@ -236,8 +236,8 @@ class MuxCommand(Command): return self.session.protocol_flags['SCREENWIDTH'][0] def style_table(self, *args, **kwargs): - border_color = self.account.option.get('border_color') - column_color = self.account.option.get('column_names_color') + 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] @@ -267,9 +267,9 @@ class MuxCommand(Command): def render_header(self, header_text=None, fill_character=None, edge_character=None, mode='header', color_header=True): colors = dict() - colors['border'] = self.account.option.get('border_color') - colors['headertext'] = self.account.option.get('%s_text_color' % mode) - colors['headerstar'] = self.account.option.get('%s_star_color' % mode) + 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: @@ -288,7 +288,7 @@ class MuxCommand(Command): else: center_string = '' - fill_character = self.account.option.get('%s_fill' % mode) + fill_character = self.account.options.get('%s_fill' % mode) remain_fill = width - len(center_string) if remain_fill % 2 == 0: diff --git a/evennia/settings_default.py b/evennia/settings_default.py index a43529b825..d7c2a55eb3 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -493,21 +493,24 @@ TYPECLASS_AGGRESSIVE_CACHE = True # Options ###################################################################### -# Replace entries in this dictionary to change the default stylings -# Evennia uses for commands. Or add more entries! Accounts can have -# per-user settings that override these. +# Replace entries in this dictionary to change the default options +# 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') ACCOUNT_OPTIONS = { - '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.', 'word', '='), - 'separator_fill': ('Fill for Separator Lines.', 'word', '-'), - 'footer_fill': ('Fill for Footer Lines.', 'word', '='), - 'help_category_color': ('Help category names.', 'color', 'g'), - 'help_entry_color': ('Help entry names.', 'color', 'c'), + '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'), } @@ -564,6 +567,10 @@ PROTOTYPEFUNC_MODULES = ["evennia.utils.prototypefuncs", # override those in earlier ones. VALIDFUNC_MODULES = ['evennia.utils.validfuncs', ] +# Modules holding Option classes. Those in later modules will +# override ones in earlier modules. +OPTIONCLASS_MODULES = ['evennia.utils.opclasses', ] + ###################################################################### # Default Account setup and access ###################################################################### diff --git a/evennia/utils/opclasses.py b/evennia/utils/opclasses.py new file mode 100644 index 0000000000..041675df14 --- /dev/null +++ b/evennia/utils/opclasses.py @@ -0,0 +1,319 @@ +import datetime as _dt +from evennia.utils.ansi import ANSIString as _ANSI +from evennia.utils.validfuncs import _TZ_DICT +from evennia.utils.valid import VALID_HANDLER 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: + expect_type (str): What users will see this as asking for. Example: Color or email. + valid: Shortcut to the loaded VALID_HANDLER. + valid_type (str): The key of the Validator this uses. + """ + expect_type = '' + valid = _VAL + valid_type = '' + + 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 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.valid_save(self.save_data) + self.loaded = True + return True + except Exception as e: + print(e) # need some kind of error message here! + return False + + def customized(self): + return self.value_storage != self.default_value + + def valid_save(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 clear(self): + """ + Resets this Option to default settings. + + Returns: + self. Why? + """ + self.value_storage = None + self.loaded = False + return self + + @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 + + def validate(self, value, account): + """ + 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 self.do_validate(value, account) + + def do_validate(self, value, account): + """ + Second layer of abstraction on validation due to design choices. + + Args: + value: + account: + + Returns: + + """ + return self.valid[self.valid_type](value, thing_name=self.key, account=account) + + def set(self, value, account): + """ + Takes user input, presumed to be a string, and changes the value if it is a valid input. + + Args: + value: + account: + + Returns: + + """ + final_value = self.validate(value, account) + self.value_storage = final_value + self.loaded = True + self.save() + return self.display() + + def display(self): + """ + 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 export(self): + """ + Serializes the save data for Attribute storage if it's something complicated. + + Returns: + Whatever best handles the Attribute. + """ + return self.value_storage + + 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.export()) + + +class Text(_BaseOption): + expect_type = 'Text' + valid_type = 'text' + + def do_validate(self, value, account): + if not str(value): + raise ValueError("Must enter some text!") + return str(value) + + def valid_save(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): + expect_type = 'Email' + valid_type = 'email' + + def valid_save(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): + expect_type = 'Boolean' + valid_type = 'boolean' + + def display(self): + if self.value: + return '1 - On/True' + return '0 - Off/False' + + def export(self): + return self.value + + def valid_save(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): + expect_type = 'Color' + valid_type = 'color' + + def display(self): + return f'{self.value} - |{self.value}this|n' + + def valid_save(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): + expect_type = 'Timezone' + valid_type = 'timezone' + + @property + def default(self): + return _TZ_DICT[self.default_value] + + def valid_save(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 export(self): + return str(self.value_storage) + + +class UnsignedInteger(_BaseOption): + expect_type = 'Whole Number 0+' + valid_type = 'unsigned_integer' + + def valid_save(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): + expect_type = 'Whole Number' + valid_type = 'signed_integer' + + def valid_save(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): + expect_type = 'Whole Number 1+' + valid_type = 'positive_integer' + + def valid_save(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): + expect_type = 'Duration' + valid_type = 'duration' + + def valid_save(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 export(self): + return self.value_storage.seconds + + +class Datetime(_BaseOption): + expect_type = 'Datetime' + valid_type = 'datetime' + + def valid_save(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 export(self): + return int(self.value_storage.strftime('%s')) + + +class Future(Datetime): + expect_type = 'Future Datetime' + valid_type = 'future' + + +class Lock(Text): + expect_type = 'Lock String' + valid_type = 'lock' diff --git a/evennia/utils/option.py b/evennia/utils/option.py index 4977f49bcb..2d54c0f51b 100644 --- a/evennia/utils/option.py +++ b/evennia/utils/option.py @@ -1,4 +1,27 @@ -from evennia.utils.utils import string_partial_matching +from django.conf import settings +from evennia.utils.utils import string_partial_matching, callables_from_module + + +class OptionManager(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.OPTIONCLASS_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 Options. +OPTION_MANAGER = OptionManager() class OptionHandler(object): @@ -7,44 +30,71 @@ class OptionHandler(object): 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): + return self.get(item).value + + def get(self, item, return_obj=False): if item not in self.options_dict: raise KeyError("Option not found!") if item in self.options: - return self.options[item] - import evennia - option_def = self.options_dict[item] - save_data = self.obj.attributes.get(item, category=self.save_category) - if not save_data: - return evennia.VALID_HANDLER[option_def[1]](option_def[2]) - self.options[item] = save_data - return save_data + op_found = self.options[item] + else: + op_found = self.load_option(item) + if return_obj: + return op_found + return op_found.value - def get(self, item): - return self[item] + def load_option(self, key): + option_def = self.options_dict[key] + save_data = self.save_data.get(key, None) + self.obj.msg(save_data) + loaded_option = OPTION_MANAGER[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): + def set(self, option, value, account): """ 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. + account (AccountDB): The Account performing the setting. Necessary due to other + option lookups like timezone. Returns: New value """ - import evennia if not option: raise ValueError("Option field blank!") found = string_partial_matching(list(self.options_dict.keys()), option, ret_index=False) @@ -53,10 +103,5 @@ class OptionHandler(object): if len(found) > 1: raise ValueError(f"That matched: {', '.join(found)}. Please be more specific.") found = found[0] - option_def = self.options_dict[found] - if not value: - raise ValueError("Value field blank!") - new_value = evennia.VALID_HANDLER[option_def[1]](value, thing_name=found) - self.obj.attributes.add(found, category=self.save_category, value=new_value) - self.options[found] = new_value - return new_value + op = self.get(found, return_obj=True) + return op.set(value, account) diff --git a/evennia/utils/valid.py b/evennia/utils/valid.py index 29000821ee..9ac80ab452 100644 --- a/evennia/utils/valid.py +++ b/evennia/utils/valid.py @@ -3,6 +3,11 @@ from evennia.utils.utils import callables_from_module class ValidHandler(object): + """ + Loads and stores the final list of VALIDATOR FUNCTIONS. + + Can access these as properties or dictionary-contents. + """ def __init__(self): self.valid_storage = {} @@ -12,5 +17,9 @@ class ValidHandler(object): 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. VALID_HANDLER = ValidHandler() diff --git a/evennia/utils/validfuncs.py b/evennia/utils/validfuncs.py index a403dd3bb5..08d1d6f23e 100644 --- a/evennia/utils/validfuncs.py +++ b/evennia/utils/validfuncs.py @@ -4,6 +4,8 @@ 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 @@ -17,7 +19,7 @@ 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'): +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) @@ -26,13 +28,18 @@ def color(entry, thing_name='Color'): return entry -def datetime(entry, thing_name='Datetime', from_tz=None): +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. - from_tz (pytz): An instance of pytz from the user. + 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. @@ -59,7 +66,7 @@ def datetime(entry, thing_name='Datetime', from_tz=None): return local_tz.astimezone(utc) -def duration(entry, thing_name='Duration'): +def duration(entry, thing_name='Duration', **kwargs): """ Take a string and derive a datetime timedelta from it. @@ -98,14 +105,14 @@ def duration(entry, thing_name='Duration'): return _dt.timedelta(days, seconds, 0, 0, minutes, hours, weeks) -def future(entry, thing_name="Future Datetime", from_tz=None): +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"): +def signed_integer(entry, thing_name="Signed Integer", **kwargs): if not entry: raise ValueError(f"Must enter a whole number for {thing_name}!") try: @@ -115,21 +122,30 @@ def signed_integer(entry, thing_name="Signed Integer"): return num -def positive_integer(entry, thing_name="Positive Integer"): +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"): +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"): +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: @@ -141,24 +157,36 @@ def boolean(entry, thing_name="True/False"): raise ValueError(error) -def timezone(entry, thing_name="Timezone"): +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(_TZ_DICT.keys(), entry) + found = _partial(list(_TZ_DICT.keys()), entry) if found: return _TZ_DICT[found] raise ValueError(f"Could not find timezone '{entry}' for {thing_name}!") -def email(entry, thing_name="Email Address"): +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! + _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='lockstring', options=None): +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!") @@ -166,10 +194,9 @@ def lock(entry, thing_name='lockstring', options=None): access_type, lockfunc = locksetting.split(':', 1) if not access_type: raise ValueError("Must enter an access type!") - if options: - accmatch = _partial(options, access_type) - if not accmatch: - raise ValueError(f"Access type must be one of: {', '.join(options)}") + 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 \ No newline at end of file + return entry