diff options
| -rw-r--r-- | .github/CODEOWNERS | 6 | ||||
| -rw-r--r-- | Dockerfile | 12 | ||||
| -rw-r--r-- | bot/api.py | 2 | ||||
| -rw-r--r-- | bot/constants.py | 10 | ||||
| -rw-r--r-- | bot/errors.py | 19 | ||||
| -rw-r--r-- | bot/exts/backend/error_handler.py | 4 | ||||
| -rw-r--r-- | bot/exts/info/pypi.py | 70 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_utils.py | 5 | ||||
| -rw-r--r-- | bot/resources/tags/defaultdict.md | 21 | ||||
| -rw-r--r-- | bot/resources/tags/dict-get.md | 16 | ||||
| -rw-r--r-- | bot/resources/tags/dictcomps.md | 24 | ||||
| -rw-r--r-- | bot/resources/tags/f-strings.md | 20 | ||||
| -rw-r--r-- | bot/resources/tags/free.md | 5 | ||||
| -rw-r--r-- | bot/resources/tags/listcomps.md | 23 | ||||
| -rw-r--r-- | bot/resources/tags/local-file.md | 23 | ||||
| -rw-r--r-- | config-default.yml | 12 | 
16 files changed, 213 insertions, 59 deletions
| diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ad813d893..7217cb443 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,11 +7,15 @@ bot/exts/utils/extensions.py            @MarkKoz  bot/exts/utils/snekbox.py               @MarkKoz @Akarys42  bot/exts/help_channels/**               @MarkKoz @Akarys42  bot/exts/moderation/**                  @Akarys42 @mbaruh @Den4200 @ks129 -bot/exts/info/**                        @Akarys42 @mbaruh @Den4200 +bot/exts/info/**                        @Akarys42 @Den4200 +bot/exts/info/information.py            @mbaruh  bot/exts/filters/**                     @mbaruh  bot/exts/fun/**                         @ks129  bot/exts/utils/**                       @ks129 +# Rules +bot/rules/**                            @mbaruh +  # Utils  bot/utils/extensions.py                 @MarkKoz  bot/utils/function.py                   @MarkKoz diff --git a/Dockerfile b/Dockerfile index 5d0380b44..1a75e5669 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,10 @@  FROM python:3.8-slim -# Define Git SHA build argument -ARG git_sha="development" -  # Set pip to have cleaner logs and no saved cache  ENV PIP_NO_CACHE_DIR=false \      PIPENV_HIDE_EMOJIS=1 \      PIPENV_IGNORE_VIRTUALENVS=1 \ -    PIPENV_NOSPIN=1 \ -    GIT_SHA=$git_sha +    PIPENV_NOSPIN=1  RUN apt-get -y update \      && apt-get install -y \ @@ -25,6 +21,12 @@ WORKDIR /bot  COPY Pipfile* ./  RUN pipenv install --system --deploy +# Define Git SHA build argument +ARG git_sha="development" + +# Set Git SHA environment variable for Sentry +ENV GIT_SHA=$git_sha +  # Copy the source code in last to optimize rebuilding the image  COPY . . diff --git a/bot/api.py b/bot/api.py index d93f9f2ba..6ce9481f4 100644 --- a/bot/api.py +++ b/bot/api.py @@ -53,7 +53,7 @@ class APIClient:      @staticmethod      def _url_for(endpoint: str) -> str: -        return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" +        return f"{URLs.site_api_schema}{URLs.site_api}/{quote_url(endpoint)}"      async def close(self) -> None:          """Close the aiohttp session.""" diff --git a/bot/constants.py b/bot/constants.py index 95e22513f..8a93ff9cf 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -246,13 +246,16 @@ class Colours(metaclass=YAMLGetter):      section = "style"      subsection = "colours" +    blue: int      bright_green: int -    soft_green: int -    soft_orange: int -    soft_red: int      orange: int      pink: int      purple: int +    soft_green: int +    soft_orange: int +    soft_red: int +    white: int +    yellow: int  class DuckPond(metaclass=YAMLGetter): @@ -530,6 +533,7 @@ class URLs(metaclass=YAMLGetter):      site: str      site_api: str      site_schema: str +    site_api_schema: str      # Site endpoints      site_logs_view: str diff --git a/bot/errors.py b/bot/errors.py index 65d715203..ab0adcd42 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,4 +1,6 @@ -from typing import Hashable +from typing import Hashable, Union + +from discord import Member, User  class LockedResourceError(RuntimeError): @@ -18,3 +20,18 @@ class LockedResourceError(RuntimeError):              f"Cannot operate on {self.type.lower()} `{self.id}`; "              "it is currently locked and in use by another operation."          ) + + +class InvalidInfractedUser(Exception): +    """ +    Exception raised upon attempt of infracting an invalid user. + +    Attributes: +        `user` -- User or Member which is invalid +    """ + +    def __init__(self, user: Union[Member, User], reason: str = "User infracted is a bot."): +        self.user = user +        self.reason = reason + +        super().__init__(reason) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index ed7962b06..d2cce5558 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -12,7 +12,7 @@ from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.constants import Colours, ERROR_REPLIES, Icons, MODERATION_ROLES  from bot.converters import TagNameConverter -from bot.errors import LockedResourceError +from bot.errors import InvalidInfractedUser, LockedResourceError  from bot.exts.backend.branding._errors import BrandingError  from bot.utils.checks import InWhitelistCheckFailure @@ -82,6 +82,8 @@ class ErrorHandler(Cog):              elif isinstance(e.original, BrandingError):                  await ctx.send(embed=self._get_error_embed(random.choice(ERROR_REPLIES), str(e.original)))                  return +            elif isinstance(e.original, InvalidInfractedUser): +                await ctx.send(f"Cannot infract that user. {e.original.reason}")              else:                  await self.handle_unexpected_error(ctx, e.original)              return  # Exit early to avoid logging. diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py new file mode 100644 index 000000000..3e326e8bb --- /dev/null +++ b/bot/exts/info/pypi.py @@ -0,0 +1,70 @@ +import itertools +import logging +import random + +from discord import Embed +from discord.ext.commands import Cog, Context, command +from discord.utils import escape_markdown + +from bot.bot import Bot +from bot.constants import Colours, NEGATIVE_REPLIES + +URL = "https://pypi.org/pypi/{package}/json" +FIELDS = ("author", "requires_python", "summary", "license") +PYPI_ICON = "https://cdn.discordapp.com/emojis/766274397257334814.png" +PYPI_COLOURS = itertools.cycle((Colours.yellow, Colours.blue, Colours.white)) + +log = logging.getLogger(__name__) + + +class PyPi(Cog): +    """Cog for getting information about PyPi packages.""" + +    def __init__(self, bot: Bot): +        self.bot = bot + +    @command(name="pypi", aliases=("package", "pack")) +    async def get_package_info(self, ctx: Context, package: str) -> None: +        """Provide information about a specific package from PyPI.""" +        embed = Embed( +            title=random.choice(NEGATIVE_REPLIES), +            colour=Colours.soft_red +        ) +        embed.set_thumbnail(url=PYPI_ICON) + +        async with self.bot.http_session.get(URL.format(package=package)) as response: +            if response.status == 404: +                embed.description = "Package could not be found." + +            elif response.status == 200 and response.content_type == "application/json": +                response_json = await response.json() +                info = response_json["info"] + +                embed.title = f"{info['name']} v{info['version']}" +                embed.url = info['package_url'] +                embed.colour = next(PYPI_COLOURS) + +                for field in FIELDS: +                    field_data = info[field] + +                    # Field could be completely empty, in some cases can be a string with whitespaces, or None. +                    if field_data and not field_data.isspace(): +                        if '\n' in field_data and field == "license": +                            field_data = field_data.split('\n')[0] + +                        embed.add_field( +                            name=field.replace("_", " ").title(), +                            value=escape_markdown(field_data), +                            inline=False, +                        ) + +            else: +                embed.description = "There was an error when fetching your PyPi package." +                log.trace(f"Error when fetching PyPi package: {response.status}.") + +        await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: +    """Load the PyPi cog.""" +    bot.add_cog(PyPi(bot)) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index d0dc3f0a1..e766c1e5c 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -7,6 +7,7 @@ from discord.ext.commands import Context  from bot.api import ResponseCodeError  from bot.constants import Colours, Icons +from bot.errors import InvalidInfractedUser  log = logging.getLogger(__name__) @@ -79,6 +80,10 @@ async def post_infraction(      active: bool = True  ) -> t.Optional[dict]:      """Posts an infraction to the API.""" +    if isinstance(user, (discord.Member, discord.User)) and user.bot: +        log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.") +        raise InvalidInfractedUser(user) +      log.trace(f"Posting {infr_type} infraction for {user} to the API.")      payload = { diff --git a/bot/resources/tags/defaultdict.md b/bot/resources/tags/defaultdict.md new file mode 100644 index 000000000..b6c3175fc --- /dev/null +++ b/bot/resources/tags/defaultdict.md @@ -0,0 +1,21 @@ +**[`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict)** + +The Python `defaultdict` type behaves almost exactly like a regular Python dictionary, but if you try to access or modify a missing key, the `defaultdict` will automatically insert the key and generate a default value for it. +While instantiating a `defaultdict`, we pass in a function that tells it how to create a default value for missing keys. + +```py +>>> from collections import defaultdict +>>> my_dict = defaultdict(int) +>>> my_dict +defaultdict(<class 'int'>, {}) +``` + +In this example, we've used the `int` class which returns 0 when called like a function, so any missing key will get a default value of 0. You can also get an empty list by default with `list` or an empty string with `str`. + +```py +>>> my_dict["foo"] +0 +>>> my_dict["bar"] += 5 +>>> my_dict +defaultdict(<class 'int'>, {'foo': 0, 'bar': 5}) +``` diff --git a/bot/resources/tags/dict-get.md b/bot/resources/tags/dict-get.md new file mode 100644 index 000000000..e02df03ab --- /dev/null +++ b/bot/resources/tags/dict-get.md @@ -0,0 +1,16 @@ +Often while using dictionaries in Python, you may run into `KeyErrors`. This error is raised when you try to access a key that isn't present in your dictionary. Python gives you some neat ways to handle them. + +**The `dict.get` method** + +The [`dict.get`](https://docs.python.org/3/library/stdtypes.html#dict.get) method will return the value for the key if it exists, and None (or a default value that you specify) if the key doesn't exist. Hence it will _never raise_ a KeyError. +```py +>>> my_dict = {"foo": 1, "bar": 2} +>>> print(my_dict.get("foobar")) +None +``` +Below, 3 is the default value to be returned, because the key doesn't exist- +```py +>>> print(my_dict.get("foobar", 3)) +3 +``` +Some other methods for handling `KeyError`s gracefully are the [`dict.setdefault`](https://docs.python.org/3/library/stdtypes.html#dict.setdefault) method and [`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict) (check out the `!defaultdict` tag). diff --git a/bot/resources/tags/dictcomps.md b/bot/resources/tags/dictcomps.md index 11867d77b..6c8018761 100644 --- a/bot/resources/tags/dictcomps.md +++ b/bot/resources/tags/dictcomps.md @@ -1,20 +1,14 @@ -**Dictionary Comprehensions** - -Like lists, there is a convenient way of creating dictionaries: +Dictionary comprehensions (*dict comps*) provide a convenient way to make dictionaries, just like list comps:  ```py ->>> ftoc = {f: round((5/9)*(f-32)) for f in range(-40,101,20)} ->>> print(ftoc) -{-40: -40, -20: -29, 0: -18, 20: -7, 40: 4, 60: 16, 80: 27, 100: 38} +>>> {word.lower(): len(word) for word in ('I', 'love', 'Python')} +{'i': 1, 'love': 4, 'python': 6}  ``` -In the example above, I created a dictionary of temperatures in Fahrenheit, that are mapped to (*roughly*) their Celsius counterpart within a small range. These comprehensions are useful for succinctly creating dictionaries from some other sequence. +The syntax is very similar to list comps except that you surround it with curly braces and have two expressions: one for the key and one for the value. -They are also very useful for inverting the key value pairs of a dictionary that already exists, such that the value in the old dictionary is now the key, and the corresponding key is now its value: +One can use a dict comp to change an existing dictionary using its `items` method  ```py ->>> ctof = {v:k for k, v in ftoc.items()} ->>> print(ctof) -{-40: -40, -29: -20, -18: 0, -7: 20, 4: 40, 16: 60, 27: 80, 38: 100} +>>> first_dict = {'i': 1, 'love': 4, 'python': 6} +>>> {key.upper(): value * 2 for key, value in first_dict.items()} +{'I': 2, 'LOVE': 8, 'PYTHON': 12}  ``` - -Also like list comprehensions, you can add a conditional to it in order to filter out items you don't want. - -For more information and examples, check [PEP 274](https://www.python.org/dev/peps/pep-0274/) +For more information and examples, check out [PEP 274](https://www.python.org/dev/peps/pep-0274/) diff --git a/bot/resources/tags/f-strings.md b/bot/resources/tags/f-strings.md index 69bc82487..5ccafe723 100644 --- a/bot/resources/tags/f-strings.md +++ b/bot/resources/tags/f-strings.md @@ -1,17 +1,9 @@ -In Python, there are several ways to do string interpolation, including using `%s`\'s and by using the `+` operator to concatenate strings together. However, because some of these methods offer poor readability and require typecasting to prevent errors, you should for the most part be using a feature called format strings. +Creating a Python string with your variables using the `+` operator can be difficult to write and read. F-strings (*format-strings*) make it easy to insert values into a string. If you put an `f` in front of the first quote, you can then put Python expressions between curly braces in the string. -**In Python 3.6 or later, we can use f-strings like this:**  ```py -snake = "Pythons" -print(f"{snake} are some of the largest snakes in the world") -``` -**In earlier versions of Python or in projects where backwards compatibility is very important, use  str.format() like this:** -```py -snake = "Pythons" - -# With str.format() you can either use indexes -print("{0} are some of the largest snakes in the world".format(snake)) - -# Or keyword arguments -print("{family} are some of the largest snakes in the world".format(family=snake)) +>>> snake = "pythons" +>>> number = 21 +>>> f"There are {number * 2} {snake} on the plane." +"There are 42 pythons on the plane."  ``` +Note that even when you include an expression that isn't a string, like `number * 2`, Python will convert it to a string for you. diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md deleted file mode 100644 index 1493076c7..000000000 --- a/bot/resources/tags/free.md +++ /dev/null @@ -1,5 +0,0 @@ -**We have a new help channel system!** - -Please see <#704250143020417084> for further information. - -A more detailed guide can be found on [our website](https://pythondiscord.com/pages/resources/guides/help-channels/). diff --git a/bot/resources/tags/listcomps.md b/bot/resources/tags/listcomps.md index 0003b9bb8..ba00a4bf7 100644 --- a/bot/resources/tags/listcomps.md +++ b/bot/resources/tags/listcomps.md @@ -1,14 +1,19 @@ -Do you ever find yourself writing something like: +Do you ever find yourself writing something like this?  ```py -even_numbers = [] -for n in range(20): -    if n % 2 == 0: -        even_numbers.append(n) +>>> squares = [] +>>> for n in range(5): +...    squares.append(n ** 2) +[0, 1, 4, 9, 16]  ``` -Using list comprehensions can simplify this significantly, and greatly improve code readability. If we rewrite the example above to use list comprehensions, it would look like this: +Using list comprehensions can make this both shorter and more readable. As a list comprehension, the same code would look like this:  ```py -even_numbers = [n for n in range(20) if n % 2 == 0] +>>> [n ** 2 for n in range(5)] +[0, 1, 4, 9, 16] +``` +List comprehensions also get an `if` statement: +```python +>>> [n ** 2 for n in range(5) if n % 2 == 0] +[0, 4, 16]  ``` -This also works for generators, dicts and sets by using `()` or `{}` instead of `[]`. -For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python) or [PEP 202](https://www.python.org/dev/peps/pep-0202/). +For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python). diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md new file mode 100644 index 000000000..ae41d589c --- /dev/null +++ b/bot/resources/tags/local-file.md @@ -0,0 +1,23 @@ +Thanks to discord.py, sending local files as embed images is simple. You have to create an instance of [`discord.File`](https://discordpy.readthedocs.io/en/latest/api.html#discord.File) class: +```py +# When you know the file exact path, you can pass it. +file = discord.File("/this/is/path/to/my/file.png", filename="file.png") + +# When you have the file-like object, then you can pass this instead path. +with open("/this/is/path/to/my/file.png", "rb") as f: +    file = discord.File(f) +``` +When using the file-like object, you have to open it in `rb` mode. Also, in this case, passing `filename` to it is not necessary. +Please note that `filename` can't contain underscores. This is a Discord limitation. + +[`discord.Embed`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed) instances have a [`set_image`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed.set_image) method which can be used to set an attachment as an image: +```py +embed = discord.Embed() +# Set other fields +embed.set_image(url="attachment://file.png")  # Filename here must be exactly same as attachment filename. +``` +After this, you can send an embed with an attachment to Discord: +```py +await channel.send(file=file, embed=embed) +``` +This example uses [`discord.TextChannel`](https://discordpy.readthedocs.io/en/latest/api.html#discord.TextChannel) for sending, but any instance of [`discord.abc.Messageable`](https://discordpy.readthedocs.io/en/latest/api.html#discord.abc.Messageable) can be used for sending. diff --git a/config-default.yml b/config-default.yml index e9dce7845..beaf89f2c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -24,13 +24,16 @@ bot:  style:      colours: +        blue: 0x3775a8          bright_green: 0x01d277 -        soft_green: 0x68c290 -        soft_orange: 0xf9cb54 -        soft_red: 0xcd6d6d          orange: 0xe67e22          pink: 0xcf84e0          purple: 0xb734eb +        soft_green: 0x68c290 +        soft_orange: 0xf9cb54 +        soft_red: 0xcd6d6d +        white: 0xfffffe +        yellow: 0xffd241      emojis:          badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" @@ -335,7 +338,8 @@ keys:  urls:      # PyDis site vars      site:        &DOMAIN       "pythondiscord.com" -    site_api:    &API    !JOIN ["api.", *DOMAIN] +    site_api:    &API          "pydis-api.default.svc.cluster.local" +    site_api_schema:           "http://"      site_paste:  &PASTE  !JOIN ["paste.", *DOMAIN]      site_schema: &SCHEMA       "https://"      site_staff:  &STAFF  !JOIN ["staff.", *DOMAIN] | 
