aboutsummaryrefslogtreecommitdiff
path: root/diplomacy/utils
diff options
context:
space:
mode:
Diffstat (limited to 'diplomacy/utils')
-rw-r--r--diplomacy/utils/__init__.py22
-rw-r--r--diplomacy/utils/common.py212
-rw-r--r--diplomacy/utils/constants.py58
-rw-r--r--diplomacy/utils/convoy_paths.py223
-rw-r--r--diplomacy/utils/errors.py128
-rw-r--r--diplomacy/utils/exceptions.py178
-rw-r--r--diplomacy/utils/export.py164
-rw-r--r--diplomacy/utils/game_phase_data.py47
-rw-r--r--diplomacy/utils/jsonable.py141
-rw-r--r--diplomacy/utils/keywords.py39
-rw-r--r--diplomacy/utils/network_data.py79
-rw-r--r--diplomacy/utils/parsing.py505
-rw-r--r--diplomacy/utils/priority_dict.py102
-rw-r--r--diplomacy/utils/scheduler_event.py42
-rw-r--r--diplomacy/utils/sorted_dict.py259
-rw-r--r--diplomacy/utils/sorted_set.py157
-rw-r--r--diplomacy/utils/strings.py236
-rw-r--r--diplomacy/utils/tests/__init__.py16
-rw-r--r--diplomacy/utils/tests/test_common.py147
-rw-r--r--diplomacy/utils/tests/test_jsonable.py81
-rw-r--r--diplomacy/utils/tests/test_jsonable_changes.py189
-rw-r--r--diplomacy/utils/tests/test_parsing.py307
-rw-r--r--diplomacy/utils/tests/test_priority_dict.py102
-rw-r--r--diplomacy/utils/tests/test_sorted_dict.py154
-rw-r--r--diplomacy/utils/tests/test_sorted_set.py168
-rw-r--r--diplomacy/utils/tests/test_time.py77
-rw-r--r--diplomacy/utils/time.py85
27 files changed, 3918 insertions, 0 deletions
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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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__', '<N/A>'), __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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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 <https://www.gnu.org/licenses/>.
+# ==============================================================================
+""" 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)