From 1fde371ec4b896bb37607cc583d276745743636d Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Sun, 24 May 2026 02:35:49 +0300 Subject: [PATCH 01/20] correct changelog improvement reference for CI/CD --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f766004..ba991c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,8 @@ - Add docstrings to all modules - Migrate from Poetry to uv for package management - Replace `setup.py` with `pyproject.toml` using hatchling as build backend +- Replace Travis CI/CD with GitHub Actions - Add GitHub Actions workflow for publishing to PyPI -- Update GitHub Actions to Node.js 24 compatible versions **Bugfixes** From bbd81c60ee92b384dd78c33ac38a7509f0265fb4 Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Sun, 24 May 2026 02:39:11 +0300 Subject: [PATCH 02/20] add ruff lint and format check to CI --- .github/workflows/test_suite.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml index 649e2e5..33ea62a 100644 --- a/.github/workflows/test_suite.yml +++ b/.github/workflows/test_suite.yml @@ -4,6 +4,25 @@ name: Test Suite on: [push] jobs: + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + - name: Install dependencies + run: uv sync + - name: Ruff check + run: uv run ruff check . + - name: Ruff format + run: uv run ruff format --check . + tests: name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest From 4d28bef50908523df98d032ee08896be750aba0b Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Sun, 24 May 2026 02:53:42 +0300 Subject: [PATCH 03/20] remove requirements.txt, superseded by uv.lock --- requirements.txt | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 74fb549..0000000 --- a/requirements.txt +++ /dev/null @@ -1,33 +0,0 @@ -anyio==4.13.0 -asttokens==3.0.1 -certifi==2026.5.20 -colorama==0.4.6 ; sys_platform == 'win32' -decorator==5.3.1 -exceptiongroup==1.3.1 ; python_full_version < '3.11' -executing==2.2.1 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -idna==3.16 -ipdb==0.13.13 -ipython==8.39.0 ; python_full_version < '3.11' -ipython==9.13.0 ; python_full_version >= '3.11' -ipython-pygments-lexers==1.1.1 ; python_full_version >= '3.11' -iso8601==2.1.0 -jedi==0.20.0 -matplotlib-inline==0.2.2 -parso==0.8.7 -pexpect==4.9.0 ; sys_platform != 'emscripten' and sys_platform != 'win32' -prompt-toolkit==3.0.52 -psutil==7.2.2 ; python_full_version >= '3.11' -ptyprocess==0.7.0 ; sys_platform != 'emscripten' and sys_platform != 'win32' -pure-eval==0.2.3 -pygments==2.20.0 -ruff==0.15.14 -stack-data==0.6.3 -tomli==2.4.1 ; python_full_version < '3.11' -traitlets==5.15.0 -typing-extensions==4.15.0 ; python_full_version < '3.13' -tzdata==2026.2 ; sys_platform == 'win32' -tzlocal==5.3.1 -wcwidth==0.7.0 From 15d52c1e30318c78c4f482b409a7e0e145392cfa Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Sun, 24 May 2026 03:00:20 +0300 Subject: [PATCH 04/20] fix error handling in fetch() to re-raise non-422 errors with original traceback --- challonge/api.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/challonge/api.py b/challonge/api.py index 94d01c3..b56efc9 100644 --- a/challonge/api.py +++ b/challonge/api.py @@ -107,12 +107,11 @@ def fetch(method, uri, params_prefix=None, timeout=30.0, **params): ) response.raise_for_status() except HTTPStatusError as e: - if e.response.status_code != 422: - e.response.raise_for_status() - # wrap up application-level errors - doc = e.response.json() - if doc.get("errors"): - raise ChallongeException(*doc["errors"]) from e + if e.response.status_code == 422: + doc = e.response.json() + if doc.get("errors"): + raise ChallongeException(*doc["errors"]) from e + raise return response From f918bf9858ec65059ad2c0d00f0dee05e51a9351 Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Sun, 24 May 2026 03:13:45 +0300 Subject: [PATCH 05/20] simplify _parse() to only convert _at fields to datetime, drop float coercion --- challonge/api.py | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/challonge/api.py b/challonge/api.py index b56efc9..2f299ef 100644 --- a/challonge/api.py +++ b/challonge/api.py @@ -150,27 +150,12 @@ def _parse(data): # extract the nested dict. ex. {"tournament": {"url": "7k1safq" ...}} d = {ik: v for k in data.keys() for ik, v in data[k].items()} - # convert datetime strings to datetime objects - # and float number strings to float - to_parse = dict(d) - for k, v in to_parse.items(): - if k in { - "name", - "display_name", - "display_name_with_invitation_email_address", - "username", - "challonge_username", - }: - continue # do not test type of fields which are always strings - if isinstance(v, str): + for k, v in d.items(): + if k.endswith("_at") and isinstance(v, str): try: - dt = iso8601.parse_date(v) - d[k] = dt.astimezone(tz) + d[k] = iso8601.parse_date(v).astimezone(tz) except iso8601.ParseError: - try: - d[k] = float(v) - except ValueError: - pass + pass return d From 1f41b5a1a3b5f001eaa1fab8c558ffaa7eb27446 Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Sun, 24 May 2026 03:18:07 +0300 Subject: [PATCH 06/20] validate envelope structure in _parse() with a clear error instead of AttributeError --- challonge/api.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/challonge/api.py b/challonge/api.py index 2f299ef..4410ea8 100644 --- a/challonge/api.py +++ b/challonge/api.py @@ -148,7 +148,13 @@ def _parse(data): return [_parse(subdata) for subdata in data] # extract the nested dict. ex. {"tournament": {"url": "7k1safq" ...}} - d = {ik: v for k in data.keys() for ik, v in data[k].items()} + d = {} + for envelope_key, inner in data.items(): + if not isinstance(inner, dict): + raise ChallongeException( + f"Unexpected API response: '{envelope_key}' value is {type(inner).__name__}, expected dict" + ) + d.update(inner) for k, v in d.items(): if k.endswith("_at") and isinstance(v, str): From 1529dceb60c5edee10bb7eaad6276971ee9d1ba9 Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Sun, 24 May 2026 03:23:24 +0300 Subject: [PATCH 07/20] replace json.loads(response.text) with response.json(), drop unused import --- challonge/api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/challonge/api.py b/challonge/api.py index 4410ea8..5525aba 100644 --- a/challonge/api.py +++ b/challonge/api.py @@ -1,4 +1,3 @@ -import json from zoneinfo import ZoneInfo import iso8601 @@ -130,7 +129,7 @@ def fetch_and_parse(method, uri, params_prefix=None, timeout=30.0, **params): A dict representing the json response """ response = fetch(method, uri, params_prefix, timeout, **params) - return _parse(json.loads(response.text)) + return _parse(response.json()) def _parse(data): From 8c493bc90a77d2737560cb3f285c21d5ff3c63d9 Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Fri, 29 May 2026 00:21:02 +0300 Subject: [PATCH 08/20] update README: use h2 headings, simplify installation section --- README.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 96b6362..b825fe6 100644 --- a/README.md +++ b/README.md @@ -3,21 +3,17 @@ Lightweight Python wrapper for the [Challonge API](http://api.challonge.com/v1). The pychallonge module was created by [Russ Amos](https://github.com/russ-) -# Python version support +## Python version support -- `3.10+` +- 3.10 or later -# Installation +## Installation -For the stable version +The `pychallonge` package is available on PyPI and you can install it through your favorite package manager: pip install pychallonge -For latest development - - pip install -e git+https://github.com/ZEDGR/pychallonge#egg=pychallonge - -# Usage +## Usage ```python import challonge @@ -44,7 +40,7 @@ print(tournament["started_at"]) # 2011-07-31 16:16:02-04:00 See [challonge.com](http://api.challonge.com/v1) for full API documentation. -# API Issues +## API Issues The Challonge API has some issues with the attachments endpoints. When uploading an attachment with a file (asset), the API returns a 500 internal server error. @@ -58,7 +54,7 @@ Datetime fields from the API carry inconsistent timezone offsets. Pychallonge normalises these to your machine's local timezone. You can also set a specific timezone with the `set_timezone` function. -# Running the tests +## Running the tests Tests make real API calls and require a Challonge account. Set `CHALLONGE_USER` and `CHALLONGE_KEY` in your environment before running. From de68821329d40e99aa9cbbd8c3dc6d79abff1411 Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Fri, 29 May 2026 00:26:39 +0300 Subject: [PATCH 09/20] switch to pytest, add to dev dependencies --- pyproject.toml | 1 + uv.lock | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index da76f77..e872237 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ Homepage = "https://github.com/ZEDGR/pychallonge" [dependency-groups] dev = [ "ipdb>=0.13.13", + "pytest>=9.0.3", "ruff>=0.15.14", ] diff --git a/uv.lock b/uv.lock index 5bcdcaa..64d46b5 100644 --- a/uv.lock +++ b/uv.lock @@ -123,6 +123,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, ] +[[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 = "ipdb" version = "0.13.13" @@ -234,6 +243,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + [[package]] name = "parso" version = "0.8.7" @@ -255,6 +273,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[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 = "prompt-toolkit" version = "3.0.52" @@ -326,6 +353,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "ipdb" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -339,6 +367,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "ipdb", specifier = ">=0.13.13" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "ruff", specifier = ">=0.15.14" }, ] @@ -351,6 +380,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + [[package]] name = "ruff" version = "0.15.14" From d6d54c8835b3371d72119d27f71c6000d5545e4a Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Fri, 29 May 2026 00:32:48 +0300 Subject: [PATCH 10/20] refactor tests to pytest style, update ci to use pytest --- .github/workflows/test_suite.yml | 2 +- tests.py | 341 ++++++++++++------------------- 2 files changed, 132 insertions(+), 211 deletions(-) diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml index 33ea62a..7167286 100644 --- a/.github/workflows/test_suite.yml +++ b/.github/workflows/test_suite.yml @@ -46,4 +46,4 @@ jobs: env: CHALLONGE_USER: ${{ secrets.CHALLONGE_USER }} CHALLONGE_KEY: ${{ secrets.CHALLONGE_KEY }} - run: uv run python -m unittest tests.py -v + run: uv run pytest tests.py -v diff --git a/tests.py b/tests.py index 5111d7d..cedd4f4 100644 --- a/tests.py +++ b/tests.py @@ -2,9 +2,9 @@ import os import random import string -import unittest import httpx +import pytest import tzlocal import challonge @@ -19,129 +19,120 @@ def _get_random_name(): ) -class APITestCase(unittest.TestCase): +class TestAPI: def test_set_credentials(self): challonge.set_credentials(username, api_key) - self.assertEqual(challonge.api._credentials["user"], username) - self.assertEqual(challonge.api._credentials["api_key"], api_key) + assert challonge.api._credentials["user"] == username + assert challonge.api._credentials["api_key"] == api_key def test_get_credentials(self): challonge.api._credentials["user"] = username challonge.api._credentials["api_key"] = api_key - self.assertEqual(challonge.get_credentials(), (username, api_key)) + assert challonge.get_credentials() == (username, api_key) def test_get_local_timezone(self): - tz = challonge.get_timezone() - local_tz = tzlocal.get_localzone() - self.assertEqual(tz, local_tz) + assert challonge.get_timezone() == tzlocal.get_localzone() def test_set_get_timezone(self): - test_tz = "Asia/Seoul" - challonge.set_timezone(test_tz) - tz = challonge.get_timezone() - self.assertEqual(str(tz), test_tz) + challonge.set_timezone("Asia/Seoul") + assert str(challonge.get_timezone()) == "Asia/Seoul" def test_call(self): challonge.set_credentials(username, api_key) - self.assertNotEqual(challonge.fetch("GET", "tournaments"), "") + assert challonge.fetch("GET", "tournaments") != "" -class TournamentsTestCase(unittest.TestCase): - def setUp(self): +class TestTournaments: + @pytest.fixture(autouse=True) + def setup(self): challonge.set_credentials(username, api_key) self.random_name = _get_random_name() - self.t = challonge.tournaments.create(self.random_name, self.random_name) - - def tearDown(self): + yield challonge.tournaments.destroy(self.t["id"]) def test_index(self): - ts = challonge.tournaments.index() - ts = list(filter(lambda x: x["id"] == self.t["id"], ts)) - self.assertEqual(len(ts), 1) - self.assertEqual(self.t, ts[0]) + ts = list( + filter(lambda x: x["id"] == self.t["id"], challonge.tournaments.index()) + ) + assert len(ts) == 1 + assert self.t == ts[0] def test_index_filter_by_state(self): - ts = challonge.tournaments.index(state="pending") - ts = list(filter(lambda x: x["id"] == self.t["id"], ts)) - self.assertEqual(len(ts), 1) - self.assertEqual(self.t, ts[0]) - - ts = challonge.tournaments.index(state="in_progress") - ts = list(filter(lambda x: x["id"] == self.t["id"], ts)) - self.assertEqual(ts, []) + ts = list( + filter( + lambda x: x["id"] == self.t["id"], + challonge.tournaments.index(state="pending"), + ) + ) + assert len(ts) == 1 + assert self.t == ts[0] + + ts = list( + filter( + lambda x: x["id"] == self.t["id"], + challonge.tournaments.index(state="in_progress"), + ) + ) + assert ts == [] def test_index_filter_by_created(self): ts = challonge.tournaments.index( created_after=datetime.datetime.now().date() - datetime.timedelta(days=1) ) - ts = filter(lambda x: x["id"] == self.t["id"], ts) - self.assertTrue(self.t["id"] in map(lambda x: x["id"], ts)) + assert self.t["id"] in map(lambda x: x["id"], ts) def test_show(self): - self.assertEqual(challonge.tournaments.show(self.t["id"]), self.t) + assert challonge.tournaments.show(self.t["id"]) == self.t def test_update_name(self): t = challonge.tournaments.update(self.t["id"], name="Test!") + assert t["name"] == "Test!" + assert t["updated_at"] >= self.t["updated_at"] - self.assertEqual(t["name"], "Test!") t.pop("name") - self.t.pop("name") - - self.assertTrue(t["updated_at"] >= self.t["updated_at"]) t.pop("updated_at") + self.t.pop("name") self.t.pop("updated_at") - - self.assertEqual(t, self.t) + assert t == self.t def test_update_private(self): challonge.tournaments.update(self.t["id"], private=True) - - t = challonge.tournaments.show(self.t["id"]) - - self.assertEqual(t["private"], True) + assert challonge.tournaments.show(self.t["id"])["private"] is True def test_update_type(self): challonge.tournaments.update(self.t["id"], tournament_type="round robin") - - t = challonge.tournaments.show(self.t["id"]) - - self.assertEqual(t["tournament_type"], "round robin") + assert ( + challonge.tournaments.show(self.t["id"])["tournament_type"] == "round robin" + ) def test_open(self): challonge.tournaments.update(self.t["id"], prediction_method=1) challonge.participants.create(self.t["id"], "#1") challonge.participants.create(self.t["id"], "#2") - challonge.tournaments.open_for_predictions(self.t["id"]) - - t = challonge.tournaments.show(self.t["id"]) - self.assertEqual(t["state"], "accepting_predictions") + assert ( + challonge.tournaments.show(self.t["id"])["state"] == "accepting_predictions" + ) def test_start(self): - # we have to add participants in order to start() - self.assertRaises( - challonge.ChallongeException, challonge.tournaments.start, self.t["id"] - ) + with pytest.raises(challonge.ChallongeException): + challonge.tournaments.start(self.t["id"]) - self.assertEqual(self.t["started_at"], None) + assert self.t["started_at"] is None challonge.participants.create(self.t["id"], "#1") challonge.participants.create(self.t["id"], "#2") - challonge.tournaments.start(self.t["id"]) - - t = challonge.tournaments.show(self.t["id"]) - self.assertNotEqual(t["started_at"], None) + assert challonge.tournaments.show(self.t["id"])["started_at"] is not None def test_finalize(self): challonge.participants.create(self.t["id"], "#1") challonge.participants.create(self.t["id"], "#2") - challonge.tournaments.start(self.t["id"]) + ms = challonge.matches.index(self.t["id"]) - self.assertEqual(ms[0]["state"], "open") + assert ms[0]["state"] == "open" challonge.matches.update( self.t["id"], @@ -149,227 +140,188 @@ def test_finalize(self): scores_csv="3-2,4-1,2-2", winner_id=ms[0]["player1_id"], ) - challonge.tournaments.finalize(self.t["id"]) - t = challonge.tournaments.show(self.t["id"]) - - self.assertNotEqual(t["completed_at"], None) + assert challonge.tournaments.show(self.t["id"])["completed_at"] is not None def test_reset(self): - # have to add participants in order to start() challonge.participants.create(self.t["id"], "#1") challonge.participants.create(self.t["id"], "#2") - challonge.tournaments.start(self.t["id"]) - # we can't add participants to a started tournament... - self.assertRaises( - challonge.ChallongeException, - challonge.participants.create, - self.t["id"], - "name", - ) + with pytest.raises(challonge.ChallongeException): + challonge.participants.create(self.t["id"], "name") challonge.tournaments.reset(self.t["id"]) - # but we can add participants to a reset tournament p = challonge.participants.create(self.t["id"], "name") - challonge.participants.destroy(self.t["id"], p["id"]) -class ParticipantsTestCase(unittest.TestCase): - def setUp(self): +class TestParticipants: + @pytest.fixture(autouse=True) + def setup(self): challonge.set_credentials(username, api_key) self.t_name = _get_random_name() self.ps_names = [_get_random_name(), _get_random_name()] self.t = challonge.tournaments.create(self.t_name, self.t_name) self.ps = challonge.participants.bulk_add(self.t["id"], self.ps_names) - - def tearDown(self): + yield challonge.tournaments.destroy(self.t["id"]) def test_index(self): ps = challonge.participants.index(self.t["id"]) - self.assertEqual(len(ps), 2) - - self.assertTrue(self.ps[0] == ps[0] or self.ps[0] == ps[1]) - self.assertTrue(self.ps[1] == ps[0] or self.ps[1] == ps[1]) + assert len(ps) == 2 + assert self.ps[0] in ps + assert self.ps[1] in ps def test_show(self): p1 = challonge.participants.show(self.t["id"], self.ps[0]["id"]) - self.assertEqual(p1["id"], self.ps[0]["id"]) + assert p1["id"] == self.ps[0]["id"] def test_create(self): new_player = challonge.participants.create(self.t["id"], _get_random_name()) - res = challonge.participants.show(self.t["id"], new_player["id"]) - self.assertEqual(res, new_player) + assert challonge.participants.show(self.t["id"], new_player["id"]) == new_player def test_create_with_number_names(self): - player_with_only_numbers_in_name = "".join( - [str(random.randint(0, 9)) for _ in range(0, 9)] - ) - new_player = challonge.participants.create( - self.t["id"], player_with_only_numbers_in_name + name = "".join([str(random.randint(0, 9)) for _ in range(9)]) + new_player = challonge.participants.create(self.t["id"], name) + assert ( + challonge.participants.show(self.t["id"], new_player["id"])["name"] == name ) - res = challonge.participants.show(self.t["id"], new_player["id"]) - self.assertEqual(res["name"], player_with_only_numbers_in_name) def test_update(self): p1 = challonge.participants.update(self.t["id"], self.ps[0]["id"], misc="Test!") + assert p1["misc"] == "Test!" + assert p1["updated_at"] >= self.ps[0]["updated_at"] - self.assertEqual(p1["misc"], "Test!") - self.ps[0].pop("misc") p1.pop("misc") - - self.assertTrue(p1["updated_at"] >= self.ps[0]["updated_at"]) - self.ps[0].pop("updated_at") p1.pop("updated_at") + self.ps[0].pop("misc") + self.ps[0].pop("updated_at") + assert self.ps[0] == p1 - self.assertEqual(self.ps[0], p1) - - @unittest.skip("Skipping because of API Issues") + @pytest.mark.skip( + reason="API issue: undo_check_in leaves checked_in=True in response" + ) def test_check_in_and_undo_check_in(self): timezone = challonge.get_timezone() - # Get the local time plus 30 minutes. test_date = datetime.datetime.now(tz=timezone) + datetime.timedelta(minutes=30) - challonge.tournaments.update( self.t["id"], check_in_duration=30, start_at=test_date ) p1 = challonge.participants.check_in(self.t["id"], self.ps[0]["id"]) p2 = challonge.participants.check_in(self.t["id"], self.ps[1]["id"]) + assert p1["checked_in"] + assert p2["checked_in"] - self.assertTrue(p1["checked_in"]) - self.assertTrue(p2["checked_in"]) - - # check the undo process p1 = challonge.participants.undo_check_in(self.t["id"], self.ps[0]["id"]) p2 = challonge.participants.undo_check_in(self.t["id"], self.ps[1]["id"]) - - self.assertFalse(p1["checked_in"]) - self.assertFalse(p2["checked_in"]) + assert not p1["checked_in"] + assert not p2["checked_in"] def test_destroy_before_tournament_start(self): - # delete participant before the start of the tournament challonge.participants.destroy(self.t["id"], self.ps[0]["id"]) - p = challonge.participants.index(self.t["id"]) - self.assertEqual(len(p), 1) + assert len(challonge.participants.index(self.t["id"])) == 1 def test_destroy_after_tournament_start(self): - # delete participant after the start of the tournament challonge.tournaments.start(self.t["id"]) challonge.participants.destroy(self.t["id"], self.ps[1]["id"]) - p2 = challonge.participants.show(self.t["id"], self.ps[1]["id"]) - self.assertFalse(p2["active"]) + assert not challonge.participants.show(self.t["id"], self.ps[1]["id"])["active"] def test_randomize(self): ps = challonge.participants.randomize(self.t["id"]) - self.assertIsInstance(ps, list) - self.assertEqual(len(ps), len(self.ps)) + assert isinstance(ps, list) + assert len(ps) == len(self.ps) -class MatchesTestCase(unittest.TestCase): - def setUp(self): +class TestMatches: + @pytest.fixture(autouse=True) + def setup(self): challonge.set_credentials(username, api_key) self.t_name = _get_random_name() - self.t = challonge.tournaments.create(self.t_name, self.t_name) self.ps = challonge.participants.bulk_add( self.t["id"], [_get_random_name(), _get_random_name()] ) challonge.tournaments.start(self.t["id"]) - - def tearDown(self): + yield challonge.tournaments.destroy(self.t["id"]) def test_index(self): ms = challonge.matches.index(self.t["id"]) - - self.assertEqual(len(ms), 1) + assert len(ms) == 1 m = ms[0] - - ps = set((self.ps[0]["id"], self.ps[1]["id"])) - self.assertEqual(ps, set((m["player1_id"], m["player2_id"]))) - self.assertEqual(m["state"], "open") + assert {self.ps[0]["id"], self.ps[1]["id"]} == { + m["player1_id"], + m["player2_id"], + } + assert m["state"] == "open" def test_show(self): - ms = challonge.matches.index(self.t["id"]) - for m in ms: - self.assertEqual(m, challonge.matches.show(self.t["id"], m["id"])) + for m in challonge.matches.index(self.t["id"]): + assert m == challonge.matches.show(self.t["id"], m["id"]) def test_update_reopen(self): - ms = challonge.matches.index(self.t["id"]) - m = ms[0] - self.assertEqual(m["state"], "open") + m = challonge.matches.index(self.t["id"])[0] + assert m["state"] == "open" m = challonge.matches.update( self.t["id"], m["id"], scores_csv="3-2,4-1,2-2", winner_id=m["player1_id"] ) - - self.assertEqual(m["state"], "complete") + assert m["state"] == "complete" m = challonge.matches.reopen(self.t["id"], m["id"]) - self.assertEqual(m["state"], "open") + assert m["state"] == "open" def test_mark_as_underway(self): - ms = challonge.matches.index(self.t["id"]) - m = ms[0] - + m = challonge.matches.index(self.t["id"])[0] m = challonge.matches.mark_as_underway(self.t["id"], m["id"]) - self.assertIsInstance(m["underway_at"], datetime.datetime) + assert isinstance(m["underway_at"], datetime.datetime) def test_unmark_as_underway(self): - ms = challonge.matches.index(self.t["id"]) - m = ms[0] - + m = challonge.matches.index(self.t["id"])[0] challonge.matches.mark_as_underway(self.t["id"], m["id"]) m = challonge.matches.unmark_as_underway(self.t["id"], m["id"]) - self.assertIsNone(m["underway_at"]) + assert m["underway_at"] is None -class AttachmentsTestCase(unittest.TestCase): - def setUp(self): +class TestAttachments: + @pytest.fixture(autouse=True) + def setup(self): challonge.set_credentials(username, api_key) self.t_name = _get_random_name() - self.t = challonge.tournaments.create( self.t_name, self.t_name, accept_attachments=True ) - self.ps = challonge.participants.bulk_add( self.t["id"], [_get_random_name(), _get_random_name()] ) challonge.tournaments.start(self.t["id"]) self.match = challonge.matches.index(self.t["id"])[0] - - def tearDown(self): + yield challonge.tournaments.destroy(self.t["id"]) def test_index(self): challonge.attachments.create( self.t["id"], self.match["id"], url="http://test.com" ) - challonge.attachments.create( self.t["id"], self.match["id"], url="http://test2.com" ) - - a = challonge.attachments.index(self.t["id"], self.match["id"]) - self.assertEqual(len(a), 2) + assert len(challonge.attachments.index(self.t["id"], self.match["id"])) == 2 def test_create_url(self): a = challonge.attachments.create( self.t["id"], self.match["id"], url="http://test.com" ) - self.assertEqual(a["url"], "http://test.com") + assert a["url"] == "http://test.com" def test_create_description(self): a = challonge.attachments.create( self.t["id"], self.match["id"], description="test text!" ) - self.assertEqual(a["description"], "test text!") + assert a["description"] == "test text!" def test_create_url_with_description(self): a = challonge.attachments.create( @@ -378,40 +330,33 @@ def test_create_url_with_description(self): url="http://test.com", description="just a test", ) + assert a["url"] == "http://test.com" + assert a["description"] == "just a test" - self.assertEqual(a["url"], "http://test.com") - self.assertEqual(a["description"], "just a test") - - @unittest.skip("Skipping because of API Issues") + @pytest.mark.skip(reason="API issue: file upload returns 500") def test_create_file(self): image = httpx.get("https://picsum.photos/200/300") a1 = challonge.attachments.create(self.t["id"], self.match["id"], asset=image) - a2 = challonge.attachments.show(self.t["id"], self.match["id"], a1["id"]) + assert a1["asset"] == a2["asset"] - self.assertEqual(a1["asset"], a2["asset"]) - - @unittest.skip("Skipping because of API Issues") + @pytest.mark.skip(reason="API issue: file upload returns 500") def test_create_file_with_description(self): image = httpx.get("https://picsum.photos/200/300") a1 = challonge.attachments.create( self.t["id"], self.match["id"], asset=image, description="just a test" ) - a2 = challonge.attachments.show(self.t["id"], self.match["id"], a1["id"]) - - self.assertEqual(a1["asset"], a2["asset"]) + assert a1["asset"] == a2["asset"] def test_update_url(self): a = challonge.attachments.create( self.t["id"], self.match["id"], url="http://test.com" ) - a = challonge.attachments.update( self.t["id"], self.match["id"], a["id"], url="https://newtest.com" ) - - self.assertEqual(a["url"], "https://newtest.com") + assert a["url"] == "https://newtest.com" def test_update_description(self): a = challonge.attachments.create( @@ -423,8 +368,7 @@ def test_update_description(self): a["id"], description="This is an updated test!", ) - - self.assertEqual(a["description"], "This is an updated test!") + assert a["description"] == "This is an updated test!" def test_update_url_with_description(self): a = challonge.attachments.create( @@ -433,7 +377,6 @@ def test_update_url_with_description(self): url="http://test.com", description="hello there!", ) - a = challonge.attachments.update( self.t["id"], self.match["id"], @@ -441,29 +384,25 @@ def test_update_url_with_description(self): url="http://newtest.com", description="added a new url!", ) + assert a["url"] == "http://newtest.com" + assert a["description"] == "added a new url!" - self.assertEqual(a["url"], "http://newtest.com") - self.assertEqual(a["description"], "added a new url!") - - @unittest.skip("Skipping because of API Issues") + @pytest.mark.skip(reason="API issue: file upload returns 500") def test_update_file(self): image = httpx.get("https://picsum.photos/200/300") a1 = challonge.attachments.create(self.t["id"], self.match["id"], asset=image) - image = httpx.get("https://picsum.photos/200/300") a2 = challonge.attachments.update( self.t["id"], self.match["id"], a1["id"], asset=image ) + assert a1["asset"] != a2["asset"] - self.assertNotEqual(a1["asset"], a2["asset"]) - - @unittest.skip("Skipping because of API Issues") + @pytest.mark.skip(reason="API issue: file upload returns 500") def test_update_file_with_description(self): image = httpx.get("https://picsum.photos/200/300") a1 = challonge.attachments.create( self.t["id"], self.match["id"], asset=image, description="just a test" ) - image = httpx.get("https://picsum.photos/200/300") a2 = challonge.attachments.update( self.t["id"], @@ -472,24 +411,21 @@ def test_update_file_with_description(self): asset=image, description="just a second test", ) + assert a1["asset"] != a2["asset"] + assert a1["description"] != a2["description"] - self.assertNotEqual(a1["asset"], a2["asset"]) - self.assertNotEqual(a1["description"], a2["description"]) - - @unittest.skip("Skipping because of API Issues") + @pytest.mark.skip(reason="API issue: file upload returns 500") def test_update_file_only_description(self): image = httpx.get("https://picsum.photos/200/300") a1 = challonge.attachments.create( self.t["id"], self.match["id"], asset=image, description="just a test" ) - image = httpx.get("https://picsum.photos/200/300") a2 = challonge.attachments.update( self.t["id"], self.match["id"], a1["id"], description="just a second test" ) - - self.assertEqual(a1["asset"], a2["asset"]) - self.assertNotEqual(a1["description"], a2["description"]) + assert a1["asset"] == a2["asset"] + assert a1["description"] != a2["description"] def test_destroy(self): a = challonge.attachments.create( @@ -498,20 +434,5 @@ def test_destroy(self): url="http://test.com", description="just a test", ) - challonge.attachments.destroy(self.t["id"], self.match["id"], a["id"]) - a = challonge.attachments.index(self.t["id"], self.match["id"]) - - self.assertEqual(a, []) - - -if __name__ == "__main__": - username = os.environ.get("CHALLONGE_USER") - api_key = os.environ.get("CHALLONGE_KEY") - if not username or not api_key: - raise RuntimeError( - "You must add CHALLONGE_USER and CHALLONGE_KEY \ - to your environment variables to run the test suite" - ) - - unittest.main() + assert challonge.attachments.index(self.t["id"], self.match["id"]) == [] From 57bd8792ddce14d5d17b1d8dfbd9a6db3364eb91 Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Fri, 29 May 2026 00:58:29 +0300 Subject: [PATCH 11/20] add dataclass models for tournament, participant, match, attachment --- challonge/models.py | 84 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 challonge/models.py diff --git a/challonge/models.py b/challonge/models.py new file mode 100644 index 0000000..9e7d5ca --- /dev/null +++ b/challonge/models.py @@ -0,0 +1,84 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class Tournament: + id: int + name: str + url: str + tournament_type: str + state: str + description: str | None = None + game_name: str | None = None + game_id: int | None = None + private: bool = False + open_signup: bool = False + hold_third_place_match: bool = False + teams: bool = False + signup_cap: int | None = None + check_in_duration: int | None = None + participants_count: int = 0 + prediction_method: int = 0 + swiss_rounds: int = 0 + starts_at: datetime | None = None + started_at: datetime | None = None + completed_at: datetime | None = None + predictions_opened_at: datetime | None = None + check_in_opened_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +@dataclass +class Participant: + id: int + tournament_id: int + name: str + seed: int + active: bool = True + final_rank: int | None = None + username: str | None = None + email: str | None = None + group_id: int | None = None + misc: str | None = None + checked_in: bool = False + checked_in_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +@dataclass +class Match: + id: int + tournament_id: int + state: str + round: int + identifier: str + player1_id: int | None = None + player2_id: int | None = None + player1_prereq_match_id: int | None = None + player2_prereq_match_id: int | None = None + winner_id: int | None = None + loser_id: int | None = None + scores_csv: str | None = None + suggested_play_order: int | None = None + group_id: int | None = None + has_attachment: bool = False + underway_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +@dataclass +class MatchAttachment: + id: int + match_id: int + url: str | None = None + description: str | None = None + asset_file_name: str | None = None + asset_content_type: str | None = None + asset_file_size: int | None = None + asset_url: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None From 8f85de718acece456577ff5f7deb7f44cffc7fcb Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Fri, 29 May 2026 00:58:37 +0300 Subject: [PATCH 12/20] implement client architecture, replace global state with Client and AsyncClient --- challonge/__init__.py | 13 +-- challonge/api.py | 184 ++---------------------------------- challonge/attachments.py | 97 +++---------------- challonge/client.py | 116 +++++++++++++++++++++++ challonge/matches.py | 99 ++++---------------- challonge/participants.py | 165 +++++---------------------------- challonge/tournaments.py | 190 ++++++-------------------------------- 7 files changed, 210 insertions(+), 654 deletions(-) create mode 100644 challonge/client.py diff --git a/challonge/__init__.py b/challonge/__init__.py index 1ffbce7..ff79c50 100644 --- a/challonge/__init__.py +++ b/challonge/__init__.py @@ -1,10 +1,3 @@ -from challonge import attachments, matches, participants, tournaments -from challonge.api import ( - ChallongeException, - fetch, - get_credentials, - get_timezone, - set_credentials, - set_timezone, - set_user_agent, -) +from challonge.api import ChallongeException +from challonge.client import AsyncClient, Client +from challonge.models import Match, MatchAttachment, Participant, Tournament diff --git a/challonge/api.py b/challonge/api.py index 5525aba..b6a4a39 100644 --- a/challonge/api.py +++ b/challonge/api.py @@ -1,152 +1,18 @@ -from zoneinfo import ZoneInfo +import dataclasses import iso8601 -import tzlocal -from httpx import HTTPStatusError, request - -tz = tzlocal.get_localzone() -user_agent = "pychallonge" - -CHALLONGE_API_URL = "api.challonge.com/v1" - -_credentials = { - "user": None, - "api_key": None, -} class ChallongeException(Exception): pass -def set_credentials(username, api_key): - """Set the challonge.com api credentials to use.""" - _credentials["user"] = username - _credentials["api_key"] = api_key - - -def set_user_agent(agent): - """Set User-Agent in the HTTP requests. - - :keyword param agent: string - ex. 'test agent 1' - """ - global user_agent - user_agent = agent - - -def set_timezone(new_tz=None): - """Set the timezone for datetime fields. - By default is your machine's time. - If it's called without parameter sets the - local time again. - - Args: - new_tz (str, optional): timezone string. Defaults to None. - ex. 'Europe/Athens', - 'Asia/Seoul', - 'America/Los_Angeles', - 'UTC' - """ - global tz - if new_tz: - tz = ZoneInfo(new_tz) - else: - tz = tzlocal.get_localzone() - - -def get_credentials(): - """Retrieve the challonge.com credentials set with set_credentials(). - - Returns: - A tuple with user and API key - """ - return _credentials["user"], _credentials["api_key"] - - -def get_timezone(): - """Return currently timezone in use. - - Returns: - A timezone object - """ - return tz - - -def fetch(method, uri, params_prefix=None, timeout=30.0, **params): - """Fetch the given uri and return the contents of the response. - - Args: - method (str): The HTTP method for the API request (GET, POST, PUT, DELETE) - uri (str): The URI of the API endpoint - params_prefix (str, optional): It is one of the "name", "url", "tournament_type". Defaults to None. - timeout (float, optional): The timeout of the request in seconds. Defaults to 30.0 seconds - params (list, optional): The parameters of the tournament - - Returns: - A str representing the json response - """ - p_params = _prepare_params(params, params_prefix) - if method == "POST" or method == "PUT": - r_data = {"data": p_params} - else: - r_data = {"params": p_params} - - # build the HTTP request and use basic authentication - url = f"https://{CHALLONGE_API_URL}/{uri}.json" - - try: - response = request( - method, - url, - headers={"User-Agent": user_agent}, - auth=get_credentials(), - timeout=timeout, - **r_data, - ) - response.raise_for_status() - except HTTPStatusError as e: - if e.response.status_code == 422: - doc = e.response.json() - if doc.get("errors"): - raise ChallongeException(*doc["errors"]) from e - raise - - return response - - -def fetch_and_parse(method, uri, params_prefix=None, timeout=30.0, **params): - """Fetch the given uri and return python dictionary with parsed data-types. - - Args: - method (str): The HTTP method for the API request (GET, POST, PUT, DELETE) - uri (str): The URI of the API endpoint - params_prefix (str, optional): It is one of the "name", "url", "tournament_type". Defaults to None. - timeout (float, optional): The timeout of the request in seconds. Defaults to 30.0 seconds - params (list, optional): The parameters of the tournament - - Returns: - A dict representing the json response - """ - response = fetch(method, uri, params_prefix, timeout, **params) - return _parse(response.json()) - - -def _parse(data): - """Recursively convert a json into python data types. - - Args: - data (dict): The dict with the response - - Returns: - A dict with the values converted to appropriate python data types - """ +def _parse(data, target_class, tz): if not data: return [] elif isinstance(data, (tuple, list)): - return [_parse(subdata) for subdata in data] + return [_parse(subdata, target_class, tz) for subdata in data] - # extract the nested dict. ex. {"tournament": {"url": "7k1safq" ...}} d = {} for envelope_key, inner in data.items(): if not isinstance(inner, dict): @@ -161,27 +27,12 @@ def _parse(data): d[k] = iso8601.parse_date(v).astimezone(tz) except iso8601.ParseError: pass - return d - - -def _prepare_params(dirty_params, prefix=None): - """Prepares parameters to be sent to challonge.com. - - Args: - dirty_params (dict): The parameters given for the API request - prefix (str, optional): Defaults to None. - Note: - The `prefix` can be used to convert parameters with keys that - look like ("name", "url", "tournament_type") into something like - ("tournament[name]", "tournament[url]", "tournament[tournament_type]"), - which is how challonge.com expects parameters describing specific - objects. + known = {f.name for f in dataclasses.fields(target_class)} + return target_class(**{k: v for k, v in d.items() if k in known}) - Returns: - A list of parameters in format ready to use for the API request - """ +def _prepare_params(dirty_params, prefix=None): params = {} for k, v in dirty_params.items(): v = _prepare_value(v) @@ -193,25 +44,10 @@ def _prepare_params(dirty_params, prefix=None): def _prepare_value(val): - """Change value format to be accepted by challonge.com API. This function is used by _prepare_params. - - Args: - val (obj): values from prepare_params (int, str, bool, datetime, etc) - - Returns: - The value in a correct format (lowercase for str for bool values and isoformat for the datetime objects) - """ - prepared_val = None if hasattr(val, "isoformat"): - prepared_val = val.isoformat() + return val.isoformat() elif isinstance(val, bool): - # challonge.com only accepts lowercase true/false - prepared_val = str(val).lower() + return str(val).lower() elif isinstance(val, (tuple, list)): - prepared_val = [] - for v in val: - prepared_val.append(_prepare_value(v)) - else: - prepared_val = val - - return prepared_val + return [_prepare_value(v) for v in val] + return val diff --git a/challonge/attachments.py b/challonge/attachments.py index f5e2a0e..10e0686 100644 --- a/challonge/attachments.py +++ b/challonge/attachments.py @@ -1,90 +1,21 @@ -from challonge import api +from challonge.models import MatchAttachment -def index(tournament, match_id): - """Retrieve a set of attachments created for a specific match. +class AttachmentsClient: + def __init__(self, client): + self._c = client - Args: - tournament (int or str): The tournament's id or name - match_id (int): The match's id for the specific tournament + def index(self, tournament, match_id): + return self._c._fetch_and_parse("GET", f"tournaments/{tournament}/matches/{match_id}/attachments", target_class=MatchAttachment) - Returns: - A list with the tournament's attachments - """ - return api.fetch_and_parse( - "GET", f"tournaments/{tournament}/matches/{match_id}/attachments" - ) + def create(self, tournament, match_id, **params): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/matches/{match_id}/attachments", "match_attachment", target_class=MatchAttachment, **params) + def show(self, tournament, match_id, attachment_id): + return self._c._fetch_and_parse("GET", f"tournaments/{tournament}/matches/{match_id}/attachments/{attachment_id}", target_class=MatchAttachment) -def create(tournament, match_id, **params): - """Create a new attachment for the specific match. + def update(self, tournament, match_id, attachment_id, **params): + return self._c._fetch_and_parse("PUT", f"tournaments/{tournament}/matches/{match_id}/attachments/{attachment_id}", "match_attachment", target_class=MatchAttachment, **params) - Args: - tournament (int or str): The tournament's id or name - match_id (int): The match's id for the specific tournament - **params (optional): extra keyword arguments used for the setup of the attachment (asset, url, description) - - Returns: - A dict representing the created attachment - """ - return api.fetch_and_parse( - "POST", - f"tournaments/{tournament}/matches/{match_id}/attachments", - "match_attachment", - **params, - ) - - -def show(tournament, match_id, attachment_id): - """Retrieve a single match attachment record. - - Args: - tournament (int or str): The tournament's id or name - match_id (int): The match's id for the specific tournament - attachment_id (int): The attachment's id for the specific match - - Returns: - A dict representing the attachment - """ - return api.fetch_and_parse( - "GET", - f"tournaments/{tournament}/matches/{match_id}/attachments/{attachment_id}", - ) - - -def update(tournament, match_id, attachment_id, **params): - """Update the attributes of a match attachment. - - Args: - tournament (int or str): The tournament's id or name - match_id (int): The match's id for the specific tournament - attachment_id (int): The attachment's id for the specific match - **params (optional): extra keyword arguments used for the update of the attachment (asset, url, description) - - - Returns: - A dict representing the updated attachment - """ - return api.fetch_and_parse( - "PUT", - f"tournaments/{tournament}/matches/{match_id}/attachments/{attachment_id}", - "match_attachment", - **params, - ) - - -def destroy(tournament, match_id, attachment_id): - """Delete a match attachment. - - Args: - tournament (int or str): The tournament's id or name - match_id (int): The match's id for the specific tournament - attachment_id (int): The attachment's id for the specific match - - Returns: - None - """ - api.fetch( - "DELETE", - f"tournaments/{tournament}/matches/{match_id}/attachments/{attachment_id}", - ) + def destroy(self, tournament, match_id, attachment_id): + return self._c._fetch("DELETE", f"tournaments/{tournament}/matches/{match_id}/attachments/{attachment_id}") diff --git a/challonge/client.py b/challonge/client.py new file mode 100644 index 0000000..a2b3d7c --- /dev/null +++ b/challonge/client.py @@ -0,0 +1,116 @@ +from zoneinfo import ZoneInfo + +import tzlocal +from httpx import AsyncClient as HttpxAsyncClient +from httpx import Client as HttpxClient +from httpx import HTTPStatusError + +from challonge.api import ChallongeException, _parse, _prepare_params +from challonge.attachments import AttachmentsClient +from challonge.matches import MatchesClient +from challonge.participants import ParticipantsClient +from challonge.tournaments import TournamentsClient + +CHALLONGE_API_URL = "api.challonge.com/v1" + + +class Client: + def __init__(self, user, api_key, *, timezone=None, user_agent="pychallonge", timeout=30.0): + self._user = user + self._api_key = api_key + self._tz = ZoneInfo(timezone) if timezone else tzlocal.get_localzone() + self._user_agent = user_agent + self._timeout = timeout + self._http = HttpxClient() + + self.tournaments = TournamentsClient(self) + self.participants = ParticipantsClient(self) + self.matches = MatchesClient(self) + self.attachments = AttachmentsClient(self) + + def _fetch(self, method, uri, params_prefix=None, **params): + p_params = _prepare_params(params, params_prefix) + r_data = {"data": p_params} if method in ("POST", "PUT") else {"params": p_params} + url = f"https://{CHALLONGE_API_URL}/{uri}.json" + + try: + response = self._http.request( + method, url, + headers={"User-Agent": self._user_agent}, + auth=(self._user, self._api_key), + timeout=self._timeout, + **r_data, + ) + response.raise_for_status() + except HTTPStatusError as e: + if e.response.status_code == 422: + doc = e.response.json() + if doc.get("errors"): + raise ChallongeException(*doc["errors"]) from e + raise + + return response + + def _fetch_and_parse(self, method, uri, params_prefix=None, target_class=None, **params): + response = self._fetch(method, uri, params_prefix, **params) + return _parse(response.json(), target_class, self._tz) + + def close(self): + self._http.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + +class AsyncClient: + def __init__(self, user, api_key, *, timezone=None, user_agent="pychallonge", timeout=30.0): + self._user = user + self._api_key = api_key + self._tz = ZoneInfo(timezone) if timezone else tzlocal.get_localzone() + self._user_agent = user_agent + self._timeout = timeout + self._http = HttpxAsyncClient() + + self.tournaments = TournamentsClient(self) + self.participants = ParticipantsClient(self) + self.matches = MatchesClient(self) + self.attachments = AttachmentsClient(self) + + async def _fetch(self, method, uri, params_prefix=None, **params): + p_params = _prepare_params(params, params_prefix) + r_data = {"data": p_params} if method in ("POST", "PUT") else {"params": p_params} + url = f"https://{CHALLONGE_API_URL}/{uri}.json" + + try: + response = await self._http.request( + method, url, + headers={"User-Agent": self._user_agent}, + auth=(self._user, self._api_key), + timeout=self._timeout, + **r_data, + ) + response.raise_for_status() + except HTTPStatusError as e: + if e.response.status_code == 422: + doc = e.response.json() + if doc.get("errors"): + raise ChallongeException(*doc["errors"]) from e + raise + + return response + + async def _fetch_and_parse(self, method, uri, params_prefix=None, target_class=None, **params): + response = await self._fetch(method, uri, params_prefix, **params) + return _parse(response.json(), target_class, self._tz) + + async def aclose(self): + await self._http.aclose() + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + await self.aclose() diff --git a/challonge/matches.py b/challonge/matches.py index f84c339..9510832 100644 --- a/challonge/matches.py +++ b/challonge/matches.py @@ -1,91 +1,24 @@ -from challonge import api +from challonge.models import Match -def index(tournament, **params): - """Retrieve a tournament's match list. +class MatchesClient: + def __init__(self, client): + self._c = client - Args: - tournament (int or str): The tournament's id or name - **params (optional): the keyword arguments used to filter the results with state and/or participant_id + def index(self, tournament, **params): + return self._c._fetch_and_parse("GET", f"tournaments/{tournament}/matches", target_class=Match, **params) - Returns: - A list with the tournament's matches - """ - return api.fetch_and_parse("GET", f"tournaments/{tournament}/matches", **params) + def show(self, tournament, match_id, **params): + return self._c._fetch_and_parse("GET", f"tournaments/{tournament}/matches/{match_id}", target_class=Match, **params) + def update(self, tournament, match_id, **params): + return self._c._fetch_and_parse("PUT", f"tournaments/{tournament}/matches/{match_id}", "match", target_class=Match, **params) -def show(tournament, match_id, **params): - """Retrieve a single match record for a tournament. + def reopen(self, tournament, match_id): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/matches/{match_id}/reopen", target_class=Match) - Args: - tournament (int or str): The tournament's id or name - match_id (int): The match's id for the specific tournament - **params (optional): The keywords arguments to include attachments. + def mark_as_underway(self, tournament, match_id): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/matches/{match_id}/mark_as_underway", target_class=Match) - Returns: - A dict with the match details - """ - return api.fetch_and_parse( - "GET", f"tournaments/{tournament}/matches/{match_id}", **params - ) - - -def update(tournament, match_id, **params): - """Update/submit the score(s) for a match. - - Args: - tournament (int or str): The tournament's id or name - match_id (int): The match's id for the specific tournament - **params (optional): the keyword arguments used to update of the match - - Returns: - A dict representing the updated match - """ - return api.fetch_and_parse( - "PUT", f"tournaments/{tournament}/matches/{match_id}", "match", **params - ) - - -def reopen(tournament, match_id): - """Reopens a match that was marked completed, automatically resetting matches that follow it. - - Args: - tournament (int or str): The tournament's id or name - match_id (int): The match's id for the specific tournament - - Returns: - A dict representing the reopened match - """ - return api.fetch_and_parse( - "POST", f"tournaments/{tournament}/matches/{match_id}/reopen" - ) - - -def mark_as_underway(tournament, match_id): - """Sets "underway_at" to the current time and highlights the match in the bracket. - - Args: - tournament (int or str): The tournament's id or name - match_id (int): The match's id for the specific tournament - - Returns: - A dict representing the match with underway_at set - """ - return api.fetch_and_parse( - "POST", f"tournaments/{tournament}/matches/{match_id}/mark_as_underway" - ) - - -def unmark_as_underway(tournament, match_id): - """Clears "underway_at" and unhighlights the match in the bracket. - - Args: - tournament (int or str): The tournament's id or name - match_id (int): The match's id for the specific tournament - - Returns: - A dict representing the match with underway_at cleared - """ - return api.fetch_and_parse( - "POST", f"tournaments/{tournament}/matches/{match_id}/unmark_as_underway" - ) + def unmark_as_underway(self, tournament, match_id): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/matches/{match_id}/unmark_as_underway", target_class=Match) diff --git a/challonge/participants.py b/challonge/participants.py index 827a369..048f9f2 100644 --- a/challonge/participants.py +++ b/challonge/participants.py @@ -1,152 +1,35 @@ -from challonge import api +from challonge.models import Participant -def index(tournament): - """Retrieve a tournament's participant list. +class ParticipantsClient: + def __init__(self, client): + self._c = client - Args: - tournament (int or str): The tournament's id or name + def index(self, tournament): + return self._c._fetch_and_parse("GET", f"tournaments/{tournament}/participants", target_class=Participant) - Returns: - A list with the tournament's participants - """ - return api.fetch_and_parse("GET", f"tournaments/{tournament}/participants") + def create(self, tournament, name, **params): + params.update({"name": name}) + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/participants", "participant", target_class=Participant, **params) + def bulk_add(self, tournament, names, **params): + params.update({"name": names}) + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/participants/bulk_add", "participants[]", target_class=Participant, **params) -def create(tournament, name, **params): - """Add a participant to a tournament. + def show(self, tournament, participant_id, **params): + return self._c._fetch_and_parse("GET", f"tournaments/{tournament}/participants/{participant_id}", target_class=Participant, **params) - Args: - tournament (int or str): The tournament's id or name - name (str): The participant's name - **params (optional): extra keyword arguments used for the setup of the participant + def update(self, tournament, participant_id, **params): + return self._c._fetch_and_parse("PUT", f"tournaments/{tournament}/participants/{participant_id}", "participant", target_class=Participant, **params) - Returns: - A dict representing the created participant - """ - params.update({"name": name}) + def check_in(self, tournament, participant_id): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/participants/{participant_id}/check_in", target_class=Participant) - return api.fetch_and_parse( - "POST", f"tournaments/{tournament}/participants", "participant", **params - ) + def undo_check_in(self, tournament, participant_id): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/participants/{participant_id}/undo_check_in", target_class=Participant) + def destroy(self, tournament, participant_id): + return self._c._fetch("DELETE", f"tournaments/{tournament}/participants/{participant_id}") -def bulk_add(tournament, names, **params): - """Bulk add participants to a tournament (up until it is started). - - Args: - tournament (int or str): The tournament's id or name - names (list): A list of participants names (str) - **params (optional): extra keyword arguments used for the setup of the participants - - Returns: - A list representing the created participants - """ - params.update({"name": names}) - - return api.fetch_and_parse( - "POST", - f"tournaments/{tournament}/participants/bulk_add", - "participants[]", - **params, - ) - - -def show(tournament, participant_id, **params): - """Retrieve a single participant record for a tournament. - - Args: - tournament (int or str): The tournament's id or name - participant_id (int): The participant's id for the specific tournament - **params (optional): The keywords arguments to include matches. - - Returns: - A dict with the match details - """ - return api.fetch_and_parse( - "GET", f"tournaments/{tournament}/participants/{participant_id}", **params - ) - - -def update(tournament, participant_id, **params): - """Update the attributes of a tournament participant. - - Args: - tournament (int or str): The tournament's id or name - participant_id (int): The participant's id for the specific tournament - **params (optional): The keywords arguments used to update the participant. - - Returns: - A dict representing the updated participant - """ - return api.fetch_and_parse( - "PUT", - f"tournaments/{tournament}/participants/{participant_id}", - "participant", - **params, - ) - - -def check_in(tournament, participant_id): - """Checks a participant in. - - Args: - tournament (int or str): The tournament's id or name - participant_id (int): The participant's id for the specific tournament - - Returns: - A dict representing the checked-in participant - """ - return api.fetch_and_parse( - "POST", f"tournaments/{tournament}/participants/{participant_id}/check_in" - ) - - -def undo_check_in(tournament, participant_id): - """Marks a participant as having not checked in. - - Args: - tournament (int or str): The tournament's id or name - participant_id (int): The participant's id for the specific tournament - - Returns: - A dict representing the participant - """ - return api.fetch_and_parse( - "POST", f"tournaments/{tournament}/participants/{participant_id}/undo_check_in" - ) - - -def destroy(tournament, participant_id): - """Destroys or deactivates a participant. - - If tournament has not started, delete a participant, automatically - filling in the abandoned seed number. - - If tournament is underway, mark a participant inactive, automatically - forfeiting his/her remaining matches. - - Args: - tournament (int or str): The tournament's id or name - participant_id (int): The participant's id for the specific tournament - - Returns: - None - """ - api.fetch("DELETE", f"tournaments/{tournament}/participants/{participant_id}") - - -def randomize(tournament): - """Randomize seeds among participants. - - Only applicable before a tournament has started. - - Args: - tournament (int or str): The tournament's id or name - - Returns: - A list of participants with randomized seeds - """ - return api.fetch_and_parse( - "POST", f"tournaments/{tournament}/participants/randomize" - ) + def randomize(self, tournament): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/participants/randomize", target_class=Participant) diff --git a/challonge/tournaments.py b/challonge/tournaments.py index 3218ade..017bee1 100644 --- a/challonge/tournaments.py +++ b/challonge/tournaments.py @@ -1,176 +1,40 @@ -from challonge import api +from challonge.models import Tournament -def index(**params): - """Retrieve a set of tournaments created with your account. +class TournamentsClient: + def __init__(self, client): + self._c = client - Args: - **params (optional): the keyword arguments used to filter the results (state, type, created_after, created_before, subdomain) + def index(self, **params): + return self._c._fetch_and_parse("GET", "tournaments", target_class=Tournament, **params) - Returns: - A list of dicts representing tournaments - """ - return api.fetch_and_parse("GET", "tournaments", **params) + def create(self, name, url, tournament_type="single elimination", **params): + params.update({"name": name, "url": url, "tournament_type": tournament_type}) + return self._c._fetch_and_parse("POST", "tournaments", "tournament", target_class=Tournament, **params) + def show(self, tournament, **params): + return self._c._fetch_and_parse("GET", f"tournaments/{tournament}", target_class=Tournament, **params) -def create(name, url, tournament_type="single elimination", **params): - """Create a new tournament. + def update(self, tournament, **params): + return self._c._fetch_and_parse("PUT", f"tournaments/{tournament}", "tournament", target_class=Tournament, **params) - Args: - name (str): The name of the tournament - url (str): The name of the tournament for the URL subdomain/path - tournament_type (str, optional): The default is "single elimination". Other choices are double elimination, round robin, swiss - **params (optional): extra keyword arguments used for the setup of the tournament + def destroy(self, tournament): + return self._c._fetch("DELETE", f"tournaments/{tournament}") - Returns: - A dict representing the created tournament - """ - params.update( - { - "name": name, - "url": url, - "tournament_type": tournament_type, - } - ) + def process_check_ins(self, tournament, **params): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/process_check_ins", target_class=Tournament, **params) - return api.fetch_and_parse("POST", "tournaments", "tournament", **params) + def abort_check_in(self, tournament, **params): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/abort_check_in", target_class=Tournament, **params) + def open_for_predictions(self, tournament, **params): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/open_for_predictions", target_class=Tournament, **params) -def show(tournament, **params): - """Retrieve a single tournament record created with your account. + def start(self, tournament, **params): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/start", target_class=Tournament, **params) - Args: - tournament (int or str): The tournament's id or name - **params (optional): The keywords arguments to include participants and/or matches + def finalize(self, tournament, **params): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/finalize", target_class=Tournament, **params) - Returns: - A dict representing the tournament - """ - return api.fetch_and_parse("GET", f"tournaments/{tournament}", **params) - - -def update(tournament, **params): - """Update a tournament's attributes. - - Args: - tournament (int or str): The tournament's id or name - **params (optional): extra keyword arguments used for the update of the tournament - - Returns: - A dict representing the updated tournament - """ - return api.fetch_and_parse( - "PUT", f"tournaments/{tournament}", "tournament", **params - ) - - -def destroy(tournament): - """Deletes a tournament along with all its associated records. There is no undo, so use with care! - - Args: - tournament (int or str): The tournament's id or name - - Returns: - None - """ - api.fetch("DELETE", f"tournaments/{tournament}") - - -def process_check_ins(tournament, **params): - """This should be invoked after a tournament's check-in window closes before the tournament is started. - - 1) Marks participants who have not checked in as inactive. - 2) Moves inactive participants to bottom seeds (ordered by original seed). - 3) Transitions the tournament state from 'checking_in' to 'checked_in' - - Args: - tournament (int or str): The tournament's id or name - **params (optional): The keywords arguments to include participants and/or matches - - Returns: - A dict representing the updated tournament - """ - return api.fetch_and_parse( - "POST", f"tournaments/{tournament}/process_check_ins", **params - ) - - -def abort_check_in(tournament, **params): - """When your tournament is in a 'checking_in' or 'checked_in' state, - there's no way to edit the tournament's start time (start_at) - or check-in duration (check_in_duration). - You must first abort check-in, then you may edit those attributes. - - 1) Makes all participants active and clears their checked_in_at times. - 2) Transitions the tournament state from 'checking_in' or 'checked_in' to 'pending' - - Args: - tournament (int or str): The tournament's id or name - **params (optional): The keywords arguments to include participants and/or matches - - Returns: - A dict representing the updated tournament - """ - return api.fetch_and_parse( - "POST", f"tournaments/{tournament}/abort_check_in", **params - ) - - -def open_for_predictions(tournament, **params): - """Open predictions for a tournament - - Sets the state of the tournament to start accepting predictions. - 'prediction_method' must be set to 1 (exponential scoring) or 2 (linear scoring) to use this option. - - Args: - tournament (int or str): The tournament's id or name - **params (optional): The keywords arguments to include participants and/or matches - - Returns: - A dict representing the updated tournament - """ - return api.fetch_and_parse( - "POST", f"tournaments/{tournament}/open_for_predictions", **params - ) - - -def start(tournament, **params): - """Start a tournament, opening up matches for score reporting. The tournament must have at least 2 participants. - - Args: - tournament (int or str): The tournament's id or name - **params (optional): The keywords arguments to include participants and/or matches - - Returns: - A dict representing the updated tournament - """ - return api.fetch_and_parse("POST", f"tournaments/{tournament}/start", **params) - - -def finalize(tournament, **params): - """Finalize a tournament that has had all match scores submitted, rendering its results permanent. - - Args: - tournament (int or str): The tournament's id or name - **params (optional): The keywords arguments to include participants and/or matches - - Returns: - A dict representing the updated tournament - """ - return api.fetch_and_parse("POST", f"tournaments/{tournament}/finalize", **params) - - -def reset(tournament, **params): - """Reset a tournament, clearing all of its scores and attachments. - - You can then add/remove/edit participants before starting the - tournament again. - - Args: - tournament (int or str): The tournament's id or name - **params (optional): The keywords arguments to include participants and/or matches - - Returns: - A dict representing the updated tournament - """ - return api.fetch_and_parse("POST", f"tournaments/{tournament}/reset", **params) + def reset(self, tournament, **params): + return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/reset", target_class=Tournament, **params) From 294c59c6fc6d96966576b633e2d25b851f71263a Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Fri, 29 May 2026 00:58:43 +0300 Subject: [PATCH 13/20] update tests to use Client, switch to attribute access on dataclasses --- tests.py | 439 ++++++++++++++++++++++++------------------------------- 1 file changed, 187 insertions(+), 252 deletions(-) diff --git a/tests.py b/tests.py index cedd4f4..cf08603 100644 --- a/tests.py +++ b/tests.py @@ -1,3 +1,4 @@ +import dataclasses import datetime import os import random @@ -7,7 +8,7 @@ import pytest import tzlocal -import challonge +from challonge import AsyncClient, Client, ChallongeException username = os.environ.get("CHALLONGE_USER") api_key = os.environ.get("CHALLONGE_KEY") @@ -20,217 +21,183 @@ def _get_random_name(): class TestAPI: - def test_set_credentials(self): - challonge.set_credentials(username, api_key) - assert challonge.api._credentials["user"] == username - assert challonge.api._credentials["api_key"] == api_key + def test_credentials_stored(self): + with Client(user="testuser", api_key="testkey") as client: + assert client._user == "testuser" + assert client._api_key == "testkey" - def test_get_credentials(self): - challonge.api._credentials["user"] = username - challonge.api._credentials["api_key"] = api_key - assert challonge.get_credentials() == (username, api_key) + def test_default_timezone(self): + with Client(user=username, api_key=api_key) as client: + assert client._tz == tzlocal.get_localzone() - def test_get_local_timezone(self): - assert challonge.get_timezone() == tzlocal.get_localzone() + def test_custom_timezone(self): + with Client(user=username, api_key=api_key, timezone="Asia/Seoul") as client: + assert str(client._tz) == "Asia/Seoul" - def test_set_get_timezone(self): - challonge.set_timezone("Asia/Seoul") - assert str(challonge.get_timezone()) == "Asia/Seoul" - - def test_call(self): - challonge.set_credentials(username, api_key) - assert challonge.fetch("GET", "tournaments") != "" + def test_context_manager(self): + with Client(user=username, api_key=api_key) as client: + result = client.tournaments.index() + assert isinstance(result, list) class TestTournaments: @pytest.fixture(autouse=True) def setup(self): - challonge.set_credentials(username, api_key) + self.client = Client(user=username, api_key=api_key) self.random_name = _get_random_name() - self.t = challonge.tournaments.create(self.random_name, self.random_name) + self.t = self.client.tournaments.create(self.random_name, self.random_name) yield - challonge.tournaments.destroy(self.t["id"]) + self.client.tournaments.destroy(self.t.id) + self.client.close() def test_index(self): - ts = list( - filter(lambda x: x["id"] == self.t["id"], challonge.tournaments.index()) - ) + ts = list(filter(lambda x: x.id == self.t.id, self.client.tournaments.index())) assert len(ts) == 1 assert self.t == ts[0] def test_index_filter_by_state(self): - ts = list( - filter( - lambda x: x["id"] == self.t["id"], - challonge.tournaments.index(state="pending"), - ) - ) + ts = list(filter(lambda x: x.id == self.t.id, self.client.tournaments.index(state="pending"))) assert len(ts) == 1 assert self.t == ts[0] - ts = list( - filter( - lambda x: x["id"] == self.t["id"], - challonge.tournaments.index(state="in_progress"), - ) - ) + ts = list(filter(lambda x: x.id == self.t.id, self.client.tournaments.index(state="in_progress"))) assert ts == [] def test_index_filter_by_created(self): - ts = challonge.tournaments.index( + ts = self.client.tournaments.index( created_after=datetime.datetime.now().date() - datetime.timedelta(days=1) ) - assert self.t["id"] in map(lambda x: x["id"], ts) + assert self.t.id in [x.id for x in ts] def test_show(self): - assert challonge.tournaments.show(self.t["id"]) == self.t + assert self.client.tournaments.show(self.t.id) == self.t def test_update_name(self): - t = challonge.tournaments.update(self.t["id"], name="Test!") - assert t["name"] == "Test!" - assert t["updated_at"] >= self.t["updated_at"] - - t.pop("name") - t.pop("updated_at") - self.t.pop("name") - self.t.pop("updated_at") - assert t == self.t + t = self.client.tournaments.update(self.t.id, name="Test!") + assert t.name == "Test!" + assert t.updated_at >= self.t.updated_at + assert dataclasses.replace(t, name=self.t.name, updated_at=self.t.updated_at) == self.t def test_update_private(self): - challonge.tournaments.update(self.t["id"], private=True) - assert challonge.tournaments.show(self.t["id"])["private"] is True + self.client.tournaments.update(self.t.id, private=True) + assert self.client.tournaments.show(self.t.id).private is True def test_update_type(self): - challonge.tournaments.update(self.t["id"], tournament_type="round robin") - assert ( - challonge.tournaments.show(self.t["id"])["tournament_type"] == "round robin" - ) + self.client.tournaments.update(self.t.id, tournament_type="round robin") + assert self.client.tournaments.show(self.t.id).tournament_type == "round robin" def test_open(self): - challonge.tournaments.update(self.t["id"], prediction_method=1) - challonge.participants.create(self.t["id"], "#1") - challonge.participants.create(self.t["id"], "#2") - challonge.tournaments.open_for_predictions(self.t["id"]) - assert ( - challonge.tournaments.show(self.t["id"])["state"] == "accepting_predictions" - ) + self.client.tournaments.update(self.t.id, prediction_method=1) + self.client.participants.create(self.t.id, "#1") + self.client.participants.create(self.t.id, "#2") + self.client.tournaments.open_for_predictions(self.t.id) + assert self.client.tournaments.show(self.t.id).state == "accepting_predictions" def test_start(self): - with pytest.raises(challonge.ChallongeException): - challonge.tournaments.start(self.t["id"]) + with pytest.raises(ChallongeException): + self.client.tournaments.start(self.t.id) - assert self.t["started_at"] is None + assert self.t.started_at is None - challonge.participants.create(self.t["id"], "#1") - challonge.participants.create(self.t["id"], "#2") - challonge.tournaments.start(self.t["id"]) - assert challonge.tournaments.show(self.t["id"])["started_at"] is not None + self.client.participants.create(self.t.id, "#1") + self.client.participants.create(self.t.id, "#2") + self.client.tournaments.start(self.t.id) + assert self.client.tournaments.show(self.t.id).started_at is not None def test_finalize(self): - challonge.participants.create(self.t["id"], "#1") - challonge.participants.create(self.t["id"], "#2") - challonge.tournaments.start(self.t["id"]) + self.client.participants.create(self.t.id, "#1") + self.client.participants.create(self.t.id, "#2") + self.client.tournaments.start(self.t.id) - ms = challonge.matches.index(self.t["id"]) - assert ms[0]["state"] == "open" + ms = self.client.matches.index(self.t.id) + assert ms[0].state == "open" - challonge.matches.update( - self.t["id"], - ms[0]["id"], + self.client.matches.update( + self.t.id, ms[0].id, scores_csv="3-2,4-1,2-2", - winner_id=ms[0]["player1_id"], + winner_id=ms[0].player1_id, ) - challonge.tournaments.finalize(self.t["id"]) - assert challonge.tournaments.show(self.t["id"])["completed_at"] is not None + self.client.tournaments.finalize(self.t.id) + assert self.client.tournaments.show(self.t.id).completed_at is not None def test_reset(self): - challonge.participants.create(self.t["id"], "#1") - challonge.participants.create(self.t["id"], "#2") - challonge.tournaments.start(self.t["id"]) + self.client.participants.create(self.t.id, "#1") + self.client.participants.create(self.t.id, "#2") + self.client.tournaments.start(self.t.id) - with pytest.raises(challonge.ChallongeException): - challonge.participants.create(self.t["id"], "name") + with pytest.raises(ChallongeException): + self.client.participants.create(self.t.id, "name") - challonge.tournaments.reset(self.t["id"]) + self.client.tournaments.reset(self.t.id) - p = challonge.participants.create(self.t["id"], "name") - challonge.participants.destroy(self.t["id"], p["id"]) + p = self.client.participants.create(self.t.id, "name") + self.client.participants.destroy(self.t.id, p.id) class TestParticipants: @pytest.fixture(autouse=True) def setup(self): - challonge.set_credentials(username, api_key) + self.client = Client(user=username, api_key=api_key) self.t_name = _get_random_name() self.ps_names = [_get_random_name(), _get_random_name()] - self.t = challonge.tournaments.create(self.t_name, self.t_name) - self.ps = challonge.participants.bulk_add(self.t["id"], self.ps_names) + self.t = self.client.tournaments.create(self.t_name, self.t_name) + self.ps = self.client.participants.bulk_add(self.t.id, self.ps_names) yield - challonge.tournaments.destroy(self.t["id"]) + self.client.tournaments.destroy(self.t.id) + self.client.close() def test_index(self): - ps = challonge.participants.index(self.t["id"]) + ps = self.client.participants.index(self.t.id) assert len(ps) == 2 assert self.ps[0] in ps assert self.ps[1] in ps def test_show(self): - p1 = challonge.participants.show(self.t["id"], self.ps[0]["id"]) - assert p1["id"] == self.ps[0]["id"] + p1 = self.client.participants.show(self.t.id, self.ps[0].id) + assert p1.id == self.ps[0].id def test_create(self): - new_player = challonge.participants.create(self.t["id"], _get_random_name()) - assert challonge.participants.show(self.t["id"], new_player["id"]) == new_player + new_player = self.client.participants.create(self.t.id, _get_random_name()) + assert self.client.participants.show(self.t.id, new_player.id) == new_player def test_create_with_number_names(self): name = "".join([str(random.randint(0, 9)) for _ in range(9)]) - new_player = challonge.participants.create(self.t["id"], name) - assert ( - challonge.participants.show(self.t["id"], new_player["id"])["name"] == name - ) + new_player = self.client.participants.create(self.t.id, name) + assert self.client.participants.show(self.t.id, new_player.id).name == name def test_update(self): - p1 = challonge.participants.update(self.t["id"], self.ps[0]["id"], misc="Test!") - assert p1["misc"] == "Test!" - assert p1["updated_at"] >= self.ps[0]["updated_at"] - - p1.pop("misc") - p1.pop("updated_at") - self.ps[0].pop("misc") - self.ps[0].pop("updated_at") - assert self.ps[0] == p1 - - @pytest.mark.skip( - reason="API issue: undo_check_in leaves checked_in=True in response" - ) + p1 = self.client.participants.update(self.t.id, self.ps[0].id, misc="Test!") + assert p1.misc == "Test!" + assert p1.updated_at >= self.ps[0].updated_at + assert dataclasses.replace(p1, misc=self.ps[0].misc, updated_at=self.ps[0].updated_at) == self.ps[0] + + @pytest.mark.skip(reason="API issue: undo_check_in leaves checked_in=True in response") def test_check_in_and_undo_check_in(self): - timezone = challonge.get_timezone() + timezone = self.client._tz test_date = datetime.datetime.now(tz=timezone) + datetime.timedelta(minutes=30) - challonge.tournaments.update( - self.t["id"], check_in_duration=30, start_at=test_date - ) + self.client.tournaments.update(self.t.id, check_in_duration=30, start_at=test_date) - p1 = challonge.participants.check_in(self.t["id"], self.ps[0]["id"]) - p2 = challonge.participants.check_in(self.t["id"], self.ps[1]["id"]) - assert p1["checked_in"] - assert p2["checked_in"] + p1 = self.client.participants.check_in(self.t.id, self.ps[0].id) + p2 = self.client.participants.check_in(self.t.id, self.ps[1].id) + assert p1.checked_in + assert p2.checked_in - p1 = challonge.participants.undo_check_in(self.t["id"], self.ps[0]["id"]) - p2 = challonge.participants.undo_check_in(self.t["id"], self.ps[1]["id"]) - assert not p1["checked_in"] - assert not p2["checked_in"] + p1 = self.client.participants.undo_check_in(self.t.id, self.ps[0].id) + p2 = self.client.participants.undo_check_in(self.t.id, self.ps[1].id) + assert not p1.checked_in + assert not p2.checked_in def test_destroy_before_tournament_start(self): - challonge.participants.destroy(self.t["id"], self.ps[0]["id"]) - assert len(challonge.participants.index(self.t["id"])) == 1 + self.client.participants.destroy(self.t.id, self.ps[0].id) + assert len(self.client.participants.index(self.t.id)) == 1 def test_destroy_after_tournament_start(self): - challonge.tournaments.start(self.t["id"]) - challonge.participants.destroy(self.t["id"], self.ps[1]["id"]) - assert not challonge.participants.show(self.t["id"], self.ps[1]["id"])["active"] + self.client.tournaments.start(self.t.id) + self.client.participants.destroy(self.t.id, self.ps[1].id) + assert not self.client.participants.show(self.t.id, self.ps[1].id).active def test_randomize(self): - ps = challonge.participants.randomize(self.t["id"]) + ps = self.client.participants.randomize(self.t.id) assert isinstance(ps, list) assert len(ps) == len(self.ps) @@ -238,201 +205,169 @@ def test_randomize(self): class TestMatches: @pytest.fixture(autouse=True) def setup(self): - challonge.set_credentials(username, api_key) + self.client = Client(user=username, api_key=api_key) self.t_name = _get_random_name() - self.t = challonge.tournaments.create(self.t_name, self.t_name) - self.ps = challonge.participants.bulk_add( - self.t["id"], [_get_random_name(), _get_random_name()] + self.t = self.client.tournaments.create(self.t_name, self.t_name) + self.ps = self.client.participants.bulk_add( + self.t.id, [_get_random_name(), _get_random_name()] ) - challonge.tournaments.start(self.t["id"]) + self.client.tournaments.start(self.t.id) yield - challonge.tournaments.destroy(self.t["id"]) + self.client.tournaments.destroy(self.t.id) + self.client.close() def test_index(self): - ms = challonge.matches.index(self.t["id"]) + ms = self.client.matches.index(self.t.id) assert len(ms) == 1 m = ms[0] - assert {self.ps[0]["id"], self.ps[1]["id"]} == { - m["player1_id"], - m["player2_id"], - } - assert m["state"] == "open" + assert {self.ps[0].id, self.ps[1].id} == {m.player1_id, m.player2_id} + assert m.state == "open" def test_show(self): - for m in challonge.matches.index(self.t["id"]): - assert m == challonge.matches.show(self.t["id"], m["id"]) + for m in self.client.matches.index(self.t.id): + assert m == self.client.matches.show(self.t.id, m.id) def test_update_reopen(self): - m = challonge.matches.index(self.t["id"])[0] - assert m["state"] == "open" + m = self.client.matches.index(self.t.id)[0] + assert m.state == "open" - m = challonge.matches.update( - self.t["id"], m["id"], scores_csv="3-2,4-1,2-2", winner_id=m["player1_id"] + m = self.client.matches.update( + self.t.id, m.id, scores_csv="3-2,4-1,2-2", winner_id=m.player1_id ) - assert m["state"] == "complete" + assert m.state == "complete" - m = challonge.matches.reopen(self.t["id"], m["id"]) - assert m["state"] == "open" + m = self.client.matches.reopen(self.t.id, m.id) + assert m.state == "open" def test_mark_as_underway(self): - m = challonge.matches.index(self.t["id"])[0] - m = challonge.matches.mark_as_underway(self.t["id"], m["id"]) - assert isinstance(m["underway_at"], datetime.datetime) + m = self.client.matches.index(self.t.id)[0] + m = self.client.matches.mark_as_underway(self.t.id, m.id) + assert isinstance(m.underway_at, datetime.datetime) def test_unmark_as_underway(self): - m = challonge.matches.index(self.t["id"])[0] - challonge.matches.mark_as_underway(self.t["id"], m["id"]) - m = challonge.matches.unmark_as_underway(self.t["id"], m["id"]) - assert m["underway_at"] is None + m = self.client.matches.index(self.t.id)[0] + self.client.matches.mark_as_underway(self.t.id, m.id) + m = self.client.matches.unmark_as_underway(self.t.id, m.id) + assert m.underway_at is None class TestAttachments: @pytest.fixture(autouse=True) def setup(self): - challonge.set_credentials(username, api_key) + self.client = Client(user=username, api_key=api_key) self.t_name = _get_random_name() - self.t = challonge.tournaments.create( + self.t = self.client.tournaments.create( self.t_name, self.t_name, accept_attachments=True ) - self.ps = challonge.participants.bulk_add( - self.t["id"], [_get_random_name(), _get_random_name()] + self.ps = self.client.participants.bulk_add( + self.t.id, [_get_random_name(), _get_random_name()] ) - challonge.tournaments.start(self.t["id"]) - self.match = challonge.matches.index(self.t["id"])[0] + self.client.tournaments.start(self.t.id) + self.match = self.client.matches.index(self.t.id)[0] yield - challonge.tournaments.destroy(self.t["id"]) + self.client.tournaments.destroy(self.t.id) + self.client.close() def test_index(self): - challonge.attachments.create( - self.t["id"], self.match["id"], url="http://test.com" - ) - challonge.attachments.create( - self.t["id"], self.match["id"], url="http://test2.com" - ) - assert len(challonge.attachments.index(self.t["id"], self.match["id"])) == 2 + self.client.attachments.create(self.t.id, self.match.id, url="http://test.com") + self.client.attachments.create(self.t.id, self.match.id, url="http://test2.com") + assert len(self.client.attachments.index(self.t.id, self.match.id)) == 2 def test_create_url(self): - a = challonge.attachments.create( - self.t["id"], self.match["id"], url="http://test.com" - ) - assert a["url"] == "http://test.com" + a = self.client.attachments.create(self.t.id, self.match.id, url="http://test.com") + assert a.url == "http://test.com" def test_create_description(self): - a = challonge.attachments.create( - self.t["id"], self.match["id"], description="test text!" - ) - assert a["description"] == "test text!" + a = self.client.attachments.create(self.t.id, self.match.id, description="test text!") + assert a.description == "test text!" def test_create_url_with_description(self): - a = challonge.attachments.create( - self.t["id"], - self.match["id"], - url="http://test.com", - description="just a test", + a = self.client.attachments.create( + self.t.id, self.match.id, + url="http://test.com", description="just a test", ) - assert a["url"] == "http://test.com" - assert a["description"] == "just a test" + assert a.url == "http://test.com" + assert a.description == "just a test" @pytest.mark.skip(reason="API issue: file upload returns 500") def test_create_file(self): image = httpx.get("https://picsum.photos/200/300") - a1 = challonge.attachments.create(self.t["id"], self.match["id"], asset=image) - a2 = challonge.attachments.show(self.t["id"], self.match["id"], a1["id"]) - assert a1["asset"] == a2["asset"] + a1 = self.client.attachments.create(self.t.id, self.match.id, asset=image) + a2 = self.client.attachments.show(self.t.id, self.match.id, a1.id) + assert a1.asset_url == a2.asset_url @pytest.mark.skip(reason="API issue: file upload returns 500") def test_create_file_with_description(self): image = httpx.get("https://picsum.photos/200/300") - a1 = challonge.attachments.create( - self.t["id"], self.match["id"], asset=image, description="just a test" + a1 = self.client.attachments.create( + self.t.id, self.match.id, asset=image, description="just a test" ) - a2 = challonge.attachments.show(self.t["id"], self.match["id"], a1["id"]) - assert a1["asset"] == a2["asset"] + a2 = self.client.attachments.show(self.t.id, self.match.id, a1.id) + assert a1.asset_url == a2.asset_url def test_update_url(self): - a = challonge.attachments.create( - self.t["id"], self.match["id"], url="http://test.com" - ) - a = challonge.attachments.update( - self.t["id"], self.match["id"], a["id"], url="https://newtest.com" - ) - assert a["url"] == "https://newtest.com" + a = self.client.attachments.create(self.t.id, self.match.id, url="http://test.com") + a = self.client.attachments.update(self.t.id, self.match.id, a.id, url="https://newtest.com") + assert a.url == "https://newtest.com" def test_update_description(self): - a = challonge.attachments.create( - self.t["id"], self.match["id"], description="test text!" + a = self.client.attachments.create(self.t.id, self.match.id, description="test text!") + a = self.client.attachments.update( + self.t.id, self.match.id, a.id, description="This is an updated test!" ) - a = challonge.attachments.update( - self.t["id"], - self.match["id"], - a["id"], - description="This is an updated test!", - ) - assert a["description"] == "This is an updated test!" + assert a.description == "This is an updated test!" def test_update_url_with_description(self): - a = challonge.attachments.create( - self.t["id"], - self.match["id"], - url="http://test.com", - description="hello there!", + a = self.client.attachments.create( + self.t.id, self.match.id, + url="http://test.com", description="hello there!", ) - a = challonge.attachments.update( - self.t["id"], - self.match["id"], - a["id"], - url="http://newtest.com", - description="added a new url!", + a = self.client.attachments.update( + self.t.id, self.match.id, a.id, + url="http://newtest.com", description="added a new url!", ) - assert a["url"] == "http://newtest.com" - assert a["description"] == "added a new url!" + assert a.url == "http://newtest.com" + assert a.description == "added a new url!" @pytest.mark.skip(reason="API issue: file upload returns 500") def test_update_file(self): image = httpx.get("https://picsum.photos/200/300") - a1 = challonge.attachments.create(self.t["id"], self.match["id"], asset=image) + a1 = self.client.attachments.create(self.t.id, self.match.id, asset=image) image = httpx.get("https://picsum.photos/200/300") - a2 = challonge.attachments.update( - self.t["id"], self.match["id"], a1["id"], asset=image - ) - assert a1["asset"] != a2["asset"] + a2 = self.client.attachments.update(self.t.id, self.match.id, a1.id, asset=image) + assert a1.asset_url != a2.asset_url @pytest.mark.skip(reason="API issue: file upload returns 500") def test_update_file_with_description(self): image = httpx.get("https://picsum.photos/200/300") - a1 = challonge.attachments.create( - self.t["id"], self.match["id"], asset=image, description="just a test" + a1 = self.client.attachments.create( + self.t.id, self.match.id, asset=image, description="just a test" ) image = httpx.get("https://picsum.photos/200/300") - a2 = challonge.attachments.update( - self.t["id"], - self.match["id"], - a1["id"], - asset=image, - description="just a second test", + a2 = self.client.attachments.update( + self.t.id, self.match.id, a1.id, + asset=image, description="just a second test", ) - assert a1["asset"] != a2["asset"] - assert a1["description"] != a2["description"] + assert a1.asset_url != a2.asset_url + assert a1.description != a2.description @pytest.mark.skip(reason="API issue: file upload returns 500") def test_update_file_only_description(self): image = httpx.get("https://picsum.photos/200/300") - a1 = challonge.attachments.create( - self.t["id"], self.match["id"], asset=image, description="just a test" + a1 = self.client.attachments.create( + self.t.id, self.match.id, asset=image, description="just a test" ) image = httpx.get("https://picsum.photos/200/300") - a2 = challonge.attachments.update( - self.t["id"], self.match["id"], a1["id"], description="just a second test" + a2 = self.client.attachments.update( + self.t.id, self.match.id, a1.id, description="just a second test" ) - assert a1["asset"] == a2["asset"] - assert a1["description"] != a2["description"] + assert a1.asset_url == a2.asset_url + assert a1.description != a2.description def test_destroy(self): - a = challonge.attachments.create( - self.t["id"], - self.match["id"], - url="http://test.com", - description="just a test", + a = self.client.attachments.create( + self.t.id, self.match.id, + url="http://test.com", description="just a test", ) - challonge.attachments.destroy(self.t["id"], self.match["id"], a["id"]) - assert challonge.attachments.index(self.t["id"], self.match["id"]) == [] + self.client.attachments.destroy(self.t.id, self.match.id, a.id) + assert self.client.attachments.index(self.t.id, self.match.id) == [] From 635ad390b9e589e1ef15be2faea2a38af680af07 Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Fri, 29 May 2026 01:00:28 +0300 Subject: [PATCH 14/20] ruff format client and domain modules --- challonge/attachments.py | 33 +++++++++++++++++++---- challonge/client.py | 30 +++++++++++++++------ challonge/matches.py | 37 ++++++++++++++++++++----- challonge/participants.py | 57 ++++++++++++++++++++++++++++++++------- challonge/tournaments.py | 56 +++++++++++++++++++++++++++++++------- 5 files changed, 175 insertions(+), 38 deletions(-) diff --git a/challonge/attachments.py b/challonge/attachments.py index 10e0686..3a58b7c 100644 --- a/challonge/attachments.py +++ b/challonge/attachments.py @@ -6,16 +6,39 @@ def __init__(self, client): self._c = client def index(self, tournament, match_id): - return self._c._fetch_and_parse("GET", f"tournaments/{tournament}/matches/{match_id}/attachments", target_class=MatchAttachment) + return self._c._fetch_and_parse( + "GET", + f"tournaments/{tournament}/matches/{match_id}/attachments", + target_class=MatchAttachment, + ) def create(self, tournament, match_id, **params): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/matches/{match_id}/attachments", "match_attachment", target_class=MatchAttachment, **params) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/matches/{match_id}/attachments", + "match_attachment", + target_class=MatchAttachment, + **params, + ) def show(self, tournament, match_id, attachment_id): - return self._c._fetch_and_parse("GET", f"tournaments/{tournament}/matches/{match_id}/attachments/{attachment_id}", target_class=MatchAttachment) + return self._c._fetch_and_parse( + "GET", + f"tournaments/{tournament}/matches/{match_id}/attachments/{attachment_id}", + target_class=MatchAttachment, + ) def update(self, tournament, match_id, attachment_id, **params): - return self._c._fetch_and_parse("PUT", f"tournaments/{tournament}/matches/{match_id}/attachments/{attachment_id}", "match_attachment", target_class=MatchAttachment, **params) + return self._c._fetch_and_parse( + "PUT", + f"tournaments/{tournament}/matches/{match_id}/attachments/{attachment_id}", + "match_attachment", + target_class=MatchAttachment, + **params, + ) def destroy(self, tournament, match_id, attachment_id): - return self._c._fetch("DELETE", f"tournaments/{tournament}/matches/{match_id}/attachments/{attachment_id}") + return self._c._fetch( + "DELETE", + f"tournaments/{tournament}/matches/{match_id}/attachments/{attachment_id}", + ) diff --git a/challonge/client.py b/challonge/client.py index a2b3d7c..66c3a30 100644 --- a/challonge/client.py +++ b/challonge/client.py @@ -15,7 +15,9 @@ class Client: - def __init__(self, user, api_key, *, timezone=None, user_agent="pychallonge", timeout=30.0): + def __init__( + self, user, api_key, *, timezone=None, user_agent="pychallonge", timeout=30.0 + ): self._user = user self._api_key = api_key self._tz = ZoneInfo(timezone) if timezone else tzlocal.get_localzone() @@ -30,12 +32,15 @@ def __init__(self, user, api_key, *, timezone=None, user_agent="pychallonge", ti def _fetch(self, method, uri, params_prefix=None, **params): p_params = _prepare_params(params, params_prefix) - r_data = {"data": p_params} if method in ("POST", "PUT") else {"params": p_params} + r_data = ( + {"data": p_params} if method in ("POST", "PUT") else {"params": p_params} + ) url = f"https://{CHALLONGE_API_URL}/{uri}.json" try: response = self._http.request( - method, url, + method, + url, headers={"User-Agent": self._user_agent}, auth=(self._user, self._api_key), timeout=self._timeout, @@ -51,7 +56,9 @@ def _fetch(self, method, uri, params_prefix=None, **params): return response - def _fetch_and_parse(self, method, uri, params_prefix=None, target_class=None, **params): + def _fetch_and_parse( + self, method, uri, params_prefix=None, target_class=None, **params + ): response = self._fetch(method, uri, params_prefix, **params) return _parse(response.json(), target_class, self._tz) @@ -66,7 +73,9 @@ def __exit__(self, *args): class AsyncClient: - def __init__(self, user, api_key, *, timezone=None, user_agent="pychallonge", timeout=30.0): + def __init__( + self, user, api_key, *, timezone=None, user_agent="pychallonge", timeout=30.0 + ): self._user = user self._api_key = api_key self._tz = ZoneInfo(timezone) if timezone else tzlocal.get_localzone() @@ -81,12 +90,15 @@ def __init__(self, user, api_key, *, timezone=None, user_agent="pychallonge", ti async def _fetch(self, method, uri, params_prefix=None, **params): p_params = _prepare_params(params, params_prefix) - r_data = {"data": p_params} if method in ("POST", "PUT") else {"params": p_params} + r_data = ( + {"data": p_params} if method in ("POST", "PUT") else {"params": p_params} + ) url = f"https://{CHALLONGE_API_URL}/{uri}.json" try: response = await self._http.request( - method, url, + method, + url, headers={"User-Agent": self._user_agent}, auth=(self._user, self._api_key), timeout=self._timeout, @@ -102,7 +114,9 @@ async def _fetch(self, method, uri, params_prefix=None, **params): return response - async def _fetch_and_parse(self, method, uri, params_prefix=None, target_class=None, **params): + async def _fetch_and_parse( + self, method, uri, params_prefix=None, target_class=None, **params + ): response = await self._fetch(method, uri, params_prefix, **params) return _parse(response.json(), target_class, self._tz) diff --git a/challonge/matches.py b/challonge/matches.py index 9510832..96ba2f4 100644 --- a/challonge/matches.py +++ b/challonge/matches.py @@ -6,19 +6,44 @@ def __init__(self, client): self._c = client def index(self, tournament, **params): - return self._c._fetch_and_parse("GET", f"tournaments/{tournament}/matches", target_class=Match, **params) + return self._c._fetch_and_parse( + "GET", f"tournaments/{tournament}/matches", target_class=Match, **params + ) def show(self, tournament, match_id, **params): - return self._c._fetch_and_parse("GET", f"tournaments/{tournament}/matches/{match_id}", target_class=Match, **params) + return self._c._fetch_and_parse( + "GET", + f"tournaments/{tournament}/matches/{match_id}", + target_class=Match, + **params, + ) def update(self, tournament, match_id, **params): - return self._c._fetch_and_parse("PUT", f"tournaments/{tournament}/matches/{match_id}", "match", target_class=Match, **params) + return self._c._fetch_and_parse( + "PUT", + f"tournaments/{tournament}/matches/{match_id}", + "match", + target_class=Match, + **params, + ) def reopen(self, tournament, match_id): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/matches/{match_id}/reopen", target_class=Match) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/matches/{match_id}/reopen", + target_class=Match, + ) def mark_as_underway(self, tournament, match_id): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/matches/{match_id}/mark_as_underway", target_class=Match) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/matches/{match_id}/mark_as_underway", + target_class=Match, + ) def unmark_as_underway(self, tournament, match_id): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/matches/{match_id}/unmark_as_underway", target_class=Match) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/matches/{match_id}/unmark_as_underway", + target_class=Match, + ) diff --git a/challonge/participants.py b/challonge/participants.py index 048f9f2..3592158 100644 --- a/challonge/participants.py +++ b/challonge/participants.py @@ -6,30 +6,69 @@ def __init__(self, client): self._c = client def index(self, tournament): - return self._c._fetch_and_parse("GET", f"tournaments/{tournament}/participants", target_class=Participant) + return self._c._fetch_and_parse( + "GET", f"tournaments/{tournament}/participants", target_class=Participant + ) def create(self, tournament, name, **params): params.update({"name": name}) - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/participants", "participant", target_class=Participant, **params) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/participants", + "participant", + target_class=Participant, + **params, + ) def bulk_add(self, tournament, names, **params): params.update({"name": names}) - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/participants/bulk_add", "participants[]", target_class=Participant, **params) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/participants/bulk_add", + "participants[]", + target_class=Participant, + **params, + ) def show(self, tournament, participant_id, **params): - return self._c._fetch_and_parse("GET", f"tournaments/{tournament}/participants/{participant_id}", target_class=Participant, **params) + return self._c._fetch_and_parse( + "GET", + f"tournaments/{tournament}/participants/{participant_id}", + target_class=Participant, + **params, + ) def update(self, tournament, participant_id, **params): - return self._c._fetch_and_parse("PUT", f"tournaments/{tournament}/participants/{participant_id}", "participant", target_class=Participant, **params) + return self._c._fetch_and_parse( + "PUT", + f"tournaments/{tournament}/participants/{participant_id}", + "participant", + target_class=Participant, + **params, + ) def check_in(self, tournament, participant_id): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/participants/{participant_id}/check_in", target_class=Participant) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/participants/{participant_id}/check_in", + target_class=Participant, + ) def undo_check_in(self, tournament, participant_id): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/participants/{participant_id}/undo_check_in", target_class=Participant) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/participants/{participant_id}/undo_check_in", + target_class=Participant, + ) def destroy(self, tournament, participant_id): - return self._c._fetch("DELETE", f"tournaments/{tournament}/participants/{participant_id}") + return self._c._fetch( + "DELETE", f"tournaments/{tournament}/participants/{participant_id}" + ) def randomize(self, tournament): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/participants/randomize", target_class=Participant) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/participants/randomize", + target_class=Participant, + ) diff --git a/challonge/tournaments.py b/challonge/tournaments.py index 017bee1..9f7bdc2 100644 --- a/challonge/tournaments.py +++ b/challonge/tournaments.py @@ -6,35 +6,71 @@ def __init__(self, client): self._c = client def index(self, **params): - return self._c._fetch_and_parse("GET", "tournaments", target_class=Tournament, **params) + return self._c._fetch_and_parse( + "GET", "tournaments", target_class=Tournament, **params + ) def create(self, name, url, tournament_type="single elimination", **params): params.update({"name": name, "url": url, "tournament_type": tournament_type}) - return self._c._fetch_and_parse("POST", "tournaments", "tournament", target_class=Tournament, **params) + return self._c._fetch_and_parse( + "POST", "tournaments", "tournament", target_class=Tournament, **params + ) def show(self, tournament, **params): - return self._c._fetch_and_parse("GET", f"tournaments/{tournament}", target_class=Tournament, **params) + return self._c._fetch_and_parse( + "GET", f"tournaments/{tournament}", target_class=Tournament, **params + ) def update(self, tournament, **params): - return self._c._fetch_and_parse("PUT", f"tournaments/{tournament}", "tournament", target_class=Tournament, **params) + return self._c._fetch_and_parse( + "PUT", + f"tournaments/{tournament}", + "tournament", + target_class=Tournament, + **params, + ) def destroy(self, tournament): return self._c._fetch("DELETE", f"tournaments/{tournament}") def process_check_ins(self, tournament, **params): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/process_check_ins", target_class=Tournament, **params) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/process_check_ins", + target_class=Tournament, + **params, + ) def abort_check_in(self, tournament, **params): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/abort_check_in", target_class=Tournament, **params) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/abort_check_in", + target_class=Tournament, + **params, + ) def open_for_predictions(self, tournament, **params): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/open_for_predictions", target_class=Tournament, **params) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/open_for_predictions", + target_class=Tournament, + **params, + ) def start(self, tournament, **params): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/start", target_class=Tournament, **params) + return self._c._fetch_and_parse( + "POST", f"tournaments/{tournament}/start", target_class=Tournament, **params + ) def finalize(self, tournament, **params): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/finalize", target_class=Tournament, **params) + return self._c._fetch_and_parse( + "POST", + f"tournaments/{tournament}/finalize", + target_class=Tournament, + **params, + ) def reset(self, tournament, **params): - return self._c._fetch_and_parse("POST", f"tournaments/{tournament}/reset", target_class=Tournament, **params) + return self._c._fetch_and_parse( + "POST", f"tournaments/{tournament}/reset", target_class=Tournament, **params + ) From 8e2861a5d9e537835821f1bfa5e366caf792918c Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Fri, 29 May 2026 01:00:35 +0300 Subject: [PATCH 15/20] ruff format tests, remove unused AsyncClient import --- tests.py | 95 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 71 insertions(+), 24 deletions(-) diff --git a/tests.py b/tests.py index cf08603..7a72e9e 100644 --- a/tests.py +++ b/tests.py @@ -8,7 +8,7 @@ import pytest import tzlocal -from challonge import AsyncClient, Client, ChallongeException +from challonge import ChallongeException, Client username = os.environ.get("CHALLONGE_USER") api_key = os.environ.get("CHALLONGE_KEY") @@ -56,11 +56,21 @@ def test_index(self): assert self.t == ts[0] def test_index_filter_by_state(self): - ts = list(filter(lambda x: x.id == self.t.id, self.client.tournaments.index(state="pending"))) + ts = list( + filter( + lambda x: x.id == self.t.id, + self.client.tournaments.index(state="pending"), + ) + ) assert len(ts) == 1 assert self.t == ts[0] - ts = list(filter(lambda x: x.id == self.t.id, self.client.tournaments.index(state="in_progress"))) + ts = list( + filter( + lambda x: x.id == self.t.id, + self.client.tournaments.index(state="in_progress"), + ) + ) assert ts == [] def test_index_filter_by_created(self): @@ -76,7 +86,10 @@ def test_update_name(self): t = self.client.tournaments.update(self.t.id, name="Test!") assert t.name == "Test!" assert t.updated_at >= self.t.updated_at - assert dataclasses.replace(t, name=self.t.name, updated_at=self.t.updated_at) == self.t + assert ( + dataclasses.replace(t, name=self.t.name, updated_at=self.t.updated_at) + == self.t + ) def test_update_private(self): self.client.tournaments.update(self.t.id, private=True) @@ -113,7 +126,8 @@ def test_finalize(self): assert ms[0].state == "open" self.client.matches.update( - self.t.id, ms[0].id, + self.t.id, + ms[0].id, scores_csv="3-2,4-1,2-2", winner_id=ms[0].player1_id, ) @@ -169,13 +183,22 @@ def test_update(self): p1 = self.client.participants.update(self.t.id, self.ps[0].id, misc="Test!") assert p1.misc == "Test!" assert p1.updated_at >= self.ps[0].updated_at - assert dataclasses.replace(p1, misc=self.ps[0].misc, updated_at=self.ps[0].updated_at) == self.ps[0] + assert ( + dataclasses.replace( + p1, misc=self.ps[0].misc, updated_at=self.ps[0].updated_at + ) + == self.ps[0] + ) - @pytest.mark.skip(reason="API issue: undo_check_in leaves checked_in=True in response") + @pytest.mark.skip( + reason="API issue: undo_check_in leaves checked_in=True in response" + ) def test_check_in_and_undo_check_in(self): timezone = self.client._tz test_date = datetime.datetime.now(tz=timezone) + datetime.timedelta(minutes=30) - self.client.tournaments.update(self.t.id, check_in_duration=30, start_at=test_date) + self.client.tournaments.update( + self.t.id, check_in_duration=30, start_at=test_date + ) p1 = self.client.participants.check_in(self.t.id, self.ps[0].id) p2 = self.client.participants.check_in(self.t.id, self.ps[1].id) @@ -274,17 +297,23 @@ def test_index(self): assert len(self.client.attachments.index(self.t.id, self.match.id)) == 2 def test_create_url(self): - a = self.client.attachments.create(self.t.id, self.match.id, url="http://test.com") + a = self.client.attachments.create( + self.t.id, self.match.id, url="http://test.com" + ) assert a.url == "http://test.com" def test_create_description(self): - a = self.client.attachments.create(self.t.id, self.match.id, description="test text!") + a = self.client.attachments.create( + self.t.id, self.match.id, description="test text!" + ) assert a.description == "test text!" def test_create_url_with_description(self): a = self.client.attachments.create( - self.t.id, self.match.id, - url="http://test.com", description="just a test", + self.t.id, + self.match.id, + url="http://test.com", + description="just a test", ) assert a.url == "http://test.com" assert a.description == "just a test" @@ -306,12 +335,18 @@ def test_create_file_with_description(self): assert a1.asset_url == a2.asset_url def test_update_url(self): - a = self.client.attachments.create(self.t.id, self.match.id, url="http://test.com") - a = self.client.attachments.update(self.t.id, self.match.id, a.id, url="https://newtest.com") + a = self.client.attachments.create( + self.t.id, self.match.id, url="http://test.com" + ) + a = self.client.attachments.update( + self.t.id, self.match.id, a.id, url="https://newtest.com" + ) assert a.url == "https://newtest.com" def test_update_description(self): - a = self.client.attachments.create(self.t.id, self.match.id, description="test text!") + a = self.client.attachments.create( + self.t.id, self.match.id, description="test text!" + ) a = self.client.attachments.update( self.t.id, self.match.id, a.id, description="This is an updated test!" ) @@ -319,12 +354,17 @@ def test_update_description(self): def test_update_url_with_description(self): a = self.client.attachments.create( - self.t.id, self.match.id, - url="http://test.com", description="hello there!", + self.t.id, + self.match.id, + url="http://test.com", + description="hello there!", ) a = self.client.attachments.update( - self.t.id, self.match.id, a.id, - url="http://newtest.com", description="added a new url!", + self.t.id, + self.match.id, + a.id, + url="http://newtest.com", + description="added a new url!", ) assert a.url == "http://newtest.com" assert a.description == "added a new url!" @@ -334,7 +374,9 @@ def test_update_file(self): image = httpx.get("https://picsum.photos/200/300") a1 = self.client.attachments.create(self.t.id, self.match.id, asset=image) image = httpx.get("https://picsum.photos/200/300") - a2 = self.client.attachments.update(self.t.id, self.match.id, a1.id, asset=image) + a2 = self.client.attachments.update( + self.t.id, self.match.id, a1.id, asset=image + ) assert a1.asset_url != a2.asset_url @pytest.mark.skip(reason="API issue: file upload returns 500") @@ -345,8 +387,11 @@ def test_update_file_with_description(self): ) image = httpx.get("https://picsum.photos/200/300") a2 = self.client.attachments.update( - self.t.id, self.match.id, a1.id, - asset=image, description="just a second test", + self.t.id, + self.match.id, + a1.id, + asset=image, + description="just a second test", ) assert a1.asset_url != a2.asset_url assert a1.description != a2.description @@ -366,8 +411,10 @@ def test_update_file_only_description(self): def test_destroy(self): a = self.client.attachments.create( - self.t.id, self.match.id, - url="http://test.com", description="just a test", + self.t.id, + self.match.id, + url="http://test.com", + description="just a test", ) self.client.attachments.destroy(self.t.id, self.match.id, a.id) assert self.client.attachments.index(self.t.id, self.match.id) == [] From d58a49a355f50ae6ade21e5a45025422cc5a2233 Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Fri, 29 May 2026 01:00:43 +0300 Subject: [PATCH 16/20] update readme for v3.0 client api, async usage, pytest --- README.md | 69 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b825fe6..c88f2fd 100644 --- a/README.md +++ b/README.md @@ -16,26 +16,54 @@ The `pychallonge` package is available on PyPI and you can install it through yo ## Usage ```python -import challonge +from challonge import Client -# Tell pychallonge about your [Challonge API credentials](http://api.challonge.com/v1). -challonge.set_credentials("your_challonge_username", "your_api_key") +# Create a client with your Challonge API credentials. +client = Client(user="your_challonge_username", api_key="your_api_key") # Retrieve a tournament by its id (or its url). -tournament = challonge.tournaments.show(3272) +tournament = client.tournaments.show(3272) -# Tournaments, matches, and participants are all represented as normal Python dicts. -print(tournament["id"]) # 3272 -print(tournament["name"]) # My Awesome Tournament -print(tournament["started_at"]) # None +# Tournaments, matches, and participants are returned as typed dataclasses. +print(tournament.id) # 3272 +print(tournament.name) # My Awesome Tournament +print(tournament.started_at) # None # Retrieve the participants for a given tournament. -participants = challonge.participants.index(tournament["id"]) -print(len(participants)) # 13 +participants = client.participants.index(tournament.id) +print(len(participants)) # 13 # Mutations (POST/PUT) return the updated resource directly. -tournament = challonge.tournaments.start(tournament["id"]) -print(tournament["started_at"]) # 2011-07-31 16:16:02-04:00 +tournament = client.tournaments.start(tournament.id) +print(tournament.started_at) # 2011-07-31 16:16:02-04:00 + +# Close the client when done, or use it as a context manager. +client.close() +``` + +### Context manager + +```python +with Client(user="your_challonge_username", api_key="your_api_key") as client: + tournament = client.tournaments.show(3272) +``` + +### Async + +```python +from challonge import AsyncClient + +async with AsyncClient(user="your_challonge_username", api_key="your_api_key") as client: + tournament = await client.tournaments.show(3272) + participants = await client.participants.index(tournament.id) +``` + +### Timezone + +By default datetime fields are normalised to your machine's local timezone. Pass a timezone string to override: + +```python +client = Client(user="your_challonge_username", api_key="your_api_key", timezone="UTC") ``` See [challonge.com](http://api.challonge.com/v1) for full API documentation. @@ -50,10 +78,6 @@ The check-in undo endpoint has unexpected behaviour: the `checked_in` field in the API response remains `True` even after a successful undo. The participant is correctly marked as not checked in on the website. -Datetime fields from the API carry inconsistent timezone offsets. Pychallonge -normalises these to your machine's local timezone. You can also set a specific -timezone with the `set_timezone` function. - ## Running the tests Tests make real API calls and require a Challonge account. Set `CHALLONGE_USER` @@ -61,15 +85,16 @@ and `CHALLONGE_KEY` in your environment before running. $ git clone https://github.com/ZEDGR/pychallonge $ cd pychallonge - $ CHALLONGE_USER=my_user CHALLONGE_KEY=my_api_key uv run python -m unittest tests.py + $ CHALLONGE_USER=my_user CHALLONGE_KEY=my_api_key uv run pytest tests.py -v Note that several tournaments are created and destroyed over the course of the tests. If any test fails mid-run, orphaned tournaments can be cleaned up as follows: ```python -import challonge -challonge.set_credentials("my_user", "my_api_key") -for t in challonge.tournaments.index(): - if t["name"].startswith("pychal"): - challonge.tournaments.destroy(t["id"]) +from challonge import Client + +with Client(user="my_user", api_key="my_api_key") as client: + for t in client.tournaments.index(): + if t.name.startswith("pychal"): + client.tournaments.destroy(t.id) ``` From 57d7891cb74dd20b8b7a51b9aa0b4d3153e2100b Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Fri, 29 May 2026 01:10:41 +0300 Subject: [PATCH 17/20] add maintainer credit to readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c88f2fd..1b62099 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # pychallonge Lightweight Python wrapper for the [Challonge API](http://api.challonge.com/v1). -The pychallonge module was created by [Russ Amos](https://github.com/russ-) +The pychallonge module was created by [Russ Amos](https://github.com/russ-) and maintained by [George Lemanis](https://github.com/ZEDGR) ## Python version support From 1bac3a1eaae264a7a3439bc988a5de589131f7b2 Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Fri, 29 May 2026 01:31:54 +0300 Subject: [PATCH 18/20] add async smoke tests, add pytest-asyncio dev dependency --- pyproject.toml | 4 ++++ tests.py | 47 ++++++++++++++++++++++++++++++++++++++++++++++- uv.lock | 25 +++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e872237..8a0a265 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,13 @@ Homepage = "https://github.com/ZEDGR/pychallonge" dev = [ "ipdb>=0.13.13", "pytest>=9.0.3", + "pytest-asyncio>=1.4.0", "ruff>=0.15.14", ] +[tool.pytest.ini_options] +asyncio_mode = "auto" + [tool.ruff] target-version = "py310" # match your project's Python version line-length = 88 diff --git a/tests.py b/tests.py index 7a72e9e..e3a5eac 100644 --- a/tests.py +++ b/tests.py @@ -8,7 +8,7 @@ import pytest import tzlocal -from challonge import ChallongeException, Client +from challonge import AsyncClient, ChallongeException, Client username = os.environ.get("CHALLONGE_USER") api_key = os.environ.get("CHALLONGE_KEY") @@ -418,3 +418,48 @@ def test_destroy(self): ) self.client.attachments.destroy(self.t.id, self.match.id, a.id) assert self.client.attachments.index(self.t.id, self.match.id) == [] + + +class TestAsyncClient: + @pytest.fixture(autouse=True) + async def setup(self): + self.client = AsyncClient(user=username, api_key=api_key) + self.random_name = _get_random_name() + self.t = await self.client.tournaments.create( + self.random_name, self.random_name + ) + yield + await self.client.tournaments.destroy(self.t.id) + await self.client.aclose() + + async def test_tournament_show(self): + t = await self.client.tournaments.show(self.t.id) + assert t.id == self.t.id + assert t.name == self.t.name + + async def test_participant_create_and_show(self): + p = await self.client.participants.create(self.t.id, _get_random_name()) + assert (await self.client.participants.show(self.t.id, p.id)).id == p.id + + async def test_match_index(self): + ps_names = [_get_random_name(), _get_random_name()] + await self.client.participants.bulk_add(self.t.id, ps_names) + await self.client.tournaments.start(self.t.id) + ms = await self.client.matches.index(self.t.id) + assert len(ms) == 1 + assert ms[0].state == "open" + + async def test_attachment_create_and_destroy(self): + t = await self.client.tournaments.create( + _get_random_name(), _get_random_name(), accept_attachments=True + ) + await self.client.participants.bulk_add( + t.id, [_get_random_name(), _get_random_name()] + ) + await self.client.tournaments.start(t.id) + match = (await self.client.matches.index(t.id))[0] + a = await self.client.attachments.create(t.id, match.id, url="http://test.com") + assert a.url == "http://test.com" + await self.client.attachments.destroy(t.id, match.id, a.id) + assert await self.client.attachments.index(t.id, match.id) == [] + await self.client.tournaments.destroy(t.id) diff --git a/uv.lock b/uv.lock index 64d46b5..59b7ad0 100644 --- a/uv.lock +++ b/uv.lock @@ -29,6 +29,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -354,6 +363,7 @@ dependencies = [ dev = [ { name = "ipdb" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, ] @@ -368,6 +378,7 @@ requires-dist = [ dev = [ { name = "ipdb", specifier = ">=0.13.13" }, { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, { name = "ruff", specifier = ">=0.15.14" }, ] @@ -398,6 +409,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "ruff" version = "0.15.14" From c0b4167d464de5e258cc958503564459b8ab4bba Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Tue, 2 Jun 2026 22:55:03 +0300 Subject: [PATCH 19/20] bump version to 3.0.0, note phases 3+4 move to v4.0 --- ROADMAP_v3.md | 330 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 ROADMAP_v3.md diff --git a/ROADMAP_v3.md b/ROADMAP_v3.md new file mode 100644 index 0000000..eed2dd0 --- /dev/null +++ b/ROADMAP_v3.md @@ -0,0 +1,330 @@ +# pychallonge v3.0 Roadmap + +## Current state (v2.1 branch) + +- Sync-only, httpx `request()` (stateless) +- Returns plain `dict` / `list[dict]` — no typing +- API v1 (`api.challonge.com/v1`), HTTP Basic auth +- Global mutable state (`_credentials`, `tz`) + +--- + +## Phase 1 — Async + Client Refactor + +**Goal:** Replace global mutable state with a proper client class and add async support. + +**Design:** + +```python +# Sync +client = challonge.Client(user="x", api_key="y") +t = client.tournaments.show("my-tourney") + +# Async +async with challonge.AsyncClient(user="x", api_key="y") as client: + t = await client.tournaments.show("my-tourney") +``` + +**New module: `challonge/client.py`** + +- `Client` — wraps `httpx.Client`, holds credentials and timezone +- `AsyncClient` — wraps `httpx.AsyncClient`, implements `__aenter__`/`__aexit__` +- `client.tournaments`, `client.participants`, `client.matches`, `client.attachments` are sub-clients bound to the parent +- `api.py` becomes internal helpers; module-level functions become thin wrappers around a default `Client` for backwards compatibility +- `async_fetch()` uses `await client.request(...)` — all domain coroutines just `await` it + +**No new dependencies** — httpx already supports both sync and async natively. + +--- + +## Phase 2 — DataClass Models + +**Goal:** `fetch_and_parse()` returns typed objects instead of raw dicts. + +**New module: `challonge/models.py`** + +```python +@dataclass +class Tournament: + id: int + name: str + url: str + tournament_type: str + state: str + game_name: str | None = None + private: bool = False + starts_at: datetime | None = None + description: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + +@dataclass +class Participant: + id: int + tournament_id: int + name: str + seed: int + active: bool + final_rank: int | None = None + username: str | None = None + group_id: int | None = None + misc: str | None = None + checked_in_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + +@dataclass +class Match: + id: int + tournament_id: int + state: str + round: int + identifier: str + scores: str + player1_id: int | None = None + player2_id: int | None = None + winner_id: int | None = None + suggested_play_order: int | None = None + score_in_sets: list | None = None + tie: bool = False + created_at: datetime | None = None + updated_at: datetime | None = None + +@dataclass +class MatchAttachment: + id: int + url: str | None = None + description: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None +``` + +**Changes to `_parse()`:** + +- Receives a `target_class: type[T]` argument +- After unwrapping the envelope and converting `_at` fields, constructs and returns `T(**d)` +- Uses `dataclasses.fields()` to filter only known keys — handles API adding new fields gracefully + +**Breaking change:** dict → dataclass (attribute access is a superset for most use cases, but not drop-in). This is intentional and justifies the major version bump. + +--- + +## Phase 3 — Challonge API v2.1 + +**Goal:** Target the new `api.challonge.com/v2.1` endpoint with full JSON:API support. + +### Protocol changes + +| Concern | v1 (current) | v2.1 (new) | +|---|---|---| +| Base URL | `api.challonge.com/v1` | `api.challonge.com/v2.1` | +| Auth | `httpx auth=(user, key)` Basic | Headers: `Authorization-Type: v1` + `Authorization: {key}` (or OAuth2 Bearer) | +| `Content-Type` | not required | `application/vnd.api+json` (mandatory) | +| `Accept` | not required | `application/json` (mandatory) | +| Request body | form-encoded bracket-notation | `{"data": {"type": "...", "attributes": {...}}}` | +| Response envelope | `{"tournament": {...}}` | `{"data": {"id": "...", "type": "...", "attributes": {...}}}` | +| List response | `[{"tournament": {...}}]` | `{"data": [...]}` with `page`/`per_page` pagination | +| DELETE return | body | 204 No Content | +| Errors | `{"errors": [...]}` | `{"errors": [{"status": 422, "detail": "...", "source": {"pointer": "..."}}]}` | + +### `api.py` changes + +- `_prepare_params()` eliminated — replaced by `_build_body(type, attributes)` +- `fetch()` auth switches from `httpx auth=` to explicit headers +- `_parse()` rewritten: unwraps `data.attributes`, flattens `timestamps` and `states` sub-objects, handles both single object and list shapes +- Note: match attachment timestamps use `createdAt`/`updatedAt` (camelCase) — API inconsistency, handle explicitly in `_parse()` + +### Endpoint mapping + +**Tournaments** +``` +GET /tournaments.json list (+ page, per_page, state, type, created_after/before) +POST /tournaments.json create +GET /tournaments/{id}.json show +PUT /tournaments/{id}.json update +DEL /tournaments/{id}.json destroy +PUT /tournaments/{id}/change_state.json replaces: start, finalize, reset, + process_check_ins, abort_check_in, + open_for_predictions + state values: start | finalize | reset | process_checkin | abort_checkin + | open_predictions | start_group_stage | finalize_group_stage | reset_group_stage +``` + +**Participants** +``` +GET /tournaments/{id}/participants.json list (+ page, per_page) +POST /tournaments/{id}/participants.json create +GET /tournaments/{id}/participants/{id}.json show +PUT /tournaments/{id}/participants/{id}.json update +DEL /tournaments/{id}/participants/{id}.json destroy +POST /tournaments/{id}/participants/bulk_add.json bulk create (max 20, body: data.attributes.participants[]) +DEL /tournaments/{id}/participants/clear.json clear all (new) +POST /tournaments/{id}/participants/randomize.json randomize +POST /tournaments/{id}/participants/{id}/register OAuth self-registration (new) +DEL /tournaments/{id}/participants/{id}/register OAuth self-unregistration (new) +``` + +**Matches** +``` +GET /tournaments/{id}/matches.json list (+ page, per_page, state, participant_id) +GET /tournaments/{id}/matches/{id}.json show +PUT /tournaments/{id}/matches/{id}.json update (body format changed — see below) +PUT /tournaments/{id}/matches/{id}/change_state.json + state values: reopen | mark_as_underway | unmark_as_underway +``` + +Match update request body (v2.1): +```json +{ + "data": { + "type": "match", + "attributes": { + "match": [ + {"participant_id": "355", "score_set": "2-0", "rank": 1, "advancing": true} + ], + "tie": false, + "location": "Table 1", + "scheduled_time": "2024-01-01T10:00:00Z" + } + } +} +``` + +**Match Attachments** +``` +GET /tournaments/{id}/matches/{id}/attachments.json list +POST /tournaments/{id}/matches/{id}/attachments.json create +GET /tournaments/{id}/matches/{id}/attachments/{id}.json show +PUT /tournaments/{id}/matches/{id}/attachments/{id}.json update +DEL /tournaments/{id}/matches/{id}/attachments/{id}.json destroy +``` + +### Response field structure (v2.1) + +**Participant** — `states` and `timestamps` are nested sub-objects in the API response, flattened by `_parse()`: +```json +{ + "id": "76", "type": "participant", + "attributes": { + "name": "Player 1", "seed": 1, "tournament_id": 21, + "username": null, "final_rank": 1, "group_id": null, + "states": {"active": true}, + "misc": "", + "timestamps": {"created_at": "2023-04-21T14:29:06.374Z", "updated_at": null} + } +} +``` + +**Match** — player references in `relationships`, also flattened by `_parse()`: +```json +{ + "id": "8008135", "type": "match", + "attributes": { + "state": "complete", "round": 1, "identifier": "A", + "scores": "2 - 0", "winner_id": 355, + "score_in_sets": [[3,1],[4,2]], + "points_by_participant": [], + "timestamps": {"created_at": "2023-04-21T14:29:06.374Z", "updated_at": null}, + "relationships": { + "player1": {"data": {"id": "355", "type": "participant"}}, + "player2": {"data": {"id": "354", "type": "participant"}} + } + } +} +``` + +### v2.1 changelog highlights (from Challonge docs) + +- Attribute naming is now consistently `snake_case` (v2.0 used inconsistent camelCase) +- Tournament IDs are integer IDs again (v2.0 used URL-based identifiers); URL is still supported +- Match winner field renamed `winners` → `winner_id` +- Group stage support: new attributes `group_stage_enabled`, `group_stage_options` +- Tie support for round robin and swiss via `tie: true` in match update +- `station_options` tournament attribute for station management + +--- + +## Phase 4 — Stations & Station Queuers + +**Goal:** Implement the two new v2.1-only resource types for managing physical play stations and their match queues. + +**Concepts:** + +- **Station** — a physical play area (PC, console, stream desk) scoped to a tournament. A station can have one active match assigned via `match_id`. +- **Station Queuer** — a match queued to play at a station, with an ordered `position`. The station's waitlist. +- Tournament opt-in via `station_options` in the tournament attributes (`auto_assign`, `only_start_matches_with_assigned_stations`). + +**Depends on:** Phase 2 (dataclasses) and Phase 3 (v2.1). No v1 equivalents exist. + +### New module: `challonge/stations.py` + +``` +GET /tournaments/{id}/stations.json list (+ page, per_page, community_id) +POST /tournaments/{id}/stations.json create +GET /tournaments/{id}/stations/{station_id}.json show +PUT /tournaments/{id}/stations/{station_id}.json update +DEL /tournaments/{id}/stations/{station_id}.json destroy (returns 200 + body, not 204) +``` + +**Station dataclass:** +```python +@dataclass +class Station: + id: int + name: str # required on create/update + stream_url: str | None = None + details: str | None = None # private, visible only to assigned players + match_id: int | None = None # currently assigned match +``` + +**Functions:** +```python +def index(tournament) -> list[Station] +def create(tournament, name, *, stream_url=None, details=None) -> Station +def show(tournament, station_id) -> Station +def update(tournament, station_id, **params) -> Station +def destroy(tournament, station_id) -> Station # returns the deleted station +``` + +> **Note:** The raw API doc lists `type: "participant"` in the update request body — this is a doc typo. We send `type: "station"` and verify against the live API. + +### New module: `challonge/station_queuers.py` + +``` +GET /tournaments/{id}/stations/{sid}/station_queuers.json list (+ page, per_page) +POST /tournaments/{id}/stations/{sid}/station_queuers.json create +GET /tournaments/{id}/stations/{sid}/station_queuers/{qid}.json show +PUT /tournaments/{id}/stations/{sid}/station_queuers/{qid}.json update +DEL /tournaments/{id}/stations/{sid}/station_queuers/{qid}.json destroy +``` + +**StationQueuer dataclass:** +```python +@dataclass +class StationQueuer: + id: int + match_id: int # required — the match to queue + position: int | None = None # insertion position in the queue +``` + +**Functions:** +```python +def index(tournament, station_id) -> list[StationQueuer] +def create(tournament, station_id, match_id, *, position=None) -> StationQueuer +def show(tournament, station_id, queuer_id) -> StationQueuer +def update(tournament, station_id, queuer_id, match_id, *, position=None) -> StationQueuer +def destroy(tournament, station_id, queuer_id) -> None +``` + +--- + +## Summary + +| Phase | What | Key deliverables | Effort | Release | +|-------|------|-----------------|--------|---------| +| **1** | Async + Client refactor | `client.py` with `Client` / `AsyncClient`, kill global state | Medium | v3.0 ✅ | +| **2** | DataClass models | `models.py` with `Tournament`, `Participant`, `Match`, `MatchAttachment` | Medium | v3.0 ✅ | +| **3** | Challonge API v2.1 | New auth headers, JSON:API body/parse, `change_state` endpoints, pagination | High | v4.0 | +| **4** | Stations & Station Queuers | `stations.py`, `station_queuers.py`, `Station` + `StationQueuer` dataclasses | Low | v4.0 | + +Phases 1 and 2 shipped as v3.0. Phases 3 and 4 continue in v4.0. diff --git a/pyproject.toml b/pyproject.toml index 8a0a265..ec7fccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ packages = ["challonge"] [project] name = "pychallonge" -version = "2.0.0" +version = "3.0.0" description = "Lightweight Python wrapper for the Challonge API" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 59b7ad0..9376fb3 100644 --- a/uv.lock +++ b/uv.lock @@ -351,7 +351,7 @@ wheels = [ [[package]] name = "pychallonge" -version = "2.0.0" +version = "3.0.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 50157699615eb0a4b6255617064696220770bd19 Mon Sep 17 00:00:00 2001 From: George Lemanis Date: Tue, 2 Jun 2026 22:55:06 +0300 Subject: [PATCH 20/20] update changelog for v3.0.0 --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba991c6..df691ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Release History +## 3.0.0 (2026-06-02) + +**Breaking Changes** + +- Replace module-level functions with a `Client` / `AsyncClient` class — all calls now go through an instance (`client.tournaments.show(...)` instead of `challonge.tournaments.show(...)`) +- API responses are now typed dataclasses (`Tournament`, `Participant`, `Match`, `MatchAttachment`) instead of plain dicts — use attribute access (`t.name`) instead of key access (`t["name"]`) +- `set_credentials()`, `set_timezone()`, and other module-level state helpers removed — pass `user`, `api_key`, and `timezone` to the `Client` constructor instead +- `fetch()` / `fetch_and_parse()` are no longer public + +**New Features** + +- `AsyncClient` with full async/await support via `httpx.AsyncClient` — all domain methods are awaitable +- Context manager support: `with Client(...) as client` and `async with AsyncClient(...) as client` +- `timezone` parameter on `Client` / `AsyncClient` accepts IANA timezone strings (e.g. `"Asia/Seoul"`) +- New `models.py` module with `Tournament`, `Participant`, `Match`, and `MatchAttachment` dataclasses + +**Improvements** + +- Switch from unittest to pytest +- Add async smoke tests covering all four resource domains + ## 2.0.0 (2026-05-24) **Breaking Changes**