diff --git a/.gitignore b/.gitignore index ebbd23a..10b5603 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,7 @@ _testing.py # Packaging *.egg-info bin -build +docs/build dist MANIFEST diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..b132679 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,10 @@ +version: 2 + +sphinx: + configuration: docs/source/conf.py + +python: + version: "3.8" + install: + - requirements: requirements.txt + - requirements: docs-requirements.txt diff --git a/docs-requirements.txt b/docs-requirements.txt new file mode 100644 index 0000000..e9bc0cf --- /dev/null +++ b/docs-requirements.txt @@ -0,0 +1,3 @@ +furo +sphinxcontrib_trio +sphinx_design \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..81eb1e9 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,37 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd + +@pause diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css new file mode 100644 index 0000000..70e8bdb --- /dev/null +++ b/docs/source/_static/custom.css @@ -0,0 +1,46 @@ +.py-attribute-table { + display: flex; + flex-wrap: wrap; + flex-direction: row; + margin: 0 2em; + padding-top: 16px; +} + +.py-attribute-table-column { + flex: 1 1 auto; +} + +.py-attribute-table-column:not(:first-child) { + margin-top: 1em; +} + +.py-attribute-table-column > span { + font-weight: bold; +} + +main .py-attribute-table-column > ul { + list-style: none; + margin: 4px 0; + padding-left: 0; + font-size: 0.95em; +} + +.py-attribute-table-entry { + margin: 0; + padding: 2px 0 2px 0.2em; + display: flex; + line-height: 1.2em; +} + +.py-attribute-table-entry > a { + padding-left: 0.5em; + flex-grow: 1; +} +.py-attribute-table-badge { + flex-basis: 3em; + text-align: right; + font-size: 0.9em; + -moz-user-select: none; + -webkit-user-select: none; + user-select: none; +} \ No newline at end of file diff --git a/docs/source/client.rst b/docs/source/client.rst new file mode 100644 index 0000000..6b18d0c --- /dev/null +++ b/docs/source/client.rst @@ -0,0 +1,16 @@ +.. currentmodule:: melisa + +Melisa Module +============= + +Client +------- + +.. attributetable:: Client + +.. autoclass:: Client + :exclude-members: listen + :inherited-members: + + .. automethod:: Client.listen() + :decorator: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..e0172ee --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,75 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +import sys +import os + +sys.path.append(os.path.abspath("../..")) +sys.path.append(os.path.abspath('extensions')) + + +project = 'Melisa' +copyright = '2022, MelisaDev' +author = 'MelisaDev' + +# The full version, including alpha/beta/rc tags +release = '0.0.1' + + +# -- General configuration --------------------------------------------------- + +extensions = [ + 'sphinx_design', + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "sphinx.ext.autosectionlabel", + "sphinx.ext.extlinks", + "sphinxcontrib_trio", + "attributable" +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +exclude_patterns = [] + +intersphinx_mapping = { + "py": ("https://docs.python.org/3", None), + "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), +} + + +# -- Options for HTML output ------------------------------------------------- + +html_theme = 'furo' +html_theme_options = { + "sidebar_hide_name": True, +} +pygments_style = 'monokai' +default_dark_mode = True +html_static_path = ['_static'] +html_css_files = ["custom.css"] + +rst_prolog = """ +.. |coro| replace:: This function is a |coroutine_link|_. +.. |maybecoro| replace:: This function *could be a* |coroutine_link|_. +.. |coroutine_link| replace:: *coroutine* +.. _coroutine_link: https://docs.python.org/3/library/asyncio-task.html#coroutine +""" \ No newline at end of file diff --git a/docs/source/extensions/attributable.py b/docs/source/extensions/attributable.py new file mode 100644 index 0000000..c20eff1 --- /dev/null +++ b/docs/source/extensions/attributable.py @@ -0,0 +1,286 @@ +""" +I am so sorry, but i found this extension somewhere in the internet. +IT LOOKS REALLY BEAUTIFUL! +""" + +from sphinx.util.docutils import SphinxDirective +from sphinx.locale import _ +from docutils import nodes +from sphinx import addnodes + +from collections import OrderedDict, namedtuple +import importlib +import inspect +import re + + +class attributetable(nodes.General, nodes.Element): + pass + + +class attributetablecolumn(nodes.General, nodes.Element): + pass + + +class attributetabletitle(nodes.TextElement): + pass + + +class attributetableplaceholder(nodes.General, nodes.Element): + pass + + +class attributetablebadge(nodes.TextElement): + pass + + +class attributetable_item(nodes.Part, nodes.Element): + pass + + +def visit_attributetable_node(self, node): + class_ = node["python-class"] + self.body.append( + f'
' + ) + + +def visit_attributetablecolumn_node(self, node): + self.body.append(self.starttag( + node, 'div', CLASS='py-attribute-table-column') + ) + + +def visit_attributetabletitle_node(self, node): + self.body.append(self.starttag(node, 'span')) + + +def visit_attributetablebadge_node(self, node): + attributes = { + 'class': 'py-attribute-table-badge', + 'title': node['badge-type'], + } + self.body.append(self.starttag(node, 'span', **attributes)) + + +def visit_attributetable_item_node(self, node): + self.body.append(self.starttag( + node, 'li', CLASS='py-attribute-table-entry') + ) + + +def depart_attributetable_node(self, node): + self.body.append('
') + + +def depart_attributetablecolumn_node(self, node): + self.body.append('') + + +def depart_attributetabletitle_node(self, node): + self.body.append('') + + +def depart_attributetablebadge_node(self, node): + self.body.append('') + + +def depart_attributetable_item_node(self, node): + self.body.append('') + + +_name_parser_regex = re.compile(r'(?P[\w.]+\.)?(?P\w+)') + + +class PyAttributeTable(SphinxDirective): + has_content = False + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = False + option_spec = {} + + def parse_name(self, content): + path, name = _name_parser_regex.match(content).groups() + if path: + modulename = path.rstrip('.') + else: + modulename = self.env.temp_data.get('autodoc:module') + if not modulename: + modulename = self.env.ref_context.get('py:module') + if modulename is None: + raise RuntimeError('modulename somehow None for %s in %s.' % ( + content, self.env.docname)) + + return modulename, name + + def run(self): + """If you're curious on the HTML this is meant to generate: +
+
+ _('Attributes') + +
+ +
+ However, since this requires the tree to be complete + and parsed, it'll need to be done at a different stage and then + replaced. + """ + content = self.arguments[0].strip() + node = attributetableplaceholder('') + modulename, name = self.parse_name(content) + node['python-doc'] = self.env.docname + node['python-module'] = modulename + node['python-class'] = name + node['python-full-name'] = f'{modulename}.{name}' + return [node] + + +def build_lookup_table(env): + # Given an environment, load up a lookup table of + # full-class-name: objects + result = {} + domain = env.domains['py'] + + ignored = { + 'data', 'exception', 'module', 'class', + } + + for (fullname, _, objtype, docname, _, _) in domain.get_objects(): + if objtype in ignored: + continue + + classname, _, child = fullname.rpartition('.') + try: + result[classname].append(child) + except KeyError: + result[classname] = [child] + + return result + + +TableElement = namedtuple('TableElement', 'fullname label badge') + + +def process_attributetable(app, doctree, fromdocname): + env = app.builder.env + + lookup = build_lookup_table(env) + for node in doctree.traverse(attributetableplaceholder): + modulename, classname, fullname = node['python-module'], node['python-class'], node['python-full-name'] + groups = get_class_results(lookup, modulename, classname, fullname) + table = attributetable('') + for label, subitems in groups.items(): + if not subitems: + continue + table.append(class_results_to_node( + label, sorted(subitems, key=lambda c: c.label))) + + table['python-class'] = fullname + + node.replace_self([table] if table else []) + + +def get_class_results(lookup, modulename, name, fullname): + module = importlib.import_module(modulename) + cls = getattr(module, name) + + groups = OrderedDict([ + (_('Attributes'), []), + (_('Methods'), []), + ]) + + try: + members = lookup[fullname] + except KeyError: + return groups + + for attr in members: + attrlookup = f'{fullname}.{attr}' + key = _('Attributes') + badge = None + label = attr + value = None + + for base in cls.__mro__: + value = base.__dict__.get(attr) + if value is not None: + break + + if value is not None: + doc = value.__doc__ or '' + if inspect.iscoroutinefunction(value) or doc.startswith('|coro|'): + key = _('Methods') + badge = attributetablebadge('async', 'async') + badge['badge-type'] = _('coroutine') + elif isinstance(value, classmethod): + key = _('Methods') + label = f'{name}.{attr}' + badge = attributetablebadge('cls', 'cls') + badge['badge-type'] = _('classmethod') + elif ( + inspect.isfunction(value) + or isinstance(value, staticmethod) + ): + if ( + doc.startswith(('A decorator', 'A shortcut decorator')) + or label in ("event", "loop") + ): + # finicky but surprisingly consistent + badge = attributetablebadge('@', '@') + badge['badge-type'] = _('decorator') + key = _('Methods') + else: + key = _('Methods') + badge = attributetablebadge('def', 'def') + badge['badge-type'] = _('method') + + groups[key].append(TableElement( + fullname=attrlookup, label=label, badge=badge)) + + return groups + + +def class_results_to_node(key, elements): + title = attributetabletitle(key, key) + ul = nodes.bullet_list('') + for element in elements: + ref = nodes.reference('', '', internal=True, + refuri='#' + element.fullname, + anchorname='', + *[nodes.Text(element.label)]) + para = addnodes.compact_paragraph('', '', ref) + if element.badge is not None: + ul.append(attributetable_item('', element.badge, para)) + else: + ul.append(attributetable_item('', para)) + + return attributetablecolumn('', title, ul) + + +def setup(app): + app.add_directive('attributetable', PyAttributeTable) + app.add_node(attributetable, html=( + visit_attributetable_node, depart_attributetable_node)) + app.add_node(attributetablecolumn, html=( + visit_attributetablecolumn_node, depart_attributetablecolumn_node)) + app.add_node(attributetabletitle, html=( + visit_attributetabletitle_node, depart_attributetabletitle_node)) + app.add_node(attributetablebadge, html=( + visit_attributetablebadge_node, depart_attributetablebadge_node)) + app.add_node(attributetable_item, html=( + visit_attributetable_item_node, depart_attributetable_item_node)) + app.add_node(attributetableplaceholder) + app.connect('doctree-resolved', process_attributetable) diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..8964a24 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,15 @@ +Welcome to Melisa's documentation! +================================== + +.. toctree:: + :maxdepth: 2 + :caption: Documentation: + + client + models/index + + +Indices and tables +================== + +* :ref:`search` diff --git a/docs/source/models/index.rst b/docs/source/models/index.rst new file mode 100644 index 0000000..24a02de --- /dev/null +++ b/docs/source/models/index.rst @@ -0,0 +1,7 @@ +Melisa Models +===================== + +.. toctree:: + :maxdepth: 1 + + user \ No newline at end of file diff --git a/docs/source/models/user.rst b/docs/source/models/user.rst new file mode 100644 index 0000000..c9cf9df --- /dev/null +++ b/docs/source/models/user.rst @@ -0,0 +1,30 @@ +.. currentmodule:: melisa.models + +Melisa Models User +=========================== + +User +---- + +PremiumTypes +~~~~~~~~~~~~~ + +.. autoclass:: PremiumTypes() + +UserFlags +~~~~~~~~~~ + +.. autoclass:: UserFlags() + +VisibilityTypes +~~~~~~~~~~~~~~~~ + +.. autoclass:: VisibilityTypes() + +User +~~~~~ + +.. attributetable:: User + +.. autoclass:: User() + diff --git a/melisa/client.py b/melisa/client.py index 9b60193..a29e2d4 100644 --- a/melisa/client.py +++ b/melisa/client.py @@ -11,7 +11,33 @@ from typing import Dict, List, Union class Client: - def __init__(self, token, intents, **kwargs): + """ + This is the main instance which is between the programmer and the Discord API. + + This Client represents your bot. + + Parameters + ---------- + token : :class:`str` + The token to login (you can found it in the developer portal) + intents : :class:`~objects.app.intents.Intents` + The Discord Intents values. + activity : :class:`~models.user.presence.BotActivity` + The Activity to set (on connecting) + status : :class:`str` + The Status to set (on connecting). Can be generated using :class:`~models.user.presence.StatusType` + + Attributes + ---------- + user: :class:`~models.user.user.User` + The user object of the client + http: :class:`~core.http.HTTPClient` + HTTP client for the http-requests to the Discord API + shards: :class:`Dict[int, Shard]` + Bot's shards. + """ + + def __init__(self, token, intents, *, activity=None, status: str = None): self.shards: Dict[int, Shard] = {} self.http = HTTPClient(token) self._events = {} @@ -19,26 +45,26 @@ class Client: self.guilds = [] self.user = None - self.loop = asyncio.get_event_loop() + self._loop = asyncio.get_event_loop() - self._gateway_info = self.loop.run_until_complete(self._get_gateway()) + self._gateway_info = self._loop.run_until_complete(self._get_gateway()) self.intents = intents self._token = token - self._activity = kwargs.get("activity") - self._status = kwargs.get("status") + self._activity = activity + self._status = status async def _get_gateway(self): """Get Gateway information""" return GatewayBotInfo.from_dict(await self.http.get("gateway/bot")) def listen(self, callback: Coro): - """Method to set the listener. + """Method or Decorator to set the listener. Parameters ---------- - callback (:obj:`function`) + callback : :class:`melisa.utils.types.Coro` Coroutine Callback Function """ if not asyncio.iscoroutinefunction(callback): @@ -53,8 +79,8 @@ class Client: """ inited_shard = Shard(self, 0, 1) - asyncio.ensure_future(inited_shard.launch(activity=self._activity, status=self._status), loop=self.loop) - self.loop.run_forever() + asyncio.ensure_future(inited_shard.launch(activity=self._activity, status=self._status), loop=self._loop) + self._loop.run_forever() def run_shards(self, num_shards: int, *, shard_ids: List[int] = None): """ @@ -64,9 +90,6 @@ class Client: ---------- num_shards : :class:`int` The endpoint to send the request to. - - Keyword Arguments: - shard_ids: Optional[:class:`List[int]`] List of Ids of shards to start. """ @@ -76,8 +99,8 @@ class Client: for shard_id in shard_ids: inited_shard = Shard(self, shard_id, num_shards) - asyncio.ensure_future(inited_shard.launch(activity=self._activity, status=self._status), loop=self.loop) - self.loop.run_forever() + asyncio.ensure_future(inited_shard.launch(activity=self._activity, status=self._status), loop=self._loop) + self._loop.run_forever() def run_autosharded(self): """ @@ -89,8 +112,8 @@ class Client: for shard_id in shard_ids: inited_shard = Shard(self, shard_id, num_shards) - asyncio.ensure_future(inited_shard.launch(activity=self._activity, status=self._status), loop=self.loop) - self.loop.run_forever() + asyncio.ensure_future(inited_shard.launch(activity=self._activity, status=self._status), loop=self._loop) + self._loop.run_forever() async def fetch_user(self, user_id: Union[Snowflake, str, int]): """ diff --git a/melisa/core/gateway.py b/melisa/core/gateway.py index b891e2c..8345f6f 100644 --- a/melisa/core/gateway.py +++ b/melisa/core/gateway.py @@ -95,13 +95,12 @@ class Gateway: await self.resume() async def check_heartbeating(self): - await asyncio.sleep(20) + while True: + await asyncio.sleep(20) - if self._last_send + 60.0 < time.perf_counter(): - await self.ws.close(code=4000) - await self.handle_close(4000) - - await self.check_heartbeating() + if self._last_send + 60.0 < time.perf_counter(): + await self.ws.close(code=4000) + await self.handle_close(4000) async def send(self, payload: str) -> None: await self.ws.send_str(payload) diff --git a/melisa/models/user/user.py b/melisa/models/user/user.py index 4fe02ac..21830d7 100644 --- a/melisa/models/user/user.py +++ b/melisa/models/user/user.py @@ -111,36 +111,40 @@ class User(APIObjectBase): Attributes ---------- - id: + id: :class:`~melisa.utils.types.Snowflake` the user's id - username: + username: :class:`str` the user's username, not unique across the platform - discriminator: + discriminator: :class:`int` the user's 4-digit discord-tag - avatar: + avatar: :class:`str` the user's avatar hash - bot: + bot: :class:`bool` whether the user belongs to an OAuth2 application - system: + system: :class:`bool` whether the user is an Official Discord System user (part of the urgent message system) - mfa_enabled: + mfa_enabled: :class:`bool` whether the user has two factor enabled on their account - banner: + banner: :class:`str` the user's banner hash - accent_color: + accent_color: :class:`int` the user's banner color encoded as an integer representation of hexadecimal color code - locale: + locale: :class:`str` the user's chosen language option - verified: + verified: :class:`bool` whether the email on this account has been verified - email: + email: :class:`str` the user's email - flags: + flags: :class:`~models.user.user.UserFlags` the flags on a user's account - premium_type: + premium_type: :class:`int` the type of Nitro subscription on a user's account - public_flags: + public_flags: :class:`int` the public flags on a user's account + premium: :class:`PremiumTypes` + The user their premium type in a usable enum. + mention: :class:`str` + The user's mention string. (<@id>) """ id: APINullable[Snowflake] = None @@ -187,10 +191,6 @@ class User(APIObjectBase): """:class:`str`: The user's mention string. (<@id>)""" return "<@{}>".format(self.id) - @property - def avatar_url(self): - """Avatar url (from the discord cdn server)""" - return ( - "https://cdn.discordapp.com/avatars/{}/{}.png".format(self.id, self.avatar), - "?size=1024" - ) + def avatar_url(self) -> str: + """Avatar url (from the Discord CDN server)""" + return "https://cdn.discordapp.com/avatars/{}/{}.png?size=1024".format(self.id, self.avatar)