From 5511f7479ccd29600388be02c293fcb96e600475 Mon Sep 17 00:00:00 2001 From: Elin Andersson Lundell Date: Thu, 26 Feb 2026 19:13:21 +0100 Subject: [PATCH 01/12] feat: add test structure to sir-lancebot Fixes: #37 --- pyproject.toml | 24 +++++++ tests/test_exampletest.py | 19 +++++ uv.lock | 148 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 tests/test_exampletest.py diff --git a/pyproject.toml b/pyproject.toml index bcfec672e..3da84880c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,8 +33,13 @@ dev = [ "python-dotenv==1.0.1", "ruff==0.8.4", "taskipy==1.14.1", + "pytest==8.4.2", + "pytest-cov==7.0.0", + "pytest-subtests==0.14.1", + "pytest-xdist==3.8.0", ] + [tool.uv] prerelease = "allow" @@ -42,6 +47,16 @@ prerelease = "allow" start = "python -m bot" lint = "pre-commit run --all-files" precommit = "pre-commit install" +test = "pytest -n auto --ff" +retest = "pytest -n auto --lf" +test-cov = "pytest -n auto --cov-report= --cov" +html = "coverage html" +report = "coverage report" + +[tool.coverage.run] +branch = true +source_pkgs = ["bot"] +source = ["tests"] [tool.isort] multi_line_output = 6 @@ -80,3 +95,12 @@ known-first-party = ["bot"] order-by-type = false case-sensitive = true combine-as-imports = true + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["ANN", "D"] + +[tool.pytest.ini_options] +# We don't use nose style tests so disable them in pytest. +# This stops pytest from running functions named `setup` in test files. +# See https://github.com/python-discord/bot/pull/2229#issuecomment-1204436420 +addopts = "-p no:nose" \ No newline at end of file diff --git a/tests/test_exampletest.py b/tests/test_exampletest.py new file mode 100644 index 000000000..dcf838165 --- /dev/null +++ b/tests/test_exampletest.py @@ -0,0 +1,19 @@ +#test.py +import unittest + +def add(a, b): + return a + b + +class TestAddFunction(unittest.TestCase): + def test_add_positive_numbers(self): + self.assertEqual(add(1, 2), 3) + + def test_add_negative_numbers(self): + self.assertEqual(add(-1, -2), -3) + + def test_add_mixed_numbers(self): + self.assertEqual(add(1, -2), -1) + self.assertEqual(add(-1, 2), 1) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/uv.lock b/uv.lock index eeb252dfe..4239eaa76 100644 --- a/uv.lock +++ b/uv.lock @@ -229,6 +229,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, ] +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + [[package]] name = "discord-py" version = "2.6.4" @@ -269,6 +308,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/fc/25e5793c0f6f09626b94444a3b9faf386c587873fa8f696ad20d37e47387/emojis-0.7.0-py3-none-any.whl", hash = "sha256:a777926d8ab0bfdd51250e899a3b3524a1e969275ac8e747b4a05578fa597367", size = 28347, upload-time = "2022-12-01T12:00:07.163Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "fakeredis" version = "2.32.0" @@ -367,6 +415,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "lupa" version = "2.5" @@ -473,6 +530,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + [[package]] name = "pillow" version = "12.1.1" @@ -528,6 +594,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pre-commit" version = "4.0.1" @@ -716,6 +791,15 @@ all = [ { name = "fakeredis", extra = ["lua"] }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pyjokes" version = "0.8.3" @@ -734,6 +818,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-subtests" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/4c/ba9eab21a2250c2d46c06c0e3cd316850fde9a90da0ac8d0202f074c6817/pytest_subtests-0.14.1.tar.gz", hash = "sha256:350c00adc36c3aff676a66135c81aed9e2182e15f6c3ec8721366918bbbf7580", size = 17632, upload-time = "2024-12-10T00:21:04.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/b7/7ca948d35642ae72500efda6ba6fa61dcb6683feb596d19c4747c63c0789/pytest_subtests-0.14.1-py3-none-any.whl", hash = "sha256:e92a780d98b43118c28a16044ad9b841727bd7cb6a417073b38fd2d7ccdf052d", size = 8833, upload-time = "2024-12-10T00:20:58.873Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -868,6 +1008,10 @@ dependencies = [ dev = [ { name = "pip-licenses" }, { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-subtests" }, + { name = "pytest-xdist" }, { name = "python-dotenv" }, { name = "ruff" }, { name = "taskipy" }, @@ -896,6 +1040,10 @@ requires-dist = [ dev = [ { name = "pip-licenses", specifier = "==5.0.0" }, { name = "pre-commit", specifier = "==4.0.1" }, + { name = "pytest", specifier = "==8.4.2" }, + { name = "pytest-cov", specifier = "==7.0.0" }, + { name = "pytest-subtests", specifier = "==0.14.1" }, + { name = "pytest-xdist", specifier = "==3.8.0" }, { name = "python-dotenv", specifier = "==1.0.1" }, { name = "ruff", specifier = "==0.8.4" }, { name = "taskipy", specifier = "==1.14.1" }, From 978db15551045fe02b60c3ae8da5ab975bb94914 Mon Sep 17 00:00:00 2001 From: Oskar Nurm <19738295+oskarnurm@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:47:17 +0100 Subject: [PATCH 02/12] feat: add `repo_exists` method to github_stats command --- bot/exts/utilities/github_stats.py | 39 ++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 bot/exts/utilities/github_stats.py diff --git a/bot/exts/utilities/github_stats.py b/bot/exts/utilities/github_stats.py new file mode 100644 index 000000000..abfd1d509 --- /dev/null +++ b/bot/exts/utilities/github_stats.py @@ -0,0 +1,39 @@ +from discord.ext.commands import Cog, command, Context +from bot.constants import Tokens +from bot.bot import Bot + + +class GitHubStats(Cog): + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @command(name="github_stats") + async def ping(self, ctx: Context, repo: str = "python-discord/bot") -> None: + """ + Fetches stats for a GitHub repo. + Usage: !github_stats 2023-01-01 2023-12-31 python-discord/bot + """ + await ctx.send("Tormod's Crypt: Draw a card. If you can't, you did. No you didn't.") + + if not await self.repo_exists(repo): + await ctx.send(f"Could not find repository: `{repo}`") # TODO: add emojis as common for discord bots + return + + await ctx.send(f"Found repo: `{repo}`") + + async def repo_exists(self, repo: str) -> bool: + """ + Checks if a repository exists on GitHub. + + Args: + repo (str): The repository name in 'owner/repo' format (e.g., 'python-discord/bot'). + """ + url = f"https://api.github.com/repos/{repo}" + headers = {"Authorization": f"token {Tokens.github.get_secret_value()}"} + + async with self.bot.http_session.get(url, headers=headers) as response: + return response.status == 200 + + +async def setup(bot: Bot) -> None: + await bot.add_cog(GitHubStats(bot)) From 7806872c4d092619d77ae502d5f0617eca149943 Mon Sep 17 00:00:00 2001 From: Oskar Nurm <19738295+oskarnurm@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:54:43 +0100 Subject: [PATCH 03/12] feat: add `get_issue_count` method * feat: rename file to follow project convention * feat: add issue count method * feat: return -1 if fetching issues failed --- bot/exts/utilities/github_stats.py | 39 ----------------- bot/exts/utilities/githubstats.py | 70 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 39 deletions(-) delete mode 100644 bot/exts/utilities/github_stats.py create mode 100644 bot/exts/utilities/githubstats.py diff --git a/bot/exts/utilities/github_stats.py b/bot/exts/utilities/github_stats.py deleted file mode 100644 index abfd1d509..000000000 --- a/bot/exts/utilities/github_stats.py +++ /dev/null @@ -1,39 +0,0 @@ -from discord.ext.commands import Cog, command, Context -from bot.constants import Tokens -from bot.bot import Bot - - -class GitHubStats(Cog): - def __init__(self, bot: Bot) -> None: - self.bot = bot - - @command(name="github_stats") - async def ping(self, ctx: Context, repo: str = "python-discord/bot") -> None: - """ - Fetches stats for a GitHub repo. - Usage: !github_stats 2023-01-01 2023-12-31 python-discord/bot - """ - await ctx.send("Tormod's Crypt: Draw a card. If you can't, you did. No you didn't.") - - if not await self.repo_exists(repo): - await ctx.send(f"Could not find repository: `{repo}`") # TODO: add emojis as common for discord bots - return - - await ctx.send(f"Found repo: `{repo}`") - - async def repo_exists(self, repo: str) -> bool: - """ - Checks if a repository exists on GitHub. - - Args: - repo (str): The repository name in 'owner/repo' format (e.g., 'python-discord/bot'). - """ - url = f"https://api.github.com/repos/{repo}" - headers = {"Authorization": f"token {Tokens.github.get_secret_value()}"} - - async with self.bot.http_session.get(url, headers=headers) as response: - return response.status == 200 - - -async def setup(bot: Bot) -> None: - await bot.add_cog(GitHubStats(bot)) diff --git a/bot/exts/utilities/githubstats.py b/bot/exts/utilities/githubstats.py new file mode 100644 index 000000000..d471273f1 --- /dev/null +++ b/bot/exts/utilities/githubstats.py @@ -0,0 +1,70 @@ +from discord.ext.commands import Cog, command, Context +from bot.constants import Tokens +from bot.bot import Bot + +GITHUB_API_URL = "https://api.github.com" + + +class GitHubStats(Cog): + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @command(name="gh-stats") + async def github_stats(self, ctx: Context, start: str, end: str, repo: str = "python-discord/sir-lancebot") -> None: + """ + Fetches stats for a GitHub repo. + Usage: !github_stats 2023-01-01 2023-12-31 python-discord/bot + """ + if not await self.repo_exists(repo): + await ctx.send(f"❌ Could not find repository: `{repo}`") + return + + open = await self.get_issue_count(repo, start, end, state="created") + closed = await self.get_issue_count(repo, start, end, state="closed") + + stats_message = ( + f"Stats for **{repo}** ({start} to {end}):\n" f"Issues opened: {open}\n" f"Issues closed: {closed}" + ) + await ctx.send(stats_message) + + async def repo_exists(self, repo: str) -> bool: + """ + Checks if a repository exists on GitHub. + + Args: + repo (str): The repository name in 'owner/repo' format (e.g., 'python-discord/bot'). + """ + url = f"{GITHUB_API_URL}/repos/{repo}" + headers = {"Authorization": f"token {Tokens.github.get_secret_value()}"} + + async with self.bot.http_session.get(url, headers=headers) as response: + return response.status == 200 + + async def get_issue_count(self, repo: str, start: str, end: str, state: str) -> int: + """ + Gets the number of issues opened or closed (based on state) in a given timeframe. + + Args: + repo (str): The repository name in 'owner/repo' format (e.g., 'python-discord/bot'). + start (str): The start date (e.g., 2023-01-01). + end (str): The end date (e.g., 2023-12-31). + state (str): The state of the issue (created/closed) + """ + url = f"{GITHUB_API_URL}/search/issues" + headers = {"Authorization": f"token {Tokens.github.get_secret_value()}"} + + # The query string uses GitHub's advanced search syntax + # e.g., repo:python-discord/bot is:issue created:2023-01-01..2023-12-31 + query = f"repo:{repo} is:issue {state}:{start}..{end}" + params = {"q": query} + + async with self.bot.http_session.get(url, headers=headers, params=params) as response: + if response.status != 200: + return -1 + + data = await response.json() + return data.get("total_count", 0) + + +async def setup(bot: Bot) -> None: + await bot.add_cog(GitHubStats(bot)) From f8ff64e9f10dd4ac32a20c484fbb122dae38286d Mon Sep 17 00:00:00 2001 From: "benedictS." Date: Fri, 27 Feb 2026 20:03:59 +0100 Subject: [PATCH 04/12] feat: add `get_pr_count` --- bot/exts/utilities/githubstats.py | 44 +++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/bot/exts/utilities/githubstats.py b/bot/exts/utilities/githubstats.py index d471273f1..77b8faf01 100644 --- a/bot/exts/utilities/githubstats.py +++ b/bot/exts/utilities/githubstats.py @@ -1,6 +1,7 @@ -from discord.ext.commands import Cog, command, Context -from bot.constants import Tokens +from discord.ext.commands import Cog, Context, command + from bot.bot import Bot +from bot.constants import Tokens GITHUB_API_URL = "https://api.github.com" @@ -13,17 +14,26 @@ def __init__(self, bot: Bot) -> None: async def github_stats(self, ctx: Context, start: str, end: str, repo: str = "python-discord/sir-lancebot") -> None: """ Fetches stats for a GitHub repo. - Usage: !github_stats 2023-01-01 2023-12-31 python-discord/bot + Usage: !github_stats 2023-01-01 2023-12-31 python-discord/bot. """ if not await self.repo_exists(repo): - await ctx.send(f"❌ Could not find repository: `{repo}`") + await ctx.send(f"Could not find repository: `{repo}`") return open = await self.get_issue_count(repo, start, end, state="created") closed = await self.get_issue_count(repo, start, end, state="closed") + prs_opened = await self.get_pr_count(repo, start, end, "opened") + prs_closed = await self.get_pr_count(repo, start, end, "closed") + prs_merged = await self.get_pr_count(repo, start, end, "merged") + stats_message = ( - f"Stats for **{repo}** ({start} to {end}):\n" f"Issues opened: {open}\n" f"Issues closed: {closed}" + f"Stats for **{repo}** ({start} to {end}):\n" + f"Issues opened: {open}\n" + f"Issues closed: {closed} \n" + f"Pull Requests opened: {prs_opened} \n" + f"Pull Requests closed: {prs_closed} \n" + f"Pull Requests merged: {prs_merged} \n" ) await ctx.send(stats_message) @@ -65,6 +75,30 @@ async def get_issue_count(self, repo: str, start: str, end: str, state: str) -> data = await response.json() return data.get("total_count", 0) + async def get_pr_count(self, repo: str, start: str, end: str, action: str) -> int: + """Gets the number of PRs opened, closed or merged in a given timeframe.""" + url = f"{GITHUB_API_URL}/search/issues" + headers = {"Authorization": f"token {Tokens.github.get_secret_value()}"} + + if action == "opened": + state_query = f"created:{start}..{end}" + elif action == "merged": + state_query = f"is:merged merged:{start}..{end}" + elif action == "closed": + state_query = f"is:unmerged closed:{start}..{end}" + else: + return 0 + + query = f"repo:{repo} is:pr {state_query}" + params = {"q": query} + + async with self.bot.http_session.get(url, headers=headers, params=params) as response: + if response.status != 200: + return -1 + + data = await response.json() + return data.get("total_count", 0) + async def setup(bot: Bot) -> None: await bot.add_cog(GitHubStats(bot)) From 92f120c93180e94b283c73744934393348c60492 Mon Sep 17 00:00:00 2001 From: Selino10101 <109251476+Selino10101@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:24:09 +0100 Subject: [PATCH 05/12] feat: add `get_commit_count` * feat: implemented get_commit_count metohd * fix: fixed status code handling * fix: fixed the potential excessive API calls by reading the links header --------- Co-authored-by: Markus Selin --- bot/exts/utilities/get_commits.py | 86 +++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 bot/exts/utilities/get_commits.py diff --git a/bot/exts/utilities/get_commits.py b/bot/exts/utilities/get_commits.py new file mode 100644 index 000000000..16c0894bc --- /dev/null +++ b/bot/exts/utilities/get_commits.py @@ -0,0 +1,86 @@ +import re + +from discord.ext.commands import Cog, Context, command + +from bot.bot import Bot +from bot.constants import Tokens + + +class GetCommits(Cog): + """Example Class used for testing.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + + @command(name="get-commits") + async def github_commits(self, ctx: Context, start_str: str, end_str: str, repo_str: str) -> None: + """Hot dogs.""" + count = await self.get_commit_count(repo_str, start_str, end_str) + + count_message = ( + f"Commit count for **{repo_str}** between **{start_str}** and **{end_str}** is **{count}**" + ) + + await ctx.send(count_message) + + + # GitHub API for commits: + # https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits + # + # When we get to this stage in the command, we already know that: + # repo_str, start_str and end_str are formatted correctly. We also know that the repo exists so all we need + # to do is to make the API call. + async def get_commit_count(self, repo_str: str, start_str: str, end_str: str) -> int: + """ + Returns the number of commits done to the given repo between the start- and end-date. + + Args: + repo_str: The repository string in the format "owner/repo. + start_str: The start date of the interval. + end_str: The end date of the interval. + page: The page number. + per_page: The number of results per page in the request. + Setting this to one and reading request.links will result in the number of pages = number of commits. + """ + per_page = 1 + page = 1 + # Formatting in ISO8601 standard: + # YYYY-MM-DDTHH:MM:SSZ + start_iso = f"{start_str}T00:00:00Z" + end_iso = f"{end_str}T23:59:59Z" + + header = {"Authorization": f"token {Tokens.github.get_secret_value()}"} + + url = ( + f"https://api.github.com/repos/{repo_str}/commits" + f"?since={start_iso}&until={end_iso}&per_page={per_page}&page={page}" + ) + + async with self.bot.http_session.get(url, headers=header) as response: + + if response.status != 200: + return -1 + + commits_json = await response.json() + # No commits + if not commits_json: + return 0 + + link_header = response.headers.get("Link") + # No link header means only one page + if not link_header: + return 1 + + # Grabbing the number of pages from the Link header + match = re.search(r'page=(\d+)>; rel="last"', link_header) + + if match: + return int(match.group(1)) + + return 1 + + +async def setup(bot: Bot) -> None: + """Very weird.""" + await bot.add_cog(GetCommits(bot)) From 777b2f84452839a3793d2e069d93227282d6d5d8 Mon Sep 17 00:00:00 2001 From: AliNajb <152025042+AliNajb@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:09:50 +0100 Subject: [PATCH 06/12] feat: add `get_stars_gained` * feat: add get_stars_gained * refactor: optimize github API requests --------- Co-authored-by: Ali Najib Co-authored-by: Oskar Nurm <19738295+oskarnurm@users.noreply.github.com> --- bot/exts/utilities/githubstats.py | 127 +++++++++++++++++++++++------- 1 file changed, 97 insertions(+), 30 deletions(-) diff --git a/bot/exts/utilities/githubstats.py b/bot/exts/utilities/githubstats.py index 77b8faf01..2a853b420 100644 --- a/bot/exts/utilities/githubstats.py +++ b/bot/exts/utilities/githubstats.py @@ -1,8 +1,7 @@ from discord.ext.commands import Cog, Context, command from bot.bot import Bot -from bot.constants import Tokens - +from math import ceil GITHUB_API_URL = "https://api.github.com" @@ -22,18 +21,19 @@ async def github_stats(self, ctx: Context, start: str, end: str, repo: str = "py open = await self.get_issue_count(repo, start, end, state="created") closed = await self.get_issue_count(repo, start, end, state="closed") - - prs_opened = await self.get_pr_count(repo, start, end, "opened") - prs_closed = await self.get_pr_count(repo, start, end, "closed") - prs_merged = await self.get_pr_count(repo, start, end, "merged") - + stars_gained = await self.get_stars_gained(repo, start, end) + + if stars_gained == -2: + stars_gained_message = "N/A (repo exceeds API limit)" + elif stars_gained > 0: + stars_gained_message = f"+{stars_gained} ⭐" + elif stars_gained == 0: + stars_gained_message = "0 ⭐" + else: + stars_gained_message = "unavailable" + stats_message = ( - f"Stats for **{repo}** ({start} to {end}):\n" - f"Issues opened: {open}\n" - f"Issues closed: {closed} \n" - f"Pull Requests opened: {prs_opened} \n" - f"Pull Requests closed: {prs_closed} \n" - f"Pull Requests merged: {prs_merged} \n" + f"Stats for **{repo}** ({start} to {end}):\n" f"Issues opened: {open}\n" f"Issues closed: {closed}\n" f"Stars gained: {stars_gained_message}\n" ) await ctx.send(stats_message) @@ -75,30 +75,97 @@ async def get_issue_count(self, repo: str, start: str, end: str, state: str) -> data = await response.json() return data.get("total_count", 0) - async def get_pr_count(self, repo: str, start: str, end: str, action: str) -> int: - """Gets the number of PRs opened, closed or merged in a given timeframe.""" - url = f"{GITHUB_API_URL}/search/issues" - headers = {"Authorization": f"token {Tokens.github.get_secret_value()}"} + async def _fetch_page(self, url: str, headers: dict, page: int, cache: dict) -> list: + """Fetch a page of stargazers, using cache to avoid duplicate requests. + + Args: + url (str): The URL to fetch the page from. + headers (dict): The headers to use for the request. + page (int): The page number to fetch. + cache (dict): The cache to use to avoid duplicate requests. + """ + if page not in cache: + async with self.bot.http_session.get(url, headers=headers, params={"per_page": 100, "page": page}) as response: + if response.status != 200: + return [] + cache[page] = await response.json() + return cache[page] + + async def _get_date_at(self, url: str, headers: dict, i: int, cache: dict) -> str: + """Get the starred_at date (YYYY-MM-DD) of the star at global index i (0-based). + Args: + url (str): The URL to fetch the page from. + headers (dict): The headers to use for the request. + i (int): The global index of the star. + cache (dict): The cache to use to avoid duplicate requests. - if action == "opened": - state_query = f"created:{start}..{end}" - elif action == "merged": - state_query = f"is:merged merged:{start}..{end}" - elif action == "closed": - state_query = f"is:unmerged closed:{start}..{end}" - else: - return 0 + """ + page = (i // 100) + 1 + pos = i % 100 + page_data = await self._fetch_page(url, headers, page, cache) + # starred_at is in format YYYY-MM-DDTHH:MM:SSZ so we can just get the first 10 characters to get the date + return page_data[pos].get("starred_at", "")[:10] if page_data else "" - query = f"repo:{repo} is:pr {state_query}" - params = {"q": query} + async def get_stars_gained(self, repo: str, start: str, end: str) -> int: + """Gets the number of stars gained for a given repository in a timeframe. - async with self.bot.http_session.get(url, headers=headers, params=params) as response: + Args: + repo (str): The repository name in 'owner/repo' format (e.g., 'python-discord/bot'). + start (str): The start date (e.g., 2023-01-01). + end (str): The end date (e.g., 2023-12-31). + """ + url = f"{GITHUB_API_URL}/repos/{repo}/stargazers" + headers = { + "Authorization": f"token {Tokens.github.get_secret_value()}", + "Accept": "application/vnd.github.star+json", + } + + async with self.bot.http_session.get(f"{GITHUB_API_URL}/repos/{repo}", headers={"Authorization": f"token {Tokens.github.get_secret_value()}"}) as response: if response.status != 200: return -1 + max_stars = (await response.json()).get("stargazers_count", 0) - data = await response.json() - return data.get("total_count", 0) + if max_stars == 0: + return 0 + # GitHub API limits stargazers pagination to 40 000 entries (page 400 max) + # Because of this the output is not consistent for projects with more than 40 000 stars so we default to -2 + GITHUB_STARGAZERS_LIMIT = 40000 + if max_stars > GITHUB_STARGAZERS_LIMIT: + return -2 + searchable_stars = max_stars + + # We use a cache and binary search to limit the number of requests to the GitHub API + cache = {} + low, high = 0, searchable_stars - 1 + while low < high: + mid = (low + high) // 2 + lowdate = await self._get_date_at(url, headers, mid, cache) + if lowdate == "": + return -1 + if lowdate < start: + low = mid + 1 + else: + high = mid + left = low + + date_left = await self._get_date_at(url, headers, left, cache) + if date_left < start or date_left > end: + return 0 + low, high = left, searchable_stars - 1 + while low < high: + mid = (low + high + 1) // 2 + highdate = await self._get_date_at(url, headers, mid, cache) + if highdate == "": + return -1 + if highdate > end: + high = mid - 1 + else: + low = mid + right = low + + return right - left + 1 + async def setup(bot: Bot) -> None: await bot.add_cog(GitHubStats(bot)) From 45ac0a83ebe8abb87d11afe7f51e68a9f3574392 Mon Sep 17 00:00:00 2001 From: oskarnurm <19738295+oskarnurm@users.noreply.github.com.> Date: Sat, 28 Feb 2026 13:52:51 +0100 Subject: [PATCH 07/12] feat!: move logic from `githubstatus.py` to `githubinfo.py` --- bot/exts/utilities/githubinfo.py | 276 +++++++++++++++++++++++++------ 1 file changed, 223 insertions(+), 53 deletions(-) diff --git a/bot/exts/utilities/githubinfo.py b/bot/exts/utilities/githubinfo.py index 219d48a2b..782e84781 100644 --- a/bot/exts/utilities/githubinfo.py +++ b/bot/exts/utilities/githubinfo.py @@ -18,9 +18,7 @@ GITHUB_API_URL = "https://api.github.com" -REQUEST_HEADERS = { - "Accept": "application/vnd.github.v3+json" -} +REQUEST_HEADERS = {"Accept": "application/vnd.github.v3+json"} REPOSITORY_ENDPOINT = "https://api.github.com/orgs/{org}/repos?per_page=100&type=public" MOST_STARRED_ENDPOINT = "https://api.github.com/search/repositories?q={name}&sort=stars&order=desc&per_page=100" @@ -34,9 +32,9 @@ REQUEST_HEADERS["Authorization"] = f"token {Tokens.github.get_secret_value()}" CODE_BLOCK_RE = re.compile( - r"^`([^`\n]+)`" # Inline codeblock + r"`([^`\n]+)`" # Inline codeblock r"|```(.+?)```", # Multiline codeblock - re.DOTALL | re.MULTILINE + re.DOTALL | re.MULTILINE, ) # Maximum number of issues in one message @@ -104,18 +102,12 @@ async def cog_unload(self) -> None: """ self.refresh_repos.cancel() - @staticmethod def remove_codeblocks(message: str) -> str: """Remove any codeblock in a message.""" return CODE_BLOCK_RE.sub("", message) - async def fetch_issue( - self, - number: int, - repository: str, - user: str - ) -> IssueState | FetchError: + async def fetch_issue(self, number: int, repository: str, user: str) -> IssueState | FetchError: """ Retrieve an issue from a GitHub repository. @@ -167,9 +159,7 @@ async def fetch_issue( return IssueState(repository, number, issue_url, json_data.get("title", ""), emoji) @staticmethod - def format_embed( - results: list[IssueState | FetchError] - ) -> discord.Embed: + def format_embed(results: list[IssueState | FetchError]) -> discord.Embed: """Take a list of IssueState or FetchError and format a Discord embed for them.""" description_list = [] @@ -181,10 +171,7 @@ def format_embed( elif isinstance(result, FetchError): description_list.append(f":x: [{result.return_code}] {result.message}") - resp = discord.Embed( - colour=Colours.bright_green, - description="\n".join(description_list) - ) + resp = discord.Embed(colour=Colours.bright_green, description="\n".join(description_list)) resp.set_author(name="GitHub") return resp @@ -226,16 +213,14 @@ async def on_message(self, message: discord.Message) -> None: embed = discord.Embed( title=random.choice(ERROR_REPLIES), color=Colours.soft_red, - description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})" + description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})", ) await message.channel.send(embed=embed, delete_after=5) return for repo_issue in issues: result = await self.fetch_issue( - int(repo_issue.number), - repo_issue.repository, - repo_issue.organisation or "python-discord" + int(repo_issue.number), repo_issue.repository, repo_issue.organisation or "python-discord" ) if isinstance(result, IssueState): links.append(result) @@ -263,7 +248,7 @@ async def github_user_info(self, ctx: commands.Context, username: str) -> None: embed = discord.Embed( title=random.choice(NEGATIVE_REPLIES), description=f"The profile for `{username}` was not found.", - colour=Colours.soft_red + colour=Colours.soft_red, ) await ctx.send(embed=embed) @@ -288,25 +273,21 @@ async def github_user_info(self, ctx: commands.Context, username: str) -> None: description=f"```\n{user_data['bio']}\n```\n" if user_data["bio"] else "", colour=discord.Colour.og_blurple(), url=user_data["html_url"], - timestamp=datetime.strptime(user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + timestamp=datetime.strptime(user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC), ) embed.set_thumbnail(url=user_data["avatar_url"]) embed.set_footer(text="Account created at") if user_data["type"] == "User": - embed.add_field( - name="Followers", - value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)" + name="Followers", value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)" ) embed.add_field( - name="Following", - value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)" + name="Following", value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)" ) embed.add_field( - name="Public repos", - value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)" + name="Public repos", value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)" ) if user_data["type"] == "User": @@ -314,7 +295,7 @@ async def github_user_info(self, ctx: commands.Context, username: str) -> None: embed.add_field( name=f"Organization{'s' if len(orgs) != 1 else ''}", - value=orgs_to_add if orgs else "No organizations." + value=orgs_to_add if orgs else "No organizations.", ) embed.add_field(name="Website", value=blog) @@ -333,7 +314,7 @@ def build_embed(self, repo_data: dict) -> discord.Embed: title=repo_data["name"], description=repo_data["description"], colour=discord.Colour.og_blurple(), - url=repo_data["html_url"] + url=repo_data["html_url"], ) # if its a fork it will have a parent key try: @@ -343,18 +324,16 @@ def build_embed(self, repo_data: dict) -> discord.Embed: log.debug("Repository is not a fork.") repo_owner = repo_data["owner"] - embed.set_author( - name=repo_owner["login"], - url=repo_owner["html_url"], - icon_url=repo_owner["avatar_url"] - ) + embed.set_author(name=repo_owner["login"], url=repo_owner["html_url"], icon_url=repo_owner["avatar_url"]) - repo_created_at = datetime.strptime( - repo_data["created_at"], "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=UTC).strftime("%d/%m/%Y") - last_pushed = datetime.strptime( - repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=UTC).strftime("%d/%m/%Y at %H:%M") + repo_created_at = ( + datetime.strptime(repo_data["created_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC).strftime("%d/%m/%Y") + ) + last_pushed = ( + datetime.strptime(repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ") + .replace(tzinfo=UTC) + .strftime("%d/%m/%Y at %H:%M") + ) embed.set_footer( text=( @@ -366,6 +345,199 @@ def build_embed(self, repo_data: dict) -> discord.Embed: ) return embed + async def get_issue_count(self, repo: str, start: str, end: str, state: str) -> int: + """Gets the number of issues opened or closed (based on state) in a given timeframe.""" + url = f"{GITHUB_API_URL}/search/issues" + query = f"repo:{repo} is:issue {state}:{start}..{end}" + params = {"q": query} + + async with self.bot.http_session.get(url, headers=REQUEST_HEADERS, params=params) as response: + if response.status != 200: + return -1 + data = await response.json() + return data.get("total_count", 0) + + async def get_pr_count(self, repo: str, start: str, end: str, action: str) -> int: + """Gets the number of PRs opened, closed, or merged in a given timeframe.""" + url = f"{GITHUB_API_URL}/search/issues" + + if action == "opened": + state_query = f"created:{start}..{end}" + elif action == "merged": + state_query = f"is:merged merged:{start}..{end}" + elif action == "closed": + state_query = f"is:unmerged closed:{start}..{end}" + else: + return 0 + + query = f"repo:{repo} is:pr {state_query}" + params = {"q": query} + + async with self.bot.http_session.get(url, headers=REQUEST_HEADERS, params=params) as response: + if response.status != 200: + return -1 + + data = await response.json() + return data.get("total_count", 0) + + async def get_commit_count(self, repo_str: str, start_str: str, end_str: str) -> int: + """Returns the number of commits done to the given repo between the start and end date.""" + start_iso = f"{start_str}T00:00:00Z" + end_iso = f"{end_str}T23:59:59Z" + + url = f"https://api.github.com/repos/{repo_str}/commits" + params = {"since": start_iso, "until": end_iso, "per_page": 1, "page": 1} + + async with self.bot.http_session.get(url, headers=REQUEST_HEADERS, params=params) as response: + if response.status != 200: + return -1 + + commits_json = await response.json() + # No commits + if not commits_json: + return 0 + + link_header = response.headers.get("Link") + # No link header means only one page + if not link_header: + return 1 + + # Grabbing the number of pages from the Link header + match = re.search(r'page=(\d+)>; rel="last"', link_header) + + if match: + return int(match.group(1)) + + return 1 + + async def _fetch_page(self, url: str, headers: dict, page: int, cache: dict) -> list: + """Fetch a page of stargazers, using cache to avoid duplicate requests.""" + if page not in cache: + params = {"per_page": 100, "page": page} + async with self.bot.http_session.get(url, headers=headers, params=params) as response: + if response.status != 200: + return [] + cache[page] = await response.json() + return cache[page] + + async def _get_date_at(self, url: str, headers: dict, i: int, cache: dict) -> str: + """Get the starred_at date (YYYY-MM-DD) of the star at global index i (0-based).""" + page = (i // 100) + 1 + pos = i % 100 + page_data = await self._fetch_page(url, headers, page, cache) + + # FIX: Prevent IndexError if GitHub's cached count is higher than the actual list + if page_data and pos < len(page_data): + return page_data[pos].get("starred_at", "")[:10] + return "" + + async def get_stars_gained(self, repo: str, start: str, end: str) -> int: + """Gets the number of stars gained for a given repository in a timeframe.""" + url = f"{GITHUB_API_URL}/repos/{repo}/stargazers" + + # Copy the global headers but update the Accept header specifically for Stargazers + star_headers = REQUEST_HEADERS.copy() + star_headers["Accept"] = "application/vnd.github.star+json" + + repo_data, response = await self.fetch_data(f"{GITHUB_API_URL}/repos/{repo}") + if response.status != 200: + return -1 + + max_stars = repo_data.get("stargazers_count", 0) + + if max_stars == 0: + return 0 + + # GitHub API limits stargazers pagination to 40 000 entries (page 400 max) + # Because of this the output is not consistent for projects with more than 40 000 stars so we default to -2 + github_stargazer_limit = 40000 + if max_stars > github_stargazer_limit: + return -2 + searchable_stars = max_stars + + # We use a cache and binary search to limit the number of requests to the GitHub API + cache = {} + low, high = 0, searchable_stars - 1 + while low < high: + mid = (low + high) // 2 + lowdate = await self._get_date_at(url, star_headers, mid, cache) + if lowdate == "": + return -1 + if lowdate < start: + low = mid + 1 + else: + high = mid + left = low + + date_left = await self._get_date_at(url, star_headers, left, cache) + if date_left < start or date_left > end: + return 0 + + low, high = left, searchable_stars - 1 + while low < high: + mid = (low + high + 1) // 2 + highdate = await self._get_date_at(url, star_headers, mid, cache) + if highdate == "": + return -1 + if highdate > end: + high = mid - 1 + else: + low = mid + right = low + + return right - left + 1 + + @github_group.command(name="stats") + async def github_stats(self, ctx: commands.Context, start: str, end: str, repo: str) -> None: + """ + Fetches stats for a GitHub repo. + + Usage: !github_stats 2023-01-01 2023-12-31 python-discord/bot. + """ + async with ctx.typing(): + url = f"{GITHUB_API_URL}/repos/{repo}" + repo_data, response = await self.fetch_data(url) + + if "message" in repo_data: + embed = discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description=f"Could not find repository: `{repo}`", + colour=Colours.soft_red, + ) + await ctx.send(embed=embed) + return + + open_issues = await self.get_issue_count(repo, start, end, state="created") + closed_issues = await self.get_issue_count(repo, start, end, state="closed") + prs_opened = await self.get_pr_count(repo, start, end, "opened") + prs_closed = await self.get_pr_count(repo, start, end, "closed") + prs_merged = await self.get_pr_count(repo, start, end, "merged") + commits = await self.get_commit_count(repo, start, end) + stars_gained = await self.get_stars_gained(repo, start, end) + + if stars_gained == -2: + stars = "N/A (repo exceeds API limit)" + elif stars_gained > 0: + stars = f"+{stars_gained}" + elif stars_gained == 0: + stars = "0" + else: + stars = "unavailable" + + stats_text = ( + f"Issues opened: {open_issues}\n" + f"Issues closed: {closed_issues}\n" + f"Pull Requests opened: {prs_opened}\n" + f"Pull Requests closed: {prs_closed}\n" + f"Pull Requests merged: {prs_merged}\n" + f"Stars gained: {stars}\n" + f"Commits: {commits}" + ) + + stats_embed = discord.Embed( + title=f"Stats for {repo}", description=stats_text, colour=discord.Colour.og_blurple() + ) + await ctx.send(embed=stats_embed) @github_group.command(name="repository", aliases=("repo",)) async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: @@ -382,12 +554,11 @@ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: repo_query = "/".join(repo) repo_query_casefold = repo_query.casefold() - if repo_query.count("/") > 1: embed = discord.Embed( title=random.choice(NEGATIVE_REPLIES), description="There cannot be more than one `/` in the repository.", - colour=Colours.soft_red + colour=Colours.soft_red, ) await ctx.send(embed=embed) return @@ -401,11 +572,10 @@ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: is_pydis = True else: fetch_most_starred = True - async with ctx.typing(): # Case 1: PyDis repo if is_pydis: - repo_data = repo_query # repo_query already contains the matched repo + repo_data = repo_query # repo_query already contains the matched repo # Case 2: Not stored or PyDis, fetch most-starred matching repo elif fetch_most_starred: @@ -415,7 +585,7 @@ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: embed = discord.Embed( title=random.choice(NEGATIVE_REPLIES), description=f"No repositories found matching `{repo_query}`.", - colour=Colours.soft_red + colour=Colours.soft_red, ) await ctx.send(embed=embed) return @@ -428,12 +598,11 @@ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: embed = discord.Embed( title=random.choice(NEGATIVE_REPLIES), description=f"No repositories found matching `{repo_query}`.", - colour=Colours.soft_red + colour=Colours.soft_red, ) await ctx.send(embed=embed) return - # Case 3: Regular GitHub repo else: repo_data, _ = await self.fetch_data(f"{GITHUB_API_URL}/repos/{quote(repo_query)}") @@ -442,7 +611,7 @@ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: embed = discord.Embed( title=random.choice(NEGATIVE_REPLIES), description="The requested repository was not found.", - colour=Colours.soft_red + colour=Colours.soft_red, ) await ctx.send(embed=embed) return @@ -450,6 +619,7 @@ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: embed = self.build_embed(repo_data) await ctx.send(embed=embed) + async def setup(bot: Bot) -> None: """Load the GithubInfo cog.""" await bot.add_cog(GithubInfo(bot)) From 9899b90dd683e57ab24ecacc30ed97181282ab34 Mon Sep 17 00:00:00 2001 From: oskarnurm <19738295+oskarnurm@users.noreply.github.com.> Date: Sat, 28 Feb 2026 13:54:20 +0100 Subject: [PATCH 08/12] chore: rollback test structure for sir-lancebot --- bot/exts/utilities/get_commits.py | 86 --------------- bot/exts/utilities/githubstats.py | 171 ------------------------------ pyproject.toml | 24 ----- tests/test_exampletest.py | 19 ---- uv.lock | 148 -------------------------- 5 files changed, 448 deletions(-) delete mode 100644 bot/exts/utilities/get_commits.py delete mode 100644 bot/exts/utilities/githubstats.py delete mode 100644 tests/test_exampletest.py diff --git a/bot/exts/utilities/get_commits.py b/bot/exts/utilities/get_commits.py deleted file mode 100644 index 16c0894bc..000000000 --- a/bot/exts/utilities/get_commits.py +++ /dev/null @@ -1,86 +0,0 @@ -import re - -from discord.ext.commands import Cog, Context, command - -from bot.bot import Bot -from bot.constants import Tokens - - -class GetCommits(Cog): - """Example Class used for testing.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - - - @command(name="get-commits") - async def github_commits(self, ctx: Context, start_str: str, end_str: str, repo_str: str) -> None: - """Hot dogs.""" - count = await self.get_commit_count(repo_str, start_str, end_str) - - count_message = ( - f"Commit count for **{repo_str}** between **{start_str}** and **{end_str}** is **{count}**" - ) - - await ctx.send(count_message) - - - # GitHub API for commits: - # https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits - # - # When we get to this stage in the command, we already know that: - # repo_str, start_str and end_str are formatted correctly. We also know that the repo exists so all we need - # to do is to make the API call. - async def get_commit_count(self, repo_str: str, start_str: str, end_str: str) -> int: - """ - Returns the number of commits done to the given repo between the start- and end-date. - - Args: - repo_str: The repository string in the format "owner/repo. - start_str: The start date of the interval. - end_str: The end date of the interval. - page: The page number. - per_page: The number of results per page in the request. - Setting this to one and reading request.links will result in the number of pages = number of commits. - """ - per_page = 1 - page = 1 - # Formatting in ISO8601 standard: - # YYYY-MM-DDTHH:MM:SSZ - start_iso = f"{start_str}T00:00:00Z" - end_iso = f"{end_str}T23:59:59Z" - - header = {"Authorization": f"token {Tokens.github.get_secret_value()}"} - - url = ( - f"https://api.github.com/repos/{repo_str}/commits" - f"?since={start_iso}&until={end_iso}&per_page={per_page}&page={page}" - ) - - async with self.bot.http_session.get(url, headers=header) as response: - - if response.status != 200: - return -1 - - commits_json = await response.json() - # No commits - if not commits_json: - return 0 - - link_header = response.headers.get("Link") - # No link header means only one page - if not link_header: - return 1 - - # Grabbing the number of pages from the Link header - match = re.search(r'page=(\d+)>; rel="last"', link_header) - - if match: - return int(match.group(1)) - - return 1 - - -async def setup(bot: Bot) -> None: - """Very weird.""" - await bot.add_cog(GetCommits(bot)) diff --git a/bot/exts/utilities/githubstats.py b/bot/exts/utilities/githubstats.py deleted file mode 100644 index 2a853b420..000000000 --- a/bot/exts/utilities/githubstats.py +++ /dev/null @@ -1,171 +0,0 @@ -from discord.ext.commands import Cog, Context, command - -from bot.bot import Bot -from math import ceil -GITHUB_API_URL = "https://api.github.com" - - -class GitHubStats(Cog): - def __init__(self, bot: Bot) -> None: - self.bot = bot - - @command(name="gh-stats") - async def github_stats(self, ctx: Context, start: str, end: str, repo: str = "python-discord/sir-lancebot") -> None: - """ - Fetches stats for a GitHub repo. - Usage: !github_stats 2023-01-01 2023-12-31 python-discord/bot. - """ - if not await self.repo_exists(repo): - await ctx.send(f"Could not find repository: `{repo}`") - return - - open = await self.get_issue_count(repo, start, end, state="created") - closed = await self.get_issue_count(repo, start, end, state="closed") - stars_gained = await self.get_stars_gained(repo, start, end) - - if stars_gained == -2: - stars_gained_message = "N/A (repo exceeds API limit)" - elif stars_gained > 0: - stars_gained_message = f"+{stars_gained} ⭐" - elif stars_gained == 0: - stars_gained_message = "0 ⭐" - else: - stars_gained_message = "unavailable" - - stats_message = ( - f"Stats for **{repo}** ({start} to {end}):\n" f"Issues opened: {open}\n" f"Issues closed: {closed}\n" f"Stars gained: {stars_gained_message}\n" - ) - await ctx.send(stats_message) - - async def repo_exists(self, repo: str) -> bool: - """ - Checks if a repository exists on GitHub. - - Args: - repo (str): The repository name in 'owner/repo' format (e.g., 'python-discord/bot'). - """ - url = f"{GITHUB_API_URL}/repos/{repo}" - headers = {"Authorization": f"token {Tokens.github.get_secret_value()}"} - - async with self.bot.http_session.get(url, headers=headers) as response: - return response.status == 200 - - async def get_issue_count(self, repo: str, start: str, end: str, state: str) -> int: - """ - Gets the number of issues opened or closed (based on state) in a given timeframe. - - Args: - repo (str): The repository name in 'owner/repo' format (e.g., 'python-discord/bot'). - start (str): The start date (e.g., 2023-01-01). - end (str): The end date (e.g., 2023-12-31). - state (str): The state of the issue (created/closed) - """ - url = f"{GITHUB_API_URL}/search/issues" - headers = {"Authorization": f"token {Tokens.github.get_secret_value()}"} - - # The query string uses GitHub's advanced search syntax - # e.g., repo:python-discord/bot is:issue created:2023-01-01..2023-12-31 - query = f"repo:{repo} is:issue {state}:{start}..{end}" - params = {"q": query} - - async with self.bot.http_session.get(url, headers=headers, params=params) as response: - if response.status != 200: - return -1 - - data = await response.json() - return data.get("total_count", 0) - - async def _fetch_page(self, url: str, headers: dict, page: int, cache: dict) -> list: - """Fetch a page of stargazers, using cache to avoid duplicate requests. - - Args: - url (str): The URL to fetch the page from. - headers (dict): The headers to use for the request. - page (int): The page number to fetch. - cache (dict): The cache to use to avoid duplicate requests. - """ - if page not in cache: - async with self.bot.http_session.get(url, headers=headers, params={"per_page": 100, "page": page}) as response: - if response.status != 200: - return [] - cache[page] = await response.json() - return cache[page] - - async def _get_date_at(self, url: str, headers: dict, i: int, cache: dict) -> str: - """Get the starred_at date (YYYY-MM-DD) of the star at global index i (0-based). - Args: - url (str): The URL to fetch the page from. - headers (dict): The headers to use for the request. - i (int): The global index of the star. - cache (dict): The cache to use to avoid duplicate requests. - - """ - page = (i // 100) + 1 - pos = i % 100 - page_data = await self._fetch_page(url, headers, page, cache) - # starred_at is in format YYYY-MM-DDTHH:MM:SSZ so we can just get the first 10 characters to get the date - return page_data[pos].get("starred_at", "")[:10] if page_data else "" - - async def get_stars_gained(self, repo: str, start: str, end: str) -> int: - """Gets the number of stars gained for a given repository in a timeframe. - - Args: - repo (str): The repository name in 'owner/repo' format (e.g., 'python-discord/bot'). - start (str): The start date (e.g., 2023-01-01). - end (str): The end date (e.g., 2023-12-31). - """ - url = f"{GITHUB_API_URL}/repos/{repo}/stargazers" - headers = { - "Authorization": f"token {Tokens.github.get_secret_value()}", - "Accept": "application/vnd.github.star+json", - } - - async with self.bot.http_session.get(f"{GITHUB_API_URL}/repos/{repo}", headers={"Authorization": f"token {Tokens.github.get_secret_value()}"}) as response: - if response.status != 200: - return -1 - max_stars = (await response.json()).get("stargazers_count", 0) - - if max_stars == 0: - return 0 - - # GitHub API limits stargazers pagination to 40 000 entries (page 400 max) - # Because of this the output is not consistent for projects with more than 40 000 stars so we default to -2 - GITHUB_STARGAZERS_LIMIT = 40000 - if max_stars > GITHUB_STARGAZERS_LIMIT: - return -2 - searchable_stars = max_stars - - # We use a cache and binary search to limit the number of requests to the GitHub API - cache = {} - low, high = 0, searchable_stars - 1 - while low < high: - mid = (low + high) // 2 - lowdate = await self._get_date_at(url, headers, mid, cache) - if lowdate == "": - return -1 - if lowdate < start: - low = mid + 1 - else: - high = mid - left = low - - date_left = await self._get_date_at(url, headers, left, cache) - if date_left < start or date_left > end: - return 0 - - low, high = left, searchable_stars - 1 - while low < high: - mid = (low + high + 1) // 2 - highdate = await self._get_date_at(url, headers, mid, cache) - if highdate == "": - return -1 - if highdate > end: - high = mid - 1 - else: - low = mid - right = low - - return right - left + 1 - -async def setup(bot: Bot) -> None: - await bot.add_cog(GitHubStats(bot)) diff --git a/pyproject.toml b/pyproject.toml index 3da84880c..bcfec672e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,13 +33,8 @@ dev = [ "python-dotenv==1.0.1", "ruff==0.8.4", "taskipy==1.14.1", - "pytest==8.4.2", - "pytest-cov==7.0.0", - "pytest-subtests==0.14.1", - "pytest-xdist==3.8.0", ] - [tool.uv] prerelease = "allow" @@ -47,16 +42,6 @@ prerelease = "allow" start = "python -m bot" lint = "pre-commit run --all-files" precommit = "pre-commit install" -test = "pytest -n auto --ff" -retest = "pytest -n auto --lf" -test-cov = "pytest -n auto --cov-report= --cov" -html = "coverage html" -report = "coverage report" - -[tool.coverage.run] -branch = true -source_pkgs = ["bot"] -source = ["tests"] [tool.isort] multi_line_output = 6 @@ -95,12 +80,3 @@ known-first-party = ["bot"] order-by-type = false case-sensitive = true combine-as-imports = true - -[tool.ruff.lint.per-file-ignores] -"tests/*" = ["ANN", "D"] - -[tool.pytest.ini_options] -# We don't use nose style tests so disable them in pytest. -# This stops pytest from running functions named `setup` in test files. -# See https://github.com/python-discord/bot/pull/2229#issuecomment-1204436420 -addopts = "-p no:nose" \ No newline at end of file diff --git a/tests/test_exampletest.py b/tests/test_exampletest.py deleted file mode 100644 index dcf838165..000000000 --- a/tests/test_exampletest.py +++ /dev/null @@ -1,19 +0,0 @@ -#test.py -import unittest - -def add(a, b): - return a + b - -class TestAddFunction(unittest.TestCase): - def test_add_positive_numbers(self): - self.assertEqual(add(1, 2), 3) - - def test_add_negative_numbers(self): - self.assertEqual(add(-1, -2), -3) - - def test_add_mixed_numbers(self): - self.assertEqual(add(1, -2), -1) - self.assertEqual(add(-1, 2), 1) - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/uv.lock b/uv.lock index 4239eaa76..eeb252dfe 100644 --- a/uv.lock +++ b/uv.lock @@ -229,45 +229,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, ] -[[package]] -name = "coverage" -version = "7.13.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, -] - [[package]] name = "discord-py" version = "2.6.4" @@ -308,15 +269,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/fc/25e5793c0f6f09626b94444a3b9faf386c587873fa8f696ad20d37e47387/emojis-0.7.0-py3-none-any.whl", hash = "sha256:a777926d8ab0bfdd51250e899a3b3524a1e969275ac8e747b4a05578fa597367", size = 28347, upload-time = "2022-12-01T12:00:07.163Z" }, ] -[[package]] -name = "execnet" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, -] - [[package]] name = "fakeredis" version = "2.32.0" @@ -415,15 +367,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - [[package]] name = "lupa" version = "2.5" @@ -530,15 +473,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] -[[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, -] - [[package]] name = "pillow" version = "12.1.1" @@ -594,15 +528,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - [[package]] name = "pre-commit" version = "4.0.1" @@ -791,15 +716,6 @@ all = [ { name = "fakeredis", extra = ["lua"] }, ] -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - [[package]] name = "pyjokes" version = "0.8.3" @@ -818,62 +734,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - -[[package]] -name = "pytest-cov" -version = "7.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage" }, - { name = "pluggy" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, -] - -[[package]] -name = "pytest-subtests" -version = "0.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/4c/ba9eab21a2250c2d46c06c0e3cd316850fde9a90da0ac8d0202f074c6817/pytest_subtests-0.14.1.tar.gz", hash = "sha256:350c00adc36c3aff676a66135c81aed9e2182e15f6c3ec8721366918bbbf7580", size = 17632, upload-time = "2024-12-10T00:21:04.856Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/b7/7ca948d35642ae72500efda6ba6fa61dcb6683feb596d19c4747c63c0789/pytest_subtests-0.14.1-py3-none-any.whl", hash = "sha256:e92a780d98b43118c28a16044ad9b841727bd7cb6a417073b38fd2d7ccdf052d", size = 8833, upload-time = "2024-12-10T00:20:58.873Z" }, -] - -[[package]] -name = "pytest-xdist" -version = "3.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "execnet" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1008,10 +868,6 @@ dependencies = [ dev = [ { name = "pip-licenses" }, { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "pytest-subtests" }, - { name = "pytest-xdist" }, { name = "python-dotenv" }, { name = "ruff" }, { name = "taskipy" }, @@ -1040,10 +896,6 @@ requires-dist = [ dev = [ { name = "pip-licenses", specifier = "==5.0.0" }, { name = "pre-commit", specifier = "==4.0.1" }, - { name = "pytest", specifier = "==8.4.2" }, - { name = "pytest-cov", specifier = "==7.0.0" }, - { name = "pytest-subtests", specifier = "==0.14.1" }, - { name = "pytest-xdist", specifier = "==3.8.0" }, { name = "python-dotenv", specifier = "==1.0.1" }, { name = "ruff", specifier = "==0.8.4" }, { name = "taskipy", specifier = "==1.14.1" }, From 50832cb78b625aafc88839343fcd332d35d3231b Mon Sep 17 00:00:00 2001 From: Selino10101 <109251476+Selino10101@users.noreply.github.com> Date: Sat, 28 Feb 2026 19:44:11 +0100 Subject: [PATCH 09/12] feat: implemented validate methods (#44) * feat: implemented validate methods * fix: fixed validate methods to match the functionality of the command * fix: removed the async since it isnt needed --- bot/exts/utilities/validate.py | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 bot/exts/utilities/validate.py diff --git a/bot/exts/utilities/validate.py b/bot/exts/utilities/validate.py new file mode 100644 index 000000000..9fb2a9237 --- /dev/null +++ b/bot/exts/utilities/validate.py @@ -0,0 +1,39 @@ +import re +from datetime import UTC, datetime + + +class GitHubStatsValidate: + """Class providing validation methods for GitHub repository and date formats.""" + + def validate_repo_format(self, repo_str: str) -> bool: + """Validates that the repo is in the format owner/repo. Githubinfo has its own check so wont be used.""" + # Part one can be any char except a "/" followed by a "/" followed by any char except a "/" + pattern = r"^[^/]+/[^/]+$" + return bool(re.match(pattern, repo_str)) + + # Went with only ISO standard. Have to change the tests. + def validate_date_format(self, date_str: str) -> bool: + """Validates that the date string is formatted correctly.""" + try: + datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC) + return True + except ValueError: + return False + + + # Validates that the given dates are in order and that they are + # logically valid. + def validate_date_range(self, start_date: str, end_date: str) -> bool: + """Validates the given date range.""" + try: + start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=UTC) + end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=UTC) + except ValueError: + return False + + time_now = datetime.now(UTC) + + if start > end: + return False + + return not end > time_now From 6eba7f3bb9728da071b6c709f2dfe57926ba4820 Mon Sep 17 00:00:00 2001 From: Selino10101 <109251476+Selino10101@users.noreply.github.com> Date: Sat, 28 Feb 2026 20:45:19 +0100 Subject: [PATCH 10/12] feat: implemented the validate methods into githubinfo.py (#58) * feat: implemented the validate methods into githubinfo.py * feat: check date before API calls and simplify validate range Checking valid input before API calls is smarter as we can prevent someone rate limiting the bot this way. We also don't need to guard against future calls as GitHub will just happily return all the issues up to the present moment. --------- Co-authored-by: oskarnurm <19738295+oskarnurm@users.noreply.github.com> --- bot/exts/utilities/githubinfo.py | 49 ++++++++++++++++++++++++++++++++ bot/exts/utilities/validate.py | 39 ------------------------- 2 files changed, 49 insertions(+), 39 deletions(-) delete mode 100644 bot/exts/utilities/validate.py diff --git a/bot/exts/utilities/githubinfo.py b/bot/exts/utilities/githubinfo.py index 782e84781..cea4e73a8 100644 --- a/bot/exts/utilities/githubinfo.py +++ b/bot/exts/utilities/githubinfo.py @@ -487,6 +487,26 @@ async def get_stars_gained(self, repo: str, start: str, end: str) -> int: return right - left + 1 + def parse_date(self, date_str: str) -> datetime | None: + """Parse a YYYY-MM-DD date string into a UTC datetime.""" + try: + return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC) + except ValueError: + return None + + def validate_date_format(self, date_str: str) -> bool: + """Validates that the date string is formatted correctly.""" + return self.parse_date(date_str) is not None + + def validate_date_range(self, start_date: str, end_date: str) -> bool: + """Validate a date range for correctness and logical ordering.""" + start = self.parse_date(start_date) + end = self.parse_date(end_date) + + if start and end: + return start <= end + return False + @github_group.command(name="stats") async def github_stats(self, ctx: commands.Context, start: str, end: str, repo: str) -> None: """ @@ -495,6 +515,35 @@ async def github_stats(self, ctx: commands.Context, start: str, end: str, repo: Usage: !github_stats 2023-01-01 2023-12-31 python-discord/bot. """ async with ctx.typing(): + # Validate the date first to spare API calls + if not self.validate_date_format(start): + embed = discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description="Start date must be in YYYY-MM-DD format.", + colour=Colours.soft_red, + ) + await ctx.send(embed=embed) + + return + + if not self.validate_date_format(end): + embed = discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description="End date must be in YYYY-MM-DD format.", + colour=Colours.soft_red, + ) + await ctx.send(embed=embed) + return + + if not self.validate_date_range(start, end): + embed = discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description="Invalid date range.", + colour=Colours.soft_red, + ) + await ctx.send(embed=embed) + return + url = f"{GITHUB_API_URL}/repos/{repo}" repo_data, response = await self.fetch_data(url) diff --git a/bot/exts/utilities/validate.py b/bot/exts/utilities/validate.py deleted file mode 100644 index 9fb2a9237..000000000 --- a/bot/exts/utilities/validate.py +++ /dev/null @@ -1,39 +0,0 @@ -import re -from datetime import UTC, datetime - - -class GitHubStatsValidate: - """Class providing validation methods for GitHub repository and date formats.""" - - def validate_repo_format(self, repo_str: str) -> bool: - """Validates that the repo is in the format owner/repo. Githubinfo has its own check so wont be used.""" - # Part one can be any char except a "/" followed by a "/" followed by any char except a "/" - pattern = r"^[^/]+/[^/]+$" - return bool(re.match(pattern, repo_str)) - - # Went with only ISO standard. Have to change the tests. - def validate_date_format(self, date_str: str) -> bool: - """Validates that the date string is formatted correctly.""" - try: - datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC) - return True - except ValueError: - return False - - - # Validates that the given dates are in order and that they are - # logically valid. - def validate_date_range(self, start_date: str, end_date: str) -> bool: - """Validates the given date range.""" - try: - start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=UTC) - end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=UTC) - except ValueError: - return False - - time_now = datetime.now(UTC) - - if start > end: - return False - - return not end > time_now From b2ed216a5bdf91777668dda45161a01d3ec1c8e4 Mon Sep 17 00:00:00 2001 From: Oskar Nurm <19738295+oskarnurm@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:42:13 +0100 Subject: [PATCH 11/12] chore: fix linting (#60) --- bot/exts/utilities/githubinfo.py | 83 ++++++++++++++++++++------------ 1 file changed, 53 insertions(+), 30 deletions(-) diff --git a/bot/exts/utilities/githubinfo.py b/bot/exts/utilities/githubinfo.py index cea4e73a8..cd7850b6b 100644 --- a/bot/exts/utilities/githubinfo.py +++ b/bot/exts/utilities/githubinfo.py @@ -18,7 +18,9 @@ GITHUB_API_URL = "https://api.github.com" -REQUEST_HEADERS = {"Accept": "application/vnd.github.v3+json"} +REQUEST_HEADERS = { + "Accept": "application/vnd.github.v3+json" +} REPOSITORY_ENDPOINT = "https://api.github.com/orgs/{org}/repos?per_page=100&type=public" MOST_STARRED_ENDPOINT = "https://api.github.com/search/repositories?q={name}&sort=stars&order=desc&per_page=100" @@ -32,9 +34,9 @@ REQUEST_HEADERS["Authorization"] = f"token {Tokens.github.get_secret_value()}" CODE_BLOCK_RE = re.compile( - r"`([^`\n]+)`" # Inline codeblock + r"`([^`\n]+)`" # Inline codeblock r"|```(.+?)```", # Multiline codeblock - re.DOTALL | re.MULTILINE, + re.DOTALL | re.MULTILINE ) # Maximum number of issues in one message @@ -102,12 +104,18 @@ async def cog_unload(self) -> None: """ self.refresh_repos.cancel() + @staticmethod def remove_codeblocks(message: str) -> str: """Remove any codeblock in a message.""" return CODE_BLOCK_RE.sub("", message) - async def fetch_issue(self, number: int, repository: str, user: str) -> IssueState | FetchError: + async def fetch_issue( + self, + number: int, + repository: str, + user: str + ) -> IssueState | FetchError: """ Retrieve an issue from a GitHub repository. @@ -159,7 +167,9 @@ async def fetch_issue(self, number: int, repository: str, user: str) -> IssueSta return IssueState(repository, number, issue_url, json_data.get("title", ""), emoji) @staticmethod - def format_embed(results: list[IssueState | FetchError]) -> discord.Embed: + def format_embed( + results: list[IssueState | FetchError] + ) -> discord.Embed: """Take a list of IssueState or FetchError and format a Discord embed for them.""" description_list = [] @@ -171,7 +181,10 @@ def format_embed(results: list[IssueState | FetchError]) -> discord.Embed: elif isinstance(result, FetchError): description_list.append(f":x: [{result.return_code}] {result.message}") - resp = discord.Embed(colour=Colours.bright_green, description="\n".join(description_list)) + resp = discord.Embed( + colour=Colours.bright_green, + description="\n".join(description_list) + ) resp.set_author(name="GitHub") return resp @@ -213,14 +226,16 @@ async def on_message(self, message: discord.Message) -> None: embed = discord.Embed( title=random.choice(ERROR_REPLIES), color=Colours.soft_red, - description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})", + description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})" ) await message.channel.send(embed=embed, delete_after=5) return for repo_issue in issues: result = await self.fetch_issue( - int(repo_issue.number), repo_issue.repository, repo_issue.organisation or "python-discord" + int(repo_issue.number), + repo_issue.repository, + repo_issue.organisation or "python-discord" ) if isinstance(result, IssueState): links.append(result) @@ -248,7 +263,7 @@ async def github_user_info(self, ctx: commands.Context, username: str) -> None: embed = discord.Embed( title=random.choice(NEGATIVE_REPLIES), description=f"The profile for `{username}` was not found.", - colour=Colours.soft_red, + colour=Colours.soft_red ) await ctx.send(embed=embed) @@ -273,21 +288,25 @@ async def github_user_info(self, ctx: commands.Context, username: str) -> None: description=f"```\n{user_data['bio']}\n```\n" if user_data["bio"] else "", colour=discord.Colour.og_blurple(), url=user_data["html_url"], - timestamp=datetime.strptime(user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC), + timestamp=datetime.strptime(user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) ) embed.set_thumbnail(url=user_data["avatar_url"]) embed.set_footer(text="Account created at") if user_data["type"] == "User": + embed.add_field( - name="Followers", value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)" + name="Followers", + value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)" ) embed.add_field( - name="Following", value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)" + name="Following", + value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)" ) embed.add_field( - name="Public repos", value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)" + name="Public repos", + value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)" ) if user_data["type"] == "User": @@ -295,7 +314,7 @@ async def github_user_info(self, ctx: commands.Context, username: str) -> None: embed.add_field( name=f"Organization{'s' if len(orgs) != 1 else ''}", - value=orgs_to_add if orgs else "No organizations.", + value=orgs_to_add if orgs else "No organizations." ) embed.add_field(name="Website", value=blog) @@ -314,7 +333,7 @@ def build_embed(self, repo_data: dict) -> discord.Embed: title=repo_data["name"], description=repo_data["description"], colour=discord.Colour.og_blurple(), - url=repo_data["html_url"], + url=repo_data["html_url"] ) # if its a fork it will have a parent key try: @@ -324,17 +343,19 @@ def build_embed(self, repo_data: dict) -> discord.Embed: log.debug("Repository is not a fork.") repo_owner = repo_data["owner"] - embed.set_author(name=repo_owner["login"], url=repo_owner["html_url"], icon_url=repo_owner["avatar_url"]) - - repo_created_at = ( - datetime.strptime(repo_data["created_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC).strftime("%d/%m/%Y") - ) - last_pushed = ( - datetime.strptime(repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ") - .replace(tzinfo=UTC) - .strftime("%d/%m/%Y at %H:%M") + embed.set_author( + name=repo_owner["login"], + url=repo_owner["html_url"], + icon_url=repo_owner["avatar_url"] ) + repo_created_at = datetime.strptime( + repo_data["created_at"], "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=UTC).strftime("%d/%m/%Y") + last_pushed = datetime.strptime( + repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=UTC).strftime("%d/%m/%Y at %H:%M") + embed.set_footer( text=( f"{repo_data['forks_count']:,} ⑂ " @@ -603,11 +624,12 @@ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: repo_query = "/".join(repo) repo_query_casefold = repo_query.casefold() + if repo_query.count("/") > 1: embed = discord.Embed( title=random.choice(NEGATIVE_REPLIES), description="There cannot be more than one `/` in the repository.", - colour=Colours.soft_red, + colour=Colours.soft_red ) await ctx.send(embed=embed) return @@ -621,10 +643,11 @@ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: is_pydis = True else: fetch_most_starred = True + async with ctx.typing(): # Case 1: PyDis repo if is_pydis: - repo_data = repo_query # repo_query already contains the matched repo + repo_data = repo_query # repo_query already contains the matched repo # Case 2: Not stored or PyDis, fetch most-starred matching repo elif fetch_most_starred: @@ -634,7 +657,7 @@ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: embed = discord.Embed( title=random.choice(NEGATIVE_REPLIES), description=f"No repositories found matching `{repo_query}`.", - colour=Colours.soft_red, + colour=Colours.soft_red ) await ctx.send(embed=embed) return @@ -647,11 +670,12 @@ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: embed = discord.Embed( title=random.choice(NEGATIVE_REPLIES), description=f"No repositories found matching `{repo_query}`.", - colour=Colours.soft_red, + colour=Colours.soft_red ) await ctx.send(embed=embed) return + # Case 3: Regular GitHub repo else: repo_data, _ = await self.fetch_data(f"{GITHUB_API_URL}/repos/{quote(repo_query)}") @@ -660,7 +684,7 @@ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: embed = discord.Embed( title=random.choice(NEGATIVE_REPLIES), description="The requested repository was not found.", - colour=Colours.soft_red, + colour=Colours.soft_red ) await ctx.send(embed=embed) return @@ -668,7 +692,6 @@ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: embed = self.build_embed(repo_data) await ctx.send(embed=embed) - async def setup(bot: Bot) -> None: """Load the GithubInfo cog.""" await bot.add_cog(GithubInfo(bot)) From 36cb19cea9ff6a645097beecb925e8a53b25a72f Mon Sep 17 00:00:00 2001 From: Oskar Nurm <19738295+oskarnurm@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:06:54 +0100 Subject: [PATCH 12/12] fix: resolve feedback (#64) * fix: raise exceptions for errors instead of integers * fix: use literals when passing arguments * fix: not pass headers around and add a few missing exception raises * chore: update pydis_core * refactor: refine error handling --- bot/exts/utilities/githubinfo.py | 270 +++++++++++++++---------------- pyproject.toml | 4 +- uv.lock | 62 ++++--- 3 files changed, 175 insertions(+), 161 deletions(-) diff --git a/bot/exts/utilities/githubinfo.py b/bot/exts/utilities/githubinfo.py index cd7850b6b..3c3e57154 100644 --- a/bot/exts/utilities/githubinfo.py +++ b/bot/exts/utilities/githubinfo.py @@ -2,13 +2,15 @@ import random import re from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path +from typing import Annotated, Literal from urllib.parse import quote import discord from aiohttp import ClientResponse from discord.ext import commands, tasks +from pydis_core.utils.converters import ISODateTime from pydis_core.utils.logging import get_logger from bot.bot import Bot @@ -48,6 +50,17 @@ r"((?P[a-zA-Z0-9][a-zA-Z0-9\-]{1,39})\/)?(?P[\w\-\.]{1,100})#(?P[0-9]+)" ) +class GithubAPIError(Exception): + """Raised when GitHub API returns a non 200 status code.""" + + def __init__(self, status: int, message: str = "GitHub API error"): + self.status = status + self.message = message + super().__init__(f"{message} (Status: {status})") + +class StargazersLimitError(Exception): + """Raised when a repository exceeds the searchable stargazer limit.""" + @dataclass(eq=True, frozen=True) class FoundIssue: @@ -366,52 +379,68 @@ def build_embed(self, repo_data: dict) -> discord.Embed: ) return embed - async def get_issue_count(self, repo: str, start: str, end: str, state: str) -> int: + async def get_issue_count( + self, + repo: str, + start: datetime, + end: datetime, + action: + Literal["created", "closed"] + ) -> int: """Gets the number of issues opened or closed (based on state) in a given timeframe.""" + start_str = start.strftime("%Y-%m-%d") + end_str = end.strftime("%Y-%m-%d") url = f"{GITHUB_API_URL}/search/issues" - query = f"repo:{repo} is:issue {state}:{start}..{end}" + query = f"repo:{repo} is:issue {action}:{start_str}..{end_str}" params = {"q": query} async with self.bot.http_session.get(url, headers=REQUEST_HEADERS, params=params) as response: if response.status != 200: - return -1 + raise GithubAPIError(response.status) data = await response.json() return data.get("total_count", 0) - async def get_pr_count(self, repo: str, start: str, end: str, action: str) -> int: + async def get_pr_count( + self, + repo: str, + start: datetime, + end: datetime, + action: + Literal["opened", "merged", "closed"] + ) -> int: """Gets the number of PRs opened, closed, or merged in a given timeframe.""" + start_str = start.strftime("%Y-%m-%d") + end_str = end.strftime("%Y-%m-%d") url = f"{GITHUB_API_URL}/search/issues" - if action == "opened": - state_query = f"created:{start}..{end}" - elif action == "merged": - state_query = f"is:merged merged:{start}..{end}" - elif action == "closed": - state_query = f"is:unmerged closed:{start}..{end}" - else: - return 0 + state_query = { + "opened": f"created:{start_str}..{end_str}", + "merged": f"is:merged merged:{start_str}..{end_str}", + "closed": f"is:unmerged closed:{start_str}..{end_str}" + } - query = f"repo:{repo} is:pr {state_query}" + query = f"repo:{repo} is:pr {state_query[action]}" params = {"q": query} async with self.bot.http_session.get(url, headers=REQUEST_HEADERS, params=params) as response: if response.status != 200: - return -1 + raise GithubAPIError(response.status) data = await response.json() return data.get("total_count", 0) - async def get_commit_count(self, repo_str: str, start_str: str, end_str: str) -> int: + async def get_commit_count(self, repo: str, start: datetime, end: datetime) -> int: """Returns the number of commits done to the given repo between the start and end date.""" - start_iso = f"{start_str}T00:00:00Z" - end_iso = f"{end_str}T23:59:59Z" + end_next_day = end + timedelta(days=1) + start_iso = start.strftime("%Y-%m-%dT%H:%M:%SZ") + end_iso = end_next_day.strftime("%Y-%m-%dT%H:%M:%SZ") - url = f"https://api.github.com/repos/{repo_str}/commits" + url = f"https://api.github.com/repos/{repo}/commits" params = {"since": start_iso, "until": end_iso, "per_page": 1, "page": 1} async with self.bot.http_session.get(url, headers=REQUEST_HEADERS, params=params) as response: if response.status != 200: - return -1 + raise GithubAPIError(response.status) commits_json = await response.json() # No commits @@ -425,82 +454,83 @@ async def get_commit_count(self, repo_str: str, start_str: str, end_str: str) -> # Grabbing the number of pages from the Link header match = re.search(r'page=(\d+)>; rel="last"', link_header) - if match: return int(match.group(1)) - return 1 - - async def _fetch_page(self, url: str, headers: dict, page: int, cache: dict) -> list: - """Fetch a page of stargazers, using cache to avoid duplicate requests.""" - if page not in cache: - params = {"per_page": 100, "page": page} - async with self.bot.http_session.get(url, headers=headers, params=params) as response: - if response.status != 200: - return [] - cache[page] = await response.json() - return cache[page] - - async def _get_date_at(self, url: str, headers: dict, i: int, cache: dict) -> str: - """Get the starred_at date (YYYY-MM-DD) of the star at global index i (0-based).""" - page = (i // 100) + 1 - pos = i % 100 - page_data = await self._fetch_page(url, headers, page, cache) - - # FIX: Prevent IndexError if GitHub's cached count is higher than the actual list - if page_data and pos < len(page_data): - return page_data[pos].get("starred_at", "")[:10] - return "" - - async def get_stars_gained(self, repo: str, start: str, end: str) -> int: + # If we reach here, GitHub sent a Link header but our regex couldn't parse it. + # This is an unexpected API failure, so we raise an exception! + raise GithubAPIError(500, "Failed to parse pagination Link header for commits.") + + async def _fetch_page(self, url: str, page: int) -> list: + """Fetch a single page of stargazers from the API.""" + params = {"per_page": 100, "page": page} + headers = REQUEST_HEADERS | {"Accept": "application/vnd.github.star+json"} + + async with self.bot.http_session.get(url, headers=headers, params=params) as response: + if response.status != 200: + raise GithubAPIError(response.status) + return await response.json() + + async def get_stars_gained(self, repo: str, start: datetime, end: datetime) -> int: """Gets the number of stars gained for a given repository in a timeframe.""" + start_str = start.strftime("%Y-%m-%d") + end_str = end.strftime("%Y-%m-%d") url = f"{GITHUB_API_URL}/repos/{repo}/stargazers" + cache = {} + + async def get_date_with_cache(index: int) -> str: + """Helper to check the cache before calling the API.""" + page_num = (index // 100) + 1 + pos = index % 100 - # Copy the global headers but update the Accept header specifically for Stargazers - star_headers = REQUEST_HEADERS.copy() - star_headers["Accept"] = "application/vnd.github.star+json" + if page_num not in cache: + cache[page_num] = await self._fetch_page(url, page_num) + + page_data = cache[page_num] + if page_data and pos < len(page_data): + return page_data[pos].get("starred_at", "")[:10] + return "" repo_data, response = await self.fetch_data(f"{GITHUB_API_URL}/repos/{repo}") if response.status != 200: - return -1 + raise GithubAPIError(response.status) max_stars = repo_data.get("stargazers_count", 0) - if max_stars == 0: return 0 # GitHub API limits stargazers pagination to 40 000 entries (page 400 max) - # Because of this the output is not consistent for projects with more than 40 000 stars so we default to -2 + # Because of this the output is not consistent for projects with more than 40 000 stars so we raise and error github_stargazer_limit = 40000 if max_stars > github_stargazer_limit: - return -2 + raise StargazersLimitError("Repository exceeds the 40,000 star limit.") + searchable_stars = max_stars # We use a cache and binary search to limit the number of requests to the GitHub API - cache = {} low, high = 0, searchable_stars - 1 while low < high: mid = (low + high) // 2 - lowdate = await self._get_date_at(url, star_headers, mid, cache) + lowdate = await get_date_with_cache(mid) if lowdate == "": - return -1 - if lowdate < start: + raise GithubAPIError(500, "Failed to fetch stargazer date during binary search") + if lowdate < start_str: low = mid + 1 else: high = mid left = low - date_left = await self._get_date_at(url, star_headers, left, cache) - if date_left < start or date_left > end: + date_left = await get_date_with_cache(left) + if date_left < start_str or date_left > end_str: return 0 low, high = left, searchable_stars - 1 while low < high: mid = (low + high + 1) // 2 - highdate = await self._get_date_at(url, star_headers, mid, cache) + highdate = await get_date_with_cache(mid) if highdate == "": - return -1 - if highdate > end: + raise GithubAPIError(500, "Failed to fetch stargazer date during binary search") + if highdate > end_str: high = mid - 1 else: low = mid @@ -508,93 +538,63 @@ async def get_stars_gained(self, repo: str, start: str, end: str) -> int: return right - left + 1 - def parse_date(self, date_str: str) -> datetime | None: - """Parse a YYYY-MM-DD date string into a UTC datetime.""" - try: - return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC) - except ValueError: - return None - - def validate_date_format(self, date_str: str) -> bool: - """Validates that the date string is formatted correctly.""" - return self.parse_date(date_str) is not None - - def validate_date_range(self, start_date: str, end_date: str) -> bool: - """Validate a date range for correctness and logical ordering.""" - start = self.parse_date(start_date) - end = self.parse_date(end_date) - - if start and end: - return start <= end - return False - @github_group.command(name="stats") - async def github_stats(self, ctx: commands.Context, start: str, end: str, repo: str) -> None: + async def github_stats( + self, + ctx: commands.Context, + start: Annotated[datetime, ISODateTime], + end: Annotated[datetime, ISODateTime], + repo_query: str + ) -> None: """ Fetches stats for a GitHub repo. Usage: !github_stats 2023-01-01 2023-12-31 python-discord/bot. """ async with ctx.typing(): - # Validate the date first to spare API calls - if not self.validate_date_format(start): - embed = discord.Embed( - title=random.choice(NEGATIVE_REPLIES), - description="Start date must be in YYYY-MM-DD format.", - colour=Colours.soft_red, - ) - await ctx.send(embed=embed) - - return - - if not self.validate_date_format(end): - embed = discord.Embed( - title=random.choice(NEGATIVE_REPLIES), - description="End date must be in YYYY-MM-DD format.", - colour=Colours.soft_red, - ) - await ctx.send(embed=embed) - return - - if not self.validate_date_range(start, end): - embed = discord.Embed( - title=random.choice(NEGATIVE_REPLIES), - description="Invalid date range.", - colour=Colours.soft_red, - ) - await ctx.send(embed=embed) - return - - url = f"{GITHUB_API_URL}/repos/{repo}" - repo_data, response = await self.fetch_data(url) - - if "message" in repo_data: + try: + # Check date + now = datetime.now(UTC) + if end > now: + end = now # cap future dates to today + if start > end: + raise commands.BadArgument("The start date must be before, or equal to the end date.") + + # Protect against directory traversal + if repo_query.count("/") != 1 or ".." in repo_query: + raise commands.BadArgument("Repository must be in the format `owner/repo`.") + repo = quote(repo_query) + + # Check if repo exists + url = f"{GITHUB_API_URL}/repos/{repo}" + repo_data, _ = await self.fetch_data(url) + if "message" in repo_data: + raise commands.BadArgument(f"Could not find repository: `{repo_query}`") + + # Get stats + open_issues = await self.get_issue_count(repo, start, end, action="created") + closed_issues = await self.get_issue_count(repo, start, end, action="closed") + prs_opened = await self.get_pr_count(repo, start, end, "opened") + prs_closed = await self.get_pr_count(repo, start, end, "closed") + prs_merged = await self.get_pr_count(repo, start, end, "merged") + commits = await self.get_commit_count(repo, start, end) + + try: + stars_gained = await self.get_stars_gained(repo, start, end) + stars = f"+{stars_gained}" if stars_gained > 0 else "0" + except StargazersLimitError: + stars = "N/A (repo exceeded API limit)" + + except (commands.BadArgument, GithubAPIError) as e: embed = discord.Embed( title=random.choice(NEGATIVE_REPLIES), - description=f"Could not find repository: `{repo}`", + description=str(e), colour=Colours.soft_red, ) await ctx.send(embed=embed) return - open_issues = await self.get_issue_count(repo, start, end, state="created") - closed_issues = await self.get_issue_count(repo, start, end, state="closed") - prs_opened = await self.get_pr_count(repo, start, end, "opened") - prs_closed = await self.get_pr_count(repo, start, end, "closed") - prs_merged = await self.get_pr_count(repo, start, end, "merged") - commits = await self.get_commit_count(repo, start, end) - stars_gained = await self.get_stars_gained(repo, start, end) - - if stars_gained == -2: - stars = "N/A (repo exceeds API limit)" - elif stars_gained > 0: - stars = f"+{stars_gained}" - elif stars_gained == 0: - stars = "0" - else: - stars = "unavailable" - - stats_text = ( + text = ( f"Issues opened: {open_issues}\n" f"Issues closed: {closed_issues}\n" f"Pull Requests opened: {prs_opened}\n" @@ -605,7 +605,7 @@ async def github_stats(self, ctx: commands.Context, start: str, end: str, repo: ) stats_embed = discord.Embed( - title=f"Stats for {repo}", description=stats_text, colour=discord.Colour.og_blurple() + title=f"Stats for {repo}", description=text, colour=discord.Colour.og_blurple() ) await ctx.send(embed=stats_embed) diff --git a/pyproject.toml b/pyproject.toml index bcfec672e..aafa9f21a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ name = "sir-lancebot" version = "0.1.0" description = "A Discord bot designed as a fun and beginner-friendly learning environment for writing bot features and learning open-source." dependencies = [ - "pydis-core[all]==11.8.0", + "pydis-core[all]==11.9.0", "arrow==1.3.0", "beautifulsoup4==4.12.3", "colorama==0.4.6; sys_platform == \"win32\"", @@ -18,7 +18,7 @@ dependencies = [ "emojis==0.7.0", "lxml==6.0.0", "pillow==12.1.1", - "pydantic==2.10.1", + "pydantic>=2.12", "pydantic-settings==2.8.1", "pyjokes==0.8.3", "PyYAML==6.0.2", diff --git a/uv.lock b/uv.lock index eeb252dfe..2e488dda9 100644 --- a/uv.lock +++ b/uv.lock @@ -645,41 +645,42 @@ wheels = [ [[package]] name = "pydantic" -version = "2.10.1" +version = "2.13.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/bd/7fc610993f616d2398958d0028d15eaf53bde5f80cb2edb7aa4f1feaf3a7/pydantic-2.10.1.tar.gz", hash = "sha256:a4daca2dc0aa429555e0656d6bf94873a7dc5f54ee42b1f5873d666fb3f35560", size = 783717, upload-time = "2024-11-22T00:58:43.709Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/71/0e0cffabd25021b7245b69133b9c10c41b06960e4629739643df96a17174/pydantic-2.13.0b2.tar.gz", hash = "sha256:255b95518090cd7090b605ef975957b07f724778f71dafc850a7442e088e7b99", size = 835671, upload-time = "2026-02-24T17:07:44.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/fc/fda48d347bd50a788dd2a0f318a52160f911b86fc2d8b4c86f4d7c9bceea/pydantic-2.10.1-py3-none-any.whl", hash = "sha256:a8d20db84de64cf4a7d59e899c2caf0fe9d660c7cfc482528e7020d7dd189a7e", size = 455329, upload-time = "2024-11-22T00:58:40.347Z" }, + { url = "https://files.pythonhosted.org/packages/53/c5/4d39af2fbf81f04a5acf8cdd9add3085129ca35eb8ba21b5b42c96803924/pydantic-2.13.0b2-py3-none-any.whl", hash = "sha256:42a3dee97ad2b50b7489ad4fe8dfec509cb613487da9a3c19d480f0880e223bc", size = 468371, upload-time = "2026-02-24T17:07:42.545Z" }, ] [[package]] name = "pydantic-core" -version = "2.27.1" +version = "2.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785, upload-time = "2024-11-22T00:24:49.865Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/5d/f33a858a3b38ca2ecea6a12d749a8dae1052098cf61f88403a585bd64906/pydantic_core-2.42.0.tar.gz", hash = "sha256:34068adadf673c872f01265fa17ec00073e99d7f53f6d499bdfae652f330b3d2", size = 471009, upload-time = "2026-02-23T17:57:19.71Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/d6/91cb99a3c59d7b072bded9959fbeab0a9613d5a4935773c0801f1764c156/pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", size = 1895033, upload-time = "2024-11-22T00:22:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/07/42/d35033f81a28b27dedcade9e967e8a40981a765795c9ebae2045bcef05d3/pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", size = 1807542, upload-time = "2024-11-22T00:22:43.341Z" }, - { url = "https://files.pythonhosted.org/packages/41/c2/491b59e222ec7e72236e512108ecad532c7f4391a14e971c963f624f7569/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", size = 1827854, upload-time = "2024-11-22T00:22:44.96Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f3/363652651779113189cefdbbb619b7b07b7a67ebb6840325117cc8cc3460/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737", size = 1857389, upload-time = "2024-11-22T00:22:47.305Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/be804aed6b479af5a945daec7538d8bf358d668bdadde4c7888a2506bdfb/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2", size = 2037934, upload-time = "2024-11-22T00:22:49.093Z" }, - { url = "https://files.pythonhosted.org/packages/42/01/295f0bd4abf58902917e342ddfe5f76cf66ffabfc57c2e23c7681a1a1197/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107", size = 2735176, upload-time = "2024-11-22T00:22:50.822Z" }, - { url = "https://files.pythonhosted.org/packages/9d/a0/cd8e9c940ead89cc37812a1a9f310fef59ba2f0b22b4e417d84ab09fa970/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51", size = 2160720, upload-time = "2024-11-22T00:22:52.638Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/9d0980e286627e0aeca4c352a60bd760331622c12d576e5ea4441ac7e15e/pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a", size = 1992972, upload-time = "2024-11-22T00:22:54.31Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ba/ae4480bc0292d54b85cfb954e9d6bd226982949f8316338677d56541b85f/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc", size = 2001477, upload-time = "2024-11-22T00:22:56.451Z" }, - { url = "https://files.pythonhosted.org/packages/55/b7/e26adf48c2f943092ce54ae14c3c08d0d221ad34ce80b18a50de8ed2cba8/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960", size = 2091186, upload-time = "2024-11-22T00:22:58.226Z" }, - { url = "https://files.pythonhosted.org/packages/ba/cc/8491fff5b608b3862eb36e7d29d36a1af1c945463ca4c5040bf46cc73f40/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23", size = 2154429, upload-time = "2024-11-22T00:22:59.985Z" }, - { url = "https://files.pythonhosted.org/packages/78/d8/c080592d80edd3441ab7f88f865f51dae94a157fc64283c680e9f32cf6da/pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05", size = 1833713, upload-time = "2024-11-22T00:23:01.715Z" }, - { url = "https://files.pythonhosted.org/packages/83/84/5ab82a9ee2538ac95a66e51f6838d6aba6e0a03a42aa185ad2fe404a4e8f/pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337", size = 1987897, upload-time = "2024-11-22T00:23:03.497Z" }, - { url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983, upload-time = "2024-11-22T00:23:05.983Z" }, + { url = "https://files.pythonhosted.org/packages/d9/73/f1ca9122a23924bb1b09e15b09e48dcf1ccbef8eb7151ffde8ba7723350e/pydantic_core-2.42.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:02fd2b4a62efa12e004fce2bfd2648cf8c39efc5dfc5ed5f196eb4ccefc7db4e", size = 2141091, upload-time = "2026-02-23T17:56:20.877Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a7/dfba778590b8b7fc2660320d6124b666b902fe7f3bb60f79bfd75f8d6cfb/pydantic_core-2.42.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c042694870c20053b8814a57c416cd2c6273fe462a440460005c791c24c39baf", size = 1960616, upload-time = "2026-02-23T17:55:42.248Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/83901df720fe8e2ee87bf3d9c4b30b39b7e1d9e7cf280d0a8f4fc3a8b82a/pydantic_core-2.42.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f905f3a082e7498dfaa70c204b236e92d448ba966ad112a96fcaaba2c4984fba", size = 1991369, upload-time = "2026-02-23T17:56:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f1/40470e480edcc165e445ebc0c42b2358a76ba96b0ab966cab75d75fdafc4/pydantic_core-2.42.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4762081e8acc5458bf907373817cf93c927d451a1b294c1d0535b0570890d939", size = 2076495, upload-time = "2026-02-23T17:54:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/05/05/4074c6f54739ef5cc664ec35d42dcc904dece524e8efe3190c066c4e4da1/pydantic_core-2.42.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4a433bbf6304bd114b96b0ce3ed9add2ee686df448892253bca5f622c030f31", size = 2241726, upload-time = "2026-02-23T17:57:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0c/e5ba96473bfc63cccfac63a46c79f8cba8c87c75ac89c7f0b5cdb7888a81/pydantic_core-2.42.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd695305724cfce8b19a18e87809c518f56905e5c03a19e3ad061974970f717d", size = 2324251, upload-time = "2026-02-23T17:57:29.915Z" }, + { url = "https://files.pythonhosted.org/packages/bf/25/dd3e68362b4d7983bec8ccd421f06c47360aa65763774426ccf6377c8d4a/pydantic_core-2.42.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5f352ffa0ec2983b849a93714571063bfc57413b5df2f1027d7a04b6e8bdd25", size = 2108163, upload-time = "2026-02-23T17:55:51.149Z" }, + { url = "https://files.pythonhosted.org/packages/27/01/18f7b79b09b442fa5ba119b74e2dbccc2488f1cc37bf24d8a044fadeb546/pydantic_core-2.42.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e61f2a194291338d76307a29e4881a8007542150b750900c1217117fc9bb698e", size = 2198891, upload-time = "2026-02-23T17:57:33.035Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c8/dee17aee2215e2eb63772ae1ea59c256524e518b9cab724ede6c3757d666/pydantic_core-2.42.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:032f990dc1759f11f6b287e5c6eb1b0bcfbc18141779414a77269b420360b3bf", size = 2196629, upload-time = "2026-02-23T17:54:15.347Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/7b0a5f9aa56f1c03334d3bbc5add60c9b2de99ff115003670dc629cb9ac3/pydantic_core-2.42.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:9c28b42768da6b9238554ae23b39291c3bbe6f53c4810aea6414d83efd59b96a", size = 2349048, upload-time = "2026-02-23T17:56:39.338Z" }, + { url = "https://files.pythonhosted.org/packages/3a/93/e2b79095d8fd26f369263beb47e8cdfe7b23a1264d97e1a7c268625254b7/pydantic_core-2.42.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:b22af1ac75fa873d81a65cce22ada1d840583b73a129b06133097c81f6f9e53b", size = 2395157, upload-time = "2026-02-23T17:56:19.081Z" }, + { url = "https://files.pythonhosted.org/packages/58/f7/68fdf9680d716a24e5b38418a852c204a773b35eb27e74a71322cb2a018e/pydantic_core-2.42.0-cp313-cp313-win32.whl", hash = "sha256:1de0350645c8643003176659ee70b637cd80e8514a063fff36f088fcda2dba06", size = 1978125, upload-time = "2026-02-23T17:54:31.69Z" }, + { url = "https://files.pythonhosted.org/packages/b2/73/7e8f6f696127a2ff684f393b4d8a5ba733ab68b04698eaac8c0da8f3ca18/pydantic_core-2.42.0-cp313-cp313-win_amd64.whl", hash = "sha256:d34b481a8a3eba3678a96e166c6e547c0c8b026844c13d9deb70c9f1fd2b0979", size = 2076984, upload-time = "2026-02-23T17:57:39.57Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d6/7d16374c2f252bb9e416940f40472aa03f64148e2cc5a6f2549448611be9/pydantic_core-2.42.0-cp313-cp313-win_arm64.whl", hash = "sha256:5e0a65358eef041d95eef93fcf8834c2c8b83cc5a92d32f84bb3a7955dfe21c9", size = 2036707, upload-time = "2026-02-23T17:54:41.293Z" }, ] [[package]] @@ -697,17 +698,18 @@ wheels = [ [[package]] name = "pydis-core" -version = "11.8.0" +version = "11.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiodns" }, { name = "discord-py" }, { name = "pydantic" }, + { name = "python-dateutil" }, { name = "statsd" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/ed/21ac5a50474576de03491ee230f7d58ab1b14ae73e4e36a7f57a1a325b88/pydis_core-11.8.0.tar.gz", hash = "sha256:8a2579638622bb49e04059100c147b594f2ed23dfd8fbeb9660cb35013cf2099", size = 33570, upload-time = "2025-10-17T18:06:30.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/80/f11b2f3307b7a6ee2f6438659afa6a5f87fc7d3be52f19bd06741494fd5d/pydis_core-11.9.0.tar.gz", hash = "sha256:6a06b90a8719ea570b72c9a8b2d3b40bf5d9c2914c18f1f37a28dd3c5235be70", size = 34251, upload-time = "2026-03-05T01:11:01.574Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/d9/6c606d5f46ebc353afb113e5a95f0d8581d726e288bcfde276b230b18b3c/pydis_core-11.8.0-py3-none-any.whl", hash = "sha256:f0e49a4a4e1ccecb00ce85c963c3b1f0b4047b214b4e445ecea6889ae5212910", size = 44018, upload-time = "2025-10-17T18:06:29.859Z" }, + { url = "https://files.pythonhosted.org/packages/90/9d/165f416a2107d0d873ea862480b6b724675b1ac7cad01d6b09eedb0d71a5/pydis_core-11.9.0-py3-none-any.whl", hash = "sha256:39d6da3cc693662f0814e837583cf4e19ca246ffbd43ec3cc25418dfa99ab8a6", size = 45044, upload-time = "2026-03-05T01:11:02.918Z" }, ] [package.optional-dependencies] @@ -883,9 +885,9 @@ requires-dist = [ { name = "emojis", specifier = "==0.7.0" }, { name = "lxml", specifier = "==6.0.0" }, { name = "pillow", specifier = "==12.1.1" }, - { name = "pydantic", specifier = "==2.10.1" }, + { name = "pydantic", specifier = ">=2.12" }, { name = "pydantic-settings", specifier = "==2.8.1" }, - { name = "pydis-core", extras = ["all"], specifier = "==11.8.0" }, + { name = "pydis-core", extras = ["all"], specifier = "==11.9.0" }, { name = "pyjokes", specifier = "==0.8.3" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "rapidfuzz", specifier = "==3.12.2" }, @@ -987,6 +989,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.6.3"