From 6960025553df0e033de5151456c412886607ec30 Mon Sep 17 00:00:00 2001 From: Tymoteusz Paul Date: Tue, 30 Jun 2020 23:07:37 +0100 Subject: [PATCH 1/5] Correcting curly brackets colors --- evennia/contrib/color_markups.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/evennia/contrib/color_markups.py b/evennia/contrib/color_markups.py index 6a8f8c9910..8478a9ee2c 100644 --- a/evennia/contrib/color_markups.py +++ b/evennia/contrib/color_markups.py @@ -125,14 +125,14 @@ CURLY_COLOR_ANSI_EXTRA_MAP = [ (r"{c", _ANSI_HILITE + _ANSI_CYAN), (r"{w", _ANSI_HILITE + _ANSI_WHITE), # pure white (r"{x", _ANSI_HILITE + _ANSI_BLACK), # dark grey - (r"{R", _ANSI_HILITE + _ANSI_RED), - (r"{G", _ANSI_HILITE + _ANSI_GREEN), - (r"{Y", _ANSI_HILITE + _ANSI_YELLOW), - (r"{B", _ANSI_HILITE + _ANSI_BLUE), - (r"{M", _ANSI_HILITE + _ANSI_MAGENTA), - (r"{C", _ANSI_HILITE + _ANSI_CYAN), - (r"{W", _ANSI_HILITE + _ANSI_WHITE), # light grey - (r"{X", _ANSI_HILITE + _ANSI_BLACK), # pure black + (r"{R", _ANSI_UNHILITE + _ANSI_RED), + (r"{G", _ANSI_UNHILITE + _ANSI_GREEN), + (r"{Y", _ANSI_UNHILITE + _ANSI_YELLOW), + (r"{B", _ANSI_UNHILITE + _ANSI_BLUE), + (r"{M", _ANSI_UNHILITE + _ANSI_MAGENTA), + (r"{C", _ANSI_UNHILITE + _ANSI_CYAN), + (r"{W", _ANSI_UNHILITE + _ANSI_WHITE), # light grey + (r"{X", _ANSI_UNHILITE + _ANSI_BLACK), # pure black # hilight-able colors (r"{h", _ANSI_HILITE), (r"{H", _ANSI_UNHILITE), From 750922a61358ee465c7443faebf0ce1b5a5dbfdd Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 15 Jul 2020 23:16:57 +0200 Subject: [PATCH 2/5] Have EvTable width correctly handle asian alphabets as per #2166. --- CHANGELOG.md | 1 + evennia/utils/evtable.py | 26 +++++++++++------------ evennia/utils/tests/test_utils.py | 28 +++++++++++++++++++++++++ evennia/utils/utils.py | 34 +++++++++++++++++++++++++++++-- 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 344c8f0bb5..fb951fbf26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ without arguments starts a full interactive Python console. to `spawn` command to extract the raw prototype dict for manual editing. - `list_to_string` is now `iter_to_string` (but old name still works as legacy alias). It will now accept any input, including generators and single values. +- EvTable should now correctly handle columns with wider asian-characters in them. diff --git a/evennia/utils/evtable.py b/evennia/utils/evtable.py index e123589d6d..6f9115d6b4 100644 --- a/evennia/utils/evtable.py +++ b/evennia/utils/evtable.py @@ -114,7 +114,7 @@ table string. from django.conf import settings from textwrap import TextWrapper from copy import deepcopy, copy -from evennia.utils.utils import m_len, is_iter +from evennia.utils.utils import is_iter, display_len as d_len from evennia.utils.ansi import ANSIString _DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH @@ -228,7 +228,7 @@ class ANSITextWrapper(TextWrapper): indent = self.initial_indent # Maximum width for this line. - width = self.width - m_len(indent) + width = self.width - d_len(indent) # First chunk on line is whitespace -- drop it, unless this # is the very beginning of the text (ie. no lines started yet). @@ -236,7 +236,7 @@ class ANSITextWrapper(TextWrapper): del chunks[-1] while chunks: - l = m_len(chunks[-1]) + l = d_len(chunks[-1]) # Can at least squeeze this chunk onto the current line. if cur_len + l <= width: @@ -249,7 +249,7 @@ class ANSITextWrapper(TextWrapper): # The current line is full, and the next chunk is too big to # fit on *any* line (not just this one). - if chunks and m_len(chunks[-1]) > width: + if chunks and d_len(chunks[-1]) > width: self._handle_long_word(chunks, cur_line, cur_len, width) # If the last chunk on this line is all whitespace, drop it. @@ -439,7 +439,7 @@ class EvCell(object): self.valign = kwargs.get("valign", "c") self.data = self._split_lines(_to_ansi(data)) - self.raw_width = max(m_len(line) for line in self.data) + self.raw_width = max(d_len(line) for line in self.data) self.raw_height = len(self.data) # this is extra trimming required for cels in the middle of a table only @@ -478,9 +478,9 @@ class EvCell(object): width (int): The width to crop `text` to. """ - if m_len(text) > width: + if d_len(text) > width: crop_string = self.crop_string - return text[: width - m_len(crop_string)] + crop_string + return text[: width - d_len(crop_string)] + crop_string return text def _reformat(self): @@ -521,7 +521,7 @@ class EvCell(object): width = self.width adjusted_data = [] for line in data: - if 0 < width < m_len(line): + if 0 < width < d_len(line): # replace_whitespace=False, expand_tabs=False is a # fix for ANSIString not supporting expand_tabs/translate adjusted_data.extend( @@ -564,7 +564,7 @@ class EvCell(object): text (str): Centered text. """ - excess = width - m_len(text) + excess = width - d_len(text) if excess <= 0: return text if excess % 2: @@ -603,13 +603,13 @@ class EvCell(object): if line.startswith(" ") and not line.startswith(" ") else line ) - + hfill_char * (width - m_len(line)) + + hfill_char * (width - d_len(line)) for line in data ] return lines elif align == "r": return [ - hfill_char * (width - m_len(line)) + hfill_char * (width - d_len(line)) + ( " " + line.rstrip(" ") if line.endswith(" ") and not line.endswith(" ") @@ -750,7 +750,7 @@ class EvCell(object): natural_width (int): Width of cell. """ - return m_len(self.formatted[0]) # if self.formatted else 0 + return d_len(self.formatted[0]) # if self.formatted else 0 def replace_data(self, data, **kwargs): """ @@ -765,7 +765,7 @@ class EvCell(object): """ self.data = self._split_lines(_to_ansi(data)) - self.raw_width = max(m_len(line) for line in self.data) + self.raw_width = max(d_len(line) for line in self.data) self.raw_height = len(self.data) self.reformat(**kwargs) diff --git a/evennia/utils/tests/test_utils.py b/evennia/utils/tests/test_utils.py index eb01e38a83..67625bbfbb 100644 --- a/evennia/utils/tests/test_utils.py +++ b/evennia/utils/tests/test_utils.py @@ -98,6 +98,34 @@ class TestMLen(TestCase): self.assertEqual(utils.m_len({"hello": True, "Goodbye": False}), 2) +class TestDisplayLen(TestCase): + """ + Verifies that display_len behaves like m_len in all situations except those + where asian characters are involved. + """ + + def test_non_mxp_string(self): + self.assertEqual(utils.display_len("Test_string"), 11) + + def test_mxp_string(self): + self.assertEqual(utils.display_len("|lclook|ltat|le"), 2) + + def test_mxp_ansi_string(self): + self.assertEqual(utils.display_len(ANSIString("|lcl|gook|ltat|le|n")), 2) + + def test_non_mxp_ansi_string(self): + self.assertEqual(utils.display_len(ANSIString("|gHello|n")), 5) + + def test_list(self): + self.assertEqual(utils.display_len([None, None]), 2) + + def test_dict(self): + self.assertEqual(utils.display_len({"hello": True, "Goodbye": False}), 2) + + def test_east_asian(self): + self.assertEqual(utils.display_len("서서서"), 6) + + class TestANSIString(TestCase): """ Verifies that ANSIString's string-API works as intended. diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index cd4d661b8c..94866de103 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -9,7 +9,7 @@ be of use when designing your own game. import os import gc import sys -import copy +import copy import types import math import re @@ -20,6 +20,7 @@ import traceback import importlib import importlib.util import importlib.machinery +from unicodedata import east_asian_width from twisted.internet.task import deferLater from twisted.internet.defer import returnValue # noqa - used as import target from os.path import join as osjoin @@ -1819,7 +1820,7 @@ def m_len(target): back to normal len for other objects. Args: - target (string): A string with potential MXP components + target (str): A string with potential MXP components to search. Returns: @@ -1834,6 +1835,35 @@ def m_len(target): return len(target) +def display_len(target): + """ + Calculate the 'visible width' of text. This is not necessarily the same as the + number of characters in the case of certain asian characters. This will also + strip MXP patterns. + + Args: + target (string): A string with potential MXP components + to search. + + Return: + int: The visible width of the target. + + """ + # Would create circular import if in module root. + from evennia.utils.ansi import ANSI_PARSER + + if inherits_from(target, str): + # str or ANSIString + target = ANSI_PARSER.strip_mxp(target) + target = ANSI_PARSER.parse_ansi(target, strip_ansi=True) + extra_wide = ("F", "W") + return sum(2 if east_asian_width(char) in extra_wide else 1 for char in target) + else: + return len(target) + + + + # ------------------------------------------------------------------- # Search handler function # ------------------------------------------------------------------- From 98b040634b6d6705cea0a25287555fe0e65b518b Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 17 Jul 2020 11:47:23 +0200 Subject: [PATCH 3/5] Update twisted dependency as per released CVEs --- CHANGELOG.md | 1 + requirements.txt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb951fbf26..dcf218cb1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ without arguments starts a full interactive Python console. - `list_to_string` is now `iter_to_string` (but old name still works as legacy alias). It will now accept any input, including generators and single values. - EvTable should now correctly handle columns with wider asian-characters in them. +- Update Twisted requirement to >=2.3.0 to close security vulnerability diff --git a/requirements.txt b/requirements.txt index e569a8ac07..7e70b36daa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ # general django >= 2.2.5, < 2.3 -twisted >= 19.2.1, < 20.0.0 +twisted >= 20.3.0, < 21.0.0 pytz django-sekizai inflect From f7b4193c840f218ed250379fe338905ab582d49f Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 17 Jul 2020 14:25:24 +0200 Subject: [PATCH 4/5] Add $random default inlinefunc because I expected it to be there --- CHANGELOG.md | 2 +- evennia/commands/default/building.py | 10 ++++- evennia/contrib/turnbattle/tb_range.py | 33 +++++++++-------- evennia/server/sessionhandler.py | 4 +- evennia/utils/inlinefuncs.py | 51 +++++++++++++++++++++++++- 5 files changed, 79 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf218cb1e..87fd67b8d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,7 @@ without arguments starts a full interactive Python console. now accept any input, including generators and single values. - EvTable should now correctly handle columns with wider asian-characters in them. - Update Twisted requirement to >=2.3.0 to close security vulnerability - +- Add `$random` inlinefunc, supports minval,maxval arguments that can be ints and floats. ## Evennia 0.9 (2018-2019) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 65016bb23d..a51fd4169e 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2406,7 +2406,13 @@ class CmdExamine(ObjManipCommand): """ Helper function that creates a nice report about an object. - returns a string. + Args: + obj (any): Object to analyze. + avail_cmdset (CmdSet): Current cmdset for object. + + Returns: + str: The formatted string. + """ string = "\n|wName/key|n: |c%s|n (%s)" % (obj.name, obj.dbref) if hasattr(obj, "aliases") and obj.aliases.all(): @@ -2633,7 +2639,7 @@ class CmdExamine(ObjManipCommand): if obj_attrs: for attrname in obj_attrs: # we are only interested in specific attributes - caller.msg(self.format_attributes(obj, attrname, crop=False)) + caller.msg(self.format_attributes(obj, attrname, crop=False), options={"raw": True}) else: session = None if obj.sessions.count(): diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py index c0eca41487..f604de2d57 100644 --- a/evennia/contrib/turnbattle/tb_range.py +++ b/evennia/contrib/turnbattle/tb_range.py @@ -78,7 +78,7 @@ And change your game's character typeclass to inherit from TBRangeCharacter instead of the default: class Character(TBRangeCharacter): - + Do the same thing in your game's objects.py module for TBRangeObject: from evennia.contrib.turnbattle.tb_range import TBRangeObject @@ -246,10 +246,10 @@ def apply_damage(defender, damage): def at_defeat(defeated): """ Announces the defeat of a fighter in combat. - + Args: defeated (obj): Fighter that's been defeated. - + Notes: All this does is announce a defeat message by default, but if you want anything else to happen to defeated fighters (like putting them @@ -300,11 +300,11 @@ def resolve_attack(attacker, defender, attack_type, attack_value=None, defense_v def get_range(obj1, obj2): """ Gets the combat range between two objects. - + Args: obj1 (obj): First object obj2 (obj): Second object - + Returns: range (int or None): Distance between two objects or None if not applicable """ @@ -324,7 +324,7 @@ def get_range(obj1, obj2): def distance_inc(mover, target): """ Function that increases distance in range field between mover and target. - + Args: mover (obj): The object moving target (obj): The object to be moved away from @@ -340,11 +340,11 @@ def distance_inc(mover, target): def approach(mover, target): """ Manages a character's whole approach, including changes in ranges to other characters. - + Args: mover (obj): The object moving target (obj): The object to be moved toward - + Notes: The mover will also automatically move toward any objects that are closer to the target than the mover is. The mover will also move away from anything they started @@ -354,7 +354,7 @@ def approach(mover, target): def distance_dec(mover, target): """ Helper function that decreases distance in range field between mover and target. - + Args: mover (obj): The object moving target (obj): The object to be moved toward @@ -388,11 +388,11 @@ def approach(mover, target): def withdraw(mover, target): """ Manages a character's whole withdrawal, including changes in ranges to other characters. - + Args: mover (obj): The object moving target (obj): The object to be moved away from - + Notes: The mover will also automatically move away from objects that are close to the target of their withdrawl. The mover will never inadvertently move toward anything else while @@ -540,7 +540,8 @@ class TBRangeTurnHandler(DefaultScript): room as its object. Fights persist until only one participant is left with any HP or all - remaining participants choose to end the combat with the 'disengage' command. + remaining participants choose to end the combat with the 'disengage' + command. """ def at_script_creation(self): @@ -615,7 +616,7 @@ class TBRangeTurnHandler(DefaultScript): def init_range(self, to_init): """ Initializes range values for an object at the start of a fight. - + Args: to_init (object): Object to initialize range field for. """ @@ -638,14 +639,14 @@ class TBRangeTurnHandler(DefaultScript): def join_rangefield(self, to_init, anchor_obj=None, add_distance=0): """ Adds a new object to the range field of a fight in progress. - + Args: to_init (object): Object to initialize range field for. - + Kwargs: anchor_obj (object): Object to copy range values from, or None for a random object. add_distance (int): Distance to put between to_init object and anchor object. - + """ # Get a list of room's contents without to_init object. contents = self.obj.contents diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index 0d099d6d30..ed7fb2b94b 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -221,7 +221,9 @@ class SessionHandler(dict): elif isinstance(data, (str, bytes)): data = _utf8(data) - if _INLINEFUNC_ENABLED and not raw and isinstance(self, ServerSessionHandler): + if (_INLINEFUNC_ENABLED + and not raw + and isinstance(self, ServerSessionHandler)): # only parse inlinefuncs on the outgoing path (sessionhandler->) data = parse_inlinefunc(data, strip=strip_inlinefunc, session=session) diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index a92fa2ebbd..931d9eff3e 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -62,6 +62,7 @@ Error handling: import re import fnmatch +import random as base_random from django.conf import settings from evennia.utils import utils, logger @@ -69,6 +70,53 @@ from evennia.utils import utils, logger # example/testing inline functions +def random(*args, **kwargs): + """ + Inlinefunc. Returns a random number between + 0 and 1, from 0 to a maximum value, or within a given range (inclusive). + + Args: + minval (str, optional): Minimum value. If not given, assumed 0. + maxval (str, optional): Maximum value. + + Keyword argumuents: + session (Session): Session getting the string. + + Notes: + If either of the min/maxvalue has a '.' in it, a floating-point random + value will be returned. Otherwise it will be an integer value in the + given range. + + Example: + `$random()` + `$random(5)` + `$random(5, 10)` + + """ + nargs = len(args) + if nargs == 1: + # only maxval given + minval, maxval = '0', args[0] + elif nargs > 1: + minval, maxval = args[:2] + else: + minval, maxval = ('0', '1') + + if "." in minval or "." in maxval: + # float mode + try: + minval, maxval = float(minval), float(maxval) + except ValueError: + minval, maxval = 0, 1 + return "{:.2f}".format(minval + maxval * base_random.random()) + else: + # int mode + try: + minval, maxval = int(minval), int(maxval) + except ValueError: + minval, maxval = 0, 1 + return str(base_random.randint(minval, maxval)) + def pad(*args, **kwargs): """ @@ -79,7 +127,8 @@ def pad(*args, **kwargs): width (str, optional): Will be converted to integer. Width of padding. align (str, optional): Alignment of padding; one of 'c', 'l' or 'r'. - fillchar (str, optional): Character used for padding. Defaults to a space. + fillchar (str, optional): Character used for padding. Defaults to a + space. Kwargs: session (Session): Session performing the pad. From 7aa6883b943bba729b04599a87ef8d442995c837 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 18 Jul 2020 15:51:29 +0200 Subject: [PATCH 5/5] Cleanup of examine, escape inlinefuncs. Resolve #2172 --- CHANGELOG.md | 1 + evennia/commands/default/building.py | 201 +++++++++++++++------------ evennia/commands/default/tests.py | 16 ++- evennia/utils/inlinefuncs.py | 11 ++ evennia/utils/utils.py | 4 +- 5 files changed, 141 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87fd67b8d5..c41f391940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ without arguments starts a full interactive Python console. - EvTable should now correctly handle columns with wider asian-characters in them. - Update Twisted requirement to >=2.3.0 to close security vulnerability - Add `$random` inlinefunc, supports minval,maxval arguments that can be ints and floats. +- Add `evennia.utils.inlinefuncs.raw()` as a helper to escape inlinefuncs in a string. ## Evennia 0.9 (2018-2019) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index a51fd4169e..159f253883 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -16,11 +16,13 @@ from evennia.utils.utils import ( dbref, interactive, list_to_string, + display_len, ) from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus -from evennia.utils.ansi import raw +from evennia.utils.ansi import raw as ansi_raw +from evennia.utils.inlinefuncs import raw as inlinefunc_raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2357,26 +2359,37 @@ class CmdExamine(ObjManipCommand): arg_regex = r"(/\w+?(\s|$))|\s|$" account_mode = False + detail_color = "|c" + header_color = "|w" + quell_color = "|r" + separator = "-" def list_attribute(self, crop, attr, category, value): """ Formats a single attribute line. + + Args: + crop (bool): If output should be cropped if too long. + attr (str): Attribute key. + category (str): Attribute category. + value (any): Attribute value. + Returns: """ if crop: if not isinstance(value, str): value = utils.to_str(value) value = utils.crop(value) + value = inlinefunc_raw(ansi_raw(value)) if category: - string = "\n %s[%s] = %s" % (attr, category, value) + return f"{attr}[{category}] = {value}" else: - string = "\n %s = %s" % (attr, value) - string = raw(string) - return string + return f"{attr} = {value}" def format_attributes(self, obj, attrname=None, crop=True): """ Helper function that returns info about attributes and/or non-persistent data stored on object + """ if attrname: @@ -2391,16 +2404,18 @@ class CmdExamine(ObjManipCommand): ndb_attr = obj.nattributes.all(return_tuples=True) except Exception: ndb_attr = None - string = "" + output = {} if db_attr and db_attr[0]: - string += "\n|wPersistent attributes|n:" - for attr, value, category in db_attr: - string += self.list_attribute(crop, attr, category, value) + output["Persistent attribute(s)"] = "\n " + "\n ".join( + sorted(self.list_attribute(crop, attr, category, value) + for attr, value, category in db_attr) + ) if ndb_attr and ndb_attr[0]: - string += "\n|wNon-Persistent attributes|n:" - for attr, value in ndb_attr: - string += self.list_attribute(crop, attr, None, value) - return string + output["Non-Persistent attribute(s)"] = " \n" + " \n".join( + sorted(self.list_attribute(crop, attr, None, value) + for attr, value in ndb_attr) + ) + return output def format_output(self, obj, avail_cmdset): """ @@ -2414,64 +2429,83 @@ class CmdExamine(ObjManipCommand): str: The formatted string. """ - string = "\n|wName/key|n: |c%s|n (%s)" % (obj.name, obj.dbref) + hclr = self.header_color + dclr = self.detail_color + qclr = self.quell_color + + output = {} + # main key + output["Name/key"] = f"{dclr}{obj.name}|n ({obj.dbref})" + # aliases if hasattr(obj, "aliases") and obj.aliases.all(): - string += "\n|wAliases|n: %s" % (", ".join(utils.make_iter(str(obj.aliases)))) + output["Aliases"] = ", ".join(utils.make_iter(str(obj.aliases))) + # typeclass + output["Typeclass"] = f"{obj.typename} ({obj.typeclass_path})" + # sessions if hasattr(obj, "sessions") and obj.sessions.all(): - string += "\n|wSession id(s)|n: %s" % ( - ", ".join("#%i" % sess.sessid for sess in obj.sessions.all()) - ) + output["Session id(s)"] = ", ".join(f"#{sess.sessid}" for sess in obj.sessions.all()) + # email, if any if hasattr(obj, "email") and obj.email: - string += "\n|wEmail|n: |c%s|n" % obj.email + output["Email"] = f"{dclr}{obj.email}|n" + # account, for puppeted objects if hasattr(obj, "has_account") and obj.has_account: - string += "\n|wAccount|n: |c%s|n" % obj.account.name + output["Account"] = f"{dclr}{obj.account.name}|n ({obj.account.dbref})" + # account typeclass + output[" Account Typeclass"] = f"{obj.account.typename} ({obj.account.typeclass_path})" + # account permissions perms = obj.account.permissions.all() if obj.account.is_superuser: perms = [""] elif not perms: perms = [""] - string += "\n|wAccount Perms|n: %s" % (", ".join(perms)) + perms = ", ".join(perms) if obj.account.attributes.has("_quell"): - string += " |r(quelled)|n" - string += "\n|wTypeclass|n: %s (%s)" % (obj.typename, obj.typeclass_path) + perms += f" {qclr}(quelled)|n" + output[" Account Permissions"] = perms + # location if hasattr(obj, "location"): - string += "\n|wLocation|n: %s" % obj.location + loc = str(obj.location) if obj.location: - string += " (#%s)" % obj.location.id + loc += f" (#{obj.location.id})" + output["Location"] = loc + # home if hasattr(obj, "home"): - string += "\n|wHome|n: %s" % obj.home + home = str(obj.home) if obj.home: - string += " (#%s)" % obj.home.id + home += f" (#{obj.home.id})" + output["Home"] = home + # destination, for exits if hasattr(obj, "destination") and obj.destination: - string += "\n|wDestination|n: %s" % obj.destination + dest = str(obj.destination) if obj.destination: - string += " (#%s)" % obj.destination.id + dest += f" (#{obj.destination.id})" + output["Destination"] = dest + # main permissions perms = obj.permissions.all() + perms_string = "" if perms: perms_string = ", ".join(perms) - else: - perms_string = "" if obj.is_superuser: - perms_string += " [Superuser]" - - string += "\n|wPermissions|n: %s" % perms_string - + perms_string += " " + if perms_string: + output["Permissions"] = perms_string + # locks locks = str(obj.locks) if locks: - locks_string = utils.fill("; ".join([lock for lock in locks.split(";")]), indent=6) + locks_string = "\n" + utils.fill( + "; ".join([lock for lock in locks.split(";")]), indent=2) else: locks_string = " Default" - string += "\n|wLocks|n:%s" % locks_string - + output["Locks"] = locks_string + # cmdsets if not (len(obj.cmdset.all()) == 1 and obj.cmdset.current.key == "_EMPTY_CMDSET"): # all() returns a 'stack', so make a copy to sort. - stored_cmdsets = sorted(obj.cmdset.all(), key=lambda x: x.priority, reverse=True) - string += "\n|wStored Cmdset(s)|n:\n %s" % ( - "\n ".join( - "%s [%s] (%s, prio %s)" - % (cmdset.path, cmdset.key, cmdset.mergetype, cmdset.priority) - for cmdset in stored_cmdsets - if cmdset.key != "_EMPTY_CMDSET" + stored_cmdsets = sorted(obj.cmdset.all(), key=lambda x: x.priority, + reverse=True) + output["Stored Cmdset(s)"] = ( + "\n " + "\n ".join( + f"{cmdset.path} [{cmdset.key}] ({cmdset.mergetype}, prio {cmdset.priority})" + for cmdset in stored_cmdsets if cmdset.key != "_EMPTY_CMDSET" ) ) @@ -2506,40 +2540,32 @@ class CmdExamine(ObjManipCommand): pass all_cmdsets = [cmdset for cmdset in dict(all_cmdsets).values()] all_cmdsets.sort(key=lambda x: x.priority, reverse=True) - string += "\n|wMerged Cmdset(s)|n:\n %s" % ( - "\n ".join( - "%s [%s] (%s, prio %s)" - % (cmdset.path, cmdset.key, cmdset.mergetype, cmdset.priority) + output["Merged Cmdset(s)"] = ( + "\n " + "\n ".join( + f"{cmdset.path} [{cmdset.key}] ({cmdset.mergetype} prio {cmdset.priority})" for cmdset in all_cmdsets ) ) - # list the commands available to this object avail_cmdset = sorted([cmd.key for cmd in avail_cmdset if cmd.access(obj, "cmd")]) - cmdsetstr = utils.fill(", ".join(avail_cmdset), indent=2) - string += "\n|wCommands available to %s (result of Merged CmdSets)|n:\n %s" % ( - obj.key, - cmdsetstr, - ) - + cmdsetstr = "\n" + utils.fill(", ".join(avail_cmdset), indent=2) + output[f"Commands available to {obj.key} (result of Merged CmdSets)"] = str(cmdsetstr) + # scripts if hasattr(obj, "scripts") and hasattr(obj.scripts, "all") and obj.scripts.all(): - string += "\n|wScripts|n:\n %s" % obj.scripts + output["Scripts"] = "\n " + f"{obj.scripts}" # add the attributes - string += self.format_attributes(obj) - - # display Tags - tags_string = utils.fill( + output.update(self.format_attributes(obj)) + # Tags + tags = obj.tags.all(return_key_and_category=True) + tags_string = "\n" + utils.fill( ", ".join( - "%s[%s]" % (tag, category) - for tag, category in obj.tags.all(return_key_and_category=True) - ), - indent=5, + sorted(f"{tag}[{category}]" for tag, category in tags )), + indent=2, ) - if tags_string: - string += "\n|wTags[category]|n: %s" % tags_string.strip() - - # add the contents + if tags: + output["Tags[category]"] = tags_string + # Contents of object exits = [] pobjs = [] things = [] @@ -2552,24 +2578,23 @@ class CmdExamine(ObjManipCommand): else: things.append(content) if exits: - string += "\n|wExits|n: %s" % ", ".join( - ["%s(%s)" % (exit.name, exit.dbref) for exit in exits] - ) + output["Exits (has .destination)"] = ", ".join(f"{exit.name}({exit.dbref})" for exit in exits) if pobjs: - string += "\n|wCharacters|n: %s" % ", ".join( - ["|c%s|n(%s)" % (pobj.name, pobj.dbref) for pobj in pobjs] - ) + output["Characters"] = ", ".join(f"{dclr}{pobj.name}|n({pobj.dbref})" for pobj in pobjs) if things: - string += "\n|wContents|n: %s" % ", ".join( - [ - "%s(%s)" % (cont.name, cont.dbref) - for cont in obj.contents - if cont not in exits and cont not in pobjs - ] + output["Contents"] = ", ".join( + f"{cont.name}({cont.dbref})" + for cont in obj.contents + if cont not in exits and cont not in pobjs ) - separator = "-" * _DEFAULT_WIDTH - # output info - return "%s\n%s\n%s" % (separator, string.strip(), separator) + # format output + max_width = -1 + for block in output.values(): + max_width = max(max_width, max(display_len(line) for line in block.split("\n"))) + sep = self.separator * max_width + mainstr = "\n".join(f"{hclr}{header}|n: {block}" for (header, block) in output.items()) + return f"{sep}\n{mainstr}\n{sep}" + def func(self): """Process command""" @@ -2584,8 +2609,7 @@ class CmdExamine(ObjManipCommand): that function finishes. Taking the resulting cmdset, we continue to format and output the result. """ - string = self.format_output(obj, cmdset) - self.msg(string.strip()) + self.msg(self.format_output(obj, cmdset).strip()) if not self.args: # If no arguments are provided, examine the invoker's location. @@ -2639,7 +2663,10 @@ class CmdExamine(ObjManipCommand): if obj_attrs: for attrname in obj_attrs: # we are only interested in specific attributes - caller.msg(self.format_attributes(obj, attrname, crop=False), options={"raw": True}) + ret = "\n".join( + f"{self.header_color}{header}|n:{value}" + for header, value in self.format_attributes(obj, attrname, crop=False).items()) + self.caller.msg(ret) else: session = None if obj.sessions.count(): diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index c2e5bed658..f9f48ca699 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -150,11 +150,17 @@ class CommandTest(EvenniaTest): returned_msg = msg_sep.join( _RE.sub("", ansi.parse_ansi(mess, strip_ansi=noansi)) for mess in stored_msg ).strip() - if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()): + msg = msg.strip() + if msg == "" and returned_msg or not returned_msg.startswith(msg): + prt = "" + for ic, char in enumerate(msg): + import re + prt += char + sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n" sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n" sep3 = "\n" + "=" * 78 - retval = sep1 + msg.strip() + sep2 + returned_msg + sep3 + retval = sep1 + msg + sep2 + returned_msg + sep3 raise AssertionError(retval) else: returned_msg = "\n".join(str(msg) for msg in stored_msg) @@ -457,10 +463,14 @@ class TestBuilding(CommandTest): self.call(building.CmdExamine(), "*TestAccount", "Name/key: TestAccount") self.char1.db.test = "testval" - self.call(building.CmdExamine(), "self/test", "Persistent attributes:\n test = testval") + self.call(building.CmdExamine(), "self/test", "Persistent attribute(s):\n test = testval") self.call(building.CmdExamine(), "NotFound", "Could not find 'NotFound'.") self.call(building.CmdExamine(), "out", "Name/key: out") + # escape inlinefuncs + self.char1.db.test2 = "this is a $random() value." + self.call(building.CmdExamine(), "self/test2", "Persistent attribute(s):\n test2 = this is a \$random() value.") + self.room1.scripts.add(self.script.__class__) self.call(building.CmdExamine(), "") self.account.scripts.add(self.script.__class__) diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index 931d9eff3e..16fb9c3915 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -514,6 +514,17 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False return retval +def raw(string): + """ + Escape all inlinefuncs in a string so they won't get parsed. + + Args: + string (str): String with inlinefuncs to escape. + """ + def _escape(match): + return "\\" + match.group(0) + return _RE_STARTTOKEN.sub(_escape, string) + # # Nick templating # diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 94866de103..8a4e205b6a 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1842,8 +1842,8 @@ def display_len(target): strip MXP patterns. Args: - target (string): A string with potential MXP components - to search. + target (any): Something to measure the length of. If a string, it will be + measured keeping asian-character and MXP links in mind. Return: int: The visible width of the target.