diff --git a/.gitignore b/.gitignore index 212ecb6..c261afd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ test.py dist *.egg-info +docs/_build +.vscode diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..a83d77b --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1 @@ +requirements_file: docs_requirements.txt diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /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 = . +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/api.rst b/docs/api.rst new file mode 100644 index 0000000..8644447 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,90 @@ +.. currentmodule:: revolt + +API Reference +=============== + + +Client +~~~~~~~ + +.. autoclass:: Client + :members: + +Asset +~~~~~~ + +.. autoclass:: Asset + :members: + +Channel +~~~~~~~~ + +.. autoclass:: Channel + :members: + +SavedMessageChannel +~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: SavedMessageChannel + :members: + +DMChannel +~~~~~~~~~~ + +.. autoclass:: DMChannel + :members: + +Embed +~~~~~~~~~~ + +.. autoclass:: Embed + :members: + + +File +~~~~~ + +.. autoclass:: File + :members: + +Member +~~~~~~~ + +.. autoclass:: Member + :members: + +Message +~~~~~~~ + +.. autoclass:: Message + :members: + +Messageable +~~~~~~~~~~~~ + +.. autoclass:: Messageable + :members: + +Permissions +~~~~~~~~~~~~ + +.. autoclass:: Permissions + :members: + +Role +~~~~~ + +.. autoclass:: Role + :members: + +Server +~~~~~~~~~~ + +.. autoclass:: Server + :members: + +User +~~~~~~~~~~ + +.. autoclass:: User + :members: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..d6a7b9b --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,58 @@ +# 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 +import sphinx_nameko_theme + +sys.path.insert(0, os.path.abspath('..')) + + +# -- Project information ----------------------------------------------------- + +project = 'Revolt.py' +copyright = '2021-present, Zomatree' +author = 'Zomatree' + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. + +html_theme = 'nameko' +html_theme_path = [sphinx_nameko_theme.get_html_theme_path()] + + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..f0a7ccb --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,7 @@ +Welcome to Revolt.py's documentation! +==================================== + +.. toctree:: + :maxdepth: 1 + + api diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..2119f51 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +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.http://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 diff --git a/docs_requiremens.txt b/docs_requiremens.txt new file mode 100644 index 0000000..8641b55 --- /dev/null +++ b/docs_requiremens.txt @@ -0,0 +1,2 @@ +Sphinx==4.1.2 +sphinx-nameko-theme==0.0.3 diff --git a/revolt/__init__.py b/revolt/__init__.py index 89bb12d..47a1e79 100644 --- a/revolt/__init__.py +++ b/revolt/__init__.py @@ -1,5 +1,13 @@ -from .channel import TextChannel, VoiceChannel +from .asset import Asset +from .channel import DMChannel, Channel, GroupDMChannel, PartialTextChannel, SavedMessageChannel, VoiceChannel from .client import Client +from .embed import Embed +from .errors import HTTPError, RevoltError, ServerError +from .file import File +from .member import Member +from .message import Message +from .messageable import Messageable +from .permissions import Permissions +from .role import Role from .server import Server from .user import User -from .message import Message diff --git a/revolt/asset.py b/revolt/asset.py index d229c53..907ccd8 100644 --- a/revolt/asset.py +++ b/revolt/asset.py @@ -1,12 +1,32 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING if TYPE_CHECKING: from .types import File as FilePayload from .state import State from io import IOBase + class Asset: + """Represents a file on revolt + + Attributes + ----------- + id: str + The id of the asset + tag: str + The tag of the asset, this corrasponds to where the asset is used + size: int + Amount of bytes in the file + filename: str + The name of the file + height: Optional[int] + The height of the file if it is an image or video + width: Optional[int] + The width of the file if it is an image or video + content_type: str + The content type of the file + """ def __init__(self, data: FilePayload, state: State): self.id = data['_id'] self.tag = data['tag'] @@ -25,7 +45,15 @@ class Asset: self.content_type = data["content_type"] async def read(self) -> bytes: + """Reads the files content into bytes""" ... async def save(self, fp: IOBase): + """Reads the files content and saves it to a file + + Parameters + ----------- + fp: IOBase + The file to write too. + """ fp.write(await self.read()) diff --git a/revolt/channel.py b/revolt/channel.py index 10b8ba2..826c413 100644 --- a/revolt/channel.py +++ b/revolt/channel.py @@ -9,6 +9,17 @@ if TYPE_CHECKING: from .state import State class Channel: + """Base class for all channels + + Attributes + ----------- + id: :class:`str` + The id of the channel + channel_type: Literal['SavedMessage', 'DirectMessage', 'Group', 'TextChannel', 'VoiceChannel'] + The type of the channel + server: Optional[:class:`Server`] + The server the channel is part of + """ def __init__(self, data: ChannelPayload, state: State): self.state = state self.id = data["_id"] @@ -16,23 +27,28 @@ class Channel: self.server = None class SavedMessageChannel(Channel, Messageable): + """The Saved Message Channel""" def __init__(self, data: ChannelPayload, state: State): super().__init__(data, state) class DMChannel(Channel, Messageable): + """A DM channel""" def __init__(self, data: ChannelPayload, state: State): super().__init__(data, state) class GroupDMChannel(Channel, Messageable): + """A group DM channel""" def __init__(self, data: ChannelPayload, state: State): super().__init__(data, state) class TextChannel(Channel, Messageable): + """A text channel""" def __init__(self, data: ChannelPayload, state: State): super().__init__(data, state) Messageable.__init__(self, state) class PartialTextChannel(Messageable): + """A partial text channel, this will appear when the channel isnt cached""" def __init__(self, channel_id: str, state: State): super().__init__(state) @@ -42,21 +58,20 @@ class PartialTextChannel(Messageable): self.server = None class VoiceChannel(Channel): + """A voice channel""" def __init__(self, data: ChannelPayload, state: State): super().__init__(data, state) -def channel_factory(data: ChannelPayload) -> type[Channel]: - channel_type = data["channel_type"] - - if channel_type == "SavedMessage": - return SavedMessageChannel - elif channel_type == "DirectMessage": - return DMChannel - elif channel_type == "Group": - return GroupDMChannel - elif channel_type == "TextChannel": - return TextChannel - elif channel_type == "VoiceChannel": - return VoiceChannel +def channel_factory(data: ChannelPayload, state) -> Channel: + if data["channel_type"] == "SavedMessage": + return SavedMessageChannel(data, state) + elif data["channel_type"] == "DirectMessage": + return DMChannel(data, state) + elif data["channel_type"] == "Group": + return GroupDMChannel(data, state) + elif data["channel_type"] == "TextChannel": + return TextChannel(data, state) + elif data["channel_type"] == "VoiceChannel": + return VoiceChannel(data, state) else: raise Exception diff --git a/revolt/client.py b/revolt/client.py index d6a6f49..b50c391 100644 --- a/revolt/client.py +++ b/revolt/client.py @@ -23,6 +23,19 @@ if TYPE_CHECKING: logger = logging.getLogger("revolt") class Client: + """The client for interacting with revolt + + Parameters + ----------- + session: :class:`aiohttp.ClientSession` + The aiohttp session to use for http request and the websocket + token: :class:`str` + The bots token + api_url: :class:`str` + The api url for the revolt instance you are connecting to, by default it uses the offical instance hosted at revolt.chat + max_messages: :class:`int` + The max amount of messages stored in the cache, by default this is 5k + """ def __init__(self, session: aiohttp.ClientSession, token: str, api_url: str = "https://api.revolt.chat", max_messages: int = 5000): self.session = session self.token = token @@ -37,6 +50,15 @@ class Client: self.listeners: dict[str, list[tuple[Callable[..., bool], asyncio.Future[Any]]]] = {} def dispatch(self, event: str, *args: Any): + """Dispatch an event, this is typically used for testing and internals. + + Parameters + ---------- + event: class:`str` + The name of the event to dispatch, not including `on_` + args: :class:`Any` + The arguments passed to the event + """ for check, future in self.listeners.pop(event, []): if check(*args): if len(args) == 1: @@ -53,6 +75,7 @@ class Client: return json.loads(await resp.text()) async def start(self): + """Starts the client""" api_info = await self.get_api_info() self.api_info = api_info @@ -62,19 +85,69 @@ class Client: await self.websocket.start() def get_user(self, id: str) -> Optional[User]: + """Gets a user from the cache + + Parameters + ----------- + id: :class:`str` + The id of the user + + Returns + -------- + Optional[:class:`User`] + The user if found + """ self.state.get_user(id) def get_channel(self, id: str) -> Optional[Channel]: + """Gets a channel from the cache + + Parameters + ----------- + id: :class:`str` + The id of the channel + + Returns + -------- + Optional[:class:`Channel`] + The channel if found + """ self.state.get_channel(id) def get_server(self, id: str) -> Optional[Server]: + """Gets a server from the cache + + Parameters + ----------- + id: :class:`str` + The id of the server + + Returns + -------- + Optional[:class:`Server`] + The server if found + """ self.state.get_server(id) - def wait_for(self, event: str, *, check: Optional[Callable[..., bool]] = None) -> asyncio.Future[Any]: + async def wait_for(self, event: str, *, check: Optional[Callable[..., bool]] = None) -> Any: + """Waits for an event + + Parameters + ----------- + event: :class:`str` + The name of the event to wait for, without the `on_` + check: Optional[Callable[..., :class:`bool`]] + A function that says what event to wait_for, this function takes the same parameters as the event you are waiting for and should return a bool saying if that is the event you want + + Returns + -------- + Any + The parameters of the event + """ if not check: check = lambda *_: True future = asyncio.get_running_loop().create_future() self.listeners.setdefault(event, []).append((check, future)) - return future + return await future diff --git a/revolt/file.py b/revolt/file.py index 12cef62..1fb106d 100644 --- a/revolt/file.py +++ b/revolt/file.py @@ -4,6 +4,17 @@ import io import os class File: + """Respresents a file about to be uploaded to revolt + + Parameters + ----------- + file: Union[str, bytes] + The name of the file or the content of the file in bytes, text files will be need to be encoded + filename: Optional[str] + The filename of the file when being uploaded, this will default to the name of the file if one exists + spoiler: bool + Determines if the file will be a spoiler, this prefexes the filename with `SPOILER_` + """ def __init__(self, file: Union[str, bytes], *, filename: Optional[str] = None, spoiler: bool = False): if isinstance(file, str): self.f = open(file, "rb") diff --git a/revolt/member.py b/revolt/member.py index 521145b..0abd026 100644 --- a/revolt/member.py +++ b/revolt/member.py @@ -14,13 +14,30 @@ def flattern_user(member: Member, user: User): setattr(member, attr, getattr(user, attr)) class Member(User): + """Represents a member of a server, subclasses :class:`User` + + Attributes + ----------- + nickname: Optional[:class:`str`] + The nickname of the member if any + roles: list[:class:`Role`] + The roles of the member, ordered by the role's rank in decending order + server: :class:`Server` + The server the member belongs to + """ def __init__(self, data: MemberPayload, server: Server, state: State): user = state.get_user(data["_id"]["user"]) assert user flattern_user(self, user) self._state = state - self.id = data["_id"]["user"] self.nickname = data.get("nickname") - self.roles = [server.get_role(role_id) for role_id in data.get("roles", [])] + roles = [] + + for role in (server.get_role(role_id) for role_id in data.get("roles", [])): + if role: + roles.append(role) + + self.roles = sorted(roles, key=lambda role: role.rank, reverse=True) + self.server = server diff --git a/revolt/message.py b/revolt/message.py index a9c7ec8..2a1c98e 100644 --- a/revolt/message.py +++ b/revolt/message.py @@ -11,6 +11,25 @@ if TYPE_CHECKING: from .types import Message as MessagePayload class Message: + """Represents a message + + Attributes + ----------- + id: :class:`str` + The id of the message + content: :class:`str` + The content of the message, this will not include system message's content + attachments: list[:class:`Asset`] + The attachments of the message + embeds: list[:class:`Embed`] + The embeds of the message + channel: :class:`Messageable` + The channel the message was sent in + server: :class:`Server` + The server the message was sent in + author: Union[:class:`Member`, :class:`User`] + The author of the message, will be :class:`User` in DMs + """ def __init__(self, data: MessagePayload, state: State): self.state = state diff --git a/revolt/messageable.py b/revolt/messageable.py index b9f8431..31b4947 100644 --- a/revolt/messageable.py +++ b/revolt/messageable.py @@ -10,12 +10,35 @@ if TYPE_CHECKING: from .file import File class Messageable: + """Base class for all channels that you can send messages in + + Attributes + ----------- + id: :class:`str` + The id of the channel + """ id: str def __init__(self, state: State): self.state = state async def send(self, content: Optional[str] = None, embeds: Optional[list[Embed]] = None, embed: Optional[Embed] = None, attachments: Optional[list[File]] = None) -> Message: + """Sends a message in a channel, you must send at least one of either `content`, `embeds` or `attachments` + + Parameters + ----------- + content: Optional[:class:`str`] + The content of the message, this will not include system message's content + attachments: Optional[list[:class:`File`]] + The attachments of the message + embeds: Optional[list[:class:`Embed`]] + The embeds of the message + + Returns + -------- + :class:`Message` + The message that was just sent + """ if embed: embeds = [embed] diff --git a/revolt/permissions.py b/revolt/permissions.py index 8dbe053..bc8e918 100644 --- a/revolt/permissions.py +++ b/revolt/permissions.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING if TYPE_CHECKING: diff --git a/revolt/role.py b/revolt/role.py index d67f3b0..b0c4c2f 100644 --- a/revolt/role.py +++ b/revolt/role.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING @@ -6,6 +8,21 @@ if TYPE_CHECKING: from .types import Role as RolePayload class Role: + """Represents a role + + Attributes + ----------- + id: :class:`str` + The id of the role + name: :class:`str` + The name of the role + colour: :class:`str` + The colour of the role + hoist: :class:`bool` + Whether members with the role will display seperate from everyone else + rank: :class:`int` + The position of the role in the role heirarchy + """ def __init__(self, data: RolePayload, role_id: str, state: State): self.id = role_id self.name = data["name"] diff --git a/revolt/server.py b/revolt/server.py index bc88b82..d070934 100644 --- a/revolt/server.py +++ b/revolt/server.py @@ -2,6 +2,9 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional +from .permissions import Permissions +from .channel import channel_factory + if TYPE_CHECKING: from .types import Server as ServerPayload from .state import State @@ -10,16 +13,85 @@ if TYPE_CHECKING: from .channel import Channel class Server: + """Represents a server + + Attributes + ----------- + id: :class:`str` + The id of the server + name: :class:`str` + The name of the server + owner: Optional[:class:`Member`] + The owner of the server + """ def __init__(self, data: ServerPayload, state: State): self.state = state self.id = data["_id"] + self.name = data["name"] + self.owner = state.get_member(self.id, data["owner"]) + self.default_permissions = Permissions(data["default_permissions"]) self._members: dict[str, Member] = {} - self._roles: dict[str, Role] = {} - self._channels: dict[str, Channel] = {} + self._roles: dict[str, Role] = {role_id: Role(role, role_id, state) for role_id, role in data.get("roles", {}).items()} + channels = [channel_factory(channel, state) for channel in data.get("channels", [])] + self._channels: dict[str, Channel] = {channel.id: channel for channel in channels} - def get_role(self, role_id: str, /) -> Optional[Role]: + @property + def roles(self) -> list[Role]: + """list[:class:`Role`] Gets all roles in the server in decending order""" + return list(self._roles.values()) + + @property + def members(self) -> list[Member]: + """list[:class:`Member`] Gets all members in the server""" + return list(self._members.values()) + + @property + def channels(self) -> list[Channel]: + """list[:class:`Member`] Gets all channels in the server""" + return list(self._channels.values()) + + def get_role(self, role_id: str) -> Optional[Role]: + """Gets a role from the cache + + Parameters + ----------- + id: :class:`str` + The id of the role + + Returns + -------- + Optional[:class:`Role`] + The role if found + """ return self._roles.get(role_id) - def get_member(self, member_id: str, /) -> Optional[Member]: + def get_member(self, member_id: str) -> Optional[Member]: + """Gets a member from the cache + + Parameters + ----------- + id: :class:`str` + The id of the member + + Returns + -------- + Optional[:class:`Member`] + The member if found + """ return self._members.get(member_id) + + def get_channel(self, channel_id: str) -> Optional[Channel]: + """Gets a channel from the cache + + Parameters + ----------- + id: :class:`str` + The id of the channel + + Returns + -------- + Optional[:class:`Channel`] + The channel if found + """ + self._channels.get(channel_id) diff --git a/revolt/state.py b/revolt/state.py index f722aa1..966a397 100644 --- a/revolt/state.py +++ b/revolt/state.py @@ -56,10 +56,9 @@ class State: server._members[member.id] = member return member - + def add_channel(self, payload: ChannelPayload) -> Channel: - cls = channel_factory(payload) - channel = cls(payload, self) + channel = channel_factory(payload, self) self.channels[channel.id] = channel return channel diff --git a/revolt/types/category.py b/revolt/types/category.py new file mode 100644 index 0000000..5f71077 --- /dev/null +++ b/revolt/types/category.py @@ -0,0 +1,9 @@ +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from .channel import Channel + +class Category(TypedDict): + id: str + title: str + channels: list[Channel] diff --git a/revolt/types/server.py b/revolt/types/server.py index 416a833..4cf93ca 100644 --- a/revolt/types/server.py +++ b/revolt/types/server.py @@ -1,9 +1,32 @@ from __future__ import annotations + from typing import TYPE_CHECKING, TypedDict if TYPE_CHECKING: - pass + from .role import Role, Permission + from .file import File + from .category import Category + from .channel import Channel + +class SystemMessagesConfig(TypedDict, total=False): + user_joined: str + user_left: str + user_kicked: str + user_banned: str + +class _ServerOptional(TypedDict, total=False): + nonce: str + description: str + categories: list[Category] + system_messages: SystemMessagesConfig + roles: list[Role] + icon: File + banner: File class Server(TypedDict): _id: str + owner: str + name: str + channels: list[Channel] + default_permissions: Permission diff --git a/revolt/user.py b/revolt/user.py index 1b21df8..52c8480 100644 --- a/revolt/user.py +++ b/revolt/user.py @@ -7,7 +7,26 @@ if TYPE_CHECKING: from .types import User as UserPayload class User: - __flattern_attributes__ = ("name", "bot", "owner", "badges", "online", "flags") + """Represents a user + + Attributes + ----------- + id: :class:`str` + The users id + name: :class:`str` + The users name + bot: :class:`bool` + Whether or not the user is a bot + owner: Optional[:class:`User`] + The bot's owner if the user is a bot + badges: :class:`int` + The users badges + online: :class:`bool` + Whether or not the user is online + flags: :class:`int` + The user flags + """ + __flattern_attributes__ = ("id", "name", "bot", "owner", "badges", "online", "flags") def __init__(self, data: UserPayload, state: State): self.state = state @@ -17,7 +36,7 @@ class User: bot = data.get("bot") if bot: self.bot = True - self.owner = bot["owner"] + self.owner = state.get_user(bot["owner"]) else: self.bot = False self.owner = None