Is there an update on Typing.Optional on python 3.9 or am I doing something wrong?

Viewed 730

While I was coding for help command for my bot in Discord.py, This function was not working as I expected.

def syntax(command):
    cmd_and_aliases = '|'.join([str(command), *command.aliases])
    params = []
    for key, value in command.params.items():
        if key not in ('self', 'ctx'):
            params.append(f'[{key}]' if 'NoneType' in str(value) else f'<{key}>')
    params = ' '.join(params)
    return f'`{cmd_and_aliases} {params}`'

What I wanted for this function was, returning and printing names and aliases to call up an discord command and what parts are necessary and what parts are not to run that command properly. As the Optional[str] in Typing can be translated as Union[str, None], What I wanted to get was what their could be and if it can be None, then it will be appended to the params list wrappend in square bracket, or else in angle bracket.

`name|aliases <necessary part> [optional part]`

This is what the function would originally would have returned, but both the necessary part and optional part are shown as wrapped in angle brackets.

@command(name='slap', aliases=['hit'])
async def slap_member(self, ctx, member: Member, *, reason: Optional[str] = 'for no reason'):

For example, for this command the result would have been like below.

slap|hit <Member> [reason]

But what I ended was getting was:

slap|hit <Member> <reason>.

Is there something I have done wrong or is there any update that would have made this function broken? If there is something I did wrong, please help me by showing what I can do otherwise.

1 Answers

The "Why"

This behavior has indeed changed in 3.9; I believe this commit specifically is the cause. It looks like these changes begun in this commit, but I believe the manifestation of your particular issue is due to the former.

I'm not deeply familiar with the plumbing of the typing module, but from what I can gather, the representation of Optional is now special-cased and returns itself as-is, i.e. typing.Optional[x] instead of "expanding" to typing.Union[x, NoneType]. They still have the same "type", however, but the important bit here is that their "type" has changed to _UnionGenericAlias, whose __repr__ is:

def __repr__(self):
    args = self.__args__
    if len(args) == 2:
        if args[0] is type(None):
            return f'typing.Optional[{_type_repr(args[1])}]'
        elif args[1] is type(None):
            return f'typing.Optional[{_type_repr(args[0])}]'
    return super().__repr__()

Possible Solutions

First, I'd like to point out that you can use command.clean_params instead of command.params (which strangely isn't documented, from what I can tell). It functions the same, except it automatically excludes self and ctx so you don't have to check for it. I'll be using it in my solutions below.

N.B. clean_params returns parameters as inspect.Parameter objects, so we have an extra layer to go through.

I came up with two possible solutions. The first is quite close to what you have, in that we do a simple string check for typing.Optional:

for key, value in command.clean_params.items():
    optional = str(value.annotation).startswith('typing.Optional')
    params.append(f'[{key}]' if optional else f'<{key}>')

Now I personally am not fond of string checks when it comes to determining types (Generics in this case), so I would opt for something a bit different. As described in PEP 585, we can do runtime introspection on Generics, so let's take advantage of that.

for key, value in command.clean_params.items():
    anno = value.annotation
    try:
        optional = anno.__origin__ is Union and anno.__args__[1] is type(None)
    except AttributeError:
        optional = False
    params.append(f'[{key}]' if optional else f'<{key}>')

Another benefit of this approach is that it will work in Python 3.8 (and I think 3.7 as well), whereas the string checking version will obviously only work in 3.9. You could leverage sys.version_info and have multiple string checks, but that seems like too much hassle in comparison.

Related