Added Option Classes.

This commit is contained in:
Andrew Bastien 2019-04-11 10:12:16 -04:00
parent 1678db2435
commit 5bc9a42bb5
9 changed files with 472 additions and 66 deletions

View file

@ -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__

View file

@ -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')

View file

@ -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

View file

@ -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:

View file

@ -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
######################################################################

319
evennia/utils/opclasses.py Normal file
View file

@ -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'

View file

@ -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', <default value>)
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)

View file

@ -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()

View file

@ -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
return entry