"""
PokeGambler - A Pokemon themed gambling bot for Discord.
Copyright (C) 2021 Harshith Thota
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
----------------------------------------------------------------------------
RestructuredText and Parameter parsers
"""
import re
from functools import cached_property
from typing import Any, Dict, List, Optional
from ..base.enums import OptionTypes
[docs]class Options(dict):
"""
A dictionary with attribute access.
"""
def __getattr__(self, name: str) -> Optional[str]:
return self.get(name)
def __setattr__(self, name: str, value: str) -> None:
self[name] = value
[docs]class Directive:
"""
A simple Class which corresponds to reStructureText directive.
:param argument: The argument of the directive.
:type argument: Optional[str]
"""
def __init__(self, argument: Optional[str] = None):
self.role_pattern = re.compile(
r':.+:`(.+)`'
)
self.argument = argument
self.options = Options()
self.lines = []
self.children = []
def __getattr__(self, name: str):
return (
self.options.get(name) if name not in dir(self)
else self.__dict__[name]
)
@property
def line_string(self) -> str:
"""
Converts the lines of the directive into a single string.
:return: The string of the lines.
:rtype: str
"""
if self.__class__.__name__ == 'Rubric':
line_str = '\n'.join(
child.to_string() if isinstance(child, Directive)
else child
for child in self.children
)
else:
line_str = '\n'.join(self.lines)
if self.role_pattern.search(line_str):
ctx = self.role_pattern.search(line_str).group(1)
line_str = self.role_pattern.sub(
r'\|\|\1\|\|', line_str
)
line_str = self.role_pattern.sub(
ctx.split('.')[-1], line_str
)
return line_str
[docs] def to_string(self) -> str:
"""
Converts the directive into a string represenation
meant for usage in Discord Embeds.
:param ext: The extension for markdown.
:type ext: Optional[str]
:return: The string representation of the directive.
:rtype: str
"""
cls_name = self.__class__.__name__
emoji_dict = {
'Note': ':information_source:',
'Warning': ':warning:',
'Tip': ':bulb:'
}
if cls_name == 'Rubric':
return self.line_string
if cls_name == 'Code':
if not self.argument:
ext = ''
elif self.argument == 'coffee':
ext = 'scss'
else:
ext = self.argument
return f"```{ext}\n{self.line_string}\n```"
if cls_name == 'Admonition':
return f":information_source: **{self.argument}**" + \
f"```\n{self.line_string}\n```"
if cls_name in emoji_dict:
return f"{emoji_dict[cls_name]} **{cls_name}**\n" + \
f"```\n{self.line_string}\n```"
return ""
[docs]class Param:
"""
A simple Class which corresponds to function parameters.
:param name: The name of the parameter.
:type name: str
:param description: The description of the parameter.
:type description: str
"""
# pylint: disable=too-few-public-methods,too-many-instance-attributes
def __init__(
self, name: str,
description: Optional[str] = None
):
self.name = name
self.description = description
self.type = None
self.default = None
self.choices = None
self.min_value = None
self.max_value = None
self.autocomplete = False
self._parse_pattern = re.compile(
r'[ld]i[sc]t\[(.+)\]',
re.IGNORECASE
)
self._vals_pattern = re.compile(
r'(?P<name>[^#]+)(?:#(?P<description>.+)#)?:\s'
r'(?P<type>[a-zA-Z_\[\]]+)'
r'(?:\s=\s(?P<default>[^\]]+))?'
)
self._discord_pattern = re.compile(
r':class:`discord\.(.+)`'
)
self._optional_pattern = re.compile(
r'Optional\[(.+)\]'
)
self._list_pattern = re.compile(
r'\[(.+)\]'
)
def __repr__(self) -> str:
cleaned_type = self._discord_pattern.sub(
r'\1',
self._optional_pattern.sub(r'\1', self.type)
)
required = 'Optional' not in str(self.type)
return f'Param(name={self.name}, type={cleaned_type}, ' \
f'description={self.description}, ' \
f'default={self.default}, ' \
f'required={required})'
def __setattribute__(self, name: str, value: Any) -> None:
setattr(self, name, value)
if name == 'choices':
self.__resolve_choices()
[docs] def parse(self) -> Dict[str, Any]:
"""Resolves the parameter type into attributes
using regular expressions.
:return: The resolved parameters.
:rtype: Dict[str, Any]
"""
if self.name == 'message':
return []
parsed = {
attr: getattr(self, attr)
for attr in (
'name', 'description', 'type',
'default', 'autocomplete'
)
}
parsed["autocomplete"] = parsed["autocomplete"] == 'True'
self.__resolve_special_types(parsed)
parsed['required'] = 'Optional' not in parsed['type']
parsed['type'] = OptionTypes[
self._optional_pattern.sub(r'\1', parsed['type'])
]
parsed['description'] = parsed.get(
'description'
) or 'Please enter a value.'
if parsed.get('default') is not None:
if parsed['default'] == 'None':
parsed['default'] = None
elif parsed['type'] == OptionTypes.INTEGER:
parsed['default'] = int(parsed['default'])
elif parsed['type'] == OptionTypes.NUMBER:
parsed['default'] = float(parsed['default'])
elif parsed['type'] == OptionTypes.BOOLEAN:
parsed['default'] = parsed['default'] == 'True'
parsed['description'] += f" Default is {parsed['default']}."
operation = str
if parsed['type'] == OptionTypes.INTEGER:
operation = int
elif parsed['type'] == OptionTypes.NUMBER:
operation = float
if parsed['type'] in (OptionTypes.INTEGER, OptionTypes.FLOAT):
for attr in ('min_value', 'max_value'):
if getattr(self, attr) is not None:
parsed[attr] = operation(getattr(self, attr))
parsed['type'] = parsed['type'].value
if self.choices is not None:
self.__resolve_choices(operation)
parsed['choices'] = self.choices
return parsed
def __resolve_choices(self, operation=None):
if isinstance(self.choices, list):
return
choice_str = self._list_pattern.sub(r'\1', self.choices)
self.choices = [
{
"name": choice.strip(),
"value": (
operation(choice.strip()) if operation
else choice.strip()
)
}
for choice in choice_str.split(',')
if choice.strip()
]
def __resolve_special_types(self, parsed: Dict[str, Any]) -> None:
"""
Handler for discord types like User, Member, Channel, etc.
"""
parsed['type'] = self._discord_pattern.sub(r'\1', parsed['type'])
if 'Member' in parsed['type']:
parsed['type'] = parsed['type'].replace('Member', 'User')
[docs]class CustomRstParser:
"""
| A barebones reStructuredText parser using Regex.
| Can also be used in Context Manager mode.
.. note::
Might be switched to an AST based one in the future.
.. rubric:: Example
.. code:: python
>>> from scripts.utils.parsers import CustomRstParser
>>> with open('README.rst') as f:
... data = f.read()
>>> # As an object.
>>> parser = CustomRstParser()
>>> parser.parse(data)
>>> print(
... parser.sections[0].to_string()
... )
>>> # As a context manager.
>>> with CustomRstParser() as parser:
... parser.parse(data)
... print(
... parser.sections[0].to_string()
... )
>>> assert parser.sections == []
"""
def __init__(self):
param_patts = [
re.compile(
fr':(?P<attr>{attr})\s(?P<name>[^:]+):(?:\s(?P<value>.+))?$'
)
for attr in (
'param', 'type', 'default',
'choices', 'min_value', 'max_value',
'autocomplete'
)
]
self._patterns = [
re.compile(
r'^\.\. (?P<directive>\w+)\:\:(?:\s(?P<argument>.+))?$'
),
re.compile(
r':(?P<option>\w+):(?:\s(?P<value>.+))?$'
)
] + param_patts + [
re.compile(r'\s{4}.+')
]
self._tab_space = 8
#: Meta :class:`Directive`
#:
#: .. tip::
#:
#: There's usually only one Meta directive per docstring.
self.meta = None
#: All the parsed :class:`Directive`.
self.directives = []
#: All the parsed :class:`Param`.
self.params = {}
#: Rubric sections containing child :class:`Directive`.
self.sections = []
#: Additional lines before Params.
self.info = ""
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.info = ""
self.directives = []
self.params = {}
self.sections = []
@cached_property
def parsed_params(self) -> List[Dict[str, Any]]:
"""Returns a list of parsed parameters.
:return: The parsed params.
:rtype: List[Dict[str, Any]]
"""
return [
param.parse()
for param in self.params.values()
if param.name != 'message'
]
@property
def param_names(self) -> List[str]:
"""Returns a list of parsed parameter names.
:return: The parsed params.
:rtype: List[str]
"""
return [
param['name']
for param in self.parsed_params
]
[docs] def parse(self, text: str):
"""
Parses the given text and updates the internal sections,
directives and parameters.
:param text: The reST text (docstrings) to parse.
:type text: str
"""
for line in text.splitlines():
(
dir_matches, options,
param, *param_attrs
) = [
pattern.search(line.strip())
for pattern in self._patterns[:-1]
]
if dir_matches:
self.__handle_directives(dir_matches)
elif param:
param = param.groupdict()
self.params[param['name']] = Param(
param['name'],
self.__clean_line(
param.get('value')
)
)
elif any(param_attrs):
for attr in param_attrs:
if attr:
attr = attr.groupdict()
setattr(
self.params[attr['name']],
attr['attr'], attr['value']
)
elif options and self.directives:
options = options.groupdict()
self.directives[-1].options.update({
options['option']: self.__clean_line(
options.get('value')
)
})
elif line and (
self.directives or self.sections or self.meta
):
self.__handle_line(line)
elif line:
self.info += self.__clean_line(line)
def __handle_line(self, line):
pre_dedent_line = line.replace(' ', '', self._tab_space)
if self._patterns[-1].search(pre_dedent_line):
if self.directives:
self.directives[-1].lines.append(
self.__clean_line(line)
)
else:
self.meta.options[
self.meta.options.keys()[-1]
] += f" {self.__clean_line(line)}"
else:
self.sections[-1].children.append(
self.__clean_line(line)
)
@staticmethod
def __clean_line(line: str) -> str:
return (
None if not line
else line.lstrip(' ' * 4)
)
def __handle_directives(self, dir_matches):
dir_matches = dir_matches.groupdict()
self.directives.append(
type(
dir_matches['directive'].title(),
(Directive, ),
{}
)(
self.__clean_line(
dir_matches.get('argument')
)
)
)
if dir_matches['directive'].title() == 'Rubric':
self.sections.append(
self.directives[-1]
)
elif dir_matches['directive'].title() == 'Meta':
self.meta = self.directives[-1]
elif self.sections:
self.sections[-1].children.append(
self.directives[-1]
)