Skip to content

Commit 2a34bb9

Browse files
committed
feat: Use docstrings to provide descriptions for arguments
1 parent 530a5aa commit 2a34bb9

File tree

8 files changed

+125
-36
lines changed

8 files changed

+125
-36
lines changed

README.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,32 @@ async def foo(ctx: SlashContext, member: discord.Member):
4141

4242
### Descriptions
4343

44-
By default, each argument has the description `No description`, but that can be changed by providing a `Tuple` of any type and a `Literal`.
44+
By default, each argument and command has the description `No description`, but that can be changed by providing a docstring. Docstrings are supported as provided by [docstring-parser](https://pypi.org/project/docstring-parser/) — *at time of writing, that is [ReST](https://www.python.org/dev/peps/pep-0287/), [Google](https://google.github.io/styleguide/pyguide.html), and [Numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html).*
4545
```python
4646
from typing import Tuple, Literal
4747

4848
# ...
4949

50-
description = Literal["my description here"]
5150
@s.slash()
52-
async def foo(ctx: SlashContext, member: Tuple[discord.Member, description]):
53-
# This command will automatically be called 'foo', and the argument member
54-
# will have the description "my description here"
51+
async def foo(ctx: SlashContext, member: discord.Member):
52+
"""
53+
My command description here!
54+
55+
:param member: my description here
56+
"""
57+
# This command will automatically be called 'foo', and have the description
58+
# "My command description here!", and the argument `member` will have the
59+
# description "my description here".
5560
await ctx.send(f"Hello, {member.mention}")
5661
```
5762

63+
It's also possible to pass the command description through the decorator as follows, although that's not recommended (and will override any docstring provided description):
64+
```python
65+
@s.slash(description="My description!")
66+
async def command(ctx):
67+
pass
68+
```
69+
5870
## Advanced usage
5971
The same usage applies for cogs, but a different function is used.
6072

pyslash/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
from .converters import convert
99
from .decorators import slash, slash_cog
1010

11-
__version__ = "1.1.1"
11+
__version__ = "1.2.0rc1"

pyslash/decorators.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def slash_cog(name: str = None, description: str = None, guild_ids: List[int] =
2626
def decorator(function):
2727
# Use annotations
2828
params, converter_params = get_slash_kwargs(
29-
name, description, guild_ids, remove_underscore_keywords, function)
29+
function, name, description, guild_ids, remove_underscore_keywords)
3030
return cog_ext.cog_slash(**params)(convert(**converter_params)(function))
3131

3232
return decorator
@@ -53,7 +53,7 @@ def slash(slash_class: SlashCommand, name: str = None, description: str = None,
5353
def decorator(function):
5454
# Use annotations
5555
params, converter_params = get_slash_kwargs(
56-
name, description, guild_ids, remove_underscore_keywords, function)
56+
function, name, description, guild_ids, remove_underscore_keywords)
5757
return slash_class.slash(**params)(convert(**converter_params)(function))
5858

5959
return decorator

pyslash/utils.py

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import inspect
22
import keyword
3-
from typing import Any, List, Literal, Union
3+
from typing import Any, Dict, List, Literal, Union
44

55
from discord.ext import commands
66
from discord.ext.commands.errors import CommandError
77
from discord_slash.context import SlashContext
88
from discord_slash.model import SlashCommandOptionType
99
from discord_slash.utils.manage_commands import create_choice, create_option
10+
from docstring_parser import parse
11+
from docstring_parser.common import DocstringParam
1012

1113

1214
class InvalidParameter(CommandError):
@@ -105,34 +107,61 @@ def get_slash_command_type(annotation: Any) -> SlashCommandOptionType:
105107
InvalidParameter
106108
No parameter type could be found
107109
"""
110+
root_type = get_root_type(annotation)
111+
108112
# If it's a converter or an optional of a converter, it will always be a string
109-
if issubclass(get_root_type(annotation), commands.Converter):
113+
if issubclass(root_type, commands.Converter):
110114
return SlashCommandOptionType.STRING
111115

112-
type_ = SlashCommandOptionType.from_type(get_root_type(annotation))
116+
# Otherwise, try to fetch from enum
117+
type_ = SlashCommandOptionType.from_type(root_type)
113118
if type_ is None:
114119
raise InvalidParameter(
115120
f"Parameter: {str(annotation)} does not match any Discord type")
116121

117122
return type_
118123

119124

120-
def get_slash_kwargs(name: str, description: str, guild_ids: List[int], remove_underscore_keywords: bool, function):
125+
def get_descriptions(params: List[DocstringParam]) -> Dict[str, str]:
126+
"""
127+
Turn a list of docstring paramsinto a dictionary
128+
129+
Parameters
130+
----------
131+
params : List[DocstringParam]
132+
The docstring params
133+
134+
Returns
135+
-------
136+
Dict[str, str]
137+
The dictionary of parameter: description
138+
"""
139+
descriptions = {}
140+
for param in params:
141+
name, description = param.arg_name, param.description
142+
descriptions[name] = description
143+
144+
return descriptions
145+
146+
147+
def get_slash_kwargs(function: Any, name: str = None, description: str = None, guild_ids: List[int] = None, remove_underscore_keywords: bool = False):
121148
"""
122149
Get the kwargs required for cog_ext.cog_slash or SlashCommand.slash
123150
124151
Parameters
125152
----------
126-
name : str
127-
The name of the command
128-
description : str
129-
The description of the command
130-
guild_ids : List[int]
131-
List of guild IDs to add the command to
132-
remove_underscore_keywords : bool
133-
Whether to remove _ from the end of arguments that would be keywords
134153
function : any
135154
The command
155+
name : str, optional
156+
The name of the command, by default None
157+
description : str, optional
158+
The description of the command (will override any docstring provided
159+
description if not None), by default None
160+
guild_ids : List[int], optional
161+
List of guild IDs to add the command to, by default None
162+
remove_underscore_keywords : bool, optional
163+
Whether to remove _ from the end of arguments that would be keywords,
164+
by default False
136165
137166
Returns
138167
-------
@@ -143,7 +172,14 @@ def get_slash_kwargs(name: str, description: str, guild_ids: List[int], remove_u
143172
"""
144173
# Use annotations
145174
signature = inspect.signature(function)
146-
175+
# Use docstring signatures to get descriptions
176+
parsed_docstring = parse(function.__doc__)
177+
# Command description
178+
description = description or parsed_docstring.short_description
179+
# Parameter descriptions
180+
param_descriptions = get_descriptions(parsed_docstring.params)
181+
182+
# Building params for function
147183
params = dict(
148184
name=name or function.__name__,
149185
description=description or "No description",
@@ -165,11 +201,9 @@ def get_slash_kwargs(name: str, description: str, guild_ids: List[int], remove_u
165201
param_name_mapping[param_name[:-1]] = param_name
166202
param_name = param_name[:-1]
167203

168-
# If it's a tuple of a type and a literal, the second argument is a description
169-
param_description = "No description"
170-
if getattr(annotation, "__origin__", None) == tuple and len(annotation.__args__) == 2:
171-
param_description = annotation.__args__[1].__args__[0]
172-
annotation = annotation.__args__[0]
204+
# Get descriptions or use default
205+
param_description = param_descriptions.get(
206+
param_name, "No description")
173207

174208
# Default to no choices
175209
choices = None
@@ -188,6 +222,7 @@ def get_slash_kwargs(name: str, description: str, guild_ids: List[int], remove_u
188222
# Just use converter params
189223
converter_params[param_name] = annotation
190224

225+
# Add the parameter/"option"
191226
params['options'].append(create_option(
192227
name=param_name,
193228
description=param_description,

requirements.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
discord.py
2-
discord-py-slash-command
1+
discord.py==1.6.*
2+
discord-py-slash-command==1.1.*
3+
docstring_parser==0.7.*

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
install_requires = f.readlines()
99

1010
setup(name='dpyslash',
11-
version='1.1.1',
11+
version='1.2.0rc1',
1212
description='Improves slash commands for Python',
1313
author='starsflower',
1414
url='https://github.com/starsflower/discordpy-slash-commands',

tests/test.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def test_slash_kwarg_generation(self):
4141
def func(foo: str, bar: Union[Literal[1], Literal[2, "name"]], baz: Union[commands.Converter, int] = "bin"):
4242
pass
4343

44-
kwargs, _ = get_slash_kwargs("test", "test", [], False, func)
44+
kwargs, _ = get_slash_kwargs(func, "test", "test", [], False)
4545
self.assertEqual(kwargs["options"][0], {
4646
"name": "foo",
4747
"description": "No description",
@@ -69,19 +69,47 @@ def func(foo: str, bar: Union[Literal[1], Literal[2, "name"]], baz: Union[comman
6969
"choices": []
7070
})
7171

72-
def test_optional_converter(self):
72+
def test_docstrings(self):
7373
def func(baz: Optional[commands.Converter] = "bin"):
74+
"""
75+
My description for the command
76+
77+
Parameters
78+
----------
79+
baz : Optional[commands.Converter], optional
80+
My description, by default "bin"
81+
"""
7482
pass
7583

76-
kwargs, _ = get_slash_kwargs("test", "test", [], False, func)
84+
kwargs, _ = get_slash_kwargs(func)
85+
self.assertEqual(kwargs["description"], "My description for the command")
7786
self.assertEqual(kwargs["options"][0], {
7887
"name": "baz",
79-
"description": "No description",
88+
"description": "My description, by default \"bin\"",
89+
"type": SlashCommandOptionType.STRING,
90+
"required": False,
91+
"choices": []
92+
})
93+
94+
def test_plain_details(self):
95+
def func(baz: Optional[commands.Converter] = "bin"):
96+
"""
97+
My description for the command
98+
99+
:param baz: My description
100+
"""
101+
pass
102+
103+
kwargs, _ = get_slash_kwargs(func)
104+
self.assertEqual(kwargs["description"], "My description for the command")
105+
self.assertEqual(kwargs["options"][0], {
106+
"name": "baz",
107+
"description": "My description",
80108
"type": SlashCommandOptionType.STRING,
81109
"required": False,
82110
"choices": []
83111
})
84112

85113

86114
if __name__ == "__main__":
87-
unittest.main()
115+
unittest.main(verbosity=2)

tests/test_bot.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,25 @@
1919
s = SlashCommand(bot, sync_commands=True)
2020

2121

22-
@s.slash(description="Test", guild_ids=guild_ids)
23-
async def echo(ctx: SlashContext, my_arg: Tuple[str, Literal["a description"]]):
22+
@s.slash(guild_ids=guild_ids)
23+
async def echo(ctx: SlashContext, my_arg: str):
24+
"""
25+
Test
26+
27+
Parameters
28+
----------
29+
my_arg : str
30+
The description
31+
"""
2432
await ctx.send(f"You said {my_arg}")
2533

2634
@s.slash(name="test", guild_ids=guild_ids)
2735
async def _test(ctx: SlashContext, member: discord.Member):
36+
"""
37+
My command using another style
38+
39+
:param member: The member to say hello to
40+
"""
2841
await ctx.send(f"Hello {member.mention}")
2942

3043

0 commit comments

Comments
 (0)