Skip to content

Commit 3b30626

Browse files
Paillat-devLumabotsIcebluewolfCopilotrenovate[bot]
authored
feat: Setup a fully featured test setup and add initial tests on utils (#39)
Co-authored-by: Lumouille <144063653+Lumabots@users.noreply.github.com> Co-authored-by: Ice Wolfy <44532864+Icebluewolf@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Paillat <paillat@pycord.dev>
1 parent 347f78a commit 3b30626

15 files changed

+927
-580
lines changed

.github/workflows/lib-checks.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ jobs:
5252
uv run codespell --ignore-words-list="groupt,nd,ot,ro,falsy,BU" \
5353
--exclude-file=".github/workflows/codespell.yml"
5454
ruff:
55+
if: ${{ github.event_name != 'schedule' }}
5556
runs-on: ubuntu-latest
5657
steps:
5758
- name: "Checkout Repository"
@@ -97,3 +98,29 @@ jobs:
9798
run: mkdir -p -v .mypy_cache
9899
- name: "Run mypy"
99100
run: uv run mypy --non-interactive discord/
101+
tests:
102+
if: ${{ github.event_name != 'schedule' }}
103+
runs-on: ${{ matrix.os }}
104+
strategy:
105+
matrix:
106+
os: [ubuntu-latest, windows-latest, macos-latest]
107+
python-version: ['3.13', '3.12', '3.11', '3.10']
108+
steps:
109+
- name: "Checkout Repository"
110+
uses: actions/checkout@v4
111+
112+
- name: "Setup Python"
113+
uses: actions/setup-python@v5
114+
with:
115+
python-version: ${{ matrix.python-version }}
116+
117+
- name: "Install uv"
118+
uses: astral-sh/setup-uv@v6
119+
with:
120+
enable-cache: true
121+
122+
- name: Sync dependencies
123+
run: uv sync --no-python-downloads --group dev
124+
125+
- name: "Run tests"
126+
run: uv run tox

discord/enums.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
import types
2929
from enum import Enum as EnumBase
3030
from enum import IntEnum
31-
from typing import Any, Self, TypeVar, Union
31+
from typing import Any, TypeVar, Union
32+
33+
from typing_extensions import Self
3234

3335
E = TypeVar("E", bound="Enum")
3436

discord/utils/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from __future__ import annotations
2727

2828
from .public import (
29+
DISCORD_EPOCH,
2930
MISSING,
3031
UNICODE_EMOJIS,
3132
Undefined,
@@ -44,9 +45,6 @@
4445
utcnow,
4546
)
4647

47-
DISCORD_EPOCH = 1420070400000
48-
49-
5048
__all__ = (
5149
"oauth_url",
5250
"snowflake_time",
@@ -63,5 +61,6 @@
6361
"basic_autocomplete",
6462
"Undefined",
6563
"MISSING",
64+
"DISCORD_EPOCH",
6665
"UNICODE_EMOJIS",
6766
)

discord/utils/private.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,21 @@ async def sane_wait_for(futures: Iterable[Awaitable[T]], *, timeout: float) -> s
412412
return done
413413

414414

415-
class SnowflakeList(array.array[int]):
415+
# array.array is generic only since Python 3.12
416+
# ref: https://docs.python.org/3/whatsnew/3.12.html#array
417+
# We use the method suggested by mypy
418+
# ref: https://mypy.readthedocs.io/en/stable/runtime_troubles.html#using-classes-that-are-generic-in-stubs-but-not-at-runtime
419+
420+
if TYPE_CHECKING:
421+
SnowflakeListBase = array.array[int]
422+
else:
423+
if sys.version_info >= (3, 12):
424+
SnowflakeListBase = array.array[int]
425+
else:
426+
SnowflakeListBase = array.array
427+
428+
429+
class SnowflakeList(SnowflakeListBase):
416430
"""Internal data storage class to efficiently store a list of snowflakes.
417431
418432
This should have the following characteristics:
@@ -426,10 +440,6 @@ class SnowflakeList(array.array[int]):
426440

427441
__slots__ = ()
428442

429-
if TYPE_CHECKING:
430-
431-
def __init__(self, data: Iterable[int], *, is_sorted: bool = False): ...
432-
433443
def __new__(cls, data: Iterable[int], *, is_sorted: bool = False):
434444
return super().__new__(cls, "Q", data if is_sorted else sorted(data))
435445

discord/utils/public.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -546,12 +546,11 @@ def find(predicate: Callable[[T], Any], seq: Iterable[T]) -> T | None:
546546

547547

548548
try:
549-
with importlib.resources.files(__package__).joinpath("../emojis.json").open(encoding="utf-8") as f:
550-
EMOJIS_MAP = json.load(f)
549+
with importlib.resources.files(__package__).joinpath("../emojis.json").open(encoding="utf-8") as f: # pyright: ignore[reportArgumentType] # __package__ will always be discord.utils
550+
EMOJIS_MAP: dict[str, str] = json.load(f)
551551
except FileNotFoundError:
552552
_log.debug(
553553
"Couldn't find emojis.json. Is the package data missing? Discord emojis names will not work.",
554554
)
555-
556-
EMOJIS_MAP = {}
555+
EMOJIS_MAP = {} # pyright: ignore[reportConstantRedefinition]
557556
UNICODE_EMOJIS = set(EMOJIS_MAP.values())

pyproject.toml

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,16 @@ dev = [
7171
"coverage~=7.8",
7272
"mypy~=1.18.1",
7373
"pre-commit==4.3.0",
74-
"pytest~=8.4.1",
75-
"pytest-asyncio~=0.26.0",
7674
"python-dotenv>=1.1.1",
77-
"ruff>=0.11.9",
75+
"ruff>=0.12.12",
76+
"tox>=4.27.0",
77+
"tox-gh>=1.5.0",
78+
"tox-uv>=1.26.1",
79+
]
80+
81+
test = [# Not in `dev` because we use tox for testing. Tox will install these dependencies.
82+
"pytest~=8.3.5",
83+
"pytest-asyncio~=0.24.0",
7884
]
7985
ci = [
8086
"pygithub>=2.7.0",
@@ -304,3 +310,20 @@ ignore_errors = true
304310

305311
[tool.pytest.ini_options]
306312
asyncio_mode = "auto"
313+
asyncio_default_fixture_loop_scope = "function"
314+
315+
[tool.tox]
316+
requires = ["tox>=4"]
317+
env_list = ["3.13", "3.12", "3.11", "3.10"]
318+
319+
[tool.tox.env_run_base]
320+
description = "run unit tests"
321+
commands = [["pytest", { replace = "posargs", default = ["tests"], extend = true }]]
322+
dependency_groups = ["test"]
323+
324+
# GitHub actions
325+
[tool.tox.gh.python]
326+
"3.13" = ["3.13"]
327+
"3.12" = ["3.12"]
328+
"3.11" = ["3.11"]
329+
"3.10" = ["3.10"]

tests/test_basic_bot.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""
2+
The MIT License (MIT)
3+
4+
Copyright (c) 2021-present Pycord Development
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a
7+
copy of this software and associated documentation files (the "Software"),
8+
to deal in the Software without restriction, including without limitation
9+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
10+
and/or sell copies of the Software, and to permit persons to whom the
11+
Software is furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
17+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
22+
DEALINGS IN THE SOFTWARE.
23+
"""
24+
25+
import pytest
26+
27+
import discord
28+
29+
30+
@pytest.mark.asyncio
31+
async def test_bot_login_failure_login():
32+
bot = discord.Bot()
33+
34+
with pytest.raises(discord.LoginFailure):
35+
await bot.login("invalid_token")
36+
37+
38+
@pytest.mark.asyncio
39+
async def test_bot_login_failure_start():
40+
bot = discord.Bot()
41+
42+
with pytest.raises(discord.LoginFailure):
43+
await bot.start("invalid_token")
44+
45+
46+
def test_bot_login_failure_run():
47+
bot = discord.Bot()
48+
49+
with pytest.raises(discord.LoginFailure):
50+
bot.run("invalid_token")

tests/helpers.py renamed to tests/test_emojis_mapping.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""
22
The MIT License (MIT)
33
4-
Copyright (c) 2015-2021 Rapptz
54
Copyright (c) 2021-present Pycord Development
65
76
Permission is hereby granted, free of charge, to any person obtaining a
@@ -23,10 +22,8 @@
2322
DEALINGS IN THE SOFTWARE.
2423
"""
2524

26-
from typing import TypeVar
25+
from discord.utils import UNICODE_EMOJIS
2726

28-
V = TypeVar("V")
2927

30-
31-
async def coroutine(val: V) -> V:
32-
return val
28+
def test_emoji_mapping_len():
29+
assert len(UNICODE_EMOJIS) > 0, "No unicode emojis loaded"

tests/test_find_util.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""
2+
The MIT License (MIT)
3+
4+
Copyright (c) 2021-present Pycord Development
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a
7+
copy of this software and associated documentation files (the "Software"),
8+
to deal in the Software without restriction, including without limitation
9+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
10+
and/or sell copies of the Software, and to permit persons to whom the
11+
Software is furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
17+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
22+
DEALINGS IN THE SOFTWARE.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
from collections.abc import Callable, Iterable, Iterator
28+
from typing import Literal, TypeVar
29+
30+
import pytest
31+
from typing_extensions import TypeIs
32+
33+
from discord.utils import find
34+
35+
T = TypeVar("T")
36+
37+
38+
def is_even(x: int) -> bool:
39+
return x % 2 == 0
40+
41+
42+
def always_true(_: object) -> bool:
43+
return True
44+
45+
46+
def greater_than_3(x: int) -> bool:
47+
return x > 3
48+
49+
50+
def equals_1(x: int) -> TypeIs[Literal[1]]:
51+
return x == 1
52+
53+
54+
def equals_2(x: int) -> TypeIs[Literal[2]]:
55+
return x == 2
56+
57+
58+
def equals_b(c: str) -> TypeIs[Literal["b"]]:
59+
return c == "b"
60+
61+
62+
def equals_30(x: int) -> TypeIs[Literal[30]]:
63+
return x == 30
64+
65+
66+
def is_none_pred(x: object) -> TypeIs[None]:
67+
return x is None
68+
69+
70+
@pytest.mark.parametrize(
71+
("seq", "predicate", "expected"),
72+
[
73+
([], always_true, None),
74+
([1, 2, 3], greater_than_3, None),
75+
([1, 2, 3], equals_1, 1),
76+
([1, 2, 3], equals_2, 2),
77+
("abc", equals_b, "b"),
78+
((10, 20, 30), equals_30, 30),
79+
([None, False, 0], is_none_pred, None),
80+
([1, 2, 3, 4], is_even, 2),
81+
],
82+
)
83+
def test_find_basic_parametrized(
84+
seq: Iterable[T],
85+
predicate: Callable[[T], object],
86+
expected: T | None,
87+
) -> None:
88+
result = find(predicate, seq)
89+
if expected is None:
90+
assert result is None
91+
else:
92+
assert result == expected
93+
94+
95+
def test_find_with_truthy_non_boolean_predicate() -> None:
96+
seq: list[int] = [2, 4, 5, 6]
97+
result = find(lambda x: x % 2, seq)
98+
assert result == 5
99+
100+
101+
def test_find_on_generator_and_stop_early() -> None:
102+
def bad_gen() -> Iterator[str]:
103+
yield "first"
104+
raise RuntimeError("should not be reached")
105+
106+
assert find(lambda x: x == "first", bad_gen()) == "first"
107+
108+
109+
def test_find_does_not_evaluate_rest() -> None:
110+
calls: list[str] = []
111+
112+
def predicate(x: str) -> bool:
113+
calls.append(x)
114+
return x == "stop"
115+
116+
seq: list[str] = ["go", "stop", "later"]
117+
result = find(predicate, seq)
118+
assert result == "stop"
119+
assert calls == ["go", "stop"]
120+
121+
122+
def test_find_with_set_returns_first_iterated_element() -> None:
123+
data: set[str] = {"a", "b", "c"}
124+
result = find(lambda x: x in data, data)
125+
assert result in data
126+
127+
128+
def test_find_none_predicate() -> None:
129+
seq: list[int] = [42, 43, 44]
130+
result = find(lambda x: True, seq)
131+
assert result == 42

0 commit comments

Comments
 (0)