From 6187faf20384b0c5a4966343b2d4ca47f8b11e45 Mon Sep 17 00:00:00 2001 From: Philip Paquette Date: Wed, 26 Sep 2018 07:48:55 -0400 Subject: Release v1.0.0 - Diplomacy Game Engine - AGPL v3+ License --- diplomacy/utils/__init__.py | 22 ++ diplomacy/utils/common.py | 212 +++++++++++ diplomacy/utils/constants.py | 58 +++ diplomacy/utils/convoy_paths.py | 223 +++++++++++ diplomacy/utils/errors.py | 128 +++++++ diplomacy/utils/exceptions.py | 178 +++++++++ diplomacy/utils/export.py | 164 ++++++++ diplomacy/utils/game_phase_data.py | 47 +++ diplomacy/utils/jsonable.py | 141 +++++++ diplomacy/utils/keywords.py | 39 ++ diplomacy/utils/network_data.py | 79 ++++ diplomacy/utils/parsing.py | 505 +++++++++++++++++++++++++ diplomacy/utils/priority_dict.py | 102 +++++ diplomacy/utils/scheduler_event.py | 42 ++ diplomacy/utils/sorted_dict.py | 259 +++++++++++++ diplomacy/utils/sorted_set.py | 157 ++++++++ diplomacy/utils/strings.py | 236 ++++++++++++ diplomacy/utils/tests/__init__.py | 16 + diplomacy/utils/tests/test_common.py | 147 +++++++ diplomacy/utils/tests/test_jsonable.py | 81 ++++ diplomacy/utils/tests/test_jsonable_changes.py | 189 +++++++++ diplomacy/utils/tests/test_parsing.py | 307 +++++++++++++++ diplomacy/utils/tests/test_priority_dict.py | 102 +++++ diplomacy/utils/tests/test_sorted_dict.py | 154 ++++++++ diplomacy/utils/tests/test_sorted_set.py | 168 ++++++++ diplomacy/utils/tests/test_time.py | 77 ++++ diplomacy/utils/time.py | 85 +++++ 27 files changed, 3918 insertions(+) create mode 100644 diplomacy/utils/__init__.py create mode 100644 diplomacy/utils/common.py create mode 100644 diplomacy/utils/constants.py create mode 100644 diplomacy/utils/convoy_paths.py create mode 100644 diplomacy/utils/errors.py create mode 100644 diplomacy/utils/exceptions.py create mode 100644 diplomacy/utils/export.py create mode 100644 diplomacy/utils/game_phase_data.py create mode 100644 diplomacy/utils/jsonable.py create mode 100644 diplomacy/utils/keywords.py create mode 100644 diplomacy/utils/network_data.py create mode 100644 diplomacy/utils/parsing.py create mode 100644 diplomacy/utils/priority_dict.py create mode 100644 diplomacy/utils/scheduler_event.py create mode 100644 diplomacy/utils/sorted_dict.py create mode 100644 diplomacy/utils/sorted_set.py create mode 100644 diplomacy/utils/strings.py create mode 100644 diplomacy/utils/tests/__init__.py create mode 100644 diplomacy/utils/tests/test_common.py create mode 100644 diplomacy/utils/tests/test_jsonable.py create mode 100644 diplomacy/utils/tests/test_jsonable_changes.py create mode 100644 diplomacy/utils/tests/test_parsing.py create mode 100644 diplomacy/utils/tests/test_priority_dict.py create mode 100644 diplomacy/utils/tests/test_sorted_dict.py create mode 100644 diplomacy/utils/tests/test_sorted_set.py create mode 100644 diplomacy/utils/tests/test_time.py create mode 100644 diplomacy/utils/time.py (limited to 'diplomacy/utils') diff --git a/diplomacy/utils/__init__.py b/diplomacy/utils/__init__.py new file mode 100644 index 0000000..754f9f3 --- /dev/null +++ b/diplomacy/utils/__init__.py @@ -0,0 +1,22 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Utils + - Contains various utilities for the Diplomacy engine +""" +from .keywords import KEYWORDS, ALIASES +from .priority_dict import PriorityDict +from .time import str_to_seconds, trunc_time, next_time_at diff --git a/diplomacy/utils/common.py b/diplomacy/utils/common.py new file mode 100644 index 0000000..df2897a --- /dev/null +++ b/diplomacy/utils/common.py @@ -0,0 +1,212 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Common utils symbols used in diplomacy network code. """ +import base64 +import binascii +import hashlib +import traceback +import os +import re +import sys +from datetime import datetime + +import bcrypt + +from diplomacy.utils.exceptions import CommonKeyException + +# Datetime since timestamp 0. +EPOCH = datetime.utcfromtimestamp(0) + +# Regex used for conversion from camel case to snake case. +REGEX_CONSECUTIVE_UPPER_CASES = re.compile('[A-Z]{2,}') +REGEX_LOWER_THEN_UPPER_CASES = re.compile('([a-z0-9])([A-Z])') +REGEX_UNDERSCORE_THEN_LETTER = re.compile('_([a-z])') +REGEX_START_BY_LOWERCASE = re.compile('^[a-z]') + +def _sub_hash_password(password): + """ Hash long password to allow bcrypt to handle password longer than 72 characters. Module private method. + :param password: password to hash. + :return: (String) The hashed password. + """ + # Bcrypt only handles passwords up to 72 characters. We use this hashing method as a work around. + # Suggested in bcrypt PyPI page (2018/02/08 12:36 EST): https://pypi.python.org/pypi/bcrypt/3.1.0 + return base64.b64encode(hashlib.sha256(password.encode('utf-8')).digest()) + +def is_valid_password(password, hashed): + """ Check if password matches hashed. + :param password: password to check. + :param hashed: a password hashed with method hash_password(). + :return: (Boolean). Indicates if the password matches the hash. + """ + return bcrypt.checkpw(_sub_hash_password(password), hashed.encode('utf-8')) + +def hash_password(password): + """ Hash password. Accepts password longer than 72 characters. Public method. + :param password: The password to hash + :return: (String). The hashed password. + """ + return bcrypt.hashpw(_sub_hash_password(password), bcrypt.gensalt(14)).decode('utf-8') + +def generate_token(n_bytes=128): + """ Generate a token with 2 * n_bytes characters (n_bytes bytes encoded in hexadecimal). """ + return binascii.hexlify(os.urandom(n_bytes)).decode('utf-8') + +def is_dictionary(dict_to_check): + """ Check if given variable is a dictionary-like object. + :param dict_to_check: Dictionary to check. + :return: (Boolean). Indicates if the object is a dictionary. + """ + return isinstance(dict_to_check, dict) or all( + hasattr(dict_to_check, expected_attribute) + for expected_attribute in ( + '__len__', + '__contains__', + '__bool__', + '__iter__', + '__getitem__', + 'keys', + 'values', + 'items', + ) + ) + +def is_sequence(seq_to_check): + """ Check if given variable is a sequence-like object. + Note that strings and dictionary-like objects will not be considered as sequences. + :param seq_to_check: Sequence-like object to check. + :return: (Boolean). Indicates if the object is sequence-like. + """ + # Strings and dicts are not valid sequences. + if isinstance(seq_to_check, str) or is_dictionary(seq_to_check): + return False + return hasattr(seq_to_check, '__iter__') + +def camel_case_to_snake_case(name): + """ Convert a string (expected to be in camel case) to snake case. + :param name: string to convert. + :return: string: snake case version of given name. + """ + if name == '': + return name + separated_consecutive_uppers = REGEX_CONSECUTIVE_UPPER_CASES.sub(lambda m: '_'.join(c for c in m.group(0)), name) + return REGEX_LOWER_THEN_UPPER_CASES.sub(r'\1_\2', separated_consecutive_uppers).lower() + +def snake_case_to_upper_camel_case(name): + """ Convert a string (expected to be in snake case) to camel case and convert first letter to upper case + if it's in lowercase. + :param name: string to convert. + :return: camel case version of given name. + """ + if name == '': + return name + first_lower_case_to_upper = REGEX_START_BY_LOWERCASE.sub(lambda m: m.group(0).upper(), name) + return REGEX_UNDERSCORE_THEN_LETTER.sub(lambda m: m.group(1).upper(), first_lower_case_to_upper) + +def assert_no_common_keys(dict1, dict2): + """ Check that dictionaries does not share keys. + :param dict1: dict + :param dict2: dict + """ + if len(dict1) < len(dict2): + smallest_dict, biggest_dict = dict1, dict2 + else: + smallest_dict, biggest_dict = dict2, dict1 + for key in smallest_dict: + if key in biggest_dict: + raise CommonKeyException(key) + +def timestamp_microseconds(): + """ Return current timestamp with microsecond resolution. + :return: int + """ + delta = datetime.now() - EPOCH + return (delta.days * 24 * 60 * 60 + delta.seconds) * 1000000 + delta.microseconds + +def str_cmp_class(compare_function): + """ Return a new class to be used as string comparator. + + Example: + ``` + def my_cmp_func(a, b): + # a and b are two strings to compare with a specific code. + # Return -1 if a < b, 0 if a == b, 1 otherwise. + + my_class = str_cmp_class(my_cmp_func) + wrapped_str_1 = my_class(str_to_compare_1) + wrapped_str_2 = my_class(str_to_compare_2) + my_list = [wrapped_str_1, wrapped_str_2] + + # my_list will be sorted according to my_cmp_func. + my_list.sort() + ``` + + :param compare_function: a callable that takes 2 strings a and b, and compares it according to custom rules. + This function should return: + -1 (or a negative value) if a < b + 0 if a == b + 1 (or a positive value) if a > b + + :return: a comparator class, instanciable with a string. + """ + + class StringComparator: + """ A comparable wrapper class around strings. """ + + def __init__(self, value): + """ Initialize comparator with a value. Expected a string value. """ + self.value = str(value) + self.cmp_fn = compare_function + + def __str__(self): + return self.value + + def __repr__(self): + return repr(self.value) + + def __hash__(self): + return hash(self.value) + + def __eq__(self, other): + return self.cmp_fn(self.value, str(other)) == 0 + + def __lt__(self, other): + return self.cmp_fn(self.value, str(other)) < 0 + StringComparator.__name__ = 'StringComparator%s' % (id(compare_function)) + return StringComparator + +class Tornado(): + """ Utilities for Tornado. """ + + @staticmethod + def stop_loop_on_callback_error(io_loop): + """ Modify exception handler method of given IO loop so that IO loop stops and raises + as soon as an exception is thrown from a callback. + :param io_loop: IO loop + :type io_loop: tornado.ioloop.IOLoop + """ + + def new_cb_exception_handler(callback): + """ Callback exception handler used to replace IO loop default exception handler. """ + #pylint: disable=unused-argument + _, exc_value, _ = sys.exc_info() + io_loop.stop() + traceback.print_tb(exc_value.__traceback__) + print(type(exc_value).__name__) + print(exc_value) + exit(-1) + + io_loop.handle_callback_exception = new_cb_exception_handler diff --git a/diplomacy/utils/constants.py b/diplomacy/utils/constants.py new file mode 100644 index 0000000..d929e33 --- /dev/null +++ b/diplomacy/utils/constants.py @@ -0,0 +1,58 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Some constant / config values used in Diplomacy package. """ + +# Number of times to try to connect before throwing an exception. +NB_CONNECTION_ATTEMPTS = 12 + +# Time to wait between to connection trials. +ATTEMPT_DELAY_SECONDS = 5 + +# Time to wait between to server backups. +DEFAULT_BACKUP_DELAY_SECONDS = 10 * 60 # 10 minutes. + +# Default server ping interval. # Used for sockets ping. +DEFAULT_PING_SECONDS = 30 + +# Time to wait to receive a response for a request sent to server. +REQUEST_TIMEOUT_SECONDS = 30 + +# Default host name for a server to connect to. +DEFAULT_HOST = 'localhost' + +# Default port for normal non-securized server. +DEFAULT_PORT = 8432 + +# Default port for secure SSL server (not yet used). +DEFAULT_SSL_PORT = 8433 + +# Special username and password to use to connect as a bot recognized by diplomacy module. +# This bot is called "private bot". +PRIVATE_BOT_USERNAME = '#bot@2e723r43tr70fh2239-qf3947-3449-21128-9dh1321d12dm13d83820d28-9dm,xw201=ed283994f4n832483' +PRIVATE_BOT_PASSWORD = '#bot:password:28131821--mx1fh5g7hg5gg5g´[],s222222223djdjje399333x93901deedd|e[[[]{{|@S{@244f' + +# Time to wait to let a bot set orders for a dummy power. +PRIVATE_BOT_TIMEOUT_SECONDS = 60 + + +class OrderSettings: + """ Constants to define flags for attribute Power.order_is_set. """ + #pylint:disable=too-few-public-methods + ORDER_NOT_SET = 0 + ORDER_SET_EMPTY = 1 + ORDER_SET = 2 + ALL_SETTINGS = {ORDER_NOT_SET, ORDER_SET_EMPTY, ORDER_SET} diff --git a/diplomacy/utils/convoy_paths.py b/diplomacy/utils/convoy_paths.py new file mode 100644 index 0000000..f882e25 --- /dev/null +++ b/diplomacy/utils/convoy_paths.py @@ -0,0 +1,223 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Convoy paths + - Contains utilities to generate all the possible convoy paths for a given map +""" +import collections +import hashlib +import glob +import pickle +import multiprocessing +import os +from queue import Queue +import threading +import tqdm +from diplomacy.engine.map import Map +from diplomacy import settings + +# Using `os.path.expanduser()` to find home directory in a more cross-platform way. +HOME_DIRECTORY = os.path.expanduser('~') +if HOME_DIRECTORY == '~': + raise RuntimeError('Cannot find home directory. Unable to save cache') + +# Constants +__VERSION__ = '20180307_0955' + +# We need to cap convoy length, otherwise the problem gets exponential +SMALL_MAPS = ['standard', 'standard_france_austria', 'standard_germany_italy', 'ancmed', 'colonial', 'modern'] +SMALL_MAPS_CONVOY_LENGTH = 25 +ALL_MAPS_CONVOY_LENGTH = 12 +CACHE_FILE_NAME = 'convoy_paths_cache.pkl' +DISK_CACHE_PATH = os.path.join(HOME_DIRECTORY, '.cache', 'diplomacy', CACHE_FILE_NAME) + +def display_progress_bar(queue, max_loop_iters): + """ Displays a progress bar + :param queue: Multiprocessing queue to display the progress bar + :param max_loop_iters: The expected maximum number of iterations + """ + progress_bar = tqdm.tqdm(total=max_loop_iters) + for _ in iter(queue.get, None): + progress_bar.update() + progress_bar.close() + +def get_convoy_paths(map_object, start_location, max_convoy_length, queue): + """ Returns a list of possible convoy destinations with the required units to get there + Does a breadth first search from the starting location + + :param map_object: The instantiated map + :param start_location: The start location of the unit (e.g. 'LON') + :param max_convoy_length: The maximum convoy length permitted + :param queue: Multiprocessing queue to display the progress bar + :return: A list of ({req. fleets}, {reachable destinations}) + :type map_object: diplomacy.Map + """ + to_check = Queue() # Items in queue have format ({fleets location}, last fleet location) + dest_paths = {} # Dict with dest as key and a list of all paths from start_location to dest as value + + # We need to start on a coast / port + if map_object.area_type(start_location) not in ('COAST', 'PORT') or '/' in start_location: + return [] + + # Queuing all adjacent water locations from start + for loc in [loc.upper() for loc in map_object.abut_list(start_location, incl_no_coast=True)]: + if map_object.area_type(loc) in ['WATER', 'PORT']: + to_check.put(({loc}, loc)) + + # Checking all subsequent adjacencies until no more adjacencies are possible + while not to_check.empty(): + fleets_loc, last_loc = to_check.get() + + # Checking adjacencies + for loc in [loc.upper() for loc in map_object.abut_list(last_loc, incl_no_coast=True)]: + + # If we find adjacent coasts, we mark them as a possible result + if map_object.area_type(loc) in ('COAST', 'PORT') and '/' not in loc and loc != start_location: + dest_paths.setdefault(loc, []) + + # If we already have a working path that is a subset of the current fleets, we can skip + # Otherwise, we add the new path as a valid path to dest + for path in dest_paths[loc]: + if path.issubset(fleets_loc): + break + else: + dest_paths[loc] += [fleets_loc] + + # If we find adjacent water/port, we add them to the queue + elif map_object.area_type(loc) in ('WATER', 'PORT') \ + and loc not in fleets_loc \ + and len(fleets_loc) < max_convoy_length: + to_check.put((fleets_loc | {loc}, loc)) + + # Merging destinations with similar paths + similar_paths = {} + for dest, paths in dest_paths.items(): + for path in paths: + tuple_path = tuple(sorted(path)) + similar_paths.setdefault(tuple_path, set([])) + similar_paths[tuple_path] |= {dest} + + # Converting to list + results = [] + for fleets, dests in similar_paths.items(): + results += [(start_location, set(fleets), dests)] + + # Returning + queue.put(1) + return results + +def build_convoy_paths_cache(map_object, max_convoy_length): + """ Builds the convoy paths cache for a map + :param map_object: The instantiated map object + :param max_convoy_length: The maximum convoy length permitted + :return: A dictionary where the key is the number of fleets in the path and + the value is a list of convoy paths (start loc, {fleets}, {dest}) of that length for the map + :type map_object: diplomacy.Map + """ + print('Generating convoy paths for {}'.format(map_object.name)) + coasts = [loc.upper() for loc in map_object.locs + if map_object.area_type(loc) in ('COAST', 'PORT') if '/' not in loc] + + # Starts the progress bar loop + manager = multiprocessing.Manager() + queue = manager.Queue() + progress_bar = threading.Thread(target=display_progress_bar, args=(queue, len(coasts))) + progress_bar.start() + + # Getting all paths for each coasts in parallel + pool = multiprocessing.Pool(multiprocessing.cpu_count()) + tasks = [(map_object, coast, max_convoy_length, queue) for coast in coasts] + results = pool.starmap(get_convoy_paths, tasks) + pool.close() + results = [item for sublist in results for item in sublist] + queue.put(None) + progress_bar.join() + + # Splitting into buckets + buckets = collections.OrderedDict({i: [] for i in range(1, len(map_object.locs) + 1)}) + for start, fleets, dests in results: + buckets[len(fleets)] += [(start, fleets, dests)] + + # Returning + print('Found {} convoy paths for {}\n'.format(len(results), map_object.name)) + return buckets + +def get_file_md5(file_path): + """ Calculates a file MD5 hash + :param file_path: The file path + :return: The computed md5 hash + """ + hash_md5 = hashlib.md5() + with open(file_path, 'rb') as file: + for chunk in iter(lambda: file.read(4096), b''): + hash_md5.update(chunk) + return hash_md5.hexdigest() + +def add_to_cache(map_name): + """ Lazy generates convoys paths for a map and adds it to the disk cache + :param map_name: The name of the map + :return: The convoy_paths for that map + """ + disk_convoy_paths = {'__version__': __VERSION__} # Uses hash as key + + # Loading cache from disk (only if it's the correct version) + if os.path.exists(DISK_CACHE_PATH): + cache_data = pickle.load(open(DISK_CACHE_PATH, 'rb')) + if cache_data.get('__version__', '') != __VERSION__: + print('Upgrading cache from version "%s" to "%s"' % (cache_data.get('__version__', ''), __VERSION__)) + else: + disk_convoy_paths.update(cache_data) + + # Getting map MD5 hash + map_path = os.path.join(settings.PACKAGE_DIR, 'maps', map_name + '.map') + if not os.path.exists(map_path): + return None + map_hash = get_file_md5(map_path) + + # Determining the depth of the search (small maps can have larger depth) + max_convoy_length = SMALL_MAPS_CONVOY_LENGTH if map_name in SMALL_MAPS else ALL_MAPS_CONVOY_LENGTH + + # Generating and adding to alternate cache paths + if map_hash not in disk_convoy_paths: + map_object = Map(map_name, use_cache=False) + disk_convoy_paths[map_hash] = build_convoy_paths_cache(map_object, max_convoy_length) + os.makedirs(os.path.dirname(DISK_CACHE_PATH), exist_ok=True) + pickle.dump(disk_convoy_paths, open(DISK_CACHE_PATH, 'wb')) + + # Returning + return disk_convoy_paths[map_hash] + +def get_convoy_paths_cache(): + """ Returns the current cache from disk """ + disk_convoy_paths = {} # Uses hash as key + cache_convoy_paths = {} # Use map name as key + + # Loading cache from disk (only if it's the correct version) + if os.path.exists(DISK_CACHE_PATH): + cache_data = pickle.load(open(DISK_CACHE_PATH, 'rb')) + if cache_data.get('__version__', '') == __VERSION__: + disk_convoy_paths.update(cache_data) + + # Getting map name and file paths + files_path = glob.glob(settings.PACKAGE_DIR + '/maps/*.map') + for file_path in files_path: + map_name = file_path.replace(settings.PACKAGE_DIR + '/maps/', '').replace('.map', '') + map_hash = get_file_md5(file_path) + if map_hash in disk_convoy_paths: + cache_convoy_paths[map_name] = disk_convoy_paths[map_hash] + + # Returning + return cache_convoy_paths diff --git a/diplomacy/utils/errors.py b/diplomacy/utils/errors.py new file mode 100644 index 0000000..b95da38 --- /dev/null +++ b/diplomacy/utils/errors.py @@ -0,0 +1,128 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Error + - Contains the error messages used by the engine +""" +MAP_LEAST_TWO_POWERS = 'MAP DOES NOT SPECIFY AT LEAST TWO POWERS' +MAP_LOC_NOT_FOUND = 'NAMED LOCATION NOT ON MAP: %s' +MAP_SITE_ABUTS_TWICE = 'SITES ABUT TWICE %s-%s' +MAP_NO_FULL_NAME = 'MAP LOCATION HAS NO FULL NAME: %s' +MAP_ONE_WAY_ADJ = 'ONE-WAY ADJACENCY IN MAP: %s -> %s' +MAP_BAD_HOME = 'BAD HOME FOR %s: %s' +MAP_BAD_INITIAL_OWN_CENTER = 'BAD INITIAL OWNED CENTER FOR %s: %s' +MAP_BAD_INITIAL_UNITS = 'BAD INITIAL UNIT FOR %s: %s' +MAP_CENTER_MULT_OWNED = 'CENTER MULTIPLY OWNED: %s' +MAP_BAD_PHASE = 'BAD PHASE IN MAP FILE: %s' +MAP_FILE_NOT_FOUND = 'MAP FILE NOT FOUND: %s' +MAP_BAD_VICTORY_LINE = 'BAD VICTORY LINE IN MAP FILE' +MAP_BAD_ROOT_MAP_LINE = 'BAD ROOT MAP LINE' +MAP_TWO_ROOT_MAPS = 'TWO ROOT MAPS' +MAP_FILE_MULT_USED = 'FILE MULTIPLY USED: %s' +MAP_BAD_ALIASES_IN_FILE = 'BAD ALIASES IN MAP FILE: %s' +MAP_RENAME_NOT_SUPPORTED = 'THE RENAME PLACE OPERATOR -> IS NO LONGER SUPPORTED.' +MAP_BAD_RENAME_DIRECTIVE = 'BAD RENAME DIRECTIVE: %s' +MAP_INVALID_LOC_ABBREV = 'INVALID LOCATION ABBREVIATION: %s' +MAP_LOC_RESERVED_KEYWORD = 'MAP LOCATION IS RESERVED KEYWORD: %s' +MAP_DUP_LOC_OR_POWER = 'DUPLICATE MAP LOCATION OR POWER: %s' +MAP_DUP_ALIAS_OR_POWER = 'DUPLICATE MAP ALIAS OR POWER: %s' +MAP_OWNS_BEFORE_POWER = '%s BEFORE POWER: %s' +MAP_INHABITS_BEFORE_POWER = 'INHABITS BEFORE POWER: %s' +MAP_HOME_BEFORE_POWER = '%s BEFORE POWER: %s' +MAP_UNITS_BEFORE_POWER = 'UNITS BEFORE POWER' +MAP_UNIT_BEFORE_POWER = 'UNIT BEFORE POWER: %s' +MAP_INVALID_UNIT = 'INVALID UNIT: %s' +MAP_DUMMY_REQ_LIST_POWERS = 'DUMMIES REQUIRES LIST OF POWERS' +MAP_DUMMY_BEFORE_POWER = 'DUMMY BEFORE POWER' +MAP_NO_EXCEPT_AFTER_DUMMY_ALL = 'NO EXCEPT AFTER %s ALL' +MAP_NO_POWER_AFTER_DUMMY_ALL_EXCEPT = 'NO POWER AFTER %s ALL EXCEPT' +MAP_NO_DATA_TO_AMEND_FOR = 'NO DATA TO "AMEND" FOR %s' +MAP_NO_ABUTS_FOR = 'NO "ABUTS" FOR %s' +MAP_UNPLAYED_BEFORE_POWER = 'UNPLAYED BEFORE POWER' +MAP_NO_EXCEPT_AFTER_UNPLAYED_ALL = 'NO EXCEPT AFTER UNPLAYED ALL' +MAP_NO_POWER_AFTER_UNPLAYED_ALL_EXCEPT = 'NO POWER AFTER UNPLAYED ALL EXCEPT' +MAP_NO_SUCH_POWER_TO_REMOVE = 'NO SUCH POWER TO REMOVE: %s' +MAP_RENAMING_UNOWNED_DIR_NOT_ALLOWED = 'RENAMING UNOWNED DIRECTIVE NOT ALLOWED' +MAP_RENAMING_UNDEF_POWER = 'RENAMING UNDEFINED POWER %s' +MAP_RENAMING_POWER_NOT_SUPPORTED = 'THE RENAME POWER OPERATOR -> IS NO LONGER SUPPORTED.' +MAP_POWER_NAME_EMPTY_KEYWORD = 'POWER NAME IS EMPTY KEYWORD: %s' +MAP_POWER_NAME_CAN_BE_CONFUSED = 'POWER NAME CAN BE CONFUSED WITH LOCATION ALIAS OR ORDER TYPE: %s' +MAP_ILLEGAL_POWER_ABBREV = 'ILLEGAL POWER ABBREVIATION' + +GAME_UNKNOWN_POWER = 'UNKNOWN POWER OR PLACENAME: %s' +GAME_UNKNOWN_UNIT_TYPE = 'UNKNOWN UNIT TYPE: %s' +GAME_UNKNOWN_LOCATION = 'UNKNOWN PLACENAME: %s' +GAME_UNKNOWN_COAST = 'UNKNOWN COAST: %s' +GAME_UNKNOWN_ORDER_TYPE = 'UNKNOWN ORDER TYPE: %s' +GAME_FORBIDDEN_RULE = 'RULE %s PREVENTS RULE %s FROM BEING APPLIED.' +GAME_UNRECOGNIZED_ORDER_DATA = 'UNRECOGNIZED DATA IN ORDER: %s' +GAME_AMBIGUOUS_PLACE_NAME = 'AMBIGUOUS PLACENAME: %s' +GAME_BAD_PHASE_NOT_IN_FLOW = 'BAD PHASE (NOT IN FLOW)' +GAME_BAD_BEGIN_PHASE = 'BAD BEGIN PHASE' +GAME_BAD_YEAR_GAME_PHASE = 'BAD YEAR IN GAME PHASE' +GAME_BAD_ADJUSTMENT_ORDER = 'BAD ADJUSTMENT ORDER: %s' +GAME_BAD_RETREAT = 'BAD RETREAT FOR %s: %s' +GAME_ORDER_TO_INVALID_UNIT = 'ORDER TO INVALID UNIT: %s' +GAME_ORDER_INCLUDES_INVALID_UNIT = 'ORDER INCLUDES INVALID UNIT: %s' +GAME_ORDER_INCLUDES_INVALID_DEST = 'ORDER INCLUDES INVALID UNIT DESTINATION %s' +GAME_ORDER_NON_EXISTENT_UNIT = 'ORDER TO NON-EXISTENT UNIT: %s' +GAME_ORDER_TO_FOREIGN_UNIT = 'ORDER TO FOREIGN UNIT: %s' +GAME_UNIT_MAY_ONLY_HOLD = 'UNIT MAY ONLY BE ORDERED TO HOLD: %s' +GAME_CONVOY_IMPROPER_UNIT = 'CONVOY ORDER FOR IMPROPER UNIT: %s %s' +GAME_INVALID_ORDER_NON_EXISTENT_UNIT = 'CANNOT %s NON-EXISTENT UNIT: %s %s' +GAME_INVALID_ORDER_RECIPIENT = 'INVALID %s RECIPIENT: %s %s' +GAME_BAD_ORDER_SYNTAX = 'BAD %s ORDER: %s %s' +GAME_ORDER_RECIPIENT_DOES_NOT_EXIST = '%s RECIPIENT DOES NOT EXIST: %s %s' +GAME_UNIT_CANT_SUPPORT_ITSELF = 'UNIT CANNOT SUPPORT ITSELF: %s %s' +GAME_UNIT_CANT_BE_CONVOYED = 'UNIT CANNOT BE CONVOYED: %s %s' +GAME_BAD_CONVOY_DESTINATION = 'BAD CONVOY DESTINATION: %s %s' +GAME_SUPPORTED_UNIT_CANT_REACH_DESTINATION = 'SUPPORTED UNIT CANNOT REACH DESTINATION: %s %s' +GAME_UNIT_CANT_PROVIDE_SUPPORT_TO_DEST = 'UNIT CANNOT PROVIDE SUPPORT TO DESTINATION: %s %s' +GAME_IMPROPER_CONVOY_ORDER = 'IMPROPER CONVOY ORDER: %s %s' +GAME_IMPROPER_SUPPORT_ORDER = 'IMPROPER SUPPORT ORDER: %s %s' +GAME_IMPOSSIBLE_CONVOY_ORDER = 'IMPOSSIBLE CONVOY ORDER: %s %s' +GAME_BAD_MOVE_ORDER = 'BAD MOVE ORDER: %s %s' +GAME_UNIT_CANT_CONVOY = 'UNIT CANNOT CONVOY: %s %s' +GAME_MOVING_UNIT_CANT_RETURN = 'MOVING UNIT MAY NOT RETURN: %s %s' +GAME_CONVOYING_UNIT_MUST_REACH_COST = 'CONVOYING UNIT MUST REACH COAST: %s %s' +GAME_ARMY_CANT_CONVOY_TO_COAST = 'ARMY CANNOT CONVOY TO SPECIFIC COAST: %s %s' +GAME_CONVOY_UNIT_USED_TWICE = 'CONVOYING UNIT USED TWICE IN SAME CONVOY: %s %s' +GAME_UNIT_CANT_MOVE_INTO_DEST = 'UNIT CANNOT MOVE INTO DESTINATION: %s %s' +GAME_UNIT_CANT_MOVE_VIA_CONVOY_INTO_DEST = 'UNIT CANNOT MOVE VIA CONVOY INTO DESTINATION: %s %s' +GAME_BAD_CONVOY_MOVE_ORDER = 'BAD CONVOY MOVE ORDER: %s %s' +GAME_CONVOY_THROUGH_NON_EXISTENT_UNIT = 'CONVOY THROUGH NON-EXISTENT UNIT: %s %s' +GAME_IMPOSSIBLE_CONVOY = 'IMPOSSIBLE CONVOY: %s %s' +GAME_INVALID_HOLD_ORDER = 'INVALID HOLD ORDER: %s %s' +GAME_UNRECOGNIZED_ORDER_TYPE = 'UNRECOGNIZED ORDER TYPE: %s %s' +GAME_INVALID_RETREAT = 'INVALID RETREAT: %s - %s' +GAME_NO_CONTROL_OVER = 'NO CONTROL OVER %s' +GAME_UNIT_NOT_IN_RETREAT = 'UNIT NOT IN RETREAT: %s' +GAME_TWO_ORDERS_FOR_RETREATING_UNIT = 'TWO ORDERS FOR RETREATING UNIT: %s' +GAME_INVALID_RETREAT_DEST = 'INVALID RETREAT DESTINATION: %s' +GAME_BAD_RETREAT_ORDER = 'BAD RETREAT ORDER: %s' +GAME_DATA_FOR_NON_POWER = 'DATA FOR NON-POWER: %s' +GAME_UNABLE_TO_FIND_RULES = 'UNABLE TO FIND FILE CONTAINING RULES.' +GAME_BUILDS_IN_ALL_ALT_SITES = 'BUILDS IN ALL ALTERNATIVE SITES (%s): %s' +GAME_NO_SUCH_UNIT = 'NO SUCH UNIT: %s' +GAME_MULTIPLE_ORDERS_FOR_UNIT = 'MULTIPLE ORDERS FOR UNIT: %s' +GAME_INVALID_BUILD_SITE = 'INVALID BUILD SITE: %s' +GAME_MULT_BUILDS_IN_SITE = 'MULTIPLE BUILDS IN SITE: %s' +GAME_INVALID_BUILD_ORDER = 'INVALID BUILD ORDER: %s' +GAME_EXCESS_HOME_CENTER_CLAIM = 'EXCESS HOME CENTER CLAIM' + +STD_GAME_BAD_ORDER = 'BAD ORDER: %s' +STD_GAME_UNIT_REORDERED = 'UNIT REORDERED: %s' +STD_GAME_UNORDERABLE_UNIT = 'UNORDERABLE UNIT: %s' diff --git a/diplomacy/utils/exceptions.py b/diplomacy/utils/exceptions.py new file mode 100644 index 0000000..4d564a3 --- /dev/null +++ b/diplomacy/utils/exceptions.py @@ -0,0 +1,178 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Exceptions used in diplomacy network code. """ + +class DiplomacyException(Exception): + """ Diplomacy network code exception. """ + + def __init__(self, message=''): + self.message = (message or self.__doc__).strip() + super(DiplomacyException, self).__init__(self.message) + +class AlreadyScheduledException(DiplomacyException): + """ Cannot add a data already scheduled. """ + +class CommonKeyException(DiplomacyException): + """Common key error.""" + + def __init__(self, key): + super(CommonKeyException, self).__init__('Forbidden common key in two dicts (%s)' % key) + +class KeyException(DiplomacyException): + """ Key error. """ + + def __init__(self, key): + super(KeyException, self).__init__('Key error: %s' % key) + +class LengthException(DiplomacyException): + """ Length error. """ + + def __init__(self, expected_length, given_length): + super(LengthException, self).__init__('Expected length %d, got %d.' % (expected_length, given_length)) + +class NaturalIntegerException(DiplomacyException): + """ Expected a positive integer (int >= 0). """ + + def __init__(self, integer_name=''): + super(NaturalIntegerException, self).__init__( + ('Integer error: %s.%s' % (integer_name, self.__doc__)) if integer_name else '') + +class NaturalIntegerNotNullException(NaturalIntegerException): + """ Expected a strictly positive integer (int > 0). """ + +class RandomPowerException(DiplomacyException): + """ No enough playable powers to select random powers. """ + + def __init__(self, nb_powers, nb_available_powers): + super(RandomPowerException, self).__init__('Cannot randomly select %s power(s) in %s available power(s).' + % (nb_powers, nb_available_powers)) + +class TypeException(DiplomacyException): + """ Type error. """ + + def __init__(self, expected_type, given_type): + super(TypeException, self).__init__('Expected type %s, got type %s' % (expected_type, given_type)) + +class ValueException(DiplomacyException): + """ Value error. """ + + def __init__(self, expected_values, given_value): + super(ValueException, self).__init__('Forbidden value %s, expected: %s' + % (given_value, ', '.join(str(v) for v in expected_values))) + +class NotificationException(DiplomacyException): + """ Unknown notification. """ + +class ResponseException(DiplomacyException): + """ Unknown response. """ + +class RequestException(ResponseException): + """ Unknown request. """ + +class AdminTokenException(ResponseException): + """ Invalid token for admin operations. """ + +class GameCanceledException(ResponseException): + """ Game was cancelled. """ + +class GameCreationException(ResponseException): + """ Cannot create more games on that server. """ + +class GameFinishedException(ResponseException): + """ This game is finished. """ + +class GameIdException(ResponseException): + """ Invalid game ID. """ + +class GameJoinRoleException(ResponseException): + """ A token can have only one role inside a game: player, observer or omniscient. """ + +class GameMasterTokenException(ResponseException): + """ Invalid token for master operations. """ + +class GameNotPlayingException(ResponseException): + """ Game not playing. """ + +class GameObserverException(ResponseException): + """ Disallowed observation for non-master users. """ + +class GamePhaseException(ResponseException): + """ Data does not match current game phase. """ + + def __init__(self, expected=None, given=None): + message = self.__doc__.strip() + # This is to prevent an unexpected Pycharm warning about message type. + if isinstance(message, bytes): + message = message.decode() + if expected is not None: + message += ' Expected: %s' % expected + if given is not None: + message += ' Given: %s' % given + super(GamePhaseException, self).__init__(message) + +class GamePlayerException(ResponseException): + """ Invalid player. """ + +class GameRegistrationPasswordException(ResponseException): + """ Invalid game registration password. """ + +class GameSolitaireException(ResponseException): + """ A solitaire game does not accepts players. """ + +class GameTokenException(ResponseException): + """ Invalid token for this game. """ + +class MapIdException(ResponseException): + """ Invalid map ID. """ + +class MapPowerException(ResponseException): + """ Invalid map power. """ + + def __init__(self, power_name): + super(MapPowerException, self).__init__('Invalid map power %s' % power_name) + +class ServerDataDirException(ResponseException): + """ No data directory available in server folder. """ + +class FolderException(ResponseException): + """ Given folder not available in server. """ + def __init__(self, folder_path): + super(FolderException, self).__init__('Given folder not available in server: %s' % folder_path) + +class ServerGameDirException(ResponseException): + """ No games directory available in server/data folder. """ + +class ServerRegistrationException(ResponseException): + """ Registration currently not allowed on this server. """ + +class TokenException(ResponseException): + """ Invalid token. """ + +class UserException(ResponseException): + """ Invalid user. """ + +class PasswordException(ResponseException): + """ Password must not be empty. """ + +class VoteCreationException(ResponseException): + """ Only either a player or a game master for a game with at least 1 player can create a vote. """ + +class ServerDirException(ResponseException): + """ Error with working folder. """ + + def __init__(self, server_dir): + super(ServerDirException, self).__init__("No server directory available at path %s" % server_dir) diff --git a/diplomacy/utils/export.py b/diplomacy/utils/export.py new file mode 100644 index 0000000..459313d --- /dev/null +++ b/diplomacy/utils/export.py @@ -0,0 +1,164 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Exporter + - Responsible for exporting games in a standardized format to disk +""" +from diplomacy.engine.game import Game +from diplomacy.engine.map import Map +from diplomacy.utils.game_phase_data import GamePhaseData + +# Constants +RULES_TO_SKIP = ['SOLITAIRE', 'NO_DEADLINE', 'CD_DUMMIES', 'ALWAYS_WAIT', 'IGNORE_ERRORS'] + +def to_saved_game_format(game): + """ Converts a game to a standardized JSON format + :param game: game to convert. + :return: A game in the standard JSON format used to saved game (returned object is a dictionary) + :type game: Game + """ + + # Get phase history. + phases = game.get_phase_history() + # Add current game phase. + phases.append(game.get_phase_data()) + # Filter rules. + rules = [rule for rule in game.rules if rule not in RULES_TO_SKIP] + # Extend states fields. + phases_to_dict = [phase.to_dict() for phase in phases] + for phase_dct in phases_to_dict: + phase_dct['state']['game_id'] = game.game_id + phase_dct['state']['map'] = game.map_name + phase_dct['state']['rules'] = rules + + # Building saved game + return {'id': game.game_id, + 'map': game.map_name, + 'rules': rules, + 'phases': phases_to_dict} + +def is_valid_saved_game(saved_game): + """ Checks if the saved game is valid. + This is an expensive operation because it replays the game. + :param saved_game: The saved game (from to_saved_game_format) + :return: A boolean that indicates if the game is valid + """ + # pylint: disable=too-many-return-statements, too-many-nested-blocks, too-many-branches + nb_forced_phases = 0 + max_nb_forced_phases = 1 if 'DIFFERENT_ADJUDICATION' in saved_game.get('rules', []) else 0 + + # Validating default fields + if 'id' not in saved_game or not saved_game['id']: + return False + if 'map' not in saved_game: + return False + map_object = Map(saved_game['map']) + if map_object.name != saved_game['map']: + return False + if 'rules' not in saved_game: + return False + if 'phases' not in saved_game: + return False + + # Validating each phase + nb_messages = 0 + nb_phases = len(saved_game['phases']) + last_time_sent = -1 + for phase_ix in range(nb_phases): + current_phase = saved_game['phases'][phase_ix] + state = current_phase['state'] + phase_orders = current_phase['orders'] + previous_phase_name = 'FORMING' if phase_ix == 0 else saved_game['phases'][phase_ix - 1]['name'] + next_phase_name = 'COMPLETED' if phase_ix == nb_phases - 1 else saved_game['phases'][phase_ix + 1]['name'] + power_names = list(state['units'].keys()) + + # Validating messages + for message in saved_game['phases'][phase_ix]['messages']: + nb_messages += 1 + if map_object.compare_phases(previous_phase_name, message['phase']) >= 0: + return False + if map_object.compare_phases(message['phase'], next_phase_name) > 0: + return False + if message['sender'] not in power_names + ['SYSTEM']: + return False + if message['recipient'] not in power_names + ['GLOBAL']: + return False + if message['time_sent'] < last_time_sent: + return False + last_time_sent = message['time_sent'] + + # Validating phase + if phase_ix < (nb_phases - 1): + is_forced_phase = False + + # Setting game state + game = Game(saved_game['id'], map_name=saved_game['map'], rules=['SOLITAIRE'] + saved_game['rules']) + game.set_phase_data(GamePhaseData.from_dict(current_phase)) + + # Determining what phase we should expect from the dataset. + next_state = saved_game['phases'][phase_ix + 1]['state'] + + # Setting orders + game.clear_orders() + for power_name in phase_orders: + game.set_orders(power_name, phase_orders[power_name]) + + # Validating orders + orders = game.get_orders() + for power_name in orders: + if sorted(orders[power_name]) != sorted(current_phase['orders'][power_name]): + return False + if 'NO_CHECK' not in game.rules: + for order in orders[power_name]: + loc = order.split()[1] + if order not in game.get_all_possible_orders(loc): + return False + + # Validating resulting state + game.process() + + # Checking phase name + if game.get_current_phase() != next_state['name']: + is_forced_phase = True + + # Checking zobrist hash + if game.get_hash() != next_state['zobrist_hash']: + is_forced_phase = True + + # Checking units + units = game.get_units() + for power_name in units: + if sorted(units[power_name]) != sorted(next_state['units'][power_name]): + is_forced_phase = True + + # Checking centers + centers = game.get_centers() + for power_name in centers: + if sorted(centers[power_name]) != sorted(next_state['centers'][power_name]): + is_forced_phase = True + + # Allowing 1 forced phase if DIFFERENT_ADJUDICATION is in rule + if is_forced_phase: + nb_forced_phases += 1 + if nb_forced_phases > max_nb_forced_phases: + return False + + # Making sure NO_PRESS is not set + if 'NO_PRESS' in saved_game['rules'] and nb_messages > 0: + return False + + # The data is valid + return True diff --git a/diplomacy/utils/game_phase_data.py b/diplomacy/utils/game_phase_data.py new file mode 100644 index 0000000..c45eb63 --- /dev/null +++ b/diplomacy/utils/game_phase_data.py @@ -0,0 +1,47 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Utility class to save all data related to one game phase (phase name, state, messages and orders). """ +from diplomacy.engine.message import Message +from diplomacy.utils import strings, parsing +from diplomacy.utils.jsonable import Jsonable +from diplomacy.utils.sorted_dict import SortedDict + +MESSAGES_TYPE = parsing.IndexedSequenceType( + parsing.DictType(int, parsing.JsonableClassType(Message), SortedDict.builder(int, Message)), 'time_sent') + +class GamePhaseData(Jsonable): + """ Small class to represent data for a game phase: + phase name, state, orders, orders results and messages for this phase. + """ + __slots__ = ['name', 'state', 'orders', 'results', 'messages'] + + model = { + strings.NAME: str, + strings.STATE: dict, + strings.ORDERS: parsing.DictType(str, parsing.OptionalValueType(parsing.SequenceType(str))), + strings.RESULTS: parsing.DictType(str, parsing.SequenceType(str)), + strings.MESSAGES: MESSAGES_TYPE, + } + + def __init__(self, name, state, orders, results, messages): + """ Constructor. """ + self.name = '' + self.state = {} + self.orders = {} + self.results = {} + self.messages = {} + super(GamePhaseData, self).__init__(name=name, state=state, orders=orders, results=results, messages=messages) diff --git a/diplomacy/utils/jsonable.py b/diplomacy/utils/jsonable.py new file mode 100644 index 0000000..5e558ee --- /dev/null +++ b/diplomacy/utils/jsonable.py @@ -0,0 +1,141 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Abstract Jsonable class with automatic attributes checking and conversion to/from JSON dict. + To write a Jsonable sub-class: + - Define a model with expected attribute names and types. Use module `parsing` to describe expected types. + - Override initializer __init__(**kwargs): + - **first**: initialize each attribute defined in model with value None. + - **then** : call parent __init__() method. Attributes will be checked and filled by + Jsonable's __init__() method. + - If needed, add further initialization code after call to parent __init__() method. At this point, + attributes were correctly set based on defined model, and you can now work with them. + + Example: + ``` + class MyClass(Jsonable): + model = { + 'my_attribute': parsing.Sequence(int), + } + def __init__(**kwargs): + self.my_attribute = None + super(MyClass, self).__init__(**kwargs) + # my_attribute is now initialized based on model. You can then do any further initialization if needed. + ``` +""" +import logging +import ujson as json + +from diplomacy.utils import exceptions, parsing + +LOGGER = logging.getLogger(__name__) + +class Jsonable(): + """ Abstract class to ease conversion from/to JSON dict. """ + __slots__ = [] + __cached__models__ = {} + model = {} + + def __init__(self, **kwargs): + """ Validates given arguments, update them if necessary (e.g. to add default values), + and fill instance attributes with updated argument. + If a derived class adds new attributes, it must override __init__() method and + initialize new attributes (e.g. `self.attribute = None`) + **BEFORE** calling parent __init__() method. + + :param kwargs: arguments to build class. Must match keys and values types defined in model. + """ + model = self.get_model() + + # Adding default value + updated_kwargs = {model_key: None for model_key in model} + updated_kwargs.update(kwargs) + + # Validating and updating + try: + parsing.validate_data(updated_kwargs, model) + except exceptions.TypeException as exception: + LOGGER.error('Error occurred while building class %s', self.__class__) + raise exception + updated_kwargs = parsing.update_data(updated_kwargs, model) + + # Building. + for model_key in model: + setattr(self, model_key, updated_kwargs[model_key]) + + def json(self): + """ Convert this object to a JSON string ready to be sent/saved. + :return: string + """ + return json.dumps(self.to_dict()) + + def to_dict(self): + """ Convert this object to a python dictionary ready for any JSON work. + :return: dict + """ + model = self.get_model() + return {key: parsing.to_json(getattr(self, key), key_type) for key, key_type in model.items()} + + @classmethod + def update_json_dict(cls, json_dict): + """ Update a JSON dictionary before being parsed with class model. + JSON dictionary is passed by class method from_dict() (see below), and is guaranteed to contain + at least all expected model keys. Some keys may be associated to None if initial JSON dictionary + did not provide values for them. + :param json_dict: a JSON dictionary to be parsed. + :type json_dict: dict + """ + pass + + @classmethod + def from_dict(cls, json_dict): + """ Convert a JSON dictionary to an instance of this class. + :param json_dict: a JSON dictionary to parse. Dictionary with basic types (int, bool, dict, str, None, etc.) + :return: an instance from this class or from a derived one from which it's called. + :rtype: cls + """ + model = cls.get_model() + + # json_dict must be a a dictionary + if not isinstance(json_dict, dict): + raise exceptions.TypeException(dict, type(json_dict)) + + # By default, we set None for all expected keys + default_json_dict = {key: None for key in model} + default_json_dict.update(json_dict) + cls.update_json_dict(json_dict) + + # Building this object + # NB: We don't care about extra keys in provided dict, we just focus on expected keys, nothing more. + kwargs = {key: parsing.to_type(default_json_dict[key], key_type) for key, key_type in model.items()} + return cls(**kwargs) + + @classmethod + def build_model(cls): + """ Return model associated to current class. You can either define model class field + or override this function. + """ + return cls.model + + @classmethod + def get_model(cls): + """ Return model associated to current class, and cache it for future uses, to avoid + multiple rendering of model for each class derived from Jsonable. Private method. + :return: dict: model associated to current class. + """ + if cls not in cls.__cached__models__: + cls.__cached__models__[cls] = cls.build_model() + return cls.__cached__models__[cls] diff --git a/diplomacy/utils/keywords.py b/diplomacy/utils/keywords.py new file mode 100644 index 0000000..cdd9362 --- /dev/null +++ b/diplomacy/utils/keywords.py @@ -0,0 +1,39 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Aliases and keywords + - Contains aliases and keywords + - Keywords are always single words + - Aliases are only converted in a second pass, so if they contain a keyword, you should replace + the keyword with its abbreviation. +""" + +KEYWORDS = {'>': '', '-': '-', 'ARMY': 'A', 'FLEET': 'F', 'WING': 'W', 'THE': '', 'NC': '/NC', 'SC': '/SC', + 'EC': '/EC', 'WC': '/WC', 'MOVE': '', 'MOVES': '', 'MOVING': '', 'ATTACK': '', 'ATTACKS': '', + 'ATTACKING': '', 'RETREAT': 'R', 'RETREATS': 'R', 'RETREATING': 'R', 'SUPPORT': 'S', 'SUPPORTS': 'S', + 'SUPPORTING': 'S', 'CONVOY': 'C', 'CONVOYS': 'C', 'CONVOYING': 'C', 'HOLD': 'H', 'HOLDS': 'H', + 'HOLDING': 'H', 'BUILD': 'B', 'BUILDS': 'B', 'BUILDING': 'B', 'DISBAND': 'D', 'DISBANDS': 'D', + 'DISBANDING': 'D', 'DESTROY': 'D', 'DESTROYS': 'D', 'DESTROYING': 'D', 'REMOVE': 'D', 'REMOVES': 'D', + 'REMOVING': 'D', 'WAIVE': 'V', 'WAIVES': 'V', 'WAIVING': 'V', 'WAIVED': 'V', 'KEEP': 'K', 'KEEPS': 'K', + 'KEEPING': 'K', 'PROXY': 'P', 'PROXIES': 'P', 'PROXYING': 'P', 'IS': '', 'WILL': '', 'IN': '', 'AT': '', + 'ON': '', 'TO': '', 'OF': '\\', 'FROM': '\\', 'WITH': '?', 'TSR': '=', 'VIA': 'VIA', 'THROUGH': '~', + 'OVER': '~', 'BY': '~', 'OR': '|', 'BOUNCE': '|', 'CUT': '|', 'VOID': '?', 'DISLODGED': '~', + 'DESTROYED': '*'} + +ALIASES = {'NORTH COAST \\': '/NC \\', 'SOUTH COAST \\': '/SC \\', 'EAST COAST \\': '/EC \\', + 'WEST COAST \\': '/WC \\', 'AN A': 'A', 'A F': 'F', 'A W': 'W', 'NO C': '?', '~ C': '^', + '~ =': '=', '? =': '=', '~ LAND': '_', '~ WATER': '_', '~ SEA': '_', 'VIA C': 'VIA', + 'TRANS SIBERIAN RAILROAD': '=', 'V B': 'B V'} diff --git a/diplomacy/utils/network_data.py b/diplomacy/utils/network_data.py new file mode 100644 index 0000000..48b285a --- /dev/null +++ b/diplomacy/utils/network_data.py @@ -0,0 +1,79 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Abstract Jsonable class to create data intended to be exchanged on network. + Used for requests, responses and notifications. + To write a sub-class, you must first write a base class for data category (e.g. notifications): + + - Define header model for network data. + + - Define ID field for data category (e.g. "notification_id"). This will be used to create unique + identifier for every data exchanged on network. + + - Then every sub-class from base class must define parameters (params) model. Params and header + must not share any field. +""" +import uuid + +from diplomacy.utils import strings, exceptions +from diplomacy.utils.common import assert_no_common_keys, camel_case_to_snake_case +from diplomacy.utils.jsonable import Jsonable + +class NetworkData(Jsonable): + """ Abstract class for network-exchanged data. """ + __slots__ = ['name'] + # NB: header must have a `name` field and a field named `id_field`. + header = {} + params = {} + id_field = None + + def __init__(self, **kwargs): + self.name = None # type: str + + # Setting default values + kwargs[strings.NAME] = kwargs.get(strings.NAME, None) or self.get_class_name() + kwargs[self.id_field] = kwargs.get(self.id_field, None) or str(uuid.uuid4()) + if kwargs[strings.NAME] != self.get_class_name(): + raise exceptions.DiplomacyException('Expected request name %s, got %s' % + (self.get_class_name(), kwargs[strings.NAME])) + + # Building + super(NetworkData, self).__init__(**kwargs) + + @classmethod + def get_class_name(cls): + """ Returns the class name in snake_case. """ + return camel_case_to_snake_case(cls.__name__) + + @classmethod + def validate_params(cls): + """ Called when getting model to validate parameters. Called once per class. """ + pass + + @classmethod + def build_model(cls): + """ Return model associated to current class. You can either define model class field + or override this function. + """ + # Validating model parameters (header and params must have different keys) + assert_no_common_keys(cls.header, cls.params) + cls.validate_params() + + # Building model. + model = cls.header.copy() + model.update(cls.params.copy()) + model[strings.NAME] = (cls.get_class_name(),) + return model diff --git a/diplomacy/utils/parsing.py b/diplomacy/utils/parsing.py new file mode 100644 index 0000000..802fab3 --- /dev/null +++ b/diplomacy/utils/parsing.py @@ -0,0 +1,505 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Provide classes and methods to parse python objects. + + Useful for type checking and conversions from/to JSON dictionaries. + + This module use 2 definitions to distinguish values from/to JSON: item values and attribute values. + + Item value is a value retrieved from a JSON dictionary. It's generally a basic Python type + (e.g. bool, int, str, float). + + Attribute value is a value used in Python code and expected by type checking. It may be + a basic Python type, or a class instance. Note that not all classes are allowed (see + other type checkers below). + +""" +import inspect +import logging +from abc import ABCMeta, abstractmethod +from copy import copy + +from diplomacy.utils import exceptions +from diplomacy.utils.common import assert_no_common_keys, is_dictionary, is_sequence + +LOGGER = logging.getLogger(__name__) + +# ----------------------------------------------------- +# ------------ Functions --------------- +# ----------------------------------------------------- + +def update_model(model, additional_keys, allow_duplicate_keys=True): + """ Return a copy of model updated with additional keys. + :param model: (Dictionary). Model to extend + :param additional_keys: (Dictionary). Definition of the additional keys to use to update the model. + :param allow_duplicate_keys: Boolean. If True, the model key will be updated if present in additional keys. + Otherwise, an error is thrown if additional_key contains a model key. + :return: The updated model with the additional keys. + """ + assert isinstance(model, dict) + assert isinstance(additional_keys, dict) + if not allow_duplicate_keys: + assert_no_common_keys(model, additional_keys) + model_copy = model.copy() + model_copy.update(additional_keys) + return model_copy + +def extend_model(model, additional_keys): + """ Return a copy of model updated with additional model keys. Model and additional keys must no share any key. + :param model: (Dictionary). Model to update + :param additional_keys: (Dictionary). Definition of the additional keys to add to model. + :return: The updated model with the additional keys. + """ + return update_model(model, additional_keys, allow_duplicate_keys=False) + +def get_type(desired_type): + """ Return a ParserType sub-class that matches given type. + :param desired_type: basic type or ParserType sub-class. + :return: ParserType sub-class instance. + """ + # Already a ParserType, we return the object directly. + if isinstance(desired_type, ParserType): + return desired_type + + # Sequence of primitive. + # Detecting if we have a sequence of primitive classes or instances (values). + if isinstance(desired_type, (list, tuple, set)) and desired_type: + if inspect.isclass(next(iter(desired_type))): + return SequenceOfPrimitivesType(desired_type) + return EnumerationType(desired_type) + + # By default, we return a Type(expected_type). + # If expected_type is not a basic type, an exception will be raised + # (see class Type above). + return PrimitiveType(desired_type) + +def to_type(json_value, parser_type): + """ Convert a JSON value (python built-in type) to the type + described by parser_type. + :param json_value: JSON value to convert. + :param parser_type: either an instance of a ParserType, or a type convertible + to a ParserType (see function get_type() above). + :return: JSON value converted to expected type. + """ + return get_type(parser_type).to_type(json_value) + +def to_json(raw_value, parser_type): + """ Convert a value from the type described by parser_type to a JSON value. + :param raw_value: The raw value to convert to JSON. + :param parser_type: Either an instance of a ParserType, or a type convertible to a ParserType. + :return: The value converted to an equivalent JSON value. + """ + return get_type(parser_type).to_json(raw_value) + +def validate_data(data, model): + """ Validates that the data complies with the model + :param data: (Dictionary). A dict of values to validate against the model. + :param model: (Dictionary). The model to use for validation. + """ + assert isinstance(data, dict) + assert isinstance(model, dict) + + # Make sure all fields in data are of the correct type. + # Also make sure all expected fields not present in data have default values (e.g. None). + # NB: We don't care about extra keys in provided data. We only focus on expected keys. + for model_key, model_type in model.items(): + try: + get_type(model_type).validate(data.get(model_key, None)) + except exceptions.TypeException as exception: + LOGGER.error('Error occurred while checking key %s', model_key) + raise exception + +def update_data(data, model): + """ Modifies the data object to add default values if needed + :param data: (Dictionary). A dict of values to update. + :param model: (Dictionary). The model to use. + """ + # Updating the data types + for model_key, model_type in model.items(): + data_value = data.get(model_key, None) + # update() will return either same value or updated value. + data[model_key] = get_type(model_type).update(data_value) + return data + +# ----------------------------------------------------- +# ------------ Classes --------------- +# ----------------------------------------------------- + +class ParserType(metaclass=ABCMeta): + """ Abstract base class to check a specific type. """ + __slots__ = [] + # We include dict into primitive types to allow parser to accept raw untyped dict (e.g. engine game state). + primitives = (int, float, bool, str, dict) + + @abstractmethod + def validate(self, element): + """ Makes sure the element is a valid element for this parser type + :param element: The element to validate. + :return: None, but raises Error if needed. + """ + raise NotImplementedError() + + def update(self, element): + """ Returns the correct value to use in the data object. + :param element: The element the model wants to store in the data object of this parser type. + :return: The updated element to store in the data object. + The updated element might be a different value (e.g. if a default value is present) + """ + # pylint: disable=no-self-use + return element + + def to_type(self, json_value): + """ Converts a json_value to this parser type. + :param json_value: The JSON value to convert. + :return: The converted JSON value. + """ + # pylint: disable=no-self-use + return json_value + + def to_json(self, raw_value): + """ Converts a raw value (of this type) to JSON. + :param raw_value: The raw value (of this type) to convert. + :return: The resulting JSON value. + """ + # pylint: disable=no-self-use + return raw_value + +class ConverterType(ParserType): + """ Type checker that allows to use another parser type with a converter function. + Converter function will be used to convert any raw value to a value expected + by given parser type before validations and updates. + """ + def __init__(self, element_type, converter_function, json_converter_function=None): + """ Initialize a converter type. + :param element_type: expected type + :param converter_function: function to be used to check and convert values to expected type. + converter_function(value) -> value_compatible_with_expected_type + :param json_converter_function: function to be used to convert a JSON value + to an expected JSON value for element_type. If not provided, converter_function will be used. + json_converter_function(json_value) -> new JSON value valid for element_type.to_type(new_json_value) + """ + element_type = get_type(element_type) + assert not isinstance(element_type, ConverterType) + assert callable(converter_function) + self.element_type = element_type + self.converter_function = converter_function + self.json_converter_function = json_converter_function or converter_function + + def validate(self, element): + self.element_type.validate(self.converter_function(element)) + + def update(self, element): + return self.element_type.update(self.converter_function(element)) + + def to_type(self, json_value): + return self.element_type.to_type(self.json_converter_function(json_value)) + + def to_json(self, raw_value): + return self.element_type.to_json(raw_value) + +class DefaultValueType(ParserType): + """ Type checker that allows a default value. """ + __slots__ = ('element_type', 'default_json_value') + + def __init__(self, element_type, default_json_value): + """ Initialize a default type checker with expected element type and a default value (if None is present). + :param element_type: The expected type for elements (except if None is provided). + :param default_json_value: The default value to set if element=None. Must be a JSON value + convertible to element_type, so that new default value is generated from this JSON value + each time it's needed. + """ + element_type = get_type(element_type) + assert not isinstance(element_type, (DefaultValueType, OptionalValueType)) + self.element_type = element_type + self.default_json_value = default_json_value + # If default JSON value is provided, make sure it's a valid value. + if default_json_value is not None: + self.validate(self.to_type(default_json_value)) + + def __str__(self): + """ String representation """ + return '%s (default %s)' % (self.element_type, self.default_json_value) + + def validate(self, element): + if element is not None: + self.element_type.validate(element) + + def update(self, element): + if element is not None: + return self.element_type.update(element) + return None if self.default_json_value is None else self.element_type.to_type(self.default_json_value) + + def to_type(self, json_value): + json_value = self.default_json_value if json_value is None else json_value + return None if json_value is None else self.element_type.to_type(json_value) + + def to_json(self, raw_value): + return copy(self.default_json_value) if raw_value is None else self.element_type.to_json(raw_value) + +class OptionalValueType(DefaultValueType): + """ Type checker that allows None as default value. """ + __slots__ = [] + + def __init__(self, element_type): + """ Initialized a optional type checker with expected element type. + :param element_type: The expected type for elements. + """ + super(OptionalValueType, self).__init__(element_type, None) + +class SequenceType(ParserType): + """ Type checker for sequence-like objects. """ + __slots__ = ['element_type', 'sequence_builder'] + + def __init__(self, element_type, sequence_builder=None): + """ Initialize a sequence type checker with value type and optional sequence builder. + :param element_type: Expected type for sequence elements. + :param sequence_builder: (Optional). A callable used to build the sequence type. + Expected args: Iterable + """ + self.element_type = get_type(element_type) + self.sequence_builder = sequence_builder if sequence_builder is not None else lambda seq: seq + + def __str__(self): + """ String representation """ + return '[%s]' % self.element_type + + def validate(self, element): + if not is_sequence(element): + raise exceptions.TypeException('sequence', type(element)) + for seq_element in element: + self.element_type.validate(seq_element) + + def update(self, element): + # Converting each element in the list, then using the seq builder if available + sequence = [self.element_type.update(seq_element) for seq_element in element] + return self.sequence_builder(sequence) + + def to_type(self, json_value): + sequence = [self.element_type.to_type(seq_element) for seq_element in json_value] + return self.sequence_builder(sequence) + + def to_json(self, raw_value): + return [self.element_type.to_json(seq_element) for seq_element in raw_value] + +class JsonableClassType(ParserType): + """ Type checker for Jsonable classes. """ + __slots__ = ['element_type'] + + def __init__(self, jsonable_element_type): + """ Initialize a sub-class of Jsonable. + :param jsonable_element_type: Expected type (should be a subclass of Jsonable). + """ + # We import Jsonable here to prevent recursive import with module jsonable. + from diplomacy.utils.jsonable import Jsonable + assert issubclass(jsonable_element_type, Jsonable) + self.element_type = jsonable_element_type + + def __str__(self): + """ String representation """ + return self.element_type.__name__ + + def validate(self, element): + if not isinstance(element, self.element_type): + raise exceptions.TypeException(self.element_type, type(element)) + + def to_type(self, json_value): + return self.element_type.from_dict(json_value) + + def to_json(self, raw_value): + return raw_value.to_dict() + +class StringableType(ParserType): + """ Type checker for a class that can be converted to a string with str(obj) + and converted from a string with cls.from_string(str_val) or cls(str_val). + + In practice, this parser will just save object as string with str(obj), + and load object from string using cls(str_val) or cls.from_string(str_val). + So, object may have any type as long as: + str(obj) == str( object loaded from str(obj) ) + + Expected type: a class with compatible str(cls(string_repr)) or str(cls.from_string(string_repr)). + """ + __slots__ = ['element_type', 'use_from_string'] + + def __init__(self, element_type): + """ Initialize a parser type with a type convertible from/to string. + :param element_type: Expected type. Needs to be convertible to/from String. + """ + if hasattr(element_type, 'from_string'): + assert callable(element_type.from_string) + self.use_from_string = True + else: + self.use_from_string = False + self.element_type = element_type + + def __str__(self): + """ String representation """ + return self.element_type.__name__ + + def validate(self, element): + if not isinstance(element, self.element_type): + try: + # Check if given element can be handled by element type. + element_to_str = self.to_json(element) + element_from_str = self.to_type(element_to_str) + element_from_str_to_str = self.to_json(element_from_str) + assert element_to_str == element_from_str_to_str + except Exception: + # Given element can't be handled, raise a type exception. + raise exceptions.TypeException(self.element_type, type(element)) + + def to_type(self, json_value): + if self.use_from_string: + return self.element_type.from_string(json_value) + return self.element_type(json_value) + + def to_json(self, raw_value): + return str(raw_value) + +class DictType(ParserType): + """ Type checking for dictionary-like objects. """ + __slots__ = ['key_type', 'val_type', 'dict_builder'] + + def __init__(self, key_type, val_type, dict_builder=None): + """ Initialize a dict parser type with expected key type, val type, and optional dict builder. + :param key_type: The expected key type. Must be string or a stringable class. + :param val_type: The expected value type. + :param dict_builder: Callable to build attribute values. + """ + # key type muse be convertible from/to string. + self.key_type = key_type if isinstance(key_type, StringableType) else StringableType(key_type) + self.val_type = get_type(val_type) + self.dict_builder = dict_builder if dict_builder is not None else lambda dictionary: dictionary + + def __str__(self): + """ String representation """ + return '{%s => %s}' % (self.key_type, self.val_type) + + def validate(self, element): + if not is_dictionary(element): + raise exceptions.TypeException('dictionary', type(element)) + for key, value in element.items(): + self.key_type.validate(key) + self.val_type.validate(value) + + def update(self, element): + return_dict = {self.key_type.update(key): self.val_type.update(value) for key, value in element.items()} + return self.dict_builder(return_dict) + + def to_type(self, json_value): + json_dict = {self.key_type.to_type(key): self.val_type.to_type(value) for key, value in json_value.items()} + return self.dict_builder(json_dict) + + def to_json(self, raw_value): + return {self.key_type.to_json(key): self.val_type.to_json(value) for key, value in raw_value.items()} + +class IndexedSequenceType(ParserType): + """ Parser for objects stored as dictionaries in memory and saved as lists in JSON. """ + __slots__ = ['dict_type', 'sequence_type', 'key_name'] + + def __init__(self, dict_type, key_name): + """ Initializer: + :param dict_type: dictionary parser type to be used to manage object in memory. + :param key_name: name of attribute to take in sequence elements to convert sequence to a dictionary. + dct = {getattr(element, key_name): element for element in sequence} + sequence = list(dct.values()) + """ + assert isinstance(dict_type, DictType) + self.dict_type = dict_type + self.sequence_type = SequenceType(self.dict_type.val_type) + self.key_name = str(key_name) + + def __str__(self): + return '{%s.%s}' % (self.dict_type.val_type, self.key_name) + + def validate(self, element): + self.dict_type.validate(element) + + def update(self, element): + return self.dict_type.update(element) + + def to_json(self, raw_value): + """ Dict is saved as a sequence. """ + return self.sequence_type.to_json(raw_value.values()) + + def to_type(self, json_value): + """ JSON is parsed as a sequence and converted to a dict. """ + loaded_sequence = self.sequence_type.to_type(json_value) + return self.dict_type.update({getattr(element, self.key_name): element for element in loaded_sequence}) + +class EnumerationType(ParserType): + """ Type checker for a set of allowed basic values. """ + __slots__ = ['enum_values'] + + def __init__(self, enum_values): + """ Initialize sequence of values type with a sequence of allowed (primitive) values. + :param enum_values: Sequence of allowed values. + """ + enum_values = set(enum_values) + assert enum_values and all(isinstance(value, self.primitives) for value in enum_values) + self.enum_values = enum_values + + def __str__(self): + """ String representation """ + return 'in (%s)' % (', '.join(str(e) for e in sorted(self.enum_values))) + + def validate(self, element): + if not any(type(element) is type(value) and element == value for value in self.enum_values): + raise exceptions.ValueException(self.enum_values, element) + + def to_type(self, json_value): + """ For enumerations, we will validate JSON value before parsing it. """ + self.validate(json_value) + return json_value + +class SequenceOfPrimitivesType(ParserType): + """ Type checker for a set of allowed basic types. """ + __slots__ = ['seq_of_primitives'] + + def __init__(self, seq_of_primitives): + """ Initialize sequence of primitives type with a sequence of allowed primitives. + :param seq_of_primitives: Sequence of primitives. + """ + assert seq_of_primitives and all(primitive in self.primitives for primitive in seq_of_primitives) + self.seq_of_primitives = seq_of_primitives if isinstance(seq_of_primitives, tuple) else tuple(seq_of_primitives) + + def __str__(self): + """ String representation """ + return 'type in: %s' % (', '.join(t.__name__ for t in self.seq_of_primitives)) + + def validate(self, element): + if not isinstance(element, self.seq_of_primitives): + raise exceptions.TypeException(self.seq_of_primitives, type(element)) + +class PrimitiveType(ParserType): + """ Type checker for a primitive type. """ + __slots__ = ['element_type'] + + def __init__(self, element_type): + """ Initialize a primitive type. + :param element_type: Primitive type. + """ + assert element_type in self.primitives, 'Expected a primitive type, got %s.' % element_type + self.element_type = element_type + + def __str__(self): + """ String representation """ + return self.element_type.__name__ + + def validate(self, element): + if not isinstance(element, self.element_type): + raise exceptions.TypeException(self.element_type, type(element)) diff --git a/diplomacy/utils/priority_dict.py b/diplomacy/utils/priority_dict.py new file mode 100644 index 0000000..99a75c9 --- /dev/null +++ b/diplomacy/utils/priority_dict.py @@ -0,0 +1,102 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Priority Dict implementation """ +import heapq + +# ------------------------------------------------ +# Adapted from (2018/03/14s): https://docs.python.org/3.6/library/heapq.html#priority-queue-implementation-notes +# Unlicensed +class PriorityDict(dict): + """ Priority Dictionary Implementation """ + + def __init__(self, **kwargs): + """ Initialize the priority queue. + :param kwargs: (optional) initial values for priority queue. + """ + self.__heap = [] # Heap for entries. An entry is a triple (priority value, key, valid entry flag (boolean)). + # Dict itself maps key to entries. We override some dict methods (see __getitem__() below) + # to always return priority value instead of entry as dict value. + dict.__init__(self) + for key, value in kwargs.items(): + self[key] = value + + def __setitem__(self, key, val): + """ Sets a key with his associated priority + :param key: The key to set in the dictionary + :param val: The priority to associate with the key + :return: None + """ + if key in self: + del self[key] + # Create entry with val, key and a boolean indicating that entry is valid (True). + entry = [val, key, True] + dict.__setitem__(self, key, entry) + heapq.heappush(self.__heap, entry) + + def __delitem__(self, key): + """ Removes key from dict and marks associated heap entry as invalid (False). Raises KeyError if not found. """ + entry = self.pop(key) + entry[-1] = False + + def __getitem__(self, key): + """ Returns priority value associated to key. Raises KeyError if key not found. """ + return dict.__getitem__(self, key)[0] + + def __iter__(self): + """ Iterator over all keys based on their priority. """ + + def iterfn(): + """ Iterator """ + copy_of_self = self.copy() + while copy_of_self: + _, key = copy_of_self.smallest() + del copy_of_self[key] + yield key + + return iterfn() + + def smallest(self): + """ Finds the smallest item in the priority dict + :return: A tuple of (priority, key) for the item with the smallest priority + """ + while self.__heap and not self.__heap[0][-1]: + heapq.heappop(self.__heap) + return self.__heap[0][:2] if self.__heap else None + + def setdefault(self, key, d=None): + """ Sets a default for a given key """ + if key not in self: + self[key] = d + return self[key] + + def copy(self): + """ Return a copy of this priority dict. + :rtype: PriorityDict + """ + return PriorityDict(**self) + + def keys(self): + """ Make sure keys() iterates on keys based on their priority. """ + return self.__iter__() + + def values(self): + """ Makes sure values() iterates on priority values (instead of heap entries) from smallest to highest. """ + return (self[k] for k in self) + + def items(self): + """ Makes sure items() values are priority values instead of heap entries. """ + return ((key, self[key]) for key in self) diff --git a/diplomacy/utils/scheduler_event.py b/diplomacy/utils/scheduler_event.py new file mode 100644 index 0000000..1d097d8 --- /dev/null +++ b/diplomacy/utils/scheduler_event.py @@ -0,0 +1,42 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Scheduler event describing scheduler state for a specific data. """ + +from diplomacy.utils.jsonable import Jsonable + +class SchedulerEvent(Jsonable): + """ Scheduler event class. Properties: + - time_unit: unit time (in seconds) used by scheduler (time between 2 tasks checkings). + Currently 1 second in server scheduler. + - time_added: scheduler time (nb. time units) when data was added to scheduler. + - delay: scheduler time (nb. time units) to wait before processing time. + - current_time: current scheduler time (nb. time units). + """ + __slots__ = ['time_unit', 'time_added', 'delay', 'current_time'] + model = { + 'time_unit': int, + 'time_added': int, + 'delay': int, + 'current_time': int + } + + def __init__(self, **kwargs): + self.time_unit = 0 + self.time_added = 0 + self.delay = 0 + self.current_time = 0 + super(SchedulerEvent, self).__init__(**kwargs) diff --git a/diplomacy/utils/sorted_dict.py b/diplomacy/utils/sorted_dict.py new file mode 100644 index 0000000..459c652 --- /dev/null +++ b/diplomacy/utils/sorted_dict.py @@ -0,0 +1,259 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Helper class to provide a dict with sorted keys. """ +from diplomacy.utils.common import is_dictionary +from diplomacy.utils.sorted_set import SortedSet + +class SortedDict(): + """ Dict with sorted keys. """ + __slots__ = ['__val_type', '__keys', '__couples'] + + def __init__(self, key_type, val_type, kwargs=None): + """ Initialize a typed SortedDict. + :param key_type: expected type for keys. + :param val_type: expected type for values. + :param kwargs: (optional) dictionary-like object: initial values for sorted dict. + """ + self.__val_type = val_type + self.__keys = SortedSet(key_type) + self.__couples = {} + if kwargs is not None: + assert is_dictionary(kwargs) + for key, value in kwargs.items(): + self.put(key, value) + + @staticmethod + def builder(key_type, val_type): + """ Return a function to build sorted dicts from a dictionary-like object. + Returned function expects a dictionary parameter (an object with method items()). + builder_fn = SortedDict.builder(str, int) + my_sorted_dict = builder_fn({'a': 1, 'b': 2}) + + :param key_type: expected type for keys. + :param val_type: expected type for values. + :return: callable + """ + return lambda dictionary: SortedDict(key_type, val_type, dictionary) + + @property + def key_type(self): + """ Get key type. """ + return self.__keys.element_type + + @property + def val_type(self): + """ Get value type. """ + return self.__val_type + + def __str__(self): + return 'SortedDict{%s}' % ', '.join('%s:%s' % (k, self.__couples[k]) for k in self.__keys) + + def __bool__(self): + return bool(self.__keys) + + def __len__(self): + return len(self.__keys) + + def __eq__(self, other): + """ Return True if self and other are equal. + Note that self and other must also have same key and value types. + """ + assert isinstance(other, SortedDict) + return (self.key_type is other.key_type + and self.val_type is other.val_type + and len(self) == len(other) + and all(key in other and self[key] == other[key] for key in self.__keys)) + + def __getitem__(self, key): + return self.__couples[key] + + def __setitem__(self, key, value): + self.put(key, value) + + def __delitem__(self, key): + self.remove(key) + + def __iter__(self): + return self.__keys.__iter__() + + def __contains__(self, key): + return key in self.__couples + + def get(self, key, default=None): + """ Return value associated with key, or default value if key not found. """ + return self.__couples.get(key, default) + + def put(self, key, value): + """ Add a key with a value to the dict. """ + if not isinstance(value, self.__val_type): + raise TypeError('Expected value type %s, got %s' % (self.__val_type, type(value))) + if key not in self.__keys: + self.__keys.add(key) + self.__couples[key] = value + + def remove(self, key): + """ Pop (remove and return) value associated with given key, or None if key not found. """ + if key in self.__couples: + self.__keys.remove(key) + return self.__couples.pop(key, None) + + def first_key(self): + """ Get the lowest key from the dict. """ + return self.__keys[0] + + def first_value(self): + """ Get the value associated to lowest key in the dict. """ + return self.__couples[self.__keys[0]] + + def last_key(self): + """ Get the highest key from the dict. """ + return self.__keys[-1] + + def last_value(self): + """ Get the value associated to highest key in the dict. """ + return self.__couples[self.__keys[-1]] + + def last_item(self): + """ Get the item (key-value pair) for the highest key in the dict. """ + return self.__keys[-1], self.__couples[self.__keys[-1]] + + def keys(self): + """ Get an iterator to the keys in the dict. """ + return iter(self.__keys) + + def values(self): + """ Get an iterator to the values in the dict. """ + return (self.__couples[k] for k in self.__keys) + + def reversed_values(self): + """ Get an iterator to the values in the dict in reversed order or keys. """ + return (self.__couples[k] for k in reversed(self.__keys)) + + def items(self): + """ Get an iterator to the items in the dict. """ + return ((k, self.__couples[k]) for k in self.__keys) + + def sub_keys(self, key_from=None, key_to=None): + """ Return list of keys between key_from and key_to (both bounds included). """ + position_from, position_to = self._get_keys_interval(key_from, key_to) + return self.__keys[position_from:(position_to + 1)] + + def sub(self, key_from=None, key_to=None): + """ Return a list of values associated to keys between key_from and key_to (both bounds included). + + If key_from is None, lowest key in dict is used. + If key_to is None, greatest key in dict is used. + If key_from is not in dict, lowest key in dict greater than key_from is used. + If key_to is not in dict, greatest key in dict less than key_to is used. + + If dict is empty, return empty list. + With keys (None, None) return a copy of all values. + With keys (None, key_to), return values from first to the one associated to key_to. + With keys (key_from, None), return values from the one associated to key_from to the last value. + + :param key_from: start key + :param key_to: end key + :return: list: values in closed keys interval [key_from; key_to] + """ + position_from, position_to = self._get_keys_interval(key_from, key_to) + return [self.__couples[k] for k in self.__keys[position_from:(position_to + 1)]] + + def remove_sub(self, key_from=None, key_to=None): + """ Remove values associated to keys between key_from and key_to (both bounds included). + + See sub() doc about key_from and key_to. + + :param key_from: start key + :param key_to: end key + :return: nothing + """ + position_from, position_to = self._get_keys_interval(key_from, key_to) + keys_to_remove = self.__keys[position_from:(position_to + 1)] + for key in keys_to_remove: + self.remove(key) + + def key_from_index(self, index): + """ Return key matching given position in sorted dict, or None for invalid position. """ + return self.__keys[index] if -len(self.__keys) <= index < len(self.__keys) else None + + def get_previous_key(self, key): + """ Return greatest key lower than given key, or None if not exists. """ + return self.__keys.get_previous_value(key) + + def get_next_key(self, key): + """ Return smallest key greater then given key, or None if not exists. """ + return self.__keys.get_next_value(key) + + def _get_keys_interval(self, key_from, key_to): + """ Get a couple of internal key positions (index of key_from, index of key_to) allowing + to easily retrieve values in closed interval [index of key_from; index of key_to] + corresponding to Python slice [index of key_from : (index of key_to + 1)] + + If dict is empty, return (0, -1), so that python slice [0 : -1 + 1] corresponds to empty interval. + If key_from is None, lowest key in dict is used. + If key_to is None, greatest key in dict is used. + If key_from is not in dict, lowest key in dict greater than key_from is used. + If key_to is not in dict, greatest key in dict less than key_to is used. + + Thus: + - With keys (None, None), we get interval of all values. + - With keys (key_from, None), we get interval for values from key_from to the last key. + - With keys (None, key_to), we get interval for values from the first key to key_to. + + :param key_from: start key + :param key_to: end key + :return: (int, int): couple of integers: (index of key_from, index of key_to). + """ + if not self: + return 0, -1 + if key_from is not None and key_from not in self.__couples: + key_from = self.__keys.get_next_value(key_from) + if key_from is None: + return 0, -1 + if key_to is not None and key_to not in self.__couples: + key_to = self.__keys.get_previous_value(key_to) + if key_to is None: + return 0, -1 + if key_from is None and key_to is None: + key_from = self.first_key() + key_to = self.last_key() + elif key_from is not None and key_to is None: + key_to = self.last_key() + elif key_from is None and key_to is not None: + key_from = self.first_key() + if key_from > key_to: + raise IndexError('expected key_from <= key_to (%s vs %s)' % (key_from, key_to)) + position_from = self.__keys.index(key_from) + position_to = self.__keys.index(key_to) + assert position_from is not None and position_to is not None + return position_from, position_to + + def clear(self): + """ Remove all items from dict. """ + self.__couples.clear() + self.__keys.clear() + + def fill(self, dct): + """ Add given dict to this sorted dict. """ + if dct: + assert is_dictionary(dct) + for key, value in dct.items(): + self.put(key, value) + + def copy(self): + """ Return a copy of this sorted dict. """ + return SortedDict(self.__keys.element_type, self.__val_type, self.__couples) diff --git a/diplomacy/utils/sorted_set.py b/diplomacy/utils/sorted_set.py new file mode 100644 index 0000000..01cfb34 --- /dev/null +++ b/diplomacy/utils/sorted_set.py @@ -0,0 +1,157 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Sorted set implementation. """ +import bisect +from copy import copy + +from diplomacy.utils import exceptions +from diplomacy.utils.common import is_sequence + +class SortedSet(): + """ Sorted set (sorted values, each value appears once). """ + __slots__ = ('__type', '__list') + + def __init__(self, element_type, content=()): + """ Initialize a typed sorted set. + :param element_type: Expected type for values. + :param content: (optional) Sequence of values to initialize sorted set with. + """ + if not is_sequence(content): + raise exceptions.TypeException('sequence', type(content)) + self.__type = element_type + self.__list = [] + for element in content: + self.add(element) + + @staticmethod + def builder(element_type): + """ Return a function to build sorted sets from content (sequence of values). + :param element_type: expected type for sorted set values. + :return: callable + + Returned function expects a content parameter like SortedSet initializer. + builder_fn = SortedSet.builder(str) + my_sorted_set = builder_fn(['c', '3', 'p', '0']) + """ + return lambda iterable: SortedSet(element_type, iterable) + + @property + def element_type(self): + """ Get values type. """ + return self.__type + + def __str__(self): + """ String representation """ + return 'SortedSet(%s, %s)' % (self.__type.__name__, self.__list) + + def __len__(self): + """ Returns len of SortedSet """ + return len(self.__list) + + def __eq__(self, other): + """ Determines if 2 SortedSets are equal """ + assert isinstance(other, SortedSet) + return (self.element_type is other.element_type + and len(self) == len(other) + and all(a == b for a, b in zip(self, other))) + + def __getitem__(self, index): + """ Returns the item at the position index """ + return copy(self.__list[index]) + + def __iter__(self): + """ Returns an iterator """ + return self.__list.__iter__() + + def __reversed__(self): + """ Return reversed view of internal list. """ + return reversed(self.__list) + + def __contains__(self, element): + """ Determines if the element is in the SortedSet """ + assert isinstance(element, self.__type) + if self.__list: + position = bisect.bisect_left(self.__list, element) + return position != len(self.__list) and self.__list[position] == element + return False + + def add(self, element): + """ Add an element. """ + assert isinstance(element, self.__type) + if self.__list: + best_position = bisect.bisect_left(self.__list, element) + if best_position == len(self.__list): + self.__list.append(element) + elif self.__list[best_position] != element: + self.__list.insert(best_position, element) + else: + self.__list.append(element) + best_position = 0 + return best_position + + def get_next_value(self, element): + """ Get lowest value in sorted set greater than given element, or None if such values does not exists + in the sorted set. Given element may not exists in the sorted set. + """ + assert isinstance(element, self.__type) + if self.__list: + best_position = bisect.bisect_right(self.__list, element) + if best_position != len(self.__list): + if self.__list[best_position] != element: + return self.__list[best_position] + if best_position + 1 < len(self.__list): + return self.__list[best_position + 1] + return None + + def get_previous_value(self, element): + """ Get greatest value in sorted set less the given element, or None if such value does not exists + in the sorted set. Given element may not exists in the sorted set. + """ + assert isinstance(element, self.__type) + if self.__list: + best_position = bisect.bisect_left(self.__list, element) + if best_position == len(self.__list): + return self.__list[len(self.__list) - 1] + if best_position != 0: + return self.__list[best_position - 1] + return None + + def pop(self, index): + """ Remove and return value at given index. """ + return self.__list.pop(index) + + def remove(self, element): + """ Remove and return element. """ + assert isinstance(element, self.__type) + if self.__list: + position = bisect.bisect_left(self.__list, element) + if position != len(self.__list) and self.__list[position] == element: + return self.pop(position) + return None + + def index(self, element): + """ Return index of element in the set, or None if element is not in the set. """ + assert isinstance(element, self.__type) + if self.__list: + position = bisect.bisect_left(self.__list, element) + if position != len(self.__list) and self.__list[position] == element: + return position + return None + + def clear(self): + """ Remove all items from set. """ + self.__list.clear() diff --git a/diplomacy/utils/strings.py b/diplomacy/utils/strings.py new file mode 100644 index 0000000..2a74a03 --- /dev/null +++ b/diplomacy/utils/strings.py @@ -0,0 +1,236 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Some strings frequently used (to help prevent typos). """ + +ABBREV = 'abbrev' +ACTIVE = 'active' +ADJUST = 'adjust' +ADMIN = 'admin' +ADMINISTRATORS = 'administrators' +ALL_POSSIBLE_ORDERS = 'all_possible_orders' +ALLOCATE_COUNTRIES_RANDOMLY = 'allocate_countries_randomly' +ALLOW_MULTIPLE_COUNTRIES_PER_PLAYER = 'allow_multiple_countries_per_player' +ALLOW_NEW_PLAYERS_AFTER_START = 'allow_new_players_after_start' +ALLOW_OBSERVATIONS = 'allow_observations' +ALLOW_REGISTRATIONS = 'allow_registrations' +ALLOWED_VOTERS = 'allowed_voters' +ALWAYS_OMNISCIENT = 'always_omniscient' +AUTHENTICATION_TYPE = 'authentication_type' +AVAILABLE_MAPS = 'available_maps' +BACKUP_DELAY_SECONDS = 'backup_delay_seconds' +BODY = 'body' +BUFFER_SIZE = 'buffer_size' +CANCELED = 'canceled' +CENTERS = 'centers' +CHANNEL = 'channel' +CIVIL_DISORDER = 'civil_disorder' +CLEAR_INVALID_STATE_HISTORY = 'clear_invalid_state_history' +COMPLETED = 'completed' +CONTENT = 'content' +CONTROLLED_POWERS = 'controlled_powers' +CONTROLLER = 'controller' +COUNT_EXPECTED = 'count_expected' +COUNT_VOTED = 'count_voted' +CREATE_USER = 'create_user' +CURRENT_ORDER = 'current_order' +CURRENT_PHASE = 'current_phase' +CURRENT_PHASE_DATA = 'current_phase_data' +CURRENT_STATE = 'current_state' +CURRENT_TURN = 'current_turn' +DATA = 'data' +DEADLINE = 'deadline' +DEMOTE = 'demote' +DESC = 'desc' +DESIRED_COUNTRIES = 'desired_countries' +DUMMY = 'dummy' +DUMMY_PLAYER = 'dummy_player' +DUMMY_POWERS = 'dummy_powers' +ERROR = 'error' +FOR_OMNISCIENCE = 'for_omniscience' +FORCED = 'forced' +FORCED_ORDERS = 'forced_orders' +FORCED_WAIT_FLAG = 'forced_wait_flag' +FORMING = 'forming' +FROM_PHASE = 'from_phase' +FROM_TIMESTAMP = 'from_timestamp' +GAME = 'game' +GAME_ID = 'game_id' +GAME_PHASE = 'game_phase' +GAME_ROLE = 'game_role' +GAME_STARTED = 'game_started' +GAMES = 'games' +GLOBAL_MESSAGE_HISTORY = 'global_message_history' +GRADE = 'grade' +GRADE_UPDATE = 'grade_update' +HASHED = 'hashed' +HOMES = 'homes' +INCLUDE_PROTECTED = 'include_protected' +INFLUENCE = 'influence' +INITIAL_STATE = 'initial_state' +IS_DUMMY = 'is_dummy' +KICK_PLAYER = 'kick_player' +MAP_NAME = 'map_name' +MAP_POWERS = 'map_powers' +MAPS = 'maps' +MAPS_MTIME = 'maps_mtime' +MASTER_TYPE = 'master_type' +MAX_GAMES = 'max_games' +MESSAGE = 'message' +MESSAGE_HISTORY = 'message_history' +MESSAGES = 'messages' +META_RULES = 'meta_rules' +MODERATOR = 'moderator' +MODERATOR_USERNAMES = 'moderator_usernames' +MODERATORS = 'moderators' +N_CONTROLS = 'n_controls' +N_PLAYERS = 'n_players' +NAME = 'name' +NEUTRAL = 'neutral' +NO = 'no' +NO_RULES = 'no_rules' +NO_WAIT = 'no_wait' +NOTE = 'note' +NOTIFICATION = 'notification' +NOTIFICATION_ID = 'notification_id' +OBSERVER = 'observer' +OBSERVER_LEVEL = 'observer_level' +OBSERVER_NAME = 'observer_name' +OBSERVER_TYPE = 'observer_type' +OK = 'ok' +OMNISCIENT = 'omniscient' +OMNISCIENT_TYPE = 'omniscient_type' +OMNISCIENT_USERNAMES = 'omniscient_usernames' +ORDER = 'order' +ORDER_HISTORY = 'order_history' +ORDER_IS_SET = 'order_is_set' +ORDER_STATUS = 'order_status' +ORDERABLE_LOCATIONS = 'orderable_locations' +ORDERS = 'orders' +ORDERS_FINALIZED = 'orders_finalized' +ORDERS_STATUSES = 'orders_statuses' +OTHER_PLAYERS = 'other_players' +OUTCOME = 'outcome' +PARAMETERS = 'parameters' +PASSWORD = 'password' +PASSWORD_HASH = 'password_hash' +PAUSED = 'paused' +PHASE = 'phase' +PHASE_ABBR = 'phase_abbr' +PHASE_DATA = 'phase_data' +PHASE_DATA_TYPE = 'phase_data_type' +PING_SECONDS = 'ping_seconds' +PLAYER_ID = 'player_id' +PLAYERS = 'players' +POSSIBLE_ORDERS = 'possible_orders' +POWER = 'power' +POWER_FROM = 'power_from' +POWER_MESSAGE_HISTORY = 'power_message_history' +POWER_NAME = 'power_name' +POWER_NAMES = 'power_names' +POWER_NAMES_AGAINST = 'power_names_against' +POWER_NAMES_IN_FAVOUR = 'power_names_in_favour' +POWER_NAMES_NEUTRAL = 'power_names_neutral' +POWER_TO = 'power_to' +POWERS = 'powers' +POWERS_KICKED = 'powers_kicked' +PREVIOUS_PHASE = 'previous_phase' +PREVIOUS_PHASE_DATA = 'previous_phase_data' +PREVIOUS_STATE = 'previous_state' +PROMOTE = 'promote' +PROPOSAL = 'proposal' +RE_SENT = 're_sent' +REASON = 'reason' +RECIPIENT = 'recipient' +RECIPIENT_TOKEN = 'recipient_token' +REGISTER = 'register' +REGISTRATION_PASSWORD = 'registration_password' +REMOVE_CANCELED_GAMES = 'remove_canceled_games' +REMOVE_ENDED_GAMES = 'remove_ended_games' +REQUEST_ID = 'request_id' +RESULT = 'result' +RESULT_HISTORY = 'result_history' +RESULTS = 'results' +RETREATS = 'retreats' +ROLE = 'role' +RULES = 'rules' +SENDER = 'sender' +SERVER_TYPE = 'server_type' +SET_ORDER = 'set_order' +SETTINGS = 'settings' +START_AUTO = 'start_auto' +START_MASTER = 'start_master' +START_MODE = 'start_mode' +STATE = 'state' +STATE_HISTORY = 'state_history' +STATE_TYPE = 'state_type' +STATES = 'states' +STATUS = 'status' +SUPPLY_CENTERS = 'supply_centers' +TIME_SENT = 'time_sent' +TIMESTAMP = 'timestamp' +TIMESTAMP_CREATED = 'timestamp_created' +TIMESTAMP_SAVED = 'timestamp_saved' +TIMESTAMP_SYNC = 'timestamp_sync' +TIMESTAMPS = 'timestamps' +TO_PHASE = 'to_phase' +TO_TIMESTAMP = 'to_timestamp' +TOKEN = 'token' +TOKEN_TIMESTAMP = 'token_timestamp' +TOKEN_TO_USERNAME = 'token_to_username' +TOKENS = 'tokens' +TURN = 'turn' +TURN_HISTORY = 'turn_history' +UNDECIDED_PLAYER_MODE = 'undecided_player_mode' +UNITS = 'units' +USERNAME = 'username' +USERNAME_TO_TOKENS = 'username_to_tokens' +USERS = 'users' +VICTORY = 'victory' +VOTE = 'vote' +VOTE_ID = 'vote_id' +VOTE_IS_FORCED = 'vote_is_forced' +VOTES = 'votes' +WAIT = 'wait' +WIN = 'win' +WINNERS = 'winners' +WINTER_UNDECIDED_PLAYER_MODE = 'winter_undecided_player_mode' +YES = 'yes' +ZOBRIST_HASH = 'zobrist_hash' + +# Special name sets. +ALL_GAME_STATUSES = (FORMING, ACTIVE, PAUSED, COMPLETED, CANCELED) +ALL_GRADE_UPDATES = {PROMOTE, DEMOTE} +ALL_GRADES = {OMNISCIENT, ADMIN, MODERATOR} +ALL_COMM_LEVELS = {CHANNEL, GAME} +ALL_VOTE_DECISIONS = {YES, NO, NEUTRAL} +ALL_ROLE_TYPES = {OBSERVER_TYPE, OMNISCIENT_TYPE, SERVER_TYPE} +ALL_STATE_TYPES = {STATE_HISTORY, STATE, PHASE} + +def role_is_special(role): + """ Return True if role is a special role (observer or omniscient). """ + return role in {OBSERVER_TYPE, OMNISCIENT_TYPE} + +def switch_special_role(role): + """ Return opposite special role of given special role: + - observer role if omniscient role is given + - omniscient role if observer role is given + """ + if role == OBSERVER_TYPE: + return OMNISCIENT_TYPE + if role == OMNISCIENT_TYPE: + return OBSERVER_TYPE + raise ValueError('Unknown special role %s' % role) diff --git a/diplomacy/utils/tests/__init__.py b/diplomacy/utils/tests/__init__.py new file mode 100644 index 0000000..4f2769f --- /dev/null +++ b/diplomacy/utils/tests/__init__.py @@ -0,0 +1,16 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== diff --git a/diplomacy/utils/tests/test_common.py b/diplomacy/utils/tests/test_common.py new file mode 100644 index 0000000..a1c303d --- /dev/null +++ b/diplomacy/utils/tests/test_common.py @@ -0,0 +1,147 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Test for diplomacy network code utils. """ +import ujson as json + +from diplomacy.utils import common, exceptions + +def assert_raises(callback, expected_exceptions): + """ Checks that given callback raises given exceptions. """ + + try: + callback() + except expected_exceptions: + pass + else: + raise AssertionError('Should fail %s %s' % (callback, str(expected_exceptions))) + +def assert_equals(expected, computed): + """ Checks that expect == computed. """ + + if expected != computed: + raise AssertionError('\nExpected:\n=========\n%s\n\nComputed:\n=========\n%s\n' % (expected, computed)) + +def test_hash_password(): + """ Test passwords hashing. Note: slower than the other tests. """ + + password1 = '123456789' + password2 = 'abcdef' + password_unicode = 'しろいねこをみた。 白い猫を見た。' + for password in (password1, password2, password_unicode): + hashed_password = common.hash_password(password) + json_hashed_password = json.dumps(common.hash_password(password)) + hashed_password_from_json = json.loads(json_hashed_password) + # It seems hashed passwords are not necessarily the same for 2 different calls to hash function. + assert common.is_valid_password(password, hashed_password), (password, hashed_password) + assert common.is_valid_password(password, hashed_password_from_json), (password, hashed_password_from_json) + +def test_generate_token(): + """ Test token generation. """ + + for n_bytes in (128, 344): + token = common.generate_token(n_bytes) + assert isinstance(token, str) and len(token) == 2 * n_bytes + +def test_is_sequence(): + """ Test sequence type checking function. """ + + assert common.is_sequence((1, 2, 3)) + assert common.is_sequence([1, 2, 3]) + assert common.is_sequence({1, 2, 3}) + assert common.is_sequence(()) + assert common.is_sequence([]) + assert common.is_sequence(set()) + assert not common.is_sequence('i am a string') + assert not common.is_sequence({}) + assert not common.is_sequence(1) + assert not common.is_sequence(False) + assert not common.is_sequence(-2.5) + +def test_is_dictionary(): + """ Test dictionary type checking function. """ + + assert common.is_dictionary({'a': 1, 'b': 2}) + assert not common.is_dictionary((1, 2, 3)) + assert not common.is_dictionary([1, 2, 3]) + assert not common.is_dictionary({1, 2, 3}) + + assert not common.is_dictionary(()) + assert not common.is_dictionary([]) + assert not common.is_dictionary(set()) + + assert not common.is_dictionary('i am a string') + +def test_camel_to_snake_case(): + """ Test conversion from camel case to snake case. """ + + for camel, expected_snake in [ + ('a', 'a'), + ('A', 'a'), + ('AA', 'a_a'), + ('AbCdEEf', 'ab_cd_e_ef'), + ('Aa', 'aa'), + ('OnGameDone', 'on_game_done'), + ('AbstractSuperClass', 'abstract_super_class'), + ('ABCDEFghikKLm', 'a_b_c_d_e_fghik_k_lm'), + ('is_a_thing', 'is_a_thing'), + ('A_a_Aa__', 'a_a_aa__'), + ('Horrible_SuperClass_nameWith_mixedSyntax', 'horrible_super_class_name_with_mixed_syntax'), + ]: + computed_snake = common.camel_case_to_snake_case(camel) + assert computed_snake == expected_snake, ('camel : expected : computed:', camel, expected_snake, computed_snake) + +def test_snake_to_camel_case(): + """ Test conversion from snake case to camel upper case. """ + + for expected_camel, snake in [ + ('A', 'a'), + ('AA', 'a_a'), + ('AbCdEEf', 'ab_cd_e_ef'), + ('Aa', 'aa'), + ('OnGameDone', 'on_game_done'), + ('AbstractSuperClass', 'abstract_super_class'), + ('ABCDEFghikKLm', 'a_b_c_d_e_fghik_k_lm'), + ('IsAThing', 'is_a_thing'), + ('AAAa__', 'a_a_aa__'), + ('_AnHorrible_ClassName', '__an_horrible__class_name'), + ]: + computed_camel = common.snake_case_to_upper_camel_case(snake) + assert computed_camel == expected_camel, ('snake : expected : computed:', snake, expected_camel, computed_camel) + +def test_assert_no_common_keys(): + """ Test dictionary disjunction checking function. """ + + dct1 = {'a': 1, 'b': 2, 'c': 3} + dct2 = {'a': 1, 'e': 2, 'd': 3} + dct3 = {'m': 1, 'e': 2, 'f': 3} + common.assert_no_common_keys(dct1, dct3) + assert_raises(lambda: common.assert_no_common_keys(dct1, dct2), exceptions.CommonKeyException) + assert_raises(lambda: common.assert_no_common_keys(dct2, dct3), exceptions.CommonKeyException) + +def test_timestamp(): + """ Test timestamp generation. """ + + timestamp1 = common.timestamp_microseconds() + timestamp2 = common.timestamp_microseconds() + timestamp3 = common.timestamp_microseconds() + assert isinstance(timestamp1, int) + assert isinstance(timestamp2, int) + assert isinstance(timestamp3, int) + assert timestamp1 > 1e6 + assert timestamp2 > 1e6 + assert timestamp3 > 1e6 + assert timestamp1 <= timestamp2 <= timestamp3, (timestamp1, timestamp2, timestamp3) diff --git a/diplomacy/utils/tests/test_jsonable.py b/diplomacy/utils/tests/test_jsonable.py new file mode 100644 index 0000000..73d65c1 --- /dev/null +++ b/diplomacy/utils/tests/test_jsonable.py @@ -0,0 +1,81 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Test Jsonable. """ +import ujson as json + +from diplomacy.utils import parsing +from diplomacy.utils.jsonable import Jsonable +from diplomacy.utils.sorted_dict import SortedDict +from diplomacy.utils.sorted_set import SortedSet + +class MyJsonable(Jsonable): + """ Example of class derived from Jsonable. """ + __slots__ = ('field_a', 'field_b', 'field_c', 'field_d', 'field_e', 'field_f', 'field_g') + + model = { + 'field_a': bool, + 'field_b': str, + 'field_c': parsing.OptionalValueType(float), + 'field_d': parsing.DefaultValueType(str, 'super'), + 'field_e': parsing.SequenceType(int), + 'field_f': parsing.SequenceType(float, sequence_builder=SortedSet.builder(float)), + 'field_g': parsing.DefaultValueType(parsing.DictType(str, int, SortedDict.builder(str, int)), {'x': -1}) + } + + def __init__(self, **kwargs): + """ Constructor """ + self.field_a = None + self.field_b = None + self.field_c = None + self.field_d = None + self.field_e = None + self.field_f = None + self.field_g = {} + super(MyJsonable, self).__init__(**kwargs) + +def test_jsonable_parsing(): + """ Test parsing for Jsonable. """ + + attributes = ('field_a', 'field_b', 'field_c', 'field_d', 'field_e', 'field_f', 'field_g') + + # Building and validating + my_jsonable = MyJsonable(field_a=False, field_b='test', field_e={1}, field_f=[6.5]) + for attribute_name in attributes: + assert hasattr(my_jsonable, attribute_name) + assert isinstance(my_jsonable.field_a, bool) + assert isinstance(my_jsonable.field_b, str) + assert my_jsonable.field_c is None + assert isinstance(my_jsonable.field_d, str), my_jsonable.field_d + assert isinstance(my_jsonable.field_e, list) + assert isinstance(my_jsonable.field_f, SortedSet) + assert isinstance(my_jsonable.field_g, SortedDict) + assert my_jsonable.field_d == 'super' + assert my_jsonable.field_e == [1] + assert my_jsonable.field_f == SortedSet(float, (6.5,)) + assert len(my_jsonable.field_g) == 1 and my_jsonable.field_g['x'] == -1 + + # Building from its json representation and validating + from_json = MyJsonable.from_dict(json.loads(json.dumps(my_jsonable.to_dict()))) + for attribute_name in attributes: + assert hasattr(from_json, attribute_name), attribute_name + assert from_json.field_a == my_jsonable.field_a + assert from_json.field_b == my_jsonable.field_b + assert from_json.field_c == my_jsonable.field_c + assert from_json.field_d == my_jsonable.field_d + assert from_json.field_e == my_jsonable.field_e + assert from_json.field_f == my_jsonable.field_f + assert from_json.field_g == my_jsonable.field_g diff --git a/diplomacy/utils/tests/test_jsonable_changes.py b/diplomacy/utils/tests/test_jsonable_changes.py new file mode 100644 index 0000000..992a8fb --- /dev/null +++ b/diplomacy/utils/tests/test_jsonable_changes.py @@ -0,0 +1,189 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Test changes in a Jsonable schema. """ +#pylint: disable=invalid-name +from diplomacy.utils import parsing +from diplomacy.utils.jsonable import Jsonable + +def converter_to_int(val): + """ A converter from given value to an integer. Used in Version1. """ + try: + return int(val) + except ValueError: + return 0 + +class Version1(Jsonable): + """ A Jsonable with fields a, b, c, d. + NB: To parse a dict from Version22 to Version1, modified fields a and c must be convertible in Version1. + using ConverterType in Version1. + """ + model = { + 'a': parsing.ConverterType(int, converter_to_int), + 'b': parsing.OptionalValueType(str), + 'c': parsing.ConverterType(float, converter_function=float), + 'd': parsing.DefaultValueType(bool, True), + } + + def __init__(self, **kwargs): + self.a = None + self.b = None + self.c = None + self.d = None + super(Version1, self).__init__(**kwargs) + +class Version20(Jsonable): + """ Version1 with removed fields b and d. + NB: To parse a dict from Version20 to Version1, removed fields b and d must be optional in Version1. + """ + model = { + 'a': int, + 'c': float, + } + + def __init__(self, **kwargs): + self.a = None + self.c = None + super(Version20, self).__init__(**kwargs) + +class Version21(Jsonable): + """ Version1 with added fields e and f. + NB: To parse a dict from Version1 to Version21, added fields e and f must be optional in Version21. + """ + model = { + 'a': int, + 'b': str, + 'c': float, + 'd': bool, + 'e': parsing.DefaultValueType(parsing.EnumerationType([100, 200, 300, 400]), 100), + 'f': parsing.DefaultValueType(dict, {'x': 1, 'y': 2}) + } + + def __init__(self, **kwargs): + self.a = None + self.b = None + self.c = None + self.d = None + self.e = None + self.f = {} + super(Version21, self).__init__(**kwargs) + +class Version22(Jsonable): + """ Version1 with modified types for a and c. + NB: To parse a dict from Version1 to Version22, modified fields a and c must be convertible + using ConverterType in Version22. + """ + model = { + 'a': parsing.ConverterType(str, converter_function=str), + 'b': str, + 'c': parsing.ConverterType(bool, converter_function=bool), + 'd': bool, + } + + def __init__(self, **kwargs): + self.a = None + self.b = None + self.c = None + self.d = None + super(Version22, self).__init__(**kwargs) + +class Version3(Jsonable): + """ Version 1 with a modified, b removed, e added. + To parse a dict between Version3 and Version1: + - a must be convertible in both versions. + - b must be optional in Version1. + - e must be optional in Version3. + """ + model = { + 'a': parsing.ConverterType(str, converter_function=str), + 'c': float, + 'd': bool, + 'e': parsing.OptionalValueType(parsing.SequenceType(int)) + } + + def __init__(self, **kwargs): + self.a = None + self.c = None + self.d = None + self.e = None + super(Version3, self).__init__(**kwargs) + +def test_jsonable_changes_v1_v20(): + """ Test changes from Version1 to Version20. """ + v20 = Version20(a=1, c=1.5) + v1 = Version1(a=1, b='b', c=1.5, d=False) + json_v1 = v1.to_dict() + v20_from_v1 = Version20.from_dict(json_v1) + json_v20_from_v1 = v20_from_v1.to_dict() + v1_from_v20_from_v1 = Version1.from_dict(json_v20_from_v1) + assert v1_from_v20_from_v1.b is None + assert v1_from_v20_from_v1.d is True + json_v20 = v20.to_dict() + v1_from_v20 = Version1.from_dict(json_v20) + assert v1_from_v20.b is None + assert v1_from_v20.d is True + +def test_jsonable_changes_v1_v21(): + """ Test changes from Version1 to Version21. """ + v21 = Version21(a=1, b='b21', c=1.5, d=True, e=300, f=dict(x=1, y=2)) + v1 = Version1(a=1, b='b', c=1.5, d=False) + json_v1 = v1.to_dict() + v21_from_v1 = Version21.from_dict(json_v1) + assert v21_from_v1.e == 100 + assert v21_from_v1.f['x'] == 1 + assert v21_from_v1.f['y'] == 2 + json_v21_from_v1 = v21_from_v1.to_dict() + v1_from_v21_from_v1 = Version1.from_dict(json_v21_from_v1) + assert v1_from_v21_from_v1.b == 'b' + assert v1_from_v21_from_v1.d is False + json_v21 = v21.to_dict() + v1_from_v21 = Version1.from_dict(json_v21) + assert v1_from_v21.b == 'b21' + assert v1_from_v21.d is True + +def test_jsonable_changes_v1_v22(): + """ Test changes from Version1 to Version22. """ + v22 = Version22(a='a', b='b', c=False, d=False) + v1 = Version1(a=1, b='b', c=1.5, d=False) + json_v1 = v1.to_dict() + v22_from_v1 = Version22.from_dict(json_v1) + assert v22_from_v1.a == '1' + assert v22_from_v1.c is True + json_v22_from_v1 = v22_from_v1.to_dict() + v1_from_v22_from_v1 = Version1.from_dict(json_v22_from_v1) + assert v1_from_v22_from_v1.a == 1 + assert v1_from_v22_from_v1.c == 1.0 + json_v22 = v22.to_dict() + v1_from_v22 = Version1.from_dict(json_v22) + assert v1_from_v22.a == 0 + assert v1_from_v22.c == 0.0 + +def test_jsonable_changes_v1_v3(): + """ Test changes from Version1 to Version3. """ + v3 = Version3(a='a', c=1.5, d=False, e=(1, 2, 3)) + v1 = Version1(a=1, b='b', c=1.5, d=False) + json_v1 = v1.to_dict() + v3_from_v1 = Version3.from_dict(json_v1) + assert v3_from_v1.a == '1' + assert v3_from_v1.e is None + json_v3_from_v1 = v3_from_v1.to_dict() + v1_from_v3_from_v1 = Version1.from_dict(json_v3_from_v1) + assert v1_from_v3_from_v1.a == 1 + assert v1_from_v3_from_v1.b is None + json_v3 = v3.to_dict() + v1_from_v3 = Version1.from_dict(json_v3) + assert v1_from_v3.a == 0 + assert v1_from_v3.b is None diff --git a/diplomacy/utils/tests/test_parsing.py b/diplomacy/utils/tests/test_parsing.py new file mode 100644 index 0000000..f64ad26 --- /dev/null +++ b/diplomacy/utils/tests/test_parsing.py @@ -0,0 +1,307 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Test module parsing. """ +from diplomacy.utils import exceptions, parsing +from diplomacy.utils.sorted_dict import SortedDict +from diplomacy.utils.sorted_set import SortedSet +from diplomacy.utils.tests.test_common import assert_raises +from diplomacy.utils.tests.test_jsonable import MyJsonable + +class MyStringable(): + """ Example of Stringable class. + As instances of such class may be used as dict keys, class should define a proper __hash__(). + """ + + def __init__(self, value): + self.attribute = str(value) + + def __str__(self): + return 'MyStringable %s' % self.attribute + + def __hash__(self): + return hash(self.attribute) + + def __eq__(self, other): + return isinstance(other, MyStringable) and self.attribute == other.attribute + + def __lt__(self, other): + return isinstance(other, MyStringable) and self.attribute < other.attribute + + @staticmethod + def from_string(str_repr): + """ Converts a string representation `str_repr` of MyStringable to an instance of MyStringable. """ + return MyStringable(str_repr[len('MyStringable '):]) + +def test_default_value_type(): + """ Test default value type. """ + + for default_value in (True, False, None): + checker = parsing.DefaultValueType(bool, default_value) + assert_raises(lambda ch=checker: ch.validate(1), exceptions.TypeException) + assert_raises(lambda ch=checker: ch.validate(1.1), exceptions.TypeException) + assert_raises(lambda ch=checker: ch.validate(''), exceptions.TypeException) + for value in (True, False, None): + checker.validate(value) + if value is None: + assert checker.to_type(value) is default_value + assert checker.to_json(value) is default_value + else: + assert checker.to_type(value) is value + assert checker.to_json(value) is value + assert checker.update(None) is default_value + +def test_optional_value_type(): + """ Test optional value type. """ + + checker = parsing.OptionalValueType(bool) + assert_raises(lambda ch=checker: ch.validate(1), exceptions.TypeException) + assert_raises(lambda ch=checker: ch.validate(1.1), exceptions.TypeException) + assert_raises(lambda ch=checker: ch.validate(''), exceptions.TypeException) + for value in (True, False, None): + checker.validate(value) + assert checker.to_type(value) is value + assert checker.to_json(value) is value + assert checker.update(None) is None + +def test_sequence_type(): + """ Test sequence type. """ + + # With default sequence builder. + checker = parsing.SequenceType(int) + checker.validate((1, 2, 3)) + checker.validate([1, 2, 3]) + checker.validate({1, 2, 3}) + checker.validate(SortedSet(int)) + checker.validate(SortedSet(int, (1, 2, 3))) + assert_raises(lambda: checker.validate((1, 2, 3.0)), exceptions.TypeException) + assert_raises(lambda: checker.validate((1.0, 2.0, 3.0)), exceptions.TypeException) + assert isinstance(checker.to_type((1, 2, 3)), list) + # With SortedSet as sequence builder. + checker = parsing.SequenceType(float) + checker.validate((1.0, 2.0, 3.0)) + checker.validate([1.0, 2.0, 3.0]) + checker.validate({1.0, 2.0, 3.0}) + assert_raises(lambda: checker.validate((1, 2, 3.0)), exceptions.TypeException) + assert_raises(lambda: checker.validate((1.0, 2.0, 3)), exceptions.TypeException) + checker = parsing.SequenceType(int, sequence_builder=SortedSet.builder(int)) + initial_list = (1, 2, 7, 7, 1) + checker.validate(initial_list) + updated_list = checker.update(initial_list) + assert isinstance(updated_list, SortedSet) and updated_list.element_type is int + assert updated_list == SortedSet(int, (1, 2, 7)) + assert checker.to_json(updated_list) == [1, 2, 7] + assert checker.to_type([7, 2, 1, 1, 7, 1, 7]) == updated_list + +def test_jsonable_class_type(): + """ Test parser for Jsonable sub-classes. """ + + checker = parsing.JsonableClassType(MyJsonable) + my_jsonable = MyJsonable(field_a=False, field_b='test', field_e={1}, field_f=[6.5]) + my_jsonable_dict = { + 'field_a': False, + 'field_b': 'test', + 'field_e': (1, 2), + 'field_f': (1.0, 2.0), + } + checker.validate(my_jsonable) + assert_raises(lambda: checker.validate(None), exceptions.TypeException) + assert_raises(lambda: checker.validate(my_jsonable_dict), exceptions.TypeException) + +def test_stringable_type(): + """ Test stringable type. """ + + checker = parsing.StringableType(str) + checker.validate('0') + checker = parsing.StringableType(MyStringable) + checker.validate(MyStringable('test')) + assert_raises(lambda: checker.validate('test'), exceptions.TypeException) + assert_raises(lambda: checker.validate(None), exceptions.TypeException) + +def test_dict_type(): + """ Test dict type. """ + + checker = parsing.DictType(str, int) + checker.validate({'test': 1}) + assert_raises(lambda: checker.validate({'test': 1.0}), exceptions.TypeException) + checker = parsing.DictType(MyStringable, float) + checker.validate({MyStringable('12'): 2.5}) + assert_raises(lambda: checker.validate({'12': 2.5}), exceptions.TypeException) + assert_raises(lambda: checker.validate({MyStringable('12'): 2}), exceptions.TypeException) + checker = parsing.DictType(MyStringable, float, dict_builder=SortedDict.builder(MyStringable, float)) + value = {MyStringable(12): 22.0} + checker.validate(value) + updated_value = checker.update(value) + assert isinstance(updated_value, SortedDict) + assert updated_value.key_type is MyStringable + assert updated_value.val_type is float + json_value = {'MyStringable 12': 22.0} + assert checker.to_type(json_value) == SortedDict(MyStringable, float, {MyStringable('12'): 22.0}) + assert checker.to_json(SortedDict(MyStringable, float, {MyStringable(12): 22.0})) == json_value + +def test_sequence_of_values_type(): + """ Test parser for sequence of allowed values. """ + + checker = parsing.EnumerationType({'a', 'b', 'c', 'd'}) + checker.validate('d') + checker.validate('c') + checker.validate('b') + checker.validate('a') + assert_raises(lambda: checker.validate('e'), exceptions.ValueException) + +def test_sequence_of_primitives_type(): + """ Test parser for sequence of primitive types. """ + + checker = parsing.SequenceOfPrimitivesType((int, bool)) + checker.validate(False) + checker.validate(True) + checker.validate(0) + checker.validate(1) + assert_raises(lambda: checker.validate(0.0), exceptions.TypeException) + assert_raises(lambda: checker.validate(1.0), exceptions.TypeException) + assert_raises(lambda: checker.validate(''), exceptions.TypeException) + assert_raises(lambda: checker.validate('a non-empty string'), exceptions.TypeException) + +def test_primitive_type(): + """ Test parser for primitive type. """ + + checker = parsing.PrimitiveType(bool) + checker.validate(True) + checker.validate(False) + assert_raises(lambda: checker.validate(None), exceptions.TypeException) + assert_raises(lambda: checker.validate(0), exceptions.TypeException) + assert_raises(lambda: checker.validate(1), exceptions.TypeException) + assert_raises(lambda: checker.validate(''), exceptions.TypeException) + assert_raises(lambda: checker.validate('a non-empty string'), exceptions.TypeException) + +def test_model_parsing(): + """ Test parsing for a real model. """ + + model = { + 'name': str, + 'language': ('fr', 'en'), + 'myjsonable': parsing.JsonableClassType(MyJsonable), + 'mydict': parsing.DictType(str, float), + 'nothing': (bool, str), + 'default_float': parsing.DefaultValueType(float, 33.44), + 'optional_float': parsing.OptionalValueType(float) + } + bad_data_field = { + '_name_': 'hello', + 'language': 'fr', + 'myjsonable': MyJsonable(field_a=False, field_b='test', field_e={1}, field_f=[6.5]), + 'mydict': { + 'a': 2.5, + 'b': -1.6 + }, + 'nothing': 'thanks' + } + bad_data_type = { + 'name': 'hello', + 'language': 'fr', + 'myjsonable': MyJsonable(field_a=False, field_b='test', field_e={1}, field_f=[6.5]), + 'mydict': { + 'a': 2.5, + 'b': -1.6 + }, + 'nothing': 2.5 + } + bad_data_value = { + 'name': 'hello', + 'language': '__', + 'myjsonable': MyJsonable(field_a=False, field_b='test', field_e={1}, field_f=[6.5]), + 'mydict': { + 'a': 2.5, + 'b': -1.6 + }, + 'nothing': '2.5' + } + good_data = { + 'name': 'hello', + 'language': 'fr', + 'myjsonable': MyJsonable(field_a=False, field_b='test', field_e={1}, field_f=[6.5]), + 'mydict': { + 'a': 2.5, + 'b': -1.6 + }, + 'nothing': '2.5' + } + assert_raises(lambda: parsing.validate_data(bad_data_field, model), (exceptions.TypeException,)) + assert_raises(lambda: parsing.validate_data(bad_data_type, model), (exceptions.TypeException,)) + assert_raises(lambda: parsing.validate_data(bad_data_value, model), (exceptions.ValueException,)) + + assert 'default_float' not in good_data + assert 'optional_float' not in good_data + parsing.validate_data(good_data, model) + updated_good_data = parsing.update_data(good_data, model) + assert 'default_float' in updated_good_data + assert 'optional_float' in updated_good_data + assert updated_good_data['default_float'] == 33.44 + assert updated_good_data['optional_float'] is None + +def test_converter_type(): + """ Test parser converter type. """ + + def converter_to_int(val): + """ Converts value to integer """ + try: + return int(val) + except (ValueError, TypeError): + return 0 + + checker = parsing.ConverterType(str, converter_function=lambda val: 'String of %s' % val) + checker.validate('a string') + checker.validate(10) + checker.validate(True) + checker.validate(None) + checker.validate(-2.5) + assert checker.update(10) == 'String of 10' + assert checker.update(False) == 'String of False' + assert checker.update('string') == 'String of string' + checker = parsing.ConverterType(int, converter_function=converter_to_int) + checker.validate(10) + checker.validate(True) + checker.validate(None) + checker.validate(-2.5) + assert checker.update(10) == 10 + assert checker.update(True) == 1 + assert checker.update(False) == 0 + assert checker.update(-2.5) == -2 + assert checker.update('44') == 44 + assert checker.update('a') == 0 + +def test_indexed_sequence(): + """ Test parser type for dicts stored as sequences. """ + checker = parsing.IndexedSequenceType(parsing.DictType(str, parsing.JsonableClassType(MyJsonable)), 'field_b') + sequence = [ + MyJsonable(field_a=True, field_b='x1', field_e=[1, 2, 3], field_f=[1., 2., 3.]), + MyJsonable(field_a=True, field_b='x3', field_e=[1, 2, 3], field_f=[1., 2., 3.]), + MyJsonable(field_a=True, field_b='x2', field_e=[1, 2, 3], field_f=[1., 2., 3.]), + MyJsonable(field_a=True, field_b='x5', field_e=[1, 2, 3], field_f=[1., 2., 3.]), + MyJsonable(field_a=True, field_b='x4', field_e=[1, 2, 3], field_f=[1., 2., 3.]) + ] + dct = {element.field_b: element for element in sequence} + checker.validate(dct) + checker.update(dct) + jval = checker.to_json(dct) + assert isinstance(jval, list), type(jval) + from_jval = checker.to_type(jval) + assert isinstance(from_jval, dict), type(from_jval) + assert len(dct) == 5 + assert len(from_jval) == 5 + for key in ('x1', 'x2', 'x3', 'x4', 'x5'): + assert key in dct, (key, list(dct.keys())) + assert key in from_jval, (key, list(from_jval.keys())) diff --git a/diplomacy/utils/tests/test_priority_dict.py b/diplomacy/utils/tests/test_priority_dict.py new file mode 100644 index 0000000..cb7023d --- /dev/null +++ b/diplomacy/utils/tests/test_priority_dict.py @@ -0,0 +1,102 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Test class PriorityDict. """ +from diplomacy.utils.priority_dict import PriorityDict +from diplomacy.utils.tests.test_common import assert_equals + +def test_priority_dict(): + """ Test Heap class PriorityDict. """ + + for unordered_list in [ + [464, 21, 43453, 211, 324, 321, 102, 1211, 14, 875, 1, 33444, 22], + 'once upon a time in West'.split(), + 'This is a sentence with many words like panthera, lion, tiger, cat or cheetah!'.split() + ]: + expected_ordered_set = list(sorted(set(unordered_list))) + computed_sorted_list = [] + priority_dict = PriorityDict() + for element in unordered_list: + priority_dict[element] = element + while priority_dict: + value, key = priority_dict.smallest() + computed_sorted_list.append(value) + del priority_dict[key] + assert_equals(expected_ordered_set, computed_sorted_list) + +def test_item_getter_setter_deletion(): + """ Test PriorityDict item setter/getter/deletion. """ + + priority_dict = PriorityDict() + priority_dict['a'] = 12 + priority_dict['f'] = 9 + priority_dict['b'] = 23 + assert list(priority_dict.keys()) == ['f', 'a', 'b'] + assert priority_dict['a'] == 12 + assert priority_dict['f'] == 9 + assert priority_dict['b'] == 23 + priority_dict['e'] = -1 + priority_dict['a'] = 8 + del priority_dict['b'] + assert list(priority_dict.keys()) == ['e', 'a', 'f'] + assert list(priority_dict.values()) == [-1, 8, 9] + +def test_iterations(): + """ test iterations: + - for key in priority_dict + - priority_dict.keys() + - priority_dict.values() + - priority_dict.items() + """ + priorities = [464, 21, 43453, 211, 324, 321, 102, 1211, 14, 875, 1, 33444, 22] + + # Build priority dict. + priority_dict = PriorityDict() + for priority in priorities: + priority_dict['value of %s' % priority] = priority + + # Build expected priorities and keys. + expected_sorted_priorities = list(sorted(priorities)) + expected_sorted_keys = ['value of %s' % priority for priority in sorted(priorities)] + + # Iterate on priority dict. + computed_sorted_priorities = [priority_dict[key] for key in priority_dict] + # Iterate on priority dict keys. + sorted_priorities_from_key = [priority_dict[key] for key in priority_dict.keys()] + # Iterate on priority dict values. + sorted_priorities_from_values = list(priority_dict.values()) + # Iterate on priority dict items. + priority_dict_items = list(priority_dict.items()) + # Get priority dict keys. + priority_dict_keys = list(priority_dict.keys()) + # Get priority dict keys from items (to validate items). + priority_dict_keys_from_items = [item[0] for item in priority_dict_items] + # Get priority dict values from items (to validate items). + priority_dict_values_from_items = [item[1] for item in priority_dict_items] + + for expected, computed in [ + (expected_sorted_priorities, computed_sorted_priorities), + (expected_sorted_priorities, sorted_priorities_from_key), + (expected_sorted_priorities, sorted_priorities_from_values), + (expected_sorted_priorities, priority_dict_values_from_items), + (expected_sorted_keys, priority_dict_keys_from_items), + (expected_sorted_keys, priority_dict_keys), + ]: + assert_equals(expected, computed) + + # Priority dict should have not been modified. + assert_equals(len(priorities), len(priority_dict)) + assert all(key in priority_dict for key in expected_sorted_keys) diff --git a/diplomacy/utils/tests/test_sorted_dict.py b/diplomacy/utils/tests/test_sorted_dict.py new file mode 100644 index 0000000..559c36d --- /dev/null +++ b/diplomacy/utils/tests/test_sorted_dict.py @@ -0,0 +1,154 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Test class SortedDict. """ +from diplomacy.utils import common +from diplomacy.utils.sorted_dict import SortedDict +from diplomacy.utils.tests.test_common import assert_equals + +def test_init_bool_and_len(): + """ Test SortedDict initialization, length and conversion to boolean. """ + + sorted_dict = SortedDict(int, str) + assert not sorted_dict + sorted_dict = SortedDict(int, str, {2: 'two', 4: 'four', 99: 'ninety-nine'}) + assert sorted_dict + assert len(sorted_dict) == 3 + +def test_builder_and_properties(): + """ Test SortedDict builder and properties key_type and val_type. """ + + builder_float_to_bool = SortedDict.builder(float, bool) + sorted_dict = builder_float_to_bool({2.5: True, 2.7: False, 2.9: True}) + assert isinstance(sorted_dict, SortedDict) and sorted_dict.key_type is float and sorted_dict.val_type is bool + +def test_items_functions(): + """ Test SortedDict item setter/getter and methods put() and __contains__(). """ + + expected_keys = ['cat', 'lion', 'panthera', 'serval', 'tiger'] + sorted_dict = SortedDict(str, float, {'lion': 1.5, 'tiger': -2.7}) + # Test setter. + sorted_dict['panthera'] = -.88 + sorted_dict['cat'] = 2223. + # Test put(). + sorted_dict.put('serval', 39e12) + # Test __contains__. + assert 'lions' not in sorted_dict + assert all(key in sorted_dict for key in expected_keys) + # Test getter. + assert sorted_dict['cat'] == 2223. + assert sorted_dict['serval'] == 39e12 + # Test setter then getter. + assert sorted_dict['lion'] == 1.5 + sorted_dict['lion'] = 2.3 + assert sorted_dict['lion'] == 2.3 + # Test get, + assert sorted_dict.get('lions') is None + assert sorted_dict.get('lion') == 2.3 + +def test_item_deletion_and_remove(): + """ Test SortedDict methods remove() and __delitem__(). """ + + sorted_dict = SortedDict(str, float, {'lion': 1.5, 'tiger': -2.7, 'panthera': -.88, 'cat': 2223., 'serval': 39e12}) + assert len(sorted_dict) == 5 + assert 'serval' in sorted_dict + sorted_dict.remove('serval') + assert len(sorted_dict) == 4 + assert 'serval' not in sorted_dict + removed = sorted_dict.remove('tiger') + assert len(sorted_dict) == 3 + assert 'tiger' not in sorted_dict + assert removed == -2.7 + assert sorted_dict.remove('tiger') is None + assert sorted_dict.remove('key not in dict') is None + del sorted_dict['panthera'] + assert len(sorted_dict) == 2 + assert 'panthera' not in sorted_dict + assert 'cat' in sorted_dict + assert 'lion' in sorted_dict + +def test_iterations(): + """ Test SortedDict iterations (for key in dict, keys(), values(), items()). """ + + expected_sorted_keys = ['cat', 'lion', 'panthera', 'serval', 'tiger'] + expected_sorted_values = [2223., 1.5, -.88, 39e12, -2.7] + sorted_dict = SortedDict(str, float, {'lion': 1.5, 'tiger': -2.7, 'panthera': -.88, 'cat': 2223., 'serval': 39e12}) + computed_sorted_keys = [key for key in sorted_dict] + computed_sorted_keys_from_keys = list(sorted_dict.keys()) + computed_sorted_values = list(sorted_dict.values()) + keys_from_items = [] + values_from_items = [] + for key, value in sorted_dict.items(): + keys_from_items.append(key) + values_from_items.append(value) + assert_equals(expected_sorted_keys, computed_sorted_keys) + assert_equals(expected_sorted_keys, computed_sorted_keys_from_keys) + assert_equals(expected_sorted_keys, keys_from_items) + assert_equals(expected_sorted_values, values_from_items) + assert_equals(expected_sorted_values, computed_sorted_values) + +def test_bound_keys_getters(): + """ Test SortedDict methods first_key(), last_key(), last_value(), last_item(), + get_previous_key(), get_next_key(). + """ + + sorted_dict = SortedDict(str, float, {'lion': 1.5, 'tiger': -2.7}) + sorted_dict['panthera'] = -.88 + sorted_dict['cat'] = 2223. + sorted_dict['serval'] = 39e12 + assert sorted_dict.first_key() == 'cat' + assert sorted_dict.last_key() == 'tiger' + assert sorted_dict.last_value() == sorted_dict['tiger'] == -2.7 + assert sorted_dict.last_item() == ('tiger', -2.7) + assert sorted_dict.get_previous_key('cat') is None + assert sorted_dict.get_next_key('cat') == 'lion' + assert sorted_dict.get_previous_key('tiger') == 'serval' + assert sorted_dict.get_next_key('tiger') is None + assert sorted_dict.get_previous_key('panthera') == 'lion' + assert sorted_dict.get_next_key('panthera') == 'serval' + +def test_equality(): + """ Test SortedDict equality. """ + + empty_sorted_dict_float_int = SortedDict(float, int) + empty_sorted_dict_float_bool_1 = SortedDict(float, bool) + empty_sorted_dict_float_bool_2 = SortedDict(float, bool) + sorted_dict_float_int_1 = SortedDict(float, int, {2.5: 17, 3.3: 49, -5.7: 71}) + sorted_dict_float_int_2 = SortedDict(float, int, {2.5: 17, 3.3: 49, -5.7: 71}) + sorted_dict_float_int_3 = SortedDict(float, int, {2.5: -17, 3.3: 49, -5.7: 71}) + assert empty_sorted_dict_float_int != empty_sorted_dict_float_bool_1 + assert empty_sorted_dict_float_bool_1 == empty_sorted_dict_float_bool_2 + assert sorted_dict_float_int_1 == sorted_dict_float_int_2 + assert sorted_dict_float_int_1 != sorted_dict_float_int_3 + +def test_sub_and_remove_sub(): + """Test SortedDict methods sub() and remove_sub().""" + + sorted_dict = SortedDict(int, str, {k: 'value of %s' % k for k in (2, 5, 1, 9, 4, 5, 20, 0, 6, 17, 8, 3, 7, 0, 4)}) + assert sorted_dict.sub() == list(sorted_dict.values()) + assert sorted_dict.sub(-10, 4) == ['value of 0', 'value of 1', 'value of 2', 'value of 3', 'value of 4'] + assert sorted_dict.sub(15) == ['value of 17', 'value of 20'] + sorted_dict.remove_sub(-10, 4) + assert all(k not in sorted_dict for k in (0, 1, 2, 3, 4)) + sorted_dict.remove_sub(15) + assert all(k not in sorted_dict for k in (17, 20)) + +def test_is_sequence_and_is_dict(): + """Check sorted dict with is_sequence() and is_dict().""" + + assert common.is_dictionary(SortedDict(str, int, {'a': 3, 'b': -1, 'c': 12})) + assert common.is_dictionary(SortedDict(int, float), ) + assert not common.is_sequence(SortedDict(str, str)) diff --git a/diplomacy/utils/tests/test_sorted_set.py b/diplomacy/utils/tests/test_sorted_set.py new file mode 100644 index 0000000..1208cd3 --- /dev/null +++ b/diplomacy/utils/tests/test_sorted_set.py @@ -0,0 +1,168 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Test class SortedSet. """ +from diplomacy.utils import common +from diplomacy.utils.sorted_set import SortedSet +from diplomacy.utils.tests.test_common import assert_equals + +def test_init_bool_and_len(): + """ Test SortedSet initialization, length and conversion to boolean. """ + + sorted_set = SortedSet(int) + assert not sorted_set + sorted_set = SortedSet(int, (2, 4, 99)) + assert sorted_set + assert len(sorted_set) == 3 + +def test_builder_and_property(): + """ Test SortedSet builder and property element_type. """ + + builder_float = SortedSet.builder(float) + sorted_set = builder_float((2.5, 2.7, 2.9)) + assert isinstance(sorted_set, SortedSet) and sorted_set.element_type is float + +def test_item_add_get_and_contains(): + """ Test SortedSet methods add(), __getitem__(), and __contains__(). """ + + expected_values = ['cat', 'lion', 'panthera', 'serval', 'tiger'] + sorted_set = SortedSet(str, ('lion', 'tiger')) + # Test setter. + sorted_set.add('panthera') + sorted_set.add('cat') + sorted_set.add('serval') + # Test __contains__. + assert 'lions' not in sorted_set + assert all(key in sorted_set for key in expected_values) + # Test getter. + assert sorted_set[0] == 'cat' + assert sorted_set[1] == 'lion' + assert sorted_set[2] == 'panthera' + assert sorted_set[3] == 'serval' + assert sorted_set[4] == 'tiger' + # Test add then getter. + sorted_set.add('onca') + assert sorted_set[1] == 'lion' + assert sorted_set[2] == 'onca' + assert sorted_set[3] == 'panthera' + +def test_pop_and_remove(): + """ Test SortedSet methods remove() and pop(). """ + + sorted_set = SortedSet(str, ('lion', 'tiger', 'panthera', 'cat', 'serval')) + assert len(sorted_set) == 5 + assert 'serval' in sorted_set + sorted_set.remove('serval') + assert len(sorted_set) == 4 + assert 'serval' not in sorted_set + assert sorted_set.remove('tiger') == 'tiger' + assert len(sorted_set) == 3 + assert 'tiger' not in sorted_set + assert sorted_set.remove('tiger') is None + assert sorted_set.remove('key not in set') is None + index_of_panthera = sorted_set.index('panthera') + assert index_of_panthera == 2 + assert sorted_set.pop(index_of_panthera) == 'panthera' + assert len(sorted_set) == 2 + assert 'panthera' not in sorted_set + assert 'cat' in sorted_set + assert 'lion' in sorted_set + +def test_iteration(): + """ Test SortedSet iteration. """ + + expected_sorted_values = ['cat', 'lion', 'panthera', 'serval', 'tiger'] + sorted_set = SortedSet(str, ('lion', 'tiger', 'panthera', 'cat', 'serval')) + computed_sorted_values = [key for key in sorted_set] + assert_equals(expected_sorted_values, computed_sorted_values) + +def test_equality(): + """ Test SortedSet equality. """ + + empty_sorted_set_float = SortedSet(float) + empty_sorted_set_int = SortedSet(int) + another_empty_sorted_set_int = SortedSet(int) + sorted_set_float_1 = SortedSet(float, (2.5, 3.3, -5.7)) + sorted_set_float_2 = SortedSet(float, (2.5, 3.3, -5.7)) + sorted_set_float_3 = SortedSet(float, (2.5, 3.3, 5.7)) + assert empty_sorted_set_float != empty_sorted_set_int + assert empty_sorted_set_int == another_empty_sorted_set_int + assert sorted_set_float_1 == sorted_set_float_2 + assert sorted_set_float_1 != sorted_set_float_3 + +def test_getters_around_values(): + """Test SortedSet methods get_next_value() and get_previous_value().""" + + sorted_set = SortedSet(int, (2, 5, 1, 9, 4, 5, 20, 0, 6, 17, 8, 3, 7, 0, 4)) + expected = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 17, 20) + assert sorted_set + assert len(sorted_set) == len(expected) + assert all(expected[i] == sorted_set[i] for i in range(len(expected))) + assert all(e in sorted_set for e in expected) + assert sorted_set.get_next_value(0) == 1 + assert sorted_set.get_next_value(5) == 6 + assert sorted_set.get_next_value(9) == 17 + assert sorted_set.get_next_value(-1) == 0 + assert sorted_set.get_next_value(20) is None + assert sorted_set.get_previous_value(0) is None + assert sorted_set.get_previous_value(17) == 9 + assert sorted_set.get_previous_value(20) == 17 + assert sorted_set.get_previous_value(1) == 0 + assert sorted_set.get_previous_value(6) == 5 + + assert sorted_set.get_next_value(3) == 4 + assert sorted_set.get_next_value(4) == 5 + assert sorted_set.get_next_value(7) == 8 + assert sorted_set.get_next_value(8) == 9 + assert sorted_set.get_previous_value(5) == 4 + assert sorted_set.get_previous_value(4) == 3 + assert sorted_set.get_previous_value(9) == 8 + assert sorted_set.get_previous_value(8) == 7 + sorted_set.remove(8) + assert len(sorted_set) == len(expected) - 1 + assert 8 not in sorted_set + sorted_set.remove(4) + assert len(sorted_set) == len(expected) - 2 + assert 4 not in sorted_set + assert sorted_set.get_next_value(3) == 5 + assert sorted_set.get_next_value(4) == 5 + assert sorted_set.get_next_value(7) == 9 + assert sorted_set.get_next_value(8) == 9 + assert sorted_set.get_previous_value(5) == 3 + assert sorted_set.get_previous_value(4) == 3 + assert sorted_set.get_previous_value(9) == 7 + assert sorted_set.get_previous_value(8) == 7 + +def test_index(): + """ Test SortedSet method index(). """ + + sorted_set = SortedSet(int, (2, 5, 1, 9, 4, 5, 20, 0, 6, 17, 8, 3, 7, 0, 4)) + sorted_set.remove(8) + sorted_set.remove(4) + index_of_2 = sorted_set.index(2) + index_of_17 = sorted_set.index(17) + assert index_of_2 == 2 + assert sorted_set.index(4) is None + assert sorted_set.index(8) is None + assert index_of_17 == len(sorted_set) - 2 + assert sorted_set.pop(index_of_2) == 2 + +def test_common_utils_with_sorted_set(): + """Check sorted set with is_sequence() and is_dictionary().""" + assert common.is_sequence(SortedSet(int, (1, 2, 3))) + assert common.is_sequence(SortedSet(int)) + assert not common.is_dictionary(SortedSet(int, (1, 2, 3))) + assert not common.is_dictionary(SortedSet(int)) diff --git a/diplomacy/utils/tests/test_time.py b/diplomacy/utils/tests/test_time.py new file mode 100644 index 0000000..a2e7a63 --- /dev/null +++ b/diplomacy/utils/tests/test_time.py @@ -0,0 +1,77 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette, Steven Bocco +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Tests cases for time function""" +from diplomacy.utils import str_to_seconds, next_time_at, trunc_time + +def test_str_to_seconds(): + """ Tests for str_to_seconds """ + assert str_to_seconds('1W') == 604800 + assert str_to_seconds('1D') == 86400 + assert str_to_seconds('1H') == 3600 + assert str_to_seconds('1M') == 60 + assert str_to_seconds('1S') == 1 + assert str_to_seconds('1') == 1 + assert str_to_seconds(1) == 1 + + assert str_to_seconds('10W') == 10 * 604800 + assert str_to_seconds('10D') == 10 * 86400 + assert str_to_seconds('10H') == 10 * 3600 + assert str_to_seconds('10M') == 10 * 60 + assert str_to_seconds('10S') == 10 * 1 + assert str_to_seconds('10') == 10 * 1 + assert str_to_seconds(10) == 10 * 1 + + assert str_to_seconds('1W2D3H4M5S') == 1 * 604800 + 2 * 86400 + 3 * 3600 + 4 * 60 + 5 + assert str_to_seconds('1W2D3H4M5') == 1 * 604800 + 2 * 86400 + 3 * 3600 + 4 * 60 + 5 + assert str_to_seconds('11W12D13H14M15S') == 11 * 604800 + 12 * 86400 + 13 * 3600 + 14 * 60 + 15 + assert str_to_seconds('11W12D13H14M15') == 11 * 604800 + 12 * 86400 + 13 * 3600 + 14 * 60 + 15 + +def test_trunc_time(): + """ Tests for trunc_time """ + # 1498746123 = Thursday, June 29, 2017 10:22:03 AM GMT-04:00 DST + assert trunc_time(1498746123, '1M', 'America/Montreal') == 1498746180 # 10:23 + assert trunc_time(1498746123, '5M', 'America/Montreal') == 1498746300 # 10:25 + assert trunc_time(1498746123, '10M', 'America/Montreal') == 1498746600 # 10:30 + assert trunc_time(1498746123, '15M', 'America/Montreal') == 1498746600 # 10:30 + assert trunc_time(1498746123, '20M', 'America/Montreal') == 1498747200 # 10:40 + assert trunc_time(1498746123, '25M', 'America/Montreal') == 1498746300 # 10:25 + + # 1498731723 = Thursday, June 29, 2017 10:22:03 AM GMT + assert trunc_time(1498731723, '1M', 'GMT') == 1498731780 # 10:23 + assert trunc_time(1498731723, '5M', 'GMT') == 1498731900 # 10:25 + assert trunc_time(1498731723, '10M', 'GMT') == 1498732200 # 10:30 + assert trunc_time(1498731723, '15M', 'GMT') == 1498732200 # 10:30 + assert trunc_time(1498731723, '20M', 'GMT') == 1498732800 # 10:40 + assert trunc_time(1498731723, '25M', 'GMT') == 1498731900 # 10:25 + +def test_next_time_at(): + """ Tests for next_time_at """ + # 1498746123 = Thursday, June 29, 2017 10:22:03 AM GMT-04:00 DST + assert next_time_at(1498746123, '10:23', 'America/Montreal') == 1498746180 # 10:23 + assert next_time_at(1498746123, '10:25', 'America/Montreal') == 1498746300 # 10:25 + assert next_time_at(1498746123, '10:30', 'America/Montreal') == 1498746600 # 10:30 + assert next_time_at(1498746123, '10:40', 'America/Montreal') == 1498747200 # 10:40 + assert next_time_at(1498746123, '16:40', 'America/Montreal') == 1498768800 # 16:40 + assert next_time_at(1498746123, '6:20', 'America/Montreal') == 1498818000 # 6:20 (Next day) + + # 1498731723 = Thursday, June 29, 2017 10:22:03 AM GMT + assert next_time_at(1498731723, '10:23', 'GMT') == 1498731780 # 10:23 + assert next_time_at(1498731723, '10:25', 'GMT') == 1498731900 # 10:25 + assert next_time_at(1498731723, '10:30', 'GMT') == 1498732200 # 10:30 + assert next_time_at(1498731723, '10:40', 'GMT') == 1498732800 # 10:40 + assert next_time_at(1498731723, '16:40', 'GMT') == 1498754400 # 16:40 + assert next_time_at(1498731723, '6:20', 'GMT') == 1498803600 # 6:20 (Next day) diff --git a/diplomacy/utils/time.py b/diplomacy/utils/time.py new file mode 100644 index 0000000..6f250dd --- /dev/null +++ b/diplomacy/utils/time.py @@ -0,0 +1,85 @@ +# ============================================================================== +# Copyright (C) 2019 - Philip Paquette +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# ============================================================================== +""" Time functions + - Contains generic time functions (e.g. to calculate deadlines) +""" +import calendar +import datetime +import math +import pytz + +def str_to_seconds(offset_str): + """ Converts a time in format 00W00D00H00M00S in number of seconds + :param offset_str: The string to convert (e.g. '20D') + :return: Its equivalent in seconds = 1728000 + """ + mult = {'W': 7 * 24 * 60 * 60, 'D': 24 * 60 * 60, 'H': 60 * 60, 'M': 60, 'S': 1, ' ': 1} + buffer = current_sum = 0 + offset_str = str(offset_str) + + # Adding digits to buffer, when a character is found, + # multiply it with buffer and increase the current_sum + for char in offset_str: + if char.isdigit(): + buffer = buffer * 10 + int(char) + elif char.upper() in mult: + current_sum += buffer * mult[char.upper()] + buffer = 0 + else: + buffer = 0 + current_sum += buffer + + return current_sum + +def trunc_time(timestamp, trunc_interval, time_zone='GMT'): + """ Truncates time at a specific interval (e.g. 20M) (i.e. Rounds to the next :20, :40, :60) + :param timestamp: The unix epoch to truncate (e.g. 1498746120) + :param trunc_interval: The truncation interval (e.g. 60*60 or '1H') + :param time_zone: The time to use for conversion (defaults to GMT otherwise) + :return: A timestamp truncated to the nearest (future) interval + """ + midnight_ts = calendar.timegm(datetime.datetime.combine(datetime.date.today(), datetime.time.min).utctimetuple()) + midnight_offset = (timestamp - midnight_ts) % (24*3600) + + dtime = datetime.datetime.fromtimestamp(timestamp, pytz.timezone(time_zone)) + tz_offset = dtime.utcoffset().total_seconds() + interval = str_to_seconds(trunc_interval) + trunc_offset = math.ceil((midnight_offset + tz_offset) / interval) * interval + + trunc_ts = timestamp - midnight_offset + trunc_offset - tz_offset + return int(trunc_ts) + +def next_time_at(timestamp, time_at, time_zone='GMT'): + """ Returns the next timestamp at a specific 'hh:mm' + :param timestamp: The unix timestamp to convert + :param time_at: The next 'hh:mm' to have the time rounded to, or 0 to skip + :param time_zone: The time to use for conversion (defaults to GMT otherwise) + :return: A timestamp truncated to the nearest (future) hh:mm + """ + if not time_at: + return timestamp + + midnight_ts = calendar.timegm(datetime.datetime.combine(datetime.date.today(), datetime.time.min).utctimetuple()) + midnight_offset = (timestamp - midnight_ts) % (24*3600) + + dtime = datetime.datetime.fromtimestamp(timestamp, pytz.timezone(time_zone)) + tz_offset = dtime.utcoffset().total_seconds() + trunc_interval = '%dH%dM' % (int(time_at.split(':')[0]), int(time_at.split(':')[1])) if ':' in time_at else time_at + interval = str_to_seconds(trunc_interval) + at_offset = (-midnight_offset + interval - tz_offset) % (24 * 3600) + at_ts = timestamp + at_offset + return int(at_ts) -- cgit v1.2.3