diff --git a/CHANGELOG.md b/CHANGELOG.md index e45bc07133..b77feca3be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,9 @@ Up requirements to Django 3.2+ into a more consistent structure for overriding. Expanded webpage documentation considerably. - REST API list-view was shortened (#2401). New CSS/HTML. Add ReDoc for API autodoc page. - Update and fix dummyrunner with cleaner code and setup. +- Made `iter_to_str` format prettier strings, using Oxford comma. +- Added an MXP anchor tag to also support clickable web links. +- New `tasks` command for managing tasks started with `utils.delay` (PR by davewiththenicehat) ### Evennia 0.9.5 (2019-2020) @@ -149,6 +152,7 @@ without arguments starts a full interactive Python console. - Include more Web-client info in `session.protocol_flags`. - Fixes in multi-match situations - don't allow finding/listing multimatches for 3-box when only two boxes in location. +- Fix for TaskHandler with proper deferred returns/ability to cancel etc (PR by davewiththenicehat) ## Evennia 0.9 (2018-2019) diff --git a/docs/source/Concepts/Clickable-Links.md b/docs/source/Concepts/Clickable-Links.md index b29065e9e2..5a4f9ed262 100644 --- a/docs/source/Concepts/Clickable-Links.md +++ b/docs/source/Concepts/Clickable-Links.md @@ -1,11 +1,12 @@ ## Clickable links Evennia supports clickable links for clients that supports it. This marks certain text so it can be -clicked by a mouse and trigger a given Evennia command. To support clickable links, Evennia requires -the webclient or an third-party telnet client with [MXP](http://www.zuggsoft.com/zmud/mxp.htm) -support (*Note: Evennia only supports clickable links, no other MXP features*). +clicked by a mouse and either trigger a given Evennia command, or open a URL in an external web +browser. To support clickable links, Evennia requires the webclient or an third-party telnet client +with [MXP](http://www.zuggsoft.com/zmud/mxp.htm) support (*Note: Evennia only supports clickable links, no other MXP features*). - `|lc` to start the link, by defining the command to execute. +- `|lu` to start the link, by defining the URL to open. - `|lt` to continue with the text to show to the user (the link text). - `|le` to end the link text and the link definition. diff --git a/docs/source/How-To-Get-And-Give-Help.md b/docs/source/How-To-Get-And-Give-Help.md index 5e0b4dc87d..6f4560c1d5 100644 --- a/docs/source/How-To-Get-And-Give-Help.md +++ b/docs/source/How-To-Get-And-Give-Help.md @@ -4,10 +4,10 @@ ### How to *get* Help If you cannot find what you are looking for in the documentation, here's what to do: - + - If you think the documentation is not clear enough, create a [documentation ticket](github:issue). -- If you have trouble with a missing feature or a problem you think is a bug, look through the - the list of known [issue][issues] if you can't find your issue in the list, make a +- If you have trouble with a missing feature or a problem you think is a bug, look through the + the list of known [issue][issues] if you can't find your issue in the list, make a new one [here](github:issue). - If you need help, want to start a discussion or get some input on something you are working on, make a post to the [discussions group][group] This is technically a 'mailing list', but you don't @@ -26,8 +26,8 @@ eventually! Evennia is open-source and non-commercial. It relies on the time donated by its users and developers in order to progress. - Spread the word! If you like Evennia, consider writing a blog post about it. -- Take part in the Evennia community! Join the [chat][chat] or [forum][group]. -- Report problems you find or features you'd like to our [issue tracker](github:issue). +- Take part in the Evennia community! Join the [chat][chat] or [forum][group]. +- Report problems you find or features you'd like to our [issue tracker](github:issue). ```important:: Just the simple act of us know you are out there using Evennia helps a lot! @@ -35,9 +35,9 @@ Evennia is open-source and non-commercial. It relies on the time donated by its If you'd like to help develop Evennia more hands-on, here are some ways to get going: -- Look through this [online documentation](./index#Evennia-documentation) and see if you can help improve or expand the -documentation (even small things like fixing typos!). [See here](./Contributing-Docs) on how you -contribute to the docs. +- Look through this [online documentation](./index#Evennia-documentation) and see if you can help improve or expand the +documentation (even small things like fixing typos!). [See here](./Contributing-Docs) on how you +contribute to the docs. - Send a message to our [discussion group][group] and/or our [IRC chat][chat] asking about what needs doing, along with what your interests and skills are. - Take a look at our [issue tracker][issues] and see if there's something you feel like taking on. @@ -47,10 +47,10 @@ needs doing, along with what your interests and skills are. github. ... And finally, if you want to help motivate and support development you can also drop some coins -in the developer's cup. You can [make a donation via PayPal][paypal] or, even better, [become an -Evennia patron on Patreon][patreon]! This is a great way to tip your hat and show that you +in the developer's cup. You can [make a donation via PayPal][paypal] or, even better, +[become an Evennia patron on Patreon][patreon]! This is a great way to tip your hat and show that you appreciate the work done with the server! You can also encourage the community to take on particular -issues by putting up a monetary [bounty][bountysource] on it. +issues by putting up a monetary [bounty][bountysource] on it. [form]: https://docs.google.com/spreadsheet/viewform?hl=en_US&formkey=dGN0VlJXMWpCT3VHaHpscDEzY1RoZGc6MQ#gid=0 @@ -63,4 +63,4 @@ issues by putting up a monetary [bounty][bountysource] on it. [patreon]: https://www.patreon.com/griatch [patreon-img]: http://www.evennia.com/_/rsrc/1424724909023/home/evennia_patreon_100x100.png [issues-bounties]:https://github.com/evennia/evennia/labels/bounty -[bountysource]: https://www.bountysource.com/teams/evennia \ No newline at end of file +[bountysource]: https://www.bountysource.com/teams/evennia diff --git a/evennia/commands/default/cmdset_character.py b/evennia/commands/default/cmdset_character.py index 28c8fdd641..dc3b2b58b4 100644 --- a/evennia/commands/default/cmdset_character.py +++ b/evennia/commands/default/cmdset_character.py @@ -50,6 +50,7 @@ class CharacterCmdSet(CmdSet): self.add(system.CmdServerLoad()) # self.add(system.CmdPs()) self.add(system.CmdTickers()) + self.add(system.CmdTasks()) # Admin commands self.add(admin.CmdBoot()) diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index 54db99093e..08dee11376 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -8,7 +8,6 @@ System commands import code import traceback import os -import io import datetime import sys import django @@ -25,9 +24,12 @@ from evennia.utils import logger, utils, gametime, create, search from evennia.utils.eveditor import EvEditor from evennia.utils.evtable import EvTable from evennia.utils.evmore import EvMore -from evennia.utils.utils import crop, class_from_module +from evennia.utils.evmenu import ask_yes_no +from evennia.utils.utils import crop, class_from_module, iter_to_str +from evennia.scripts.taskhandler import TaskHandlerTask COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) +_TASK_HANDLER = None # delayed imports _RESOURCE = None @@ -45,6 +47,7 @@ __all__ = ( "CmdAbout", "CmdTime", "CmdServerLoad", + "CmdTasks", ) @@ -1203,3 +1206,224 @@ class CmdTickers(COMMAND_DEFAULT_CLASS): "*" if sub[5] else "-", ) self.caller.msg("|wActive tickers|n:\n" + str(table)) + + +class CmdTasks(COMMAND_DEFAULT_CLASS): + """ + Display or terminate active tasks (delays). + + Usage: + tasks[/switch] [task_id or function_name] + + Switches: + pause - Pause the callback of a task. + unpause - Process all callbacks made since pause() was called. + do_task - Execute the task (call its callback). + call - Call the callback of this task. + remove - Remove a task without executing it. + cancel - Stop a task from automatically executing. + + Notes: + A task is a single use method of delaying the call of a function. Calls are created + in code, using `evennia.utils.delay`. + See |luhttps://www.evennia.com/docs/latest/Command-Duration.html|ltthe docs|le for help. + + By default, tasks that are canceled and never called are cleaned up after one minute. + + Examples: + - `tasks/cancel move_callback` - Cancels all movement delays from the slow_exit contrib. + In this example slow exits creates it's tasks with + `utils.delay(move_delay, move_callback)` + - `tasks/cancel 2` - Cancel task id 2. + + """ + + key = "tasks" + aliases = ["delays", "task"] + switch_options = ("pause", "unpause", "do_task", "call", "remove", "cancel") + locks = "perm(Developer)" + help_category = "System" + + @staticmethod + def coll_date_func(task): + """Replace regex characters in date string and collect deferred function name.""" + t_comp_date = str(task[0]).replace('-', '/') + t_func_name = str(task[1]).split(' ') + t_func_mem_ref = t_func_name[3] if len(t_func_name) >= 4 else None + return t_comp_date, t_func_mem_ref + + def do_task_action(self, *args, **kwargs): + """ + Process the action of a tasks command. + + This exists to gain support with yes or no function from EvMenu. + """ + task_id = self.task_id + + # get a reference of the global task handler + global _TASK_HANDLER + if _TASK_HANDLER is None: + from evennia.scripts.taskhandler import TASK_HANDLER as _TASK_HANDLER + + # verify manipulating the correct task + task_args = _TASK_HANDLER.tasks.get(task_id, False) + if not task_args: # check if the task is still active + self.msg('Task completed while waiting for input.') + return + else: + # make certain a task with matching IDs has not been created + t_comp_date, t_func_mem_ref = self.coll_date_func(task_args) + if self.t_comp_date != t_comp_date or self.t_func_mem_ref != t_func_mem_ref: + self.msg('Task completed while waiting for input.') + return + + # Do the action requested by command caller + action_return = self.task_action() + self.msg(f'{self.action_request} request completed.') + self.msg(f'The task function {self.action_request} returned: {action_return}') + + def func(self): + # get a reference of the global task handler + global _TASK_HANDLER + if _TASK_HANDLER is None: + from evennia.scripts.taskhandler import TASK_HANDLER as _TASK_HANDLER + # handle no tasks active. + if not _TASK_HANDLER.tasks: + self.msg('There are no active tasks.') + if self.switches or self.args: + self.msg('Likely the task has completed and been removed.') + return + + # handle caller's request to manipulate a task(s) + if self.switches and self.lhs: + + # find if the argument is a task id or function name + action_request = self.switches[0] + try: + arg_is_id = int(self.lhslist[0]) + except ValueError: + arg_is_id = False + + # if the argument is a task id, proccess the action on a single task + if arg_is_id: + + err_arg_msg = 'Switch and task ID are required when manipulating a task.' + task_comp_msg = 'Task completed while processing request.' + + # handle missing arguments or switches + if not self.switches and self.lhs: + self.msg(err_arg_msg) + return + + # create a handle for the task + task_id = arg_is_id + task = TaskHandlerTask(task_id) + + # handle task no longer existing + if not task.exists(): + self.msg(f'Task {task_id} does not exist.') + return + + # get a reference of the function caller requested + switch_action = getattr(task, action_request, False) + if not switch_action: + self.msg(f'{self.switches[0]}, is not an acceptable task action or ' \ + f'{task_comp_msg.lower()}') + + # verify manipulating the correct task + if task_id in _TASK_HANDLER.tasks: + task_args = _TASK_HANDLER.tasks.get(task_id, False) + if not task_args: # check if the task is still active + self.msg(task_comp_msg) + return + else: + t_comp_date, t_func_mem_ref = self.coll_date_func(task_args) + t_func_name = str(task_args[1]).split(' ') + t_func_name = t_func_name[1] if len(t_func_name) >= 2 else None + + if task.exists(): # make certain the task has not been called yet. + prompt = (f'{action_request.capitalize()} task {task_id} with completion date ' + f'{t_comp_date} ({t_func_name}) {{options}}?') + no_msg = f'No {action_request} processed.' + # record variables for use in do_task_action method + self.task_id = task_id + self.t_comp_date = t_comp_date + self.t_func_mem_ref = t_func_mem_ref + self.task_action = switch_action + self.action_request = action_request + ask_yes_no(self.caller, + prompt=prompt, + yes_action=self.do_task_action, + no_action=no_msg, + default="Y", + allow_abort=True) + return True + else: + self.msg(task_comp_msg) + return + + # the argument is not a task id, process the action on all task deferring the function + # specified as an argument + else: + + name_match_found = False + arg_func_name = self.lhslist[0].lower() + + # repack tasks into a new dictionary + current_tasks = {} + for task_id, task_args in _TASK_HANDLER.tasks.items(): + current_tasks.update({task_id: task_args}) + + # call requested action on all tasks with the function name + for task_id, task_args in current_tasks.items(): + t_func_name = str(task_args[1]).split(' ') + t_func_name = t_func_name[1] if len(t_func_name) >= 2 else None + # skip this task if it is not for the function desired + if arg_func_name != t_func_name: + continue + name_match_found = True + task = TaskHandlerTask(task_id) + switch_action = getattr(task, action_request, False) + if switch_action: + action_return = switch_action() + self.msg(f'Task action {action_request} completed on task ID {task_id}.') + self.msg(f'The task function {action_request} returned: {action_return}') + + # provide a message if not tasks of the function name was found + if not name_match_found: + self.msg(f'No tasks deferring function name {arg_func_name} found.') + return + return True + + # check if an maleformed request was created + elif self.switches or self.lhs: + self.msg('Task command misformed.') + self.msg('Proper format tasks[/switch] [function name or task id]') + return + + # No task manupilation requested, build a table of tasks and display it + # get the width of screen in characters + width = self.client_width() + # create table header and list to hold tasks data and actions + tasks_header = ('Task ID', 'Completion Date', 'Function', 'Arguments', 'KWARGS', + 'persistent') + # empty list of lists, the size of the header + tasks_list = [list() for i in range(len(tasks_header))] + for task_id, task in _TASK_HANDLER.tasks.items(): + # collect data from the task + t_comp_date, t_func_mem_ref = self.coll_date_func(task) + t_func_name = str(task[1]).split(' ') + t_func_name = t_func_name[1] if len(t_func_name) >= 2 else None + t_args = str(task[2]) + t_kwargs = str(task[3]) + t_pers = str(task[4]) + # add task data to the tasks list + task_data = (task_id, t_comp_date, t_func_name, t_args, t_kwargs, t_pers) + for i in range(len(tasks_header)): + tasks_list[i].append(task_data[i]) + # create and display the table + tasks_table = EvTable(*tasks_header, table=tasks_list, maxwidth=width, border='cells', + align='center') + actions = (f'/{switch}' for switch in self.switch_options) + helptxt = f"\nActions: {iter_to_str(actions)}" + self.msg(str(tasks_table) + helptxt) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 01e1bb1a58..4245eb8532 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -18,6 +18,7 @@ from anything import Anything from parameterized import parameterized from django.conf import settings +from twisted.internet import task from unittest.mock import patch, Mock, MagicMock from evennia import DefaultRoom, DefaultExit, ObjectDB @@ -575,6 +576,170 @@ class TestSystem(CommandTest): def test_server_load(self): self.call(system.CmdServerLoad(), "", "Server CPU and Memory load:") +_TASK_HANDLER = None + +def func_test_cmd_tasks(): + return 'success' + +class TestCmdTasks(CommandTest): + + def setUp(self): + super().setUp() + # get a reference of TASK_HANDLER + self.timedelay = 5 + global _TASK_HANDLER + if _TASK_HANDLER is None: + from evennia.scripts.taskhandler import TASK_HANDLER as _TASK_HANDLER + _TASK_HANDLER.clock = task.Clock() + self.task_handler = _TASK_HANDLER + self.task_handler.clear() + self.task = self.task_handler.add(self.timedelay, func_test_cmd_tasks) + task_args = self.task_handler.tasks.get(self.task.get_id(), False) + + + def tearDown(self): + super().tearDown() + self.task_handler.clear() + + def test_no_tasks(self): + self.task_handler.clear() + self.call(system.CmdTasks(), '', 'There are no active tasks.') + + def test_active_task(self): + cmd_result = self.call(system.CmdTasks(), '') + for ptrn in ('Task ID', 'Completion Date', 'Function', 'KWARGS', 'persisten', + '1', r'\d+/\d+/\d+', r'\d+\:\d+\:\d+', r'ms\:\d+', 'func_test', '{}', + 'False'): + self.assertRegex(cmd_result, ptrn) + + def test_persistent_task(self): + self.task_handler.clear() + self.task_handler.add(self.timedelay, func_test_cmd_tasks, persistent=True) + cmd_result = self.call(system.CmdTasks(), '') + self.assertRegex(cmd_result, 'True') + + def test_pause_unpause(self): + # test pause + args = f'/pause {self.task.get_id()}' + wanted_msg = 'Yes or No, pause task 1? With completion date' + cmd_result = self.call(system.CmdTasks(), args, wanted_msg) + self.assertRegex(cmd_result, '\. Deferring function func_test_cmd_tasks\.') + self.char1.execute_cmd('y') + self.assertTrue(self.task.paused) + self.task_handler.clock.advance(self.timedelay + 1) + # test unpause + args = f'/unpause {self.task.get_id()}' + self.assertTrue(self.task.exists()) + wanted_msg = 'Yes or No, unpause task 1? With completion date' + cmd_result = self.call(system.CmdTasks(), args, wanted_msg) + self.assertRegex(cmd_result, '\. Deferring function func_test_cmd_tasks\.') + self.char1.execute_cmd('y') + # verify task continues after unpause + self.task_handler.clock.advance(1) + self.assertFalse(self.task.exists()) + + def test_do_task(self): + args = f'/do_task {self.task.get_id()}' + wanted_msg = 'Yes or No, do_task task 1? With completion date' + cmd_result = self.call(system.CmdTasks(), args, wanted_msg) + self.assertRegex(cmd_result, '\. Deferring function func_test_cmd_tasks\.') + self.char1.execute_cmd('y') + self.assertFalse(self.task.exists()) + + def test_remove(self): + args = f'/remove {self.task.get_id()}' + wanted_msg = 'Yes or No, remove task 1? With completion date' + cmd_result = self.call(system.CmdTasks(), args, wanted_msg) + self.assertRegex(cmd_result, '\. Deferring function func_test_cmd_tasks\.') + self.char1.execute_cmd('y') + self.assertFalse(self.task.exists()) + + def test_call(self): + args = f'/call {self.task.get_id()}' + wanted_msg = 'Yes or No, call task 1? With completion date' + cmd_result = self.call(system.CmdTasks(), args, wanted_msg) + self.assertRegex(cmd_result, '\. Deferring function func_test_cmd_tasks\.') + self.char1.execute_cmd('y') + # make certain the task is still active + self.assertTrue(self.task.active()) + # go past delay time, the task should call do_task and remove itself after calling. + self.task_handler.clock.advance(self.timedelay + 1) + self.assertFalse(self.task.exists()) + + def test_cancel(self): + args = f'/cancel {self.task.get_id()}' + wanted_msg = 'Yes or No, cancel task 1? With completion date' + cmd_result = self.call(system.CmdTasks(), args, wanted_msg) + self.assertRegex(cmd_result, '\. Deferring function func_test_cmd_tasks\.') + self.char1.execute_cmd('y') + self.assertTrue(self.task.exists()) + self.assertFalse(self.task.active()) + + def test_func_name_manipulation(self): + self.task_handler.add(self.timedelay, func_test_cmd_tasks) # add an extra task + args = f'/remove func_test_cmd_tasks' + wanted_msg = 'Task action remove completed on task ID 1.|The task function remove returned: True|' \ + 'Task action remove completed on task ID 2.|The task function remove returned: True' + self.call(system.CmdTasks(), args, wanted_msg) + self.assertFalse(self.task_handler.tasks) # no tasks should exist. + + def test_wrong_func_name(self): + args = f'/remove intentional_fail' + wanted_msg = 'No tasks deferring function name intentional_fail found.' + self.call(system.CmdTasks(), args, wanted_msg) + self.assertTrue(self.task.active()) + + def test_no_input(self): + args = f'/cancel {self.task.get_id()}' + self.call(system.CmdTasks(), args) + # task should complete since no input was received + self.task_handler.clock.advance(self.timedelay + 1) + self.assertFalse(self.task.exists()) + + def test_responce_of_yes(self): + self.call(system.CmdTasks(), f'/cancel {self.task.get_id()}') + self.char1.msg = Mock() + self.char1.execute_cmd('y') + text = '' + for _, _, kwargs in self.char1.msg.mock_calls: + text += kwargs.get('text', '') + self.assertEqual(text, 'cancel request completed.The task function cancel returned: True') + self.assertTrue(self.task.exists()) + + def test_task_complete_waiting_input(self): + """Test for task completing while waiting for input.""" + self.call(system.CmdTasks(), f'/cancel {self.task.get_id()}') + self.task_handler.clock.advance(self.timedelay + 1) + self.char1.msg = Mock() + self.char1.execute_cmd('y') + text = '' + for _, _, kwargs in self.char1.msg.mock_calls: + text += kwargs.get('text', '') + self.assertEqual(text, 'Task completed while waiting for input.') + self.assertFalse(self.task.exists()) + + def test_new_task_waiting_input(self): + """ + Test task completing than a new task with the same ID being made while waitinf for input. + """ + self.assertTrue(self.task.get_id(), 1) + self.call(system.CmdTasks(), f'/cancel {self.task.get_id()}') + self.task_handler.clock.advance(self.timedelay + 1) + self.assertFalse(self.task.exists()) + self.task = self.task_handler.add(self.timedelay, func_test_cmd_tasks) + self.assertTrue(self.task.get_id(), 1) + self.char1.msg = Mock() + self.char1.execute_cmd('y') + text = '' + for _, _, kwargs in self.char1.msg.mock_calls: + text += kwargs.get('text', '') + self.assertEqual(text, 'Task completed while waiting for input.') + + def test_misformed_command(self): + wanted_msg = 'Task command misformed.|Proper format tasks[/switch] ' \ + '[function name or task id]' + self.call(system.CmdTasks(), f'/cancel', wanted_msg) + class TestAdmin(CommandTest): def test_emit(self): diff --git a/evennia/contrib/crafting/tests.py b/evennia/contrib/crafting/tests.py index 2e87df1615..3c3dfa1452 100644 --- a/evennia/contrib/crafting/tests.py +++ b/evennia/contrib/crafting/tests.py @@ -182,9 +182,9 @@ class TestCraftingRecipe(TestCase): "i1": "cons2", "i2": "cons3", "o0": "Result1", - "tools": "bar, bar2 and bar3", + "tools": "bar, bar2, and bar3", "consumables": "cons1 and cons2", - "inputs": "cons1, cons2 and cons3", + "inputs": "cons1, cons2, and cons3", "outputs": "Result1", } diff --git a/evennia/scripts/taskhandler.py b/evennia/scripts/taskhandler.py index cc23624e1d..59cbeb12a5 100644 --- a/evennia/scripts/taskhandler.py +++ b/evennia/scripts/taskhandler.py @@ -292,14 +292,25 @@ class TaskHandler(object): if not persistent: continue + safe_callback = callback if getattr(callback, "__self__", None): # `callback` is an instance method obj = callback.__self__ name = callback.__name__ - callback = (obj, name) + safe_callback = (obj, name) # Check if callback can be pickled. args and kwargs have been checked - self.to_save[task_id] = dbserialize((date, callback, args, kwargs)) + try: + dbserialize(safe_callback) + except (TypeError, AttributeError, PickleError) as err: + raise ValueError( + "the specified callback {callback} cannot be pickled. " + "It must be a top-level function in a module or an " + "instance method ({err}).".format(callback=callback, err=err) + ) + + self.to_save[task_id] = dbserialize((date, safe_callback, args, kwargs)) + ServerConfig.objects.conf("delayed_tasks", self.to_save) def add(self, timedelay, callback, *args, **kwargs): @@ -348,17 +359,6 @@ class TaskHandler(object): safe_args = [] safe_kwargs = {} - # an unsaveable callback should immediately abort - try: - dbserialize(callback) - except (TypeError, AttributeError, PickleError): - raise ValueError( - "the specified callback {} cannot be pickled. " - "It must be a top-level function in a module or an " - "instance method.".format(callback) - ) - return - # Check that args and kwargs contain picklable information for arg in args: try: diff --git a/evennia/server/portal/mxp.py b/evennia/server/portal/mxp.py index 6c938ab709..7f5b39d392 100644 --- a/evennia/server/portal/mxp.py +++ b/evennia/server/portal/mxp.py @@ -16,12 +16,14 @@ http://www.gammon.com.au/mushclient/addingservermxp.htm import re LINKS_SUB = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL) +URL_SUB = re.compile(r"\|lu(.*?)\|lt(.*?)\|le", re.DOTALL) # MXP Telnet option MXP = bytes([91]) # b"\x5b" MXP_TEMPSECURE = "\x1B[4z" MXP_SEND = MXP_TEMPSECURE + '' + "\\2" + MXP_TEMPSECURE + "" +MXP_URL = MXP_TEMPSECURE + '' + "\\2" + MXP_TEMPSECURE + "" def mxp_parse(text): @@ -38,6 +40,7 @@ def mxp_parse(text): text = text.replace("&", "&").replace("<", "<").replace(">", ">") text = LINKS_SUB.sub(MXP_SEND, text) + text = URL_SUB.sub(MXP_URL, text) return text diff --git a/evennia/server/profiling/dummyrunner_settings.py b/evennia/server/profiling/dummyrunner_settings.py index aff9404ab5..924c7453f7 100644 --- a/evennia/server/profiling/dummyrunner_settings.py +++ b/evennia/server/profiling/dummyrunner_settings.py @@ -128,8 +128,8 @@ def c_login(client): def c_login_nodig(client): "logins, don't dig its own room" - cname = DUMMY_NAME % client.gid - cpwd = DUMMY_PWD % client.gid + cname = DUMMY_NAME.format(gid=client.gid) + cpwd = DUMMY_PWD.format(gid=client.gid) cmds = ( f"create {cname} {cpwd}", f"connect {cname} {cpwd}" @@ -183,7 +183,7 @@ def c_digs(client): exitname1 = EXIT_TEMPLATE % client.counter() exitname2 = EXIT_TEMPLATE % client.counter() client.exits.extend([exitname1, exitname2]) - return ("@dig/tel %s = %s, %s" % (roomname, exitname1, exitname2),) + return ("dig/tel %s = %s, %s" % (roomname, exitname1, exitname2),) def c_creates_obj(client): @@ -191,10 +191,10 @@ def c_creates_obj(client): objname = OBJ_TEMPLATE % client.counter() client.objs.append(objname) cmds = ( - "@create %s" % objname, - '@desc %s = "this is a test object' % objname, - "@set %s/testattr = this is a test attribute value." % objname, - "@set %s/testattr2 = this is a second test attribute." % objname, + "create %s" % objname, + 'desc %s = "this is a test object' % objname, + "set %s/testattr = this is a test attribute value." % objname, + "set %s/testattr2 = this is a second test attribute." % objname, ) return cmds @@ -203,7 +203,7 @@ def c_creates_button(client): "creates example button, storing name on client" objname = TOBJ_TEMPLATE % client.counter() client.objs.append(objname) - cmds = ("@create %s:%s" % (objname, TOBJ_TYPECLASS), "@desc %s = test red button!" % objname) + cmds = ("create %s:%s" % (objname, TOBJ_TYPECLASS), "desc %s = test red button!" % objname) return cmds @@ -295,7 +295,7 @@ elif PROFILE == 'normal_builder': (0.1, c_help), (0.01, c_digs), (0.01, c_creates_obj), - (0.2, c_moves) + (0.2, c_moves), (0.1, c_measure_lag) ) elif PROFILE == 'heavy_builder': diff --git a/evennia/server/profiling/tests.py b/evennia/server/profiling/tests.py index 447229d4c4..d887a669c6 100644 --- a/evennia/server/profiling/tests.py +++ b/evennia/server/profiling/tests.py @@ -1,5 +1,6 @@ from django.test import TestCase from mock import Mock, patch, mock_open +from anything import Something from .dummyrunner_settings import ( c_creates_button, c_creates_obj, @@ -29,8 +30,8 @@ class TestDummyrunnerSettings(TestCase): self.client.cid = 1 self.client.counter = Mock(return_value=1) self.client.gid = "20171025161153-1" - self.client.name = "Dummy-%s" % self.client.gid - self.client.password = "password-%s" % self.client.gid + self.client.name = "Dummy_%s" % self.client.gid + self.client.password = Something, self.client.start_room = "testing_room_start_%s" % self.client.gid self.client.objs = [] self.client.exits = [] @@ -43,28 +44,25 @@ class TestDummyrunnerSettings(TestCase): self.assertEqual( c_login(self.client), ( - "create %s %s" % (self.client.name, self.client.password), - "connect %s %s" % (self.client.name, self.client.password), - "@dig %s" % self.client.start_room, - "@teleport %s" % self.client.start_room, - "@dig testing_room_1 = exit_1, exit_1", + Something, # create + Something, # connect + "dig %s" % self.client.start_room, + "teleport %s" % self.client.start_room, + "py from evennia.server.profiling.dummyrunner import DummyRunnerCmdSet;" + "self.cmdset.add(DummyRunnerCmdSet, persistent=False)" ), ) def test_c_login_no_dig(self): - self.assertEqual( - c_login_nodig(self.client), - ( - "create %s %s" % (self.client.name, self.client.password), - "connect %s %s" % (self.client.name, self.client.password), - ), - ) + cmd1, cmd2 = c_login_nodig(self.client) + self.assertTrue(cmd1.startswith("create " + self.client.name + " ")) + self.assertTrue(cmd2.startswith("connect " + self.client.name + " ")) def test_c_logout(self): - self.assertEqual(c_logout(self.client), "@quit") + self.assertEqual(c_logout(self.client), ("quit",)) def perception_method_tests(self, func, verb, alone_suffix=""): - self.assertEqual(func(self.client), "%s%s" % (verb, alone_suffix)) + self.assertEqual(func(self.client), ("%s%s" % (verb, alone_suffix),)) self.client.exits = ["exit1", "exit2"] self.assertEqual(func(self.client), ["%s exit1" % verb, "%s exit2" % verb]) self.client.objs = ["foo", "bar"] @@ -83,11 +81,11 @@ class TestDummyrunnerSettings(TestCase): def test_c_help(self): self.assertEqual( c_help(self.client), - ("help", "help @teleport", "help look", "help @tunnel", "help @dig"), + ("help", "dummyrunner_echo_response"), ) def test_c_digs(self): - self.assertEqual(c_digs(self.client), ("@dig/tel testing_room_1 = exit_1, exit_1")) + self.assertEqual(c_digs(self.client), ("dig/tel testing_room_1 = exit_1, exit_1", )) self.assertEqual(self.client.exits, ["exit_1", "exit_1"]) self.clear_client_lists() @@ -96,10 +94,10 @@ class TestDummyrunnerSettings(TestCase): self.assertEqual( c_creates_obj(self.client), ( - "@create %s" % objname, - '@desc %s = "this is a test object' % objname, - "@set %s/testattr = this is a test attribute value." % objname, - "@set %s/testattr2 = this is a second test attribute." % objname, + "create %s" % objname, + 'desc %s = "this is a test object' % objname, + "set %s/testattr = this is a test attribute value." % objname, + "set %s/testattr2 = this is a second test attribute." % objname, ), ) self.assertEqual(self.client.objs, [objname]) @@ -110,7 +108,7 @@ class TestDummyrunnerSettings(TestCase): typeclass_name = "contrib.tutorial_examples.red_button.RedButton" self.assertEqual( c_creates_button(self.client), - ("@create %s:%s" % (objname, typeclass_name), "@desc %s = test red button!" % objname), + ("create %s:%s" % (objname, typeclass_name), "desc %s = test red button!" % objname), ) self.assertEqual(self.client.objs, [objname]) self.clear_client_lists() @@ -119,25 +117,23 @@ class TestDummyrunnerSettings(TestCase): self.assertEqual( c_socialize(self.client), ( - "ooc Hello!", - "ooc Testing ...", - "ooc Testing ... times 2", + "pub Hello!", "say Yo!", "emote stands looking around.", ), ) def test_c_moves(self): - self.assertEqual(c_moves(self.client), "look") + self.assertEqual(c_moves(self.client), ("look",)) self.client.exits = ["south", "north"] self.assertEqual(c_moves(self.client), ["south", "north"]) self.clear_client_lists() def test_c_move_n(self): - self.assertEqual(c_moves_n(self.client), "north") + self.assertEqual(c_moves_n(self.client), ("north",)) def test_c_move_s(self): - self.assertEqual(c_moves_s(self.client), "south") + self.assertEqual(c_moves_s(self.client), ("south",)) class TestMemPlot(TestCase): diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index 0dd2d8234c..605a133397 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -223,6 +223,7 @@ class ANSIParser(object): ansi_xterm256_bright_bg_map += settings.COLOR_ANSI_XTERM256_BRIGHT_BG_EXTRA_MAP mxp_re = r"\|lc(.*?)\|lt(.*?)\|le" + mxp_url_re = r"\|lu(.*?)\|lt(.*?)\|le" # prepare regex matching brightbg_sub = re.compile( @@ -237,6 +238,7 @@ class ANSIParser(object): # xterm256_sub = re.compile(r"|".join([tup[0] for tup in xterm256_map]), re.DOTALL) ansi_sub = re.compile(r"|".join([re.escape(tup[0]) for tup in ansi_map]), re.DOTALL) mxp_sub = re.compile(mxp_re, re.DOTALL) + mxp_url_sub = re.compile(mxp_url_re, re.DOTALL) # used by regex replacer to correctly map ansi sequences ansi_map_dict = dict(ansi_map) @@ -424,7 +426,9 @@ class ANSIParser(object): string (str): The processed string. """ - return self.mxp_sub.sub(r"\2", string) + string = self.mxp_sub.sub(r"\2", string) + string = self.mxp_url_sub.sub(r"\1", string) # replace with url verbatim + return string def parse_ansi(self, string, strip_ansi=False, xterm256=False, mxp=False): """ diff --git a/evennia/utils/tests/test_tagparsing.py b/evennia/utils/tests/test_tagparsing.py index 5c47bc5b6a..ae78c8fd0d 100644 --- a/evennia/utils/tests/test_tagparsing.py +++ b/evennia/utils/tests/test_tagparsing.py @@ -145,13 +145,17 @@ class ANSIStringTestCase(TestCase): """ mxp1 = "|lclook|ltat|le" mxp2 = "Start to |lclook here|ltclick somewhere here|le first" + mxp3 = "Check out |luhttps://www.example.com|ltmy website|le!" self.assertEqual(15, len(ANSIString(mxp1))) self.assertEqual(53, len(ANSIString(mxp2))) + self.assertEqual(53, len(ANSIString(mxp3))) # These would indicate an issue with the tables. self.assertEqual(len(ANSIString(mxp1)), len(ANSIString(mxp1).split("\n")[0])) self.assertEqual(len(ANSIString(mxp2)), len(ANSIString(mxp2).split("\n")[0])) + self.assertEqual(len(ANSIString(mxp3)), len(ANSIString(mxp3).split("\n")[0])) self.assertEqual(mxp1, ANSIString(mxp1)) self.assertEqual(mxp2, str(ANSIString(mxp2))) + self.assertEqual(mxp3, str(ANSIString(mxp3))) def test_add(self): """ diff --git a/evennia/utils/tests/test_utils.py b/evennia/utils/tests/test_utils.py index 2c4011c73b..9c18e1ccd8 100644 --- a/evennia/utils/tests/test_utils.py +++ b/evennia/utils/tests/test_utils.py @@ -72,10 +72,11 @@ class TestListToString(TestCase): def test_list_to_string(self): self.assertEqual("1, 2, 3", utils.list_to_string([1, 2, 3], endsep="")) self.assertEqual('"1", "2", "3"', utils.list_to_string([1, 2, 3], endsep="", addquote=True)) - self.assertEqual("1, 2 and 3", utils.list_to_string([1, 2, 3])) + self.assertEqual("1, 2, and 3", utils.list_to_string([1, 2, 3])) self.assertEqual( - '"1", "2" and "3"', utils.list_to_string([1, 2, 3], endsep="and", addquote=True) + '"1", "2", and "3"', utils.list_to_string([1, 2, 3], endsep="and", addquote=True) ) + self.assertEqual("1 and 2", utils.list_to_string([1, 2])) class TestMLen(TestCase): diff --git a/evennia/utils/text2html.py b/evennia/utils/text2html.py index 7fb19a8ab8..bb4251caf7 100644 --- a/evennia/utils/text2html.py +++ b/evennia/utils/text2html.py @@ -103,9 +103,10 @@ class TextToHTMLparser(object): ) re_dblspace = re.compile(r" {2,}", re.M) re_url = re.compile( - r'((?:ftp|www|https?)\W+(?:(?!\.(?:\s|$)|&\w+;)[^"\',;$*^\\(){}<>\[\]\s])+)(\.(?:\s|$)|&\w+;|)' + r'(?\[\]\s])+)(\.(?:\s|$)|&\w+;|)' ) re_mxplink = re.compile(r"\|lc(.*?)\|lt(.*?)\|le", re.DOTALL) + re_mxpurl = re.compile(r"\|lu(.*?)\|lt(.*?)\|le", re.DOTALL) def _sub_bgfg(self, colormatch): # print("colormatch.groups()", colormatch.groups()) @@ -290,6 +291,21 @@ class TextToHTMLparser(object): ) return val + def sub_mxp_urls(self, match): + """ + Helper method to be passed to re.sub, + replaces MXP links with HTML code. + Args: + match (re.Matchobject): Match for substitution. + Returns: + text (str): Processed text. + """ + url, text = [grp.replace('"', "\\"") for grp in match.groups()] + val = ( + r"""{text}""".format(url=url, text=text) + ) + return val + def sub_text(self, match): """ Helper method to be passed to re.sub, @@ -337,6 +353,7 @@ class TextToHTMLparser(object): # convert all ansi to html result = re.sub(self.re_string, self.sub_text, text) result = re.sub(self.re_mxplink, self.sub_mxp_links, result) + result = re.sub(self.re_mxpurl, self.sub_mxp_urls, result) result = self.re_color(result) result = self.re_bold(result) result = self.re_underline(result) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index ee8274efe5..6e3d274080 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -381,15 +381,13 @@ def iter_to_str(initer, endsep="and", addquote=False): >>> list_to_string([1,2,3], endsep='') '1, 2, 3' >>> list_to_string([1,2,3], ensdep='and') - '1, 2 and 3' + '1, 2, and 3' >>> list_to_string([1,2,3], endsep='and', addquote=True) - '"1", "2" and "3"' + '"1", "2", and "3"' ``` """ - if not endsep: - endsep = "," - else: + if endsep: endsep = " " + endsep if not initer: return "" @@ -397,11 +395,15 @@ def iter_to_str(initer, endsep="and", addquote=False): if addquote: if len(initer) == 1: return '"%s"' % initer[0] - return ", ".join('"%s"' % v for v in initer[:-1]) + "%s %s" % (endsep, '"%s"' % initer[-1]) + elif len(initer) == 2: + return '"%s"' % ('"%s "' % endsep).join(str(v) for v in initer) + return ", ".join('"%s"' % v for v in initer[:-1]) + ",%s %s" % (endsep, '"%s"' % initer[-1]) else: if len(initer) == 1: return str(initer[0]) - return ", ".join(str(v) for v in initer[:-1]) + "%s %s" % (endsep, initer[-1]) + elif len(initer) == 2: + return ("%s " % endsep).join(str(v) for v in initer) + return ", ".join(str(v) for v in initer[:-1]) + ",%s %s" % (endsep, initer[-1]) # legacy aliases