diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..af820a45d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: test + +on: + push: + pull_request: + types: [ opened, edited ] + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ '3.8', '3.x' ] + + name: pytest ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up CPython ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel "coverage[toml]" pytest pytest-asyncio pytest-cov pytest-mock + pip install -U -r requirements.txt + + - name: Run tests + shell: bash + run: | + PYTHONPATH="$(pwd)" pytest -vs --cov=discord --cov-report term-missing:skip-covered diff --git a/.gitignore b/.gitignore index b556ebbb9..51ed18d76 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ docs/crowdin.py *.jpg *.flac *.mo +/.coverage diff --git a/pyproject.toml b/pyproject.toml index 736832917..db2927f26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,19 @@ build-backend = "setuptools.build_meta" line-length = 125 skip-string-normalization = true +[tool.coverage.run] +omit = [ + "discord/__main__.py", + "discord/types/*", + "*/_types.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "@overload" +] + [tool.isort] profile = "black" combine_as_imports = true @@ -29,3 +42,6 @@ exclude = [ ] pythonVersion = "3.8" typeCheckingMode = "basic" + +[tool.pytest.ini_options] +asyncio_mode = "strict" diff --git a/setup.py b/setup.py index af1ea214a..fcbae9591 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,13 @@ extras_require = { ], 'speed': [ 'orjson>=3.5.4', + ], + 'test': [ + 'coverage[toml]', + 'pytest', + 'pytest-asyncio', + 'pytest-cov', + 'pytest-mock' ] } diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 000000000..b0150e655 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- + +""" + +Tests for discord.utils + +""" + +import datetime +import random +import collections +import secrets +import sys +import time +import typing + +import pytest + +from discord import utils + + +# Async generator for async support +async def async_iterate(array): + for item in array: + yield item + + +def test_cached_properties(): + # cached_property + class Test: + @utils.cached_property + def time(self) -> float: + return time.perf_counter() + + instance = Test() + + assert instance.time == instance.time + + # cached_slot_property + class TestSlotted: + __slots__ = ( + '_cs_time' + ) + + @utils.cached_slot_property('_cs_time') + def time(self) -> float: + return time.perf_counter() + + instance = TestSlotted() + + assert instance.time == instance.time + assert not hasattr(instance, '__dict__') + + +@pytest.mark.parametrize( + ('snowflake', 'time_tuple'), + [ + (10000000000000000, (2015, 1, 28, 14, 16, 25)), + (12345678901234567, (2015, 2, 4, 1, 37, 19)), + (100000000000000000, (2015, 10, 3, 22, 44, 17)), + (123456789012345678, (2015, 12, 7, 16, 13, 12)), + (661720302316814366, (2020, 1, 1, 0, 0, 14)), + (1000000000000000000, (2022, 7, 22, 11, 22, 59)), + ] +) +def test_snowflake_time(snowflake: int, time_tuple: typing.Tuple[int, int, int, int, int, int]): + dt = utils.snowflake_time(snowflake) + + assert (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) == time_tuple + + assert utils.time_snowflake(dt, high=False) <= snowflake <= utils.time_snowflake(dt, high=True) + + +@pytest.mark.asyncio +async def test_get_find(): + # Generate a dictionary of random keys to values + mapping = { + secrets.token_bytes(32): secrets.token_bytes(32) + for _ in range(100) + } + + # Turn it into a shuffled iterable of pairs + pair = collections.namedtuple('pair', 'key value') + array = [pair(key=k, value=v) for k, v in mapping.items()] + random.shuffle(array) + + # Confirm all values can be found + for key, value in mapping.items(): + # Sync get + item = utils.get(array, key=key) + assert item is not None + assert item.value == value + + # Async get + item = await utils.get(async_iterate(array), key=key) + assert item is not None + assert item.value == value + + # Sync find + item = utils.find(lambda i: i.key == key, array) + assert item is not None + assert item.value == value + + # Async find + item = await utils.find(lambda i: i.key == key, async_iterate(array)) + assert item is not None + assert item.value == value + + +def test_get_slots(): + class A: + __slots__ = ('one', 'two') + + class B(A): + __slots__ = ('three', 'four') + + class C(B): + __slots__ = ('five', 'six') + + assert set(utils.get_slots(C)) == {'one', 'two', 'three', 'four', 'five', 'six'} + + +def test_valid_icon_size(): + # Valid icon sizes + for size in [16, 32, 64, 128, 256, 512, 1024, 2048, 4096]: + assert utils.valid_icon_size(size) + + # Some not valid icon sizes + for size in [-1, 0, 20, 103, 500, 8192]: + assert not utils.valid_icon_size(size) + + +@pytest.mark.parametrize( + ('url', 'code'), + [ + ('https://discordapp.com/invite/dpy', 'dpy'), + ('https://discord.com/invite/dpy', 'dpy'), + ('https://discord.gg/dpy', 'dpy'), + ] +) +def test_resolve_invite(url, code): + assert utils.resolve_invite(url) == code + + +@pytest.mark.parametrize( + ('url', 'code'), + [ + ('https://discordapp.com/template/foobar', 'foobar'), + ('https://discord.com/template/foobar', 'foobar'), + ('https://discord.new/foobar', 'foobar'), + ] +) +def test_resolve_template(url, code): + assert utils.resolve_template(url) == code + + +@pytest.mark.parametrize( + 'mention', + ['@everyone', '@here'] +) +def test_escape_mentions(mention): + assert mention not in utils.escape_mentions(mention) + assert mention not in utils.escape_mentions(f"one {mention} two") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('source', 'chunk_size', 'chunked'), + [ + ([1, 2, 3, 4, 5, 6], 2, [[1, 2], [3, 4], [5, 6]]), + ([1, 2, 3, 4, 5, 6], 3, [[1, 2, 3], [4, 5, 6]]), + ([1, 2, 3, 4, 5, 6], 4, [[1, 2, 3, 4], [5, 6]]), + ([1, 2, 3, 4, 5, 6], 5, [[1, 2, 3, 4, 5], [6]]), + ] +) +async def test_as_chunks(source, chunk_size, chunked): + assert [x for x in utils.as_chunks(source, chunk_size)] == chunked + assert [x async for x in utils.as_chunks(async_iterate(source), chunk_size)] == chunked + + +@pytest.mark.parametrize( + ('annotation', 'resolved'), + [ + (datetime.datetime, datetime.datetime), + ('datetime.datetime', datetime.datetime), + ('typing.Union[typing.Literal["a"], typing.Literal["b"]]', typing.Union[typing.Literal["a"], typing.Literal["b"]]), + ('typing.Union[typing.Union[int, str], typing.Union[bool, dict]]', typing.Union[int, str, bool, dict]), + ] +) +def test_resolve_annotation(annotation, resolved): + assert resolved == utils.resolve_annotation(annotation, globals(), locals(), None) + + +@pytest.mark.skipif(sys.version_info < (3, 10), reason="3.10 union syntax") +@pytest.mark.parametrize( + ('annotation', 'resolved'), + [ + ('int | None', typing.Optional[int]), + ('str | int', typing.Union[str, int]), + ('str | int | None', typing.Optional[typing.Union[str, int]]), + ] +) +def test_resolve_annotation_310(annotation, resolved): + assert resolved == utils.resolve_annotation(annotation, globals(), locals(), None) + + +# is_inside_class tests + +def not_a_class(): + def not_a_class_either(): + pass + return not_a_class_either + +class ThisIsAClass: + def in_a_class(self): + def not_directly_in_a_class(): + pass + return not_directly_in_a_class + + @classmethod + def a_class_method(cls): + def not_directly_in_a_class(): + pass + return not_directly_in_a_class + + @staticmethod + def a_static_method(): + def not_directly_in_a_class(): + pass + return not_directly_in_a_class + + class SubClass: + pass + + +def test_is_inside_class(): + assert not utils.is_inside_class(not_a_class) + assert not utils.is_inside_class(not_a_class()) + assert not utils.is_inside_class(ThisIsAClass) + assert utils.is_inside_class(ThisIsAClass.in_a_class) + assert utils.is_inside_class(ThisIsAClass.a_class_method) + assert utils.is_inside_class(ThisIsAClass.a_static_method) + assert not utils.is_inside_class(ThisIsAClass().in_a_class()) + assert not utils.is_inside_class(ThisIsAClass.a_class_method()) + assert not utils.is_inside_class(ThisIsAClass().a_static_method()) + assert not utils.is_inside_class(ThisIsAClass.a_static_method()) + # Only really designed for callables, although I guess it is callable due to the constructor + assert utils.is_inside_class(ThisIsAClass.SubClass) + + +@pytest.mark.parametrize( + ('dt', 'style', 'formatted'), + [ + (datetime.datetime(1970, 1, 1, 0, 0, 0, 0, tzinfo=datetime.timezone.utc), None, '<t:0>'), + (datetime.datetime(2020, 1, 1, 0, 0, 0, 0, tzinfo=datetime.timezone.utc), None, '<t:1577836800>'), + (datetime.datetime(2020, 1, 1, 0, 0, 0, 0, tzinfo=datetime.timezone.utc), 'F', '<t:1577836800:F>'), + (datetime.datetime(2033, 5, 18, 3, 33, 20, 0, tzinfo=datetime.timezone.utc), 'D', '<t:2000000000:D>'), + ] +) +def test_format_dt(dt: datetime.datetime, style: typing.Optional[utils.TimestampStyle], formatted: str): + assert utils.format_dt(dt, style=style) == formatted