Dynamic command execution, parsing, and storage.
Dyncommands allows you to dynamically import and run python functions. Useful for adding commands to IRC chatbots or CLI applications without a restart.
When parsing a string, it separates the command name from arguments, and executes the stored function with those arguments. Each time the parser is called, you can pass in your own custom kwargs that the command will have access to.
All command modules are compiled through RestrictedPython before being allowed to run.
You can turn off Restricted execution by setting CommandParser._unrestricted
to true, though this is highly discouraged when running untrusted code.
from pathlib import Path
from dyncommands import CommandParser, CommandContext, CommandSource
output: str = ''
def callback(text, *args):
global output
output = text
path = Path('path/to/directory') # Must be a directory with a `commands.json` file in it
parser = CommandParser(path) # Create the parser, which initializes using data located in the path directory
source = CommandSource(callback) # Create a source, which is used to talk back to the caller
input_ = 'command-that-returns-wow arg1 arg2' # this command would call zzz__command-that-returns-wow.py with arg1 and arg2
parser.parse(CommandContext(input_, source)) # Parse the new context and run the command and callback (If no errors occur)
assert output == 'wow'
Metadata for commands are stored in the commands.json
file of the CommandParser.commands_path
directory.
This is where all the data for the parser is loaded and stored.
All commands.json
files are validated with JSON Schemas through the jsonschema python package
key | type | description | default | required |
---|---|---|---|---|
commandPrefix |
string | Strings must start with this prefix, otherwise it is ignored. Empty string accepts all. | N/A | Yes |
commands |
array[Command] | Contains metadata for the stored command modules. | N/A | Yes |
key | type | description | default | required |
---|---|---|---|---|
name |
string | Uniquely identifies the command to the CommandParser. | N/A | Yes |
usage |
string | Usage information (How to use args). | "" | No |
description |
string | Description of command. | "" | No |
permission |
integer | The permission level the CommandSource requires to run the command. | 0 | No |
function |
boolean, null | Whether there is an associated python module to load. | null | No |
children |
array[Command] | Sub-commands; these are handled by the parent's function. (No associated modules for themselves). | [] | No |
overridable |
boolean | Whether the CommandParser can override any data inside this object (must be manually enabled). | true | No |
disabled |
boolean | If true still load command, but raise a DisabledError when attempting to execute. | false | No |
NOTE: Commands modules are not loaded unless they are listed in commands.json
with the function
key set to true.
{
"commandPrefix": "!",
"commands": [
{
"name": "test",
"usage": "test [*args:any]",
"description": "Test command.",
"permission": 500,
"function": true
},
{
"name": "test2",
"function": false
}
]
}
Dynamically-loaded commands are denoted by filename with a prefix of "zzz__". Inside a command module,
there is a function defined as command
. This function will be mapped to a Command
's function attribute
and stored in memory for execution. The function has access to any args that were parsed, as well as kwargs:
-
'self' (
Command
), which houses the metadata for the command that's being executed. -
'parser' (
CommandParser
), which stores the list of registered commands and command data. -
'context' (
CommandContext
), which supplies theCommandSource
and the original text sent for parsing.
- Any custom kwargs passed to
CommandParser.parse(context: CommandContext, **kwargs)
.
Since commands cannot import their own modules, some are included in globals (math
, random
, and string
).
Other attributes included in the global scope are: getitem
(operator.getitem), and ImproperUsageError
(dyncommands.exceptions.ImproperUsageError).
def command(*args, **kwargs):
self, context = kwargs.pop('self'), kwargs.pop('context')
source = context.source
if len(args) == 2:
amount, sides = abs(int(getitem(args, 0))), abs(int(getitem(args, 1)))
if amount > 0 and sides > 0:
dice_rolls = [f"{(str(i + 1) + ':') if amount > 1 else ''} {str(random.randint(1, sides))}/{sides}" for i in range(amount)]
source.send_feedback(f"/me \U0001f3b2 {source.display_name} rolled {'a die' if amount == 1 else str(amount) + ' dice'} with {sides} side{'' if sides == 1 else 's'}: {', '.join(dice_rolls)} \U0001f3b2")
else:
raise ImproperUsageError(self, context)
else:
raise ImproperUsageError(self, context)
At any time, you can call CommandParser.reload()
to reload all command modules and metadata from disk storage.
../
│
├───[commands_path]/
│ ├─── commands.json
│ ├─── zzz__[command1].py
│ ├─── zzz__[command2].py
│ └─── zzz__[command3].py
│
To add commands, you can either manually enter data into a commands.json
file, or use the
CommandParser.add_command(text: str, link: bool = False, **kwargs)
method.
The easiest way to use this method is to read the command module as text and pass that to the first argument.
You can also store command modules online to allow for remote installation, as setting the link parameter to True
will read text as a link, and will get the raw text data from that link. Ex: gist and pastebin.
NOTE: When adding a command, metadata for 'name' must be filled. This can be done in the form of comments.
Removing an already added command is relatively easy. Just call CommandParser.remove_command(name: str)
with the name
of the command that you want to remove, and it will delete both the metadata and the command module from the disk.
If you don't want to delete the command when removing, a better alternative is to disable it with
CommandParser.set_disabled(name: str, value: bool)
.
# Name: points
# Usage: points [get (username:string) | set (username:string amount:integer)]
# Description: Get your current points
# Permission: 0
# Children: [{'name': 'get', 'usage': 'get (username:string)', 'permission':0}, {'name': 'set', 'usage': 'set (username:string amount:integer)', 'permission':500}]
def command(*args, **kwargs):
...
parser = CommandParser('./')
with open('some_metadata.json') as _file:
get_ = {'name': 'get', 'usage': 'get (username:string)', 'permission':0}
set_ = {'name': 'set', 'usage': 'set (username:string amount:integer)', 'permission':500}
children = [get_, set_]
parser.add_command(_file.read(), name='my-command', description='Command with child commands.', children=children)
parser = CommandParser('./')
with open('some_metadata.json') as _file:
metadata = json.load(_file)
parser.add_command('https://gist.github.com/random/892hdh2fh389x0wcmksio7m', link=True, **metadata)
The dyncommand CommandParser
natively supports permission level handling, so you don't have to implement a similar
system in every command function.
Each command has the metadata value permission
,
(with the exception of the special value -1
) is the minimum permission level required from the CommandSource
.
-1
represents an "infinite" requirement, where no CommandSource
will be able to execute it while the permission
system is active.
To disable the permission system, set the CommandParser
's _ignore_permission
attribute to True.
NOTE: since this attribute starts with an "_", attempting to change it from inside a command's function will result
in failed compilation and an Exception.