From 624417b30d67915908c3beaf5187702dc5c20b4c Mon Sep 17 00:00:00 2001 From: SharpBit <31069084+SharpBit@users.noreply.github.com> Date: Thu, 29 Nov 2018 19:30:24 -0500 Subject: [PATCH] Sync Support! (#5) * v2.1.0: Sync Support! * some minor fixes * remove await in blocking test * remove unnecessary import --- CHANGELOG.md | 8 ++ CONTRIBUTING.md | 2 +- README.rst | 14 ++- brawlstats/__init__.py | 2 +- brawlstats/core.py | 182 +++++++++++++++++++++---------- brawlstats/errors.py | 18 --- docs/api.rst | 14 +-- docs/conf.py | 4 +- docs/exceptions.rst | 6 - docs/index.rst | 6 +- examples/async.py | 9 +- examples/discord_cog.py | 4 +- examples/sync.py | 21 ++++ requirements.txt | 1 + setup.py | 2 +- tests/{test.py => test_async.py} | 4 +- tests/test_blocking.py | 72 ++++++++++++ 17 files changed, 260 insertions(+), 109 deletions(-) create mode 100644 examples/sync.py rename tests/{test.py => test_async.py} (92%) create mode 100644 tests/test_blocking.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bb91a63..1c5319a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Change Log All notable changes to this project will be documented in this file. +## [2.1.0] - 11/29/18 +### Added +- Synchronous support! You can now set if you want an async client by using `is_async=True` +### Fixed +- `asyncio.TimeoutError` now properly raises `ServerError` +### Removed +- `BadRequest` and `NotFoundError` (negates v2.0.6). These were found to not be needed + ## [2.0.7] - 11/29/18 ### Added - Support for the new `/events` endpoint for current and upcoming event rotations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4738a4b..aadbc27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ 8. Add the necessary points to `CHANGELOG.md` 9. Fill up the `tests/.env` file with the suitable tokens 10. Run `flake8` from the root folder (there are certain ignored errors defined in `tox.ini`) -11. Run `python tests/test.py` from the root folder and ensure the tests are configured correctly and they return OK. ServerErrors and warnings can be disregarded. +11. Run `tox` from the root folder and ensure the tests are configured correctly and they return OK. ServerErrors can be disregarded. 12. Open your PR Do not increment version numbers but update `CHANGELOG.md` \ No newline at end of file diff --git a/README.rst b/README.rst index 40c90cc..e7cb674 100644 --- a/README.rst +++ b/README.rst @@ -13,7 +13,13 @@ BrawlStats :target: https://github.com/SharpBit/brawlstats/blob/master/LICENSE :alt: MIT License -This library is an async wrapper `Brawl Stars API`_ +This library is an async/sync wrapper `Brawl Stars API`_ + +Features +~~~~~~~~ + +- Covers the full API +- Easy to use async or sync client Installation ~~~~~~~~~~~~ @@ -43,7 +49,11 @@ it ASAP. If you need help or an API Key, join the API’s `discord server`_. Examples ~~~~~~~~ -Examples are in the `examples folder`_. ``async.py`` includes a basic usage using asyncio, and ``discord_cog.py`` shows an example Discord Bot cog using discord.py rewrite (v1.0.0a) +Examples are in the `examples folder`_. + +- ``sync.py`` includes a basic sync usage +- ``async.py`` includes a basic usage using asyncio +- ``discord_cog.py`` shows an example Discord Bot cog using discord.py rewrite (v1.0.0a) .. _Brawl Stars API: http://brawlapi.cf/api .. _docs folder: https://github.com/SharpBit/brawlstats/tree/master/docs diff --git a/brawlstats/__init__.py b/brawlstats/__init__.py index 77a8fd9..dbcc39d 100644 --- a/brawlstats/__init__.py +++ b/brawlstats/__init__.py @@ -7,7 +7,7 @@ ############ -__version__ = 'v2.0.7' +__version__ = 'v2.1.0' __title__ = 'brawlstats' __license__ = 'MIT' __author__ = 'SharpBit' diff --git a/brawlstats/core.py b/brawlstats/core.py index 64f1c8b..f5a3e62 100644 --- a/brawlstats/core.py +++ b/brawlstats/core.py @@ -1,48 +1,78 @@ import aiohttp import asyncio +import requests -from box import Box, BoxKeyError +import json -from .errors import BadRequest, InvalidTag, NotFoundError, Unauthorized, UnexpectedError, ServerError +from box import Box, BoxList + +from .errors import InvalidTag, Unauthorized, UnexpectedError, ServerError from .utils import API -class BaseBox(Box): - def __init__(self, *args, **kwargs): - kwargs['camel_killer_box'] = True - super().__init__(*args, **kwargs) +class BaseBox: + def __init__(self, client, data): + self.client = client + self.from_data(data) + + def from_data(self, data): + self.raw_data = data + if isinstance(data, list): + self._boxed_data = BoxList( + data, camel_killer_box=True + ) + else: + self._boxed_data = Box( + data, camel_killer_box=True + ) + return self + + def __getattr__(self, attr): + try: + return getattr(self._boxed_data, attr) + except AttributeError: + try: + return super().__getattr__(attr) + except AttributeError: + return None # makes it easier on the user's end + + def __getitem__(self, item): + try: + return getattr(self._boxed_data, item) + except AttributeError: + raise KeyError('No such key: {}'.format(item)) class Client: """ - This is an async client class that lets you access the API. + This is a sync/async client class that lets you access the API. Parameters ------------ token: str The API Key that you can get from https://discord.me/BrawlAPI - timeout: Optional[int] = 5 + timeout: Optional[int] = 10 A timeout for requests to the API. - session: Optional[Session] = aiohttp.ClientSession() - Use a current aiohttp session or a new one. - loop: Optional[Loop] = None - Use a current loop. Recommended to remove warnings when you run the program. + session: Optional[Session] = None + Use a current session or a make new one. + is_async: Optional[bool] = False + Makes the client async. """ def __init__(self, token, **options): - loop = options.get('loop', asyncio.get_event_loop()) - self.session = options.get('session', aiohttp.ClientSession(loop=loop)) - self.timeout = options.get('timeout', 5) + self.is_async = options.get('is_async', False) + self.session = options.get('session', aiohttp.ClientSession() if self.is_async else requests.Session()) + self.timeout = options.get('timeout', 10) self.headers = { 'Authorization': token, 'User-Agent': 'brawlstats | Python' } def __repr__(self): - return ''.format(self.timeout) + return ''.format(self.is_async, self.timeout) - async def close(self): - return await self.session.close() + def close(self): + return self.session.close() def _check_tag(self, tag, endpoint): tag = tag.upper().replace('#', '').replace('O', '0') @@ -53,26 +83,44 @@ def _check_tag(self, tag, endpoint): raise InvalidTag(endpoint + '/' + tag, 404) return tag + def _raise_for_status(self, resp, text, url): + try: + data = json.loads(text) + except json.JSONDecodeError: + data = text + + code = getattr(resp, 'status', None) or getattr(resp, 'status_code') + + if 300 > code >= 200: + return data + if code == 401: + raise Unauthorized(url, code) + if code in (400, 404): + raise InvalidTag(url, code) + if code >= 500: + raise ServerError(url, code) + + raise UnexpectedError(url, code) + async def _aget(self, url): try: async with self.session.get(url, timeout=self.timeout, headers=self.headers) as resp: - if resp.status == 200: - raw_data = await resp.json() - elif resp.status == 400: - raise BadRequest(url, resp.status) - elif resp.status == 401: - raise Unauthorized(url, resp.status) - elif resp.status == 404: - raise InvalidTag(url, resp.status) - elif resp.status in (503, 520, 521): - raise ServerError(url, resp.status) - else: - raise UnexpectedError(url, resp.status) + return self._raise_for_status(resp, await resp.text(), url) except asyncio.TimeoutError: - raise NotFoundError(url, 400) - return raw_data + raise ServerError(url, 503) - async def get_profile(self, tag: str): + def _get(self, url): + try: + with self.session.get(url, timeout=self.timeout, headers=self.headers) as resp: + return self._raise_for_status(resp, resp.text, url) + except requests.Timeout: + raise ServerError(url, 503) + + async def _get_profile_async(self, tag: str): + response = await self._aget(API.PROFILE + '/' + tag) + return Profile(self, response) + + def get_profile(self, tag: str): """Get a player's stats. Parameters @@ -84,14 +132,19 @@ async def get_profile(self, tag: str): Returns Profile """ tag = self._check_tag(tag, API.PROFILE) - response = await self._aget(API.PROFILE + '/' + tag) - response['client'] = self + if self.is_async: + return self._get_profile_async(tag) + response = self._get(API.PROFILE + '/' + tag) - return Profile(response) + return Profile(self, response) get_player = get_profile - async def get_band(self, tag: str): + async def _get_band_async(self, tag: str): + response = await self._aget(API.BAND + '/' + tag) + return Band(self, response) + + def get_band(self, tag: str): """Get a band's stats. Parameters @@ -103,11 +156,17 @@ async def get_band(self, tag: str): Returns Band """ tag = self._check_tag(tag, API.BAND) - response = await self._aget(API.BAND + '/' + tag) + if self.is_async: + return self._get_band_async(tag) + response = self._get(API.BAND + '/' + tag) + + return Band(self, response) - return Band(response) + async def _get_leaderboard_async(self, url): + response = await self._aget(url) + return Leaderboard(self, response) - async def get_leaderboard(self, player_or_band: str, count: int=200): + def get_leaderboard(self, player_or_band: str, count: int=200): """Get the top count players/bands. Parameters @@ -126,17 +185,25 @@ async def get_leaderboard(self, player_or_band: str, count: int=200): if player_or_band.lower() not in ('players', 'bands') or count > 200 or count < 1: raise ValueError("Please enter 'players' or 'bands' or make sure 'count' is between 1 and 200.") url = API.LEADERBOARD + '/' + player_or_band + '/' + str(count) - response = await self._aget(url) + if self.is_async: + return self._get_leaderboard_async(url) + response = self._get(url) + + return Leaderboard(self, response) - return Leaderboard(response) + async def _get_events_async(self): + response = await self._aget(API.EVENTS) + return Events(self, response) - async def get_events(self): + def get_events(self): """Get current and upcoming events. Returns Events""" - response = await self._aget(API.EVENTS) + if self.is_async: + return self._get_events_async() + response = self._get(API.EVENTS) - return Events(response) + return Events(self, response) class Profile(BaseBox): """ @@ -149,7 +216,7 @@ def __repr__(self): def __str__(self): return '{0.name} (#{0.tag})'.format(self) - async def get_band(self, full=False): + def get_band(self, full=False): """ Gets the player's band. @@ -163,10 +230,9 @@ async def get_band(self, full=False): if not self.band: return None if not full: - self.band['client'] = self.client - band = SimpleBand(self.band) + band = SimpleBand(self, self.band) else: - band = await self.client.get_band(self.band.tag) + band = self.client.get_band(self.band.tag) return band @@ -181,13 +247,13 @@ def __repr__(self): def __str__(self): return '{0.name} (#{0.tag})'.format(self) - async def get_full(self): + def get_full(self): """ Gets the full band statistics. Returns Band """ - return await self.client.get_band(self.tag) + return self.client.get_band(self.tag) class Band(BaseBox): @@ -208,16 +274,14 @@ class Leaderboard(BaseBox): """ def __repr__(self): - try: - return "".format(len(self.players)) - except BoxKeyError: - return "".format(len(self.bands)) + lb_type = 'player' if self.players else 'band' + count = len(self.players) if self.players else len(self.bands) + return "".format(lb_type, count) def __str__(self): - try: - return 'Player Leaderboard containing {} items'.format(len(self.players)) - except BoxKeyError: - return 'Band Leaderboard containing {} items'.format(len(self.bands)) + lb_type = 'Player' if self.players else 'Band' + count = len(self.players) if self.players else len(self.bands) + return '{} Leaderboard containing {} items'.format(lb_type, count) class Events(BaseBox): """ diff --git a/brawlstats/errors.py b/brawlstats/errors.py index 5d5f25f..fbb5fd9 100644 --- a/brawlstats/errors.py +++ b/brawlstats/errors.py @@ -5,24 +5,6 @@ def __init__(self, code, error): pass -class BadRequest(RequestError): - """Raised if you sent a bad request to the API.""" - - def __init__(self, url, code): - self.code = code - self.error = 'You sent a bad request the API.\nURL: ' + url - super().__init__(self.code, self.error) - - -class NotFoundError(RequestError): - """Raised if the tag was not found.""" - - def __init__(self, url, code): - self.code = code - self.error = 'The tag you entered was not found.\nURL: ' + url - super().__init__(self.code, self.error) - - class Unauthorized(RequestError): """Raised if your API Key is invalid or blocked.""" diff --git a/docs/api.rst b/docs/api.rst index 17ea7d9..713efdf 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -217,13 +217,13 @@ Name Type Event Attributes: -================= ==== -Name Type -================= ==== -``slot`` int -``timeInSeconds`` int -``mapId`` int -================= ==== +=================== ==== +Name Type +=================== ==== +``slot`` int +``time_in_seconds`` int +``map_id`` int +=================== ==== diff --git a/docs/conf.py b/docs/conf.py index 3d96906..f09823f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,9 +24,9 @@ author = 'SharpBit' # The short X.Y version -version = '2.0' +version = '2.1' # The full version, including alpha/beta/rc tags -release = '2.0.7' +release = '2.1.0' # -- General configuration --------------------------------------------------- diff --git a/docs/exceptions.rst b/docs/exceptions.rst index 7480c31..e16b6bd 100644 --- a/docs/exceptions.rst +++ b/docs/exceptions.rst @@ -3,12 +3,6 @@ Exceptions The possible exceptions thrown by the library. -.. autoexception:: brawlstats.errors.BadRequest - :members: - -.. autoexception:: brawlstats.errors.NotFoundError - :members: - .. autoexception:: brawlstats.errors.RequestError :members: diff --git a/docs/index.rst b/docs/index.rst index eeddaa2..0665e18 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,10 +18,8 @@ BrawlStats is an easy to use async wrapper for the unofficial `Brawl Stars API`_ Features ~~~~~~~~ -- Get a player's statistics -- Get band statistics -- Get the top 200 players and bands -- Easy to use client +- Covers the full API +- Easy to use async or sync client Installation ~~~~~~~~~~~~ diff --git a/examples/async.py b/examples/async.py index a81c715..36e7df2 100644 --- a/examples/async.py +++ b/examples/async.py @@ -1,8 +1,7 @@ import brawlstats import asyncio -loop = asyncio.get_event_loop() # do this if you don't want to get a bunch of warnings -client = brawlstats.Client('token', loop=loop) +client = brawlstats.Client('token', is_async=True) # Do not post your token on a public github # await only works in an async loop @@ -18,8 +17,12 @@ async def main(): print(player.name, player.trophies) # prints name and trophies leaderboard = await client.get_leaderboard('players', 5) # gets top 5 players - for player in leaderboard: + for player in leaderboard.players: print(player.name, player.position) + events = await client.get_events() + print(events.current[0].time_in_seconds) + # run the async loop +loop = asyncio.get_event_loop() loop.run_until_complete(main()) \ No newline at end of file diff --git a/examples/discord_cog.py b/examples/discord_cog.py index 8a86cf6..c23c8cc 100644 --- a/examples/discord_cog.py +++ b/examples/discord_cog.py @@ -1,13 +1,13 @@ import discord from discord.ext import commands -import brawlstats # import the module +import brawlstats class BrawlStars: """A simple cog for Brawl Stars commands using discord.py rewrite (v1.0.0a)""" def __init__(self, bot): self.bot = bot - self.client = brawlstats.Client('token', loop=bot.loop) # Initiliaze the client using the bot loop + self.client = brawlstats.Client('token', is_async=True) @commands.command() async def profile(self, ctx, tag: str): diff --git a/examples/sync.py b/examples/sync.py new file mode 100644 index 0000000..3ffb377 --- /dev/null +++ b/examples/sync.py @@ -0,0 +1,21 @@ +import brawlstats + +client = brawlstats.Client('token') +# Do not post your token on a public github + +player = client.get_profile('GGJVJLU2') +print(player.trophies) # access attributes using dot notation. +print(player.solo_showdown_victories) # access using snake_case instead of camelCase + +band = player.get_band(full=True) # full=True gets the full Band object +print(band.tag) +best_players = band.members[0:3] # members sorted by trophies, gets best 3 players +for player in best_players: + print(player.name, player.trophies) # prints name and trophies + +leaderboard = client.get_leaderboard('players', 5) # gets top 5 players +for player in leaderboard.players: + print(player.name, player.position) + +events = client.get_events() +print(events.current[0].time_in_seconds) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 15c78ba..8c708a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ aiohttp +requests python-box diff --git a/setup.py b/setup.py index e349b11..07ab092 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='brawlstats', - version='2.0.7', + version='2.1.0', description='An async Python API wrapper for the unofficial Brawl Stars API', long_description=long_description, long_description_content_type='text/x-rst', diff --git a/tests/test.py b/tests/test_async.py similarity index 92% rename from tests/test.py rename to tests/test_async.py index 04d7de4..52cc8ee 100644 --- a/tests/test.py +++ b/tests/test_async.py @@ -17,7 +17,7 @@ class TestAsyncClient(asynctest.TestCase): async def setUp(self): self.player_tag = 'GGJVJLU2' self.band_tag = 'QCGV8PG' - self.client = brawlstats.Client(TOKEN, loop=self.loop, timeout=30) + self.client = brawlstats.Client(TOKEN, is_async=True, timeout=30) async def tearDown(self): time.sleep(2) @@ -51,8 +51,6 @@ async def request(): self.assertAsyncRaises(brawlstats.InvalidTag, request) invalid_tag = 'AAA' self.assertAsyncRaises(brawlstats.InvalidTag, request) - invalid_tag = '2PP0PP0PP' - self.assertAsyncRaises(brawlstats.NotFoundError, request) async def test_invalid_lb(self): async def request(): diff --git a/tests/test_blocking.py b/tests/test_blocking.py new file mode 100644 index 0000000..b994f3a --- /dev/null +++ b/tests/test_blocking.py @@ -0,0 +1,72 @@ +import unittest +import time +import os + +import brawlstats +from dotenv import load_dotenv, find_dotenv + +load_dotenv(find_dotenv('./.env')) + +TOKEN = os.getenv('token') + + +class TestBlockingClient(unittest.TestCase): + """Tests all methods in the blocking client that + uses the `requests` module in `brawlstats` + """ + + def setUp(self): + self.player_tag = 'GGJVJLU2' + self.band_tag = 'QCGV8PG' + self.client = brawlstats.Client(TOKEN, is_async=False, timeout=30) + + def tearDown(self): + time.sleep(2) + self.client.close() + + def test_get_player(self): + player = self.client.get_player(self.player_tag) + self.assertEqual(player.tag, self.player_tag) + + def test_get_band(self): + band = self.client.get_band(self.band_tag) + self.assertEqual(band.tag, self.band_tag) + + def test_get_leaderboard_player(self): + lb = self.client.get_leaderboard('players') + self.assertTrue(isinstance(lb.players, list)) + + def test_get_leaderboard_band(self): + lb = self.client.get_leaderboard('bands') + self.assertTrue(isinstance(lb.bands, list)) + + def test_get_events(self): + events = self.client.get_events() + self.assertTrue(isinstance(events.current, list)) + + # Other + def test_invalid_tag(self): + get_profile = self.client.get_profile + invalid_tag = 'P' + self.assertRaises(brawlstats.InvalidTag, get_profile, invalid_tag) + invalid_tag = 'AAA' + self.assertRaises(brawlstats.InvalidTag, get_profile, invalid_tag) + + def test_invalid_lb(self): + get_lb = self.client.get_leaderboard + invalid_type = 'test' + invalid_count = 200 + self.assertRaises(ValueError, get_lb, invalid_type, invalid_count) + invalid_type = 'players' + invalid_count = 'string' + self.assertRaises(ValueError, get_lb, invalid_type, invalid_count) + invalid_type = 'players' + invalid_count = 201 + self.assertRaises(ValueError, get_lb, invalid_type, invalid_count) + invalid_type = 'players' + invalid_count = -5 + self.assertRaises(ValueError, get_lb, invalid_type, invalid_count) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file