improve error handling when downloading games: Throw proper assertions, assume nothing about returned data

This commit is contained in:
Maximilian Keßler 2023-07-05 09:00:03 +02:00
parent fabcc9ceb2
commit 184129fca0
Signed by: max
GPG key ID: BCC5A619923C0BA5
3 changed files with 118 additions and 24 deletions

View file

@ -5,6 +5,10 @@ from termcolor import colored
from hanabi import constants from hanabi import constants
class ParseError(ValueError):
pass
class DeckCard: class DeckCard:
def __init__(self, suitIndex: int, rank: int, deck_index=None): def __init__(self, suitIndex: int, rank: int, deck_index=None):
self.suitIndex: int = suitIndex self.suitIndex: int = suitIndex
@ -13,7 +17,13 @@ class DeckCard:
@staticmethod @staticmethod
def from_json(deck_card): def from_json(deck_card):
return DeckCard(**deck_card) suit_index = deck_card.get('suitIndex', None)
rank = deck_card.get('rank', None)
if suit_index is None:
raise ParseError("No suit index specified in deck_card")
if rank is None:
raise ParseError("No rank specified in deck_card")
return DeckCard(suit_index, rank)
def colorize(self): def colorize(self):
color = ["green", "blue", "magenta", "yellow", "white", "cyan"][self.suitIndex] color = ["green", "blue", "magenta", "yellow", "white", "cyan"][self.suitIndex]
@ -54,10 +64,24 @@ class Action:
@staticmethod @staticmethod
def from_json(action): def from_json(action):
action_type_int = action.get('type', None)
action_target = action.get('target', None)
action_value = action.get('value', None)
if action_type_int is None:
raise ParseError("No action type specified in action, found {}".format(action_type))
if action_target is None:
raise ParseError("No action target specified in action, found {}".format(action_target))
for val in [action_type_int, action_target, action_value]:
if val is not None and type(val) != int:
raise ParseError("Invalid data type in action, expected int, found {}".format(type(val)))
try:
action_type = ActionType(action_type_int)
except ValueError as e:
raise ParseError("Invalid action type, found {}".format(action_type_int)) from e
return Action( return Action(
ActionType(action['type']), action_type,
int(action['target']), action_target,
action.get('value', None) action_value
) )
def __repr__(self): def __repr__(self):

View file

@ -13,30 +13,84 @@ from hanabi.live import hanab_live
from hanabi import logger from hanabi import logger
class GameExportError(ValueError):
def __init__(self, game_id, msg):
super().__init__("When exporting game {}: {}".format(game_id, msg))
pass
class GameExportNoResponseFromSiteError(GameExportError):
def __init__(self, game_id):
super().__init__(game_id, "No response from site")
class GameExportInvalidResponseTypeError(GameExportError):
def __init__(self, game_id, response_type):
super().__init__(game_id, "Invalid response type (expected json, got {})".format(
response_type, game_id
))
pass
class GameExportInvalidFormatError(GameExportError):
def __init__(self, game_id, msg):
super().__init__("Invalid response format: {}".format(game_id), msg)
class GameExportInvalidNumberOfPlayersError(GameExportInvalidFormatError):
def __init__(self, game_id, expected, received):
super().__init__(game_id, "Received invalid list of players: Expected {}, got {}".format(expected, received))
pass
# #
def detailed_export_game(game_id: int, score: Optional[int] = None, var_id: Optional[int] = None, def detailed_export_game(
seed_exists: bool = False) -> None: game_id: int,
score: Optional[int] = None,
num_players: Optional[int] = None,
var_id: Optional[int] = None,
seed_exists: bool = False
) -> None:
""" """
Downloads full details of game, inserts seed and game into DB Downloads full details of game from hanab.live, inserts seed and game into DB
If seed is already present, it is left as is If seed is already present, it is left as is
If game is already present, game details will be updated If game is already present, game details will be updated
:param game_id: :param game_id: Id of game to export
:param score: If given, this will be inserted as score of the game. If not given, score is calculated :param score: If given, this will be inserted as score of the game. If not given, score is calculated
:param var_id If given, this will be inserted as variant id of the game. If not given, this is looked up :param num_players: If given, the number of players reported by the site is checked against this. If inconsistent,
InvalidNumberOfPlayersError is raised
:param var_id: If given, this will be inserted as variant id of the game. If not given, this is looked up
:param seed_exists: If specified and true, assumes that the seed is already present in database. :param seed_exists: If specified and true, assumes that the seed is already present in database.
If this is not the case, call will raise a DB insertion error If this is not the case, call will raise a DB insertion error
:raises GameExportError and its child classes
""" """
logger.debug("Importing game {}".format(game_id)) logger.debug("Importing game {}".format(game_id))
assert_msg = "Invalid response format from hanab.live while exporting game id {}".format(game_id)
game_json = site_api.get("export/{}".format(game_id)) game_json = site_api.get("export/{}".format(game_id))
assert game_json.get('id') == game_id, assert_msg if game_json is None:
raise GameExportNoResponseFromSiteError
if type(game_json) != dict:
raise GameExportInvalidResponseTypeError(game_id, type(game_json))
if game_json.get('id', None) != game_id:
raise GameExportInvalidFormatError(game_id, "Unexpected game_id {} received, expected {}".format(
game_json.get('id'), game_id
))
players = game_json.get('players', []) players = game_json.get('players', [])
if num_players is not None and len(players) != num_players:
raise GameExportInvalidNumberOfPlayersError(game_id, num_players, game_json.get('players', []))
num_players = len(players) num_players = len(players)
if num_players < 2:
raise GameExportInvalidNumberOfPlayersError(game_id, "≥2", num_players)
seed = game_json.get('seed', None) seed = game_json.get('seed', None)
if type(seed) != str:
raise GameExportInvalidFormatError(game_id, "Unexpected seed, expected string, got {}".format(seed))
options = game_json.get('options', {}) options = game_json.get('options', {})
var_id = var_id or variants.variant_id(options.get('variant', 'No Variant')) var_id = var_id or variants.variant_id(options.get('variant', 'No Variant'))
deck_plays = options.get('deckPlays', False) deck_plays = options.get('deckPlays', False)
@ -44,11 +98,16 @@ def detailed_export_game(game_id: int, score: Optional[int] = None, var_id: Opti
one_less_card = options.get('oneLessCard', False) one_less_card = options.get('oneLessCard', False)
all_or_nothing = options.get('allOrNothing', False) all_or_nothing = options.get('allOrNothing', False)
starting_player = options.get('startingPlayer', 0) starting_player = options.get('startingPlayer', 0)
actions = [hanab_game.Action.from_json(action) for action in game_json.get('actions', [])]
deck = [hanab_game.DeckCard.from_json(card) for card in game_json.get('deck', None)]
assert players != [], assert_msg try:
assert seed is not None, assert_msg actions = [hanab_game.Action.from_json(action) for action in game_json.get('actions', [])]
except hanab_game.ParseError as e:
raise GameExportInvalidFormatError(game_id, "Failed to parse actions") from e
try:
deck = [hanab_game.DeckCard.from_json(card) for card in game_json.get('deck', None)]
except hanab_game.ParseError as e:
raise GameExportInvalidFormatError(game_id, "Failed to parse deck") from e
if score is None: if score is None:
# need to play through the game once to find out its score # need to play through the game once to find out its score
@ -69,14 +128,15 @@ def detailed_export_game(game_id: int, score: Optional[int] = None, var_id: Opti
try: try:
compressed_deck = compress.compress_deck(deck) compressed_deck = compress.compress_deck(deck)
except compress.InvalidFormatError: except compress.InvalidFormatError as e:
logger.error("Failed to compress deck while exporting game {}: {}".format(game_id, deck)) logger.error("Failed to compress deck while exporting game {}: {}".format(game_id, deck))
raise raise GameExportInvalidFormatError(game_id, "Failed to compress deck") from e
try: try:
compressed_actions = compress.compress_actions(actions) compressed_actions = compress.compress_actions(actions)
except compress.InvalidFormatError: except compress.InvalidFormatError as e:
logger.error("Failed to compress actions while exporting game {}".format(game_id)) logger.error("Failed to compress actions while exporting game {}".format(game_id))
raise raise GameExportInvalidFormatError(game_id, "Failed to compress actions") from e
if not seed_exists: if not seed_exists:
database.cur.execute( database.cur.execute(
@ -107,7 +167,7 @@ def detailed_export_game(game_id: int, score: Optional[int] = None, var_id: Opti
logger.debug("Imported game {}".format(game_id)) logger.debug("Imported game {}".format(game_id))
def process_game_row(game: Dict, var_id): def process_game_row(game: Dict, var_id, export_all_games: bool = False):
game_id = game.get('id', None) game_id = game.get('id', None)
seed = game.get('seed', None) seed = game.get('seed', None)
num_players = game.get('num_players', None) num_players = game.get('num_players', None)
@ -116,6 +176,11 @@ def process_game_row(game: Dict, var_id):
if any(v is None for v in [game_id, seed, num_players, score]): if any(v is None for v in [game_id, seed, num_players, score]):
raise ValueError("Unknown response format on hanab.live") raise ValueError("Unknown response format on hanab.live")
if export_all_games:
detailed_export_game(game_id, score=score, num_players=num_players, var_id=var_id)
logger.debug("Imported game {}".format(game_id))
return
database.cur.execute("SAVEPOINT seed_insert") database.cur.execute("SAVEPOINT seed_insert")
try: try:
database.cur.execute( database.cur.execute(
@ -126,13 +191,15 @@ def process_game_row(game: Dict, var_id):
(game_id, seed, num_players, score, var_id) (game_id, seed, num_players, score, var_id)
) )
except psycopg2.errors.ForeignKeyViolation: except psycopg2.errors.ForeignKeyViolation:
# Sometimes, seed is not present in the database yet, then we will have to query the full game details
# (including the seed) to export it accordingly
database.cur.execute("ROLLBACK TO seed_insert") database.cur.execute("ROLLBACK TO seed_insert")
detailed_export_game(game_id, score, var_id) detailed_export_game(game_id, score, var_id)
database.cur.execute("RELEASE seed_insert") database.cur.execute("RELEASE seed_insert")
logger.debug("Imported game {}".format(game_id)) logger.debug("Imported game {}".format(game_id))
def download_games(var_id): def download_games(var_id, export_all_games: bool = False):
name = variants.variant_name(var_id) name = variants.variant_name(var_id)
page_size = 100 page_size = 100
if name is None: if name is None:
@ -179,7 +246,7 @@ def download_games(var_id):
if not (page == last_page or len(rows) == page_size): if not (page == last_page or len(rows) == page_size):
logger.warn('WARN: received unexpected row count ({}) on page {}'.format(len(rows), page)) logger.warn('WARN: received unexpected row count ({}) on page {}'.format(len(rows), page))
for row in rows: for row in rows:
process_game_row(row, var_id) process_game_row(row, var_id, export_all_games)
bar() bar()
database.cur.execute( database.cur.execute(
"INSERT INTO variant_game_downloads (variant_id, last_game_id) VALUES" "INSERT INTO variant_game_downloads (variant_id, last_game_id) VALUES"

View file

@ -1,4 +1,5 @@
import json import json
from typing import Optional, Dict
import requests_cache import requests_cache
import platformdirs import platformdirs
@ -10,7 +11,7 @@ from hanabi import constants
session = requests_cache.CachedSession(platformdirs.user_cache_dir(constants.APP_NAME) + '/hanab.live') session = requests_cache.CachedSession(platformdirs.user_cache_dir(constants.APP_NAME) + '/hanab.live')
def get(url, refresh=False): def get(url, refresh=False) -> Optional[Dict | str]:
# print("sending request for " + url) # print("sending request for " + url)
query = "https://hanab.live/" + url query = "https://hanab.live/" + url
logger.debug("GET {} (force_refresh={})".format(query, refresh)) logger.debug("GET {} (force_refresh={})".format(query, refresh))
@ -19,9 +20,11 @@ def get(url, refresh=False):
logger.error("Failed to get request {} from hanab.live".format(query)) logger.error("Failed to get request {} from hanab.live".format(query))
return None return None
if not response.status_code == 200: if not response.status_code == 200:
logger.error("Request {} from hanab.live produced status code {}".format(query, response.status_code))
return None return None
if "application/json" in response.headers['content-type']: if "application/json" in response.headers['content-type']:
return json.loads(response.text) return json.loads(response.text)
return response.text
def api(url, refresh=False): def api(url, refresh=False):