itsVale/Vale.py

View on GitHub
utils/examples.py

Summary

Maintainability
A
3 hrs
Test Coverage
"""
Utilites to produce examples based off of args.
"""

import functools
import itertools
import random
import re
import typing

import discord
from discord.ext import commands

from . import varpos
from .commands import all_qualified_names

__all__ = ['command_example', 'get_example', 'static_example', 'wrap_example']

_random_generators = {}


# Stuff for builtins or discord.py models
def _example(func):
    try:
        _random_generators[typing.get_type_hints(func)['return']] = func
    except KeyError:
        pass

    return func


@_example
def _random_int(ctx) -> int:
    return random.randint(1, 100)


@_example
def _random_float(ctx) -> float:
    f = float(f'{random.randint(1, 100)}.{random.randint(0, 99)}')
    return int(f) if f.is_integer() else f


@_example
def _random_bool(ctx) -> bool:
    return random.choice([True, False])


@_example
def _random_text_channel(ctx) -> discord.TextChannel:
    return f'#{random.choice(ctx.guild.text_channels)}'


@_example
def _random_voice_channel(ctx) -> discord.VoiceChannel:
    return random.choice(ctx.guild.voice_channels)


@_example
def _random_category_channel(ctx) -> discord.CategoryChannel:
    return random.choice(ctx.guild.categories)


@_example
def _random_user(ctx) -> discord.User:
    return random.choice(ctx.bot.users)


@_example
def _random_member(ctx) -> discord.Member:
    return f'@{random.choice(ctx.guild.members)}'


@_example
def _random_guild(ctx) -> discord.Guild:
    guild = random.choice(ctx.bot.guilds)
    return random.choice([guild.id, guild.name])


@_example
def _random_role(ctx) -> discord.Role:
    return random.choice(ctx.guild.roles)


DEFAULT_TEXT = 'Lorem ipsum dolor sit amet.'
_arg_examples = {}


def _load_arg_examples():
    import json
    import logging

    try:
        with open('data/arg_examples.json', encoding='utf-8') as f:
            data = json.load(f)
    except Exception:  # muh pycodestyle
        logging.getLogger(__name__).error('Failed to load static examples')
    else:
        _arg_examples.update(data)


_load_arg_examples()


def _get_static_example(key, command=''):
    for key in (f'{command}.{key}', key):
        examples = _arg_examples.get(key)
        if not examples:
            continue

        if isinstance(examples, list):
            return random.choice(examples)
        return examples

    return DEFAULT_TEXT


def _actual_command(ctx):
    command = ctx.command

    return ctx.kwargs['command'] if command.name == 'help' else command


@_example
def _random_str(ctx) -> str:
    return _get_static_example(ctx._current_parameter.name, _actual_command(ctx))


def _get_name(obj):
    try:
        return obj.__name__
    except AttributeError:
        return obj.__class__.__name__


def _is_discord_ext_converter(converter):
    module = getattr(converter, '__module__', '')
    return module.startswith('discord') and module.endswith('converter')


def _aways_classy(obj):
    return obj if isinstance(obj, type) else type(obj)


# Skidded the inspect module
def _format_annotation(annotation, base_module=None):
    if getattr(annotation, '__module__', None) == 'typing':
        return repr(annotation).replace('typing.', '')

    if isinstance(annotation, type):
        if annotation.__module__ in ('builtins', base_module):
            return annotation.__qualname__

        return f'{annotation.__module__}.{annotation.__qualname__}'

    return repr(annotation)


_NoneType = type(None)


def get_example(converter, ctx):
    """Returns a random example based on the converter."""

    if hasattr(converter, 'random_example'):
        return converter.random_example(ctx)

    if getattr(converter, '__origin__', None) is typing.Union:
        converters = converter.__args__
        if len(converters) == 2 and converters[-1] is _NoneType:
            converter = converters[0]
        else:
            converter = random.choice(converters)

        return get_example(converter, ctx)

    if _is_discord_ext_converter(converter):
        if _aways_classy(converter) is commands.clean_content:
            converter = str
        else:
            converter = getattr(discord, _get_name(converter).replace('Converter', ''))

    try:
        func = _random_generators[converter]
    except KeyError as e:
        raise ValueError(f'Unable to get an example for {_format_annotation(converter)}') from e
    else:
        return func(ctx)


# Helper functions


def wrap_example(target):
    """Wraps a converter to use a function for example generation."""

    def decorator(func):
        target.random_example = func
        return func

    return decorator


def static_example(converter):
    """Marks a converter to use the static example generator (str)."""

    converter.random_example = _random_str
    return converter


# Example generation

_get_converter = functools.partial(commands.Command._get_converter, None)

_quote_pattern = '|'.join(map(re.escape, commands.view._all_quotes))
_quote_regex = re.compile(rf'\\(.)|({_quote_pattern})')
_escape_quotes = functools.partial(_quote_regex.sub, f'\\\1\2')


def _quote(string):
    string = _escape_quotes(string)

    if any(map(str.isspace, string)):
        string = f'"{string}"'

    return string


def _is_required_parameter(param):
    return param.default is param.empty and param.kind is not param.VAR_POSITIONAL


MAX_REPEATS_FOR_VARARGS = 4


def _parameter_examples(parameters, ctx, command=None):
    command = command or ctx.command

    def parameter_example(parameter):
        ctx._current_parameter = parameter
        example = str(get_example(_get_converter(parameter), ctx))

        if not (parameter.kind == parameter.KEYWORD_ONLY and not command.rest_is_raw or example.startswith(('#', '@'))):
            example = _quote(example)

        return example

    for parameter in parameters:
        yield parameter_example(parameter)
        if parameter.kind is parameter.VAR_POSITIONAL:
            for _ in range(random.randint(varpos.requires_var_positional(parameter), MAX_REPEATS_FOR_VARARGS)):
                yield parameter_example(parameter)


def _split_params(command):
    """Splits a command's parameters into required and optional parts."""

    params = command.clean_params.values()
    required = list(itertools.takewhile(_is_required_parameter, params))

    optional = []
    for param in itertools.dropwhile(_is_required_parameter, params):
        if param.kind is param.VAR_POSITIONAL:
            args = required if varpos.requires_var_positional(command) else optional
            args.append(param)
            break

        optional.append(param)
        if param.kind is param.KEYWORD_ONLY:
            break

    return required, optional


def command_example(command, ctx):
    """Generate a working example given a command.

    If a command has optional arguments, it will generate two examples,
    one with required arguments only and one with all arguments included.
    """

    qualified_names = list(all_qualified_names(command))
    required, optional = _split_params(command)

    def generate(parameters):
        resolved = ' '.join(_parameter_examples(parameters, ctx, command))
        return f'`{ctx.clean_prefix}{random.choice(qualified_names)} {resolved}`'

    usage = generate(required)
    if not optional:
        return usage

    joined = '\n'.join(generate(required + optional[:index+1]) for index in range(len(optional)))
    return f'{usage}\n{joined}'