Refactor EvForm code for readability and style

This commit is contained in:
Griatch 2022-11-05 13:25:53 +01:00
parent 9c0f6a1b0f
commit f48db64a7c
2 changed files with 270 additions and 237 deletions

View file

@ -132,11 +132,12 @@ form will raise an error.
""" """
import re
import copy import copy
from evennia.utils.evtable import EvCell, EvTable import re
from evennia.utils.utils import all_from_module, to_str, is_iter
from evennia.utils.ansi import ANSIString from evennia.utils.ansi import ANSIString
from evennia.utils.evtable import EvCell, EvTable
from evennia.utils.utils import all_from_module, is_iter, to_str
# non-valid form-identifying characters (which can thus be # non-valid form-identifying characters (which can thus be
# used as separators between forms without being detected # used as separators between forms without being detected
@ -148,39 +149,6 @@ INVALID_FORMCHARS = r"\s\/\|\\\*\_\-\#\<\>\~\^\:\;\.\,"
_ANSI_ESCAPE = re.compile(r"\|\|") _ANSI_ESCAPE = re.compile(r"\|\|")
def _to_rect(lines):
"""
Forces all lines to be as long as the longest
Args:
lines (list): list of `ANSIString`s
Returns:
(list): list of `ANSIString`s of
same length as the longest input line
"""
maxl = max(len(line) for line in lines)
return [line + " " * (maxl - len(line)) for line in lines]
def _to_ansi(obj, regexable=False):
"convert to ANSIString"
if isinstance(obj, ANSIString):
return obj
elif isinstance(obj, str):
# since ansi will be parsed twice (here and in the normal ansi send), we have to
# escape the |-structure twice. TODO: This is tied to the default color-tag syntax
# which is not ideal for those wanting to replace/extend it ...
obj = _ANSI_ESCAPE.sub(r"||||", obj)
if isinstance(obj, dict):
return dict((key, _to_ansi(value, regexable=regexable)) for key, value in obj.items())
elif is_iter(obj):
return [_to_ansi(o) for o in obj]
else:
return ANSIString(obj, regexable=regexable)
class EvForm: class EvForm:
""" """
This object is instantiated with a text file and parses This object is instantiated with a text file and parses
@ -190,25 +158,48 @@ class EvForm:
""" """
def __init__(self, filename=None, cells=None, tables=None, form=None, **kwargs): # cell option defaults
cell_options = {
"pad_left": 0,
"pad_right": 0,
"pad_top": 0,
"pad_bottom": 0,
"align": "l",
"valign": "t",
"enforce_size": True,
}
# table option defaults
table_options = {
"pad_left": 0,
"pad_right": 0,
"pad_top": 0,
"pad_bottom": 0,
"align": "l",
"valign": "t",
"enforce_size": True,
}
def __init__(self, data=None, cells=None, tables=None, **kwargs):
""" """
Initiate the form Initiate the form
Keyword Args: Keyword Args:
filename (str): Path to template file. data (str or dict): Path to template file or a dict with
"formchar", "tablechar" and "form" keys (not case sensitive, so FORM etc
also works, to stay compatible with the in-file names). While "form/FORM"
is required, if FORMCHAR/TABLECHAR are not given, they will default to
'x' and 'c' respectively.
cells (dict): A dictionary mapping `{id: text}` cells (dict): A dictionary mapping `{id: text}`
tables (dict): A dictionary mapping `{id: EvTable}`. tables (dict): A dictionary mapping `{id: EvTable}`.
form (dict): A dictionary
`{"FORMCHAR":char, "TABLECHAR":char, "FORM":templatestring}`.
If this is given, filename is not read.
Notes: Notes:
Other kwargs are fed as options to the EvCells and EvTables Other kwargs are fed as options to the EvCells and EvTables
(see `evtable.EvCell` and `evtable.EvTable` for more info). (see `evtable.EvCell` and `evtable.EvTable` for more info).
""" """
self.filename = filename self.indata = data # storing here so we can reload later in case of a filename
self.input_form_dict = form self.options = self._parse_inkwargs(**kwargs)
self.cells_mapping = ( self.cells_mapping = (
dict((to_str(key), value) for key, value in cells.items()) if cells else {} dict((to_str(key), value) for key, value in cells.items()) if cells else {}
@ -217,253 +208,295 @@ class EvForm:
dict((to_str(key), value) for key, value in tables.items()) if tables else {} dict((to_str(key), value) for key, value in tables.items()) if tables else {}
) )
self.cellchar = "x" # work arrays
self.tablechar = "c" self.mapping = {}
self.raw_form = [] self.raw_form = []
self.form = [] self.form = []
# clean kwargs (these cannot be overridden) # will parse and build the form
self.reload()
def _parse_indata(self):
"""
Parse and validate the `self.indata` property. We do this in order to be able to
re-load the evform module if indata is a filename and catch any on-file changes.
Returns:
dict: The data dict parsed/generated from the in-data.
"""
data = self.indata
default_formchar = "x"
default_tablechar = "c"
if isinstance(data, str):
# a module path - read all variables from it
data = all_from_module(data)
if isinstance(data, dict):
data = {
"form": str(data.get("form", data.get("FORM", None))),
"formchar": str(data.get("formchar", data.get("FORMCHAR", default_formchar))),
"tablechar": str(data.get("tablechar", data.get("TABLECHAR", default_tablechar))),
}
else:
raise RuntimeError(f"EvForm invalid input: {data}.")
if not data or data["form"] is None:
raise RuntimeError("Evform data must specify a valid 'form' or 'FORM'.")
# handle empty or multi-character form/tablechars (not supported)
data["formchar"] = data["formchar"][0] if data["formchar"] else default_formchar
data["tablechar"] = data["tablechar"][0] if data["tablechar"] else default_tablechar
if re.match(rf"[{INVALID_FORMCHARS}]", data["formchar"]):
raise RuntimeError(f"Invalid formchar: {data['formchar']}")
if re.match(rf"[{INVALID_FORMCHARS}]", data["tablechar"]):
raise RuntimeError(f"Invalid tablechar: {data['tablechar']}")
return data
def _parse_inkwargs(self, **kwargs):
"""
Validate incoming kwargs that will be passed on to become cell/table options.
Keyword Args:
any: Kwargs to process.
Returns:
dict: A validated/cleaned kwarg to use for options.
"""
if "filename" in kwargs:
raise DeprecationWarning(
"EvForm's 'filename' kwarg was renamed to 'data' and can now accept both "
"a python path and a dict with 'FORMCHAR', 'TABLECHAR' and 'FORM' keys."
)
if "form" in kwargs:
raise DeprecationWarning(
"EvForms's 'form' kwarg was renamed to 'data' and can now accept both "
"a ptyhon path and a dict detailing the form."
)
# clean cell kwarg options (these cannot be overridden on the cell but must be controlled
# by the evform itself)
kwargs.pop("enforce_size", None) kwargs.pop("enforce_size", None)
kwargs.pop("width", None) kwargs.pop("width", None)
kwargs.pop("height", None) kwargs.pop("height", None)
# table/cell options
self.options = kwargs
self.reload() return kwargs
def _parse_rectangles(self, cellchar, tablechar, form, **kwargs): def _parse_to_raw_form(self):
""" """
Parse a form for rectangular formfields identified by formchar Forces all lines to be as long as the longest line, filling with whitespace.
enclosing an identifier.
Args:
lines (list): list of `ANSIString`s
Returns:
(list): list of `ANSIString`s of
same length as the longest input line
""" """
raw_form = EvForm._to_ansi(self.data["form"].split("\n"))
maxl = max(len(line) for line in raw_form)
raw_form = [line + " " * (maxl - len(line)) for line in raw_form]
if raw_form and not raw_form[0].strip():
# the first line is normally empty, we strip it.
raw_form = raw_form[1:]
return raw_form
# update options given at creation with new input - this @staticmethod
# allows e.g. self.map() to add custom settings for individual def _to_ansi(obj, regexable=False):
# cells/tables "convert anything to ANSIString"
custom_options = copy.copy(self.options)
custom_options.update(kwargs) if isinstance(obj, ANSIString):
return obj
elif isinstance(obj, str):
# since ansi will be parsed twice (here and in the normal ansi send), we have to
# escape the |-structure twice. TODO: This is tied to the default color-tag syntax
# which is not ideal for those wanting to replace/extend it ...
obj = _ANSI_ESCAPE.sub(r"||||", obj)
if isinstance(obj, dict):
return dict(
(key, EvForm._to_ansi(value, regexable=regexable)) for key, value in obj.items()
)
# regular _to_ansi (from EvTable)
elif is_iter(obj):
return [EvForm._to_ansi(o) for o in obj]
else:
return ANSIString(obj, regexable=regexable)
def _rectangles_to_mapping(self):
"""
Parse a form for rectangular formfields identified by formchar/tablechar enclosing an
identifier.
"""
formchar = self.data["formchar"]
tablechar = self.data["tablechar"]
form = self.raw_form
cell_options = copy.copy(self.cell_options)
cell_options.update(self.options)
table_options = copy.copy(self.table_options)
table_options.update(self.options)
nform = len(form) nform = len(form)
mapping = {} mapping = {}
cell_coords = {}
table_coords = {}
# Locate the identifier tags and the horizontal end coords for all forms def _get_rectangles(char):
re_cellchar = re.compile( """Find all identified rectangles marked with given char"""
r"%s+([^%s%s]+)%s+" % (cellchar, INVALID_FORMCHARS, cellchar, cellchar) rects = []
) coords = {}
re_tablechar = re.compile( regex = re.compile(rf"{char}+([^{INVALID_FORMCHARS}{char}]+){char}+")
r"%s+([^%s%s|+])%s+" % (tablechar, INVALID_FORMCHARS, tablechar, tablechar)
)
for iy, line in enumerate(_to_ansi(form, regexable=True)):
# find cells
ix0 = 0
while True:
match = re_cellchar.search(line, ix0)
if match:
# get the width of the rectangle directly from the match
cell_coords[match.group(1)] = [iy, match.start(), match.end()]
ix0 = match.end()
else:
break
# find tables
ix0 = 0
while True:
match = re_tablechar.search(line, ix0)
if match:
# get the width of the rectangle directly from the match
table_coords[match.group(1)] = [iy, match.start(), match.end()]
ix0 = match.end()
else:
break
# get rectangles and assign EvCells # find the start/width of rectangles for each line
for key, (iy, leftix, rightix) in cell_coords.items(): for iy, line in enumerate(EvForm._to_ansi(form, regexable=True)):
# scan up to find top of rectangle ix0 = 0
dy_up = 0 while True:
if iy > 0: match = regex.search(line, ix0)
for i in range(1, iy): if match:
if all(form[iy - i][ix] == cellchar for ix in range(leftix, rightix)): # get the width of the rectangle directly from the match
dy_up += 1 coords[match.group(1)] = [iy, match.start(), match.end()]
else: ix0 = match.end()
break
# find bottom edge of rectangle
dy_down = 0
if iy < nform - 1:
for i in range(1, nform - iy - 1):
if all(form[iy + i][ix] == cellchar for ix in range(leftix, rightix)):
dy_down += 1
else: else:
break break
# we have our rectangle. Calculate size of EvCell. for key, (iy, leftix, rightix) in coords.items():
iyup = iy - dy_up # scan up to find top of rectangle
iydown = iy + dy_down dy_up = 0
width = rightix - leftix if iy > 0:
height = abs(iyup - iydown) + 1 for i in range(1, iy):
if all(form[iy - i][ix] == char for ix in range(leftix, rightix)):
dy_up += 1
else:
break
# find bottom edge of rectangle
dy_down = 0
if iy < nform - 1:
for i in range(1, nform - iy - 1):
if all(form[iy + i][ix] == char for ix in range(leftix, rightix)):
dy_down += 1
else:
break
# we have all the coordinates we need. Create EvCell. # we have our rectangle. Calculate size
iyup = iy - dy_up
iydown = iy + dy_down
width = rightix - leftix
height = abs(iyup - iydown) + 1
# store (key, y, x, width, height) of triangle
rects.append((key, iyup, leftix, width, height))
return rects
# Map EvCells into form rectangles
for (key, y, x, width, height) in _get_rectangles(formchar):
# get data to populate cell
data = self.cells_mapping.get(key, "") data = self.cells_mapping.get(key, "")
# if key == "1": # generate Cell on the fly
cell = EvCell(data, width=width, height=height, **cell_options)
options = { mapping[key] = (y, x, width, height, cell)
"pad_left": 0,
"pad_right": 0,
"pad_top": 0,
"pad_bottom": 0,
"align": "l",
"valign": "t",
"enforce_size": True,
}
options.update(custom_options)
# if key=="4":
mapping[key] = ( # Map EvTables into form rectangles
iyup, for (key, y, x, width, height) in _get_rectangles(tablechar):
leftix,
width,
height,
EvCell(data, width=width, height=height, **options),
)
# get rectangles and assign Tables # get EvTable from mapping
for key, (iy, leftix, rightix) in table_coords.items():
# scan up to find top of rectangle
dy_up = 0
if iy > 0:
for i in range(1, iy):
if all(form[iy - i][ix] == tablechar for ix in range(leftix, rightix)):
dy_up += 1
else:
break
# find bottom edge of rectangle
dy_down = 0
if iy < nform - 1:
for i in range(1, nform - iy - 1):
if all(form[iy + i][ix] == tablechar for ix in range(leftix, rightix)):
dy_down += 1
else:
break
# we have our rectangle. Calculate size of Table.
iyup = iy - dy_up
iydown = iy + dy_down
width = rightix - leftix
height = abs(iyup - iydown) + 1
# we have all the coordinates we need. Create Table.
table = self.tables_mapping.get(key, None) table = self.tables_mapping.get(key, None)
options = {
"pad_left": 0,
"pad_right": 0,
"pad_top": 0,
"pad_bottom": 0,
"align": "l",
"valign": "t",
"enforce_size": True,
}
options.update(custom_options)
if table: if table:
table.reformat(width=width, height=height, **options) table.reformat(width=width, height=height, **table_options)
else: else:
table = EvTable(width=width, height=height, **options) table = EvTable(width=width, height=height, **table_options)
mapping[key] = (iyup, leftix, width, height, table)
mapping[key] = (y, x, width, height, table)
return mapping return mapping
def _populate_form(self, raw_form, mapping): def _build_form(self):
""" """
Insert cell contents into form at given locations Insert cell/table contents into form at given locations to create
the final result.
""" """
form = copy.copy(raw_form) form = copy.copy(self.raw_form)
for key, (iy0, ix0, width, height, cell_or_table) in mapping.items(): mapping = self.mapping
for key, (y, x, width, height, cell_or_table) in mapping.items():
# rect is a list of <height> lines, each <width> wide # rect is a list of <height> lines, each <width> wide
rect = cell_or_table.get() rect = cell_or_table.get()
for il, rectline in enumerate(rect): for il, rectline in enumerate(rect):
formline = form[iy0 + il] formline = form[y + il]
# insert new content, replacing old # insert new content, replacing old
form[iy0 + il] = formline[:ix0] + rectline + formline[ix0 + width :] form[y + il] = formline[:x] + rectline + formline[x + width :]
return form return form
def map(self, cells=None, tables=None, **kwargs): def reload(self):
""" """
Add mapping for form. Creates the form from a filename or data structure.
Args: Args:
cells (dict): A dictionary of {identifier:celltext} data (str or dict): Can be used to update an existing form using
tables (dict): A dictionary of {identifier:table} the same cells/tables provided on initialization or using `.map()`.
Notes:
Kwargs are passed through to Cel creation.
"""
self.data = self._parse_indata()
# Create raw form matrix, indexable with (y, x) coords
self.raw_form = self._parse_to_raw_form()
# parse and identify all rectangles in the form
self.mapping = self._rectangles_to_mapping()
# combine mapping with form template into a final result
self.form = self._build_form()
def map(self, cells=None, tables=None, data=None, **kwargs):
"""
Add mapping for form. This allows for updating an existing
evform.
Args:
cells (dict): A dictionary of {identifier:celltext}. These
will be appended to the existing mappings.
tables (dict): A dictionary of {identifier:table}. Will
be appended to the existing mapping.
data (str or dict): A path to a evform module or a dict with
the needed "FORM", "TABLE/FORMCHAR" keys. Will replace
the originally initialized form.
Keyword Args:
These will be appended to the existing cell/table options.
Notes: Notes:
kwargs will be forwarded to tables/cells. See kwargs will be forwarded to tables/cells. See
`evtable.EvCell` and `evtable.EvTable` for info. `evtable.EvCell` and `evtable.EvTable` for info.
""" """
# clean kwargs (these cannot be overridden) if data:
kwargs.pop("enforce_size", None) # storing so ._parse_indata will find it during reload
kwargs.pop("width", None) self.indata = data
kwargs.pop("height", None)
new_cells = dict((to_str(key), value) for key, value in cells.items()) if cells else {} new_cells = dict((to_str(key), value) for key, value in cells.items()) if cells else {}
new_tables = dict((to_str(key), value) for key, value in tables.items()) if tables else {} new_tables = dict((to_str(key), value) for key, value in tables.items()) if tables else {}
self.cells_mapping.update(new_cells) self.cells_mapping.update(new_cells)
self.tables_mapping.update(new_tables) self.tables_mapping.update(new_tables)
self.options.update(self._parse_inkwargs(**kwargs))
# parse and build the form
self.reload() self.reload()
def reload(self, filename=None, form=None, **kwargs):
"""
Creates the form from a stored file name.
Args:
filename (str): The file to read from.
form (dict): A mapping for the form.
Notes:
Kwargs are passed through to Cel creation.
"""
# clean kwargs (these cannot be overridden)
kwargs.pop("enforce_size", None)
kwargs.pop("width", None)
kwargs.pop("height", None)
if form or self.input_form_dict:
datadict = form if form else self.input_form_dict
self.input_form_dict = datadict
elif filename or self.filename:
filename = filename if filename else self.filename
datadict = all_from_module(filename)
self.filename = filename
else:
datadict = {}
cellchar = to_str(datadict.get("FORMCHAR", "x"))
self.cellchar = to_str(cellchar[0] if len(cellchar) > 1 else cellchar)
tablechar = datadict.get("TABLECHAR", "c")
self.tablechar = tablechar[0] if len(tablechar) > 1 else tablechar
# split into a list of list of lines. Form can be indexed with form[iy][ix]
raw_form = _to_ansi(datadict.get("FORM", "").split("\n"))
self.raw_form = _to_rect(raw_form)
# strip first line
self.raw_form = self.raw_form[1:] if self.raw_form else self.raw_form
self.options.update(kwargs)
# parse and replace
self.mapping = self._parse_rectangles(
self.cellchar, self.tablechar, self.raw_form, **kwargs
)
self.form = self._populate_form(self.raw_form, self.mapping)
def __str__(self): def __str__(self):
"Prints the form" "Prints the form"
return str(ANSIString("\n").join([line for line in self.form])) return str(ANSIString("\n").join([line for line in self.form]))

View file

@ -3,7 +3,7 @@ Unit tests for the EvForm text form generator
""" """
from django.test import TestCase from django.test import TestCase
from evennia.utils import evform, ansi, evtable from evennia.utils import ansi, evform, evtable
class TestEvForm(TestCase): class TestEvForm(TestCase):
@ -51,8 +51,8 @@ class TestEvForm(TestCase):
def _simple_form(self, form): def _simple_form(self, form):
cellsdict = {1: "Apple", 2: "Banana", 3: "Citrus", 4: "Durian"} cellsdict = {1: "Apple", 2: "Banana", 3: "Citrus", 4: "Durian"}
formdict = {"FORMCHAR": "x", "TABLECHAR": "c", "FORM": form} formdict = {"FORMCHAR": "x", "TABLECHAR": "c", "FORM": form}
form = evform.EvForm(form=formdict) form = evform.EvForm(formdict)
form.map(cellsdict) form.map(cells=cellsdict)
form = ansi.strip_ansi(str(form)) form = ansi.strip_ansi(str(form))
# this is necessary since editors/black tend to strip lines spaces # this is necessary since editors/black tend to strip lines spaces
# from the end of lines for the comparison strings. # from the end of lines for the comparison strings.
@ -112,7 +112,7 @@ class TestEvForm(TestCase):
def test_ansi_escape(self): def test_ansi_escape(self):
# note that in a msg() call, the result would be the correct |-----, # note that in a msg() call, the result would be the correct |-----,
# in a print, ansi only gets called once, so ||----- is the result # in a print, ansi only gets called once, so ||----- is the result
self.assertEqual(str(evform.EvForm(form={"FORM": "\n||-----"})), "||-----") self.assertEqual(str(evform.EvForm({"FORM": "\n||-----"})), "||-----")
def test_stacked_form(self): def test_stacked_form(self):
""" """
@ -241,7 +241,7 @@ class TestEvFormParallelTables(TestCase):
""" """
Build form to check for error. Build form to check for error.
""" """
form = evform.EvForm(form=self.formdict) form = evform.EvForm(self.formdict)
form.map( form.map(
cells={ cells={
"1": self.text1, "1": self.text1,