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