From 6ad1ae6f92acdbb6d5818c8c8ea1f5e148784253 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Tue, 26 May 2015 09:53:19 -0400 Subject: [PATCH 01/14] Add domain/routes structure --- gridcommand/__init__.py | 2 +- gridcommand/domain/__init__.py | 6 ++ gridcommand/domain/game.py | 77 ++++++++++++++++++++ gridcommand/domain/move.py | 58 +++++++++++++++ gridcommand/domain/player.py | 73 +++++++++++++++++++ gridcommand/domain/turn.py | 38 ++++++++++ gridcommand/domain/types.py | 21 ++++++ gridcommand/{views => routes}/__init__.py | 0 gridcommand/routes/base.py | 64 +++++++++++++++++ gridcommand/routes/formatters.py | 87 +++++++++++++++++++++++ gridcommand/{views => routes}/game.py | 0 gridcommand/{views => routes}/move.py | 0 gridcommand/routes/parsers.py | 26 +++++++ gridcommand/{views => routes}/player.py | 0 gridcommand/{views => routes}/root.py | 0 gridcommand/{views => routes}/turn.py | 0 16 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 gridcommand/domain/__init__.py create mode 100644 gridcommand/domain/game.py create mode 100644 gridcommand/domain/move.py create mode 100644 gridcommand/domain/player.py create mode 100644 gridcommand/domain/turn.py create mode 100644 gridcommand/domain/types.py rename gridcommand/{views => routes}/__init__.py (100%) create mode 100644 gridcommand/routes/base.py create mode 100644 gridcommand/routes/formatters.py rename gridcommand/{views => routes}/game.py (100%) rename gridcommand/{views => routes}/move.py (100%) create mode 100644 gridcommand/routes/parsers.py rename gridcommand/{views => routes}/player.py (100%) rename gridcommand/{views => routes}/root.py (100%) rename gridcommand/{views => routes}/turn.py (100%) diff --git a/gridcommand/__init__.py b/gridcommand/__init__.py index 0c0fc9c..0c5d1e8 100644 --- a/gridcommand/__init__.py +++ b/gridcommand/__init__.py @@ -13,7 +13,7 @@ exit("Python {}.{}+ is required.".format(*PYTHON_VERSION)) try: - from .views import app + from .routes import app from . import data except (ImportError, AttributeError): # pragma: no cover (manual test) import logging diff --git a/gridcommand/domain/__init__.py b/gridcommand/domain/__init__.py new file mode 100644 index 0000000..9380178 --- /dev/null +++ b/gridcommand/domain/__init__.py @@ -0,0 +1,6 @@ +"""Domain models for the application.""" + +from .move import Move, Moves +from .turn import Turn, Turns +from .player import Player, Players +from .game import Game, Games diff --git a/gridcommand/domain/game.py b/gridcommand/domain/game.py new file mode 100644 index 0000000..37007c5 --- /dev/null +++ b/gridcommand/domain/game.py @@ -0,0 +1,77 @@ +"""Classes representing games.""" + +import string +import random + +from .. import common +from .player import Players +from .turn import Turn + +log = common.logger(__name__) + + +class Game: + + """An individual game instance.""" + + KEY_CHARS = string.ascii_lowercase + string.digits + KEY_LENGTH = 8 + + def __init__(self, key=None): + self.key = key or self._generate_key() + self.players = Players() + self.turn = 0 + + def __repr__(self): + return "".format(self.key) + + @staticmethod + def _generate_key(): + return ''.join(random.choice(Game.KEY_CHARS) + for _ in range(Game.KEY_LENGTH)) + + def create_player(self, code, exc=ValueError): + if self.started: + raise exc("Game has already started.") + return self.players.create(code, exc=exc) + + def delete_player(self, color, exc=ValueError): + if self.started: + raise exc("Game has already started.") + self.players.delete(color) + + @property + def started(self): + return self.turn > 0 + + def start(self, exc=ValueError): + if len(self.players) < 2: + raise exc("At least 2 players are required.") + if self.turn == 0: + self.advance() + + def advance(self): + log.info("starting the next turn...") + self.turn += 1 + for player in self.players: + if player.turns.current: + player.turns.current.done = True + player.turns.append(Turn()) + + +class Games(dict): + + """A collection of all games in the application.""" + + def create(self): + game = Game() + self[game.key] = game + return game + + def find(self, key, exc=ValueError): + try: + player = self[key] + except KeyError: + raise exc("The game '{}' does not exist.".format(key)) from None + else: + return player diff --git a/gridcommand/domain/move.py b/gridcommand/domain/move.py new file mode 100644 index 0000000..ac9f365 --- /dev/null +++ b/gridcommand/domain/move.py @@ -0,0 +1,58 @@ +"""Classes representing player's moves.""" + + +class Move: + + """A planned transfer of tokens from one cell to another.""" + + def __init__(self, begin, end, count=0): + super().__init__() + self.begin = begin + self.end = end + self.count = count + + def __repr__(self): + return "".format(self.count, + self.begin, + self.end) + + def __eq__(self, other): + return self.begin == other.begin and self.end == other.end + + def __lt__(self, other): + if self.begin < other.begin: + return True + if self.begin > other.begin: + return False + return self.end < other.end + + +class Moves(list): + + """A collection of moves for a player.""" + + def __repr__(self): + return "<{} move{}>".format(len(self), "" if len(self) == 1 else "s") + + def get(self, begin, end): + move = Move(begin, end) + for move2 in self: + if move == move2: + return move2 + self.append(move) + return move + + def set(self, begin, end, count): + move = self.get(begin, end) + if count is not None: + move.count = count + if not move.count: + self.delete(begin, end) + return move + + def delete(self, begin, end): + move = Move(begin, end) + try: + self.remove(move) + except ValueError: + pass diff --git a/gridcommand/domain/player.py b/gridcommand/domain/player.py new file mode 100644 index 0000000..f25e875 --- /dev/null +++ b/gridcommand/domain/player.py @@ -0,0 +1,73 @@ +"""Classes representing players in a game.""" + +from ..common import logger +from .turn import Turns + + +log = logger(__name__) + + +class Player: + + """An entity that plans moves during a turn.""" + + def __init__(self, color, code=''): + super().__init__() + self.color = color + self.code = code + self.turns = Turns() + + def __repr__(self): + return "".format(self.color) + + def __eq__(self, other): + return self.color == other.color + + def authenticate(self, code, exc=ValueError): + if code != self.code: + raise exc("The code '{}' is invalid.".format(code)) + + +class Players(list): + + """A collection players in a game.""" + + COLORS = ( + 'red', + 'blue', + 'teal', + 'purple', + 'yellow', + 'orange', + 'green', + 'pink', + ) + + def __repr__(self): + return "<{} player{}>".format(len(self), "" if len(self) == 1 else "s") + + @property + def maximum(self): + return len(self.COLORS) + + def create(self, code='', exc=RuntimeError): + log.info("creating player with code %r...", code) + colors = [player.color for player in self] + for color in self.COLORS: + if color not in colors: + player = Player(color, code=code) + self.append(player) + return player + raise exc("The maximum number of players is {}.".format(self.maximum)) + + def find(self, color, exc=ValueError): + for player in self: + if player.color == color: + return player + if exc: + raise exc("The player '{}' does not exist.".format(color)) + + def delete(self, color): + player = self.find(color, exc=None) + if player: + self.remove(player) diff --git a/gridcommand/domain/turn.py b/gridcommand/domain/turn.py new file mode 100644 index 0000000..5353d10 --- /dev/null +++ b/gridcommand/domain/turn.py @@ -0,0 +1,38 @@ +"""Classes representing turns in a game.""" + +from .move import Moves + + +class Turn: + + """An individual turn for a player.""" + + def __init__(self): + super().__init__() + self.moves = Moves() + self.done = False + + def __repr__(self): + return "" + + +class Turns(list): + + """A list of turns in a game for each player.""" + + def __repr__(self): + return "<{} turn{}>".format(len(self), "" if len(self) == 1 else "s") + + @property + def current(self): + """Get the most recent turn.""" + try: + return self[-1] + except IndexError: + return None + + def find(self, number, exc=ValueError): + try: + return self[number - 1] + except IndexError: + raise exc("The turn '{}' does not exist.".format(number)) diff --git a/gridcommand/domain/types.py b/gridcommand/domain/types.py new file mode 100644 index 0000000..bf01f20 --- /dev/null +++ b/gridcommand/domain/types.py @@ -0,0 +1,21 @@ +import time + + +class Widget: + + def __init__(self, number, name): + self.number = number + self.name = name + self.inspections = [] + + def inspect(self, status=True): + inspection = Inspection(status=status) + self.inspections.append(inspection) + return inspection + + +class Inspection: + + def __init__(self, status, stamp=None): + self.status = status + self.stamp = stamp or int(time.time()) diff --git a/gridcommand/views/__init__.py b/gridcommand/routes/__init__.py similarity index 100% rename from gridcommand/views/__init__.py rename to gridcommand/routes/__init__.py diff --git a/gridcommand/routes/base.py b/gridcommand/routes/base.py new file mode 100644 index 0000000..8fc4d48 --- /dev/null +++ b/gridcommand/routes/base.py @@ -0,0 +1,64 @@ +from abc import ABCMeta, abstractmethod +from functools import wraps + + +class Formatter(metaclass=ABCMeta): + + def single(self, func): + """Decorator to format a single item.""" + + @wraps(func) + def wrapped(*args, **kwargs): + item = func(*args, **kwargs) + return self.format_single(item) + + return wrapped + + def multiple(self, func): + """Decorator to format multiple items.""" + + @wraps(func) + def wrapped(*args, **kwargs): + items = func(*args, **kwargs) + return self.format_multiple(items) + + return wrapped + + def single_with_status(self, status): + """Decorator to format a single item with status.""" + + def dectorator(func): + + @wraps(func) + def wrapped(*args, **kwargs): + item = func(*args, **kwargs) + return self.format_single(item), status + + return wrapped + + return dectorator + + def multiple_with_status(self, status): + """Decorator to format multiple items with status.""" + + def dectorator(func): + + @wraps(func) + def wrapped(*args, **kwargs): + items = func(*args, **kwargs) + return self.format_multiple(items), status + + return wrapped + + return dectorator + + @abstractmethod + def format_single(self, item): + raise NotImplementedError + + def format_multiple(self, items): + content = [] + if items: + for item in items: + content.append(self.format_single(item)) + return content diff --git a/gridcommand/routes/formatters.py b/gridcommand/routes/formatters.py new file mode 100644 index 0000000..d626068 --- /dev/null +++ b/gridcommand/routes/formatters.py @@ -0,0 +1,87 @@ +"""Formats domain objects for route responses.""" + +from flask import url_for + +from .base import Formatter + + +class GameFormatter(Formatter): + + """Serializes games into dictionaries.""" + + def format_single(self, game): + kwargs = dict(_external=True, key=game.key) + game_url = url_for('.games_detail', **kwargs) + players_url = url_for('.players_list', **kwargs) + start_url = url_for('.games_start', **kwargs) + return {'uri': game_url, + 'players': players_url, + 'start': start_url, + 'turn': game.turn} + + def format_multiple(self, games): + return [url_for('.games_detail', + _external=True, key=key) for key in games] + + +class PlayerFormatter(Formatter): + + """Serializes players into dictionaries.""" + + def format_single(self, player): + data = {'turn': len(player.turns)} + kwargs = dict(_external=True, key=game.key, color=player.color) + if auth: + kwargs.update(code=player.code) + player_url = url_for('.players_detail', **kwargs) + turns_url = url_for('.turns_list', **kwargs) + data['turns'] = turns_url + else: + player_url = url_for('.players_detail', **kwargs) + data['uri'] = player_url + return data + + def format_multiple(self, players, game): + return [url_for('.players_detail', _external=True, + key=game.key, color=player.color) for player in players] + + +class TurnFormatter(Formatter): + + """Serializes turns into dictionaries.""" + + def format_single(self, turn): + kwargs = dict(_external=True, + key=game.key, + color=player.color, + code=player.code, + number=number) + turn_url = url_for('.turns_detail', **kwargs) + moves_url = url_for('.moves_list', **kwargs) + return {'uri': turn_url, + 'moves': moves_url, + 'done': turn.done} + + def format_multiple(self, turns): + return [url_for('.turns_detail', _external=True, + key=game.key, color=player.color, code=player.code, + number=index + 1) for index in range(len(turns))] + + +class MoveFormatter(Formatter): + + """Serializes moves into dictionaries.""" + + def format_single(self, move): + return {'count': move.count} + + def format_multiple(self, moves): + return [url_for('.moves_detail', _external=True, + key=game.key, color=player.color, code=player.code, + begin=move.begin, end=move.end) for move in self] + + +game_formatter = GameFormatter() +player_formatter = PlayerFormatter() +turn_formatter = TurnFormatter() +move_formatter = MoveFormatter() diff --git a/gridcommand/views/game.py b/gridcommand/routes/game.py similarity index 100% rename from gridcommand/views/game.py rename to gridcommand/routes/game.py diff --git a/gridcommand/views/move.py b/gridcommand/routes/move.py similarity index 100% rename from gridcommand/views/move.py rename to gridcommand/routes/move.py diff --git a/gridcommand/routes/parsers.py b/gridcommand/routes/parsers.py new file mode 100644 index 0000000..847dceb --- /dev/null +++ b/gridcommand/routes/parsers.py @@ -0,0 +1,26 @@ +"""Parses requests coming into routes.""" + +from flask_api import status, exceptions +from webargs import core +from webargs.flaskparser import FlaskParser + + +parser = FlaskParser(('query', 'form', 'json', 'data')) + + +@parser.location_handler('data') +def parse_data(req, name, arg): + data = req.data + if data: + return core.get_value(data, name, arg.multiple) + else: + return core.Missing + + +@parser.error_handler +def handle_error(error): + if error.status_code == status.HTTP_400_BAD_REQUEST: + message = str(error).replace('"', "'") + raise exceptions.ParseError(message) + else: + raise error diff --git a/gridcommand/views/player.py b/gridcommand/routes/player.py similarity index 100% rename from gridcommand/views/player.py rename to gridcommand/routes/player.py diff --git a/gridcommand/views/root.py b/gridcommand/routes/root.py similarity index 100% rename from gridcommand/views/root.py rename to gridcommand/routes/root.py diff --git a/gridcommand/views/turn.py b/gridcommand/routes/turn.py similarity index 100% rename from gridcommand/views/turn.py rename to gridcommand/routes/turn.py From fb682520dae8673210168c2b9e0872a0e0fec522 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Tue, 26 May 2015 23:25:41 -0400 Subject: [PATCH 02/14] Add structure for stores and services --- Makefile | 50 ++++++-- gridcommand/__init__.py | 1 - gridcommand/data.py | 16 --- gridcommand/domain/__init__.py | 2 +- gridcommand/domain/game.py | 17 +-- gridcommand/models/__init__.py | 6 - gridcommand/models/game.py | 97 ---------------- gridcommand/models/move.py | 73 ------------ gridcommand/models/player.py | 97 ---------------- gridcommand/models/turn.py | 62 ---------- gridcommand/routes/game.py | 15 ++- gridcommand/routes/move.py | 2 - gridcommand/routes/player.py | 2 - gridcommand/routes/turn.py | 2 - gridcommand/services/__init__.py | 1 + gridcommand/services/base.py | 13 +++ gridcommand/services/game.py | 23 ++++ gridcommand/stores/__init__.py | 3 + gridcommand/stores/base.py | 20 ++++ gridcommand/stores/game.py | 109 ++++++++++++++++++ gridcommand/test/__init__.py | 1 + {tests => gridcommand/test}/conftest.py | 18 ++- gridcommand/test/domain/__init__.py | 0 .../test/domain}/test_game.py | 4 +- .../test/domain}/test_move.py | 4 +- .../test/domain}/test_player.py | 4 +- .../test/domain}/test_turn.py | 4 +- .../test/routes}/__init__.py | 2 - .../test/routes}/test_game.py | 2 +- .../test/routes}/test_move.py | 0 .../test/routes}/test_player.py | 0 .../test/routes}/test_root.py | 0 .../test/routes}/test_turn.py | 0 main.py | 4 +- scent.py | 47 ++++++++ tests/__init__.py | 2 +- tests/models/__init__.py | 1 - 37 files changed, 288 insertions(+), 416 deletions(-) delete mode 100644 gridcommand/data.py delete mode 100644 gridcommand/models/__init__.py delete mode 100644 gridcommand/models/game.py delete mode 100644 gridcommand/models/move.py delete mode 100644 gridcommand/models/player.py delete mode 100644 gridcommand/models/turn.py create mode 100644 gridcommand/services/__init__.py create mode 100644 gridcommand/services/base.py create mode 100644 gridcommand/services/game.py create mode 100644 gridcommand/stores/__init__.py create mode 100644 gridcommand/stores/base.py create mode 100644 gridcommand/stores/game.py create mode 100644 gridcommand/test/__init__.py rename {tests => gridcommand/test}/conftest.py (86%) create mode 100644 gridcommand/test/domain/__init__.py rename {tests/models => gridcommand/test/domain}/test_game.py (95%) rename {tests/models => gridcommand/test/domain}/test_move.py (90%) rename {tests/models => gridcommand/test/domain}/test_player.py (93%) rename {tests/models => gridcommand/test/domain}/test_turn.py (84%) rename {tests/views => gridcommand/test/routes}/__init__.py (50%) rename {tests/views => gridcommand/test/routes}/test_game.py (97%) rename {tests/views => gridcommand/test/routes}/test_move.py (100%) rename {tests/views => gridcommand/test/routes}/test_player.py (100%) rename {tests/views => gridcommand/test/routes}/test_root.py (100%) rename {tests/views => gridcommand/test/routes}/test_turn.py (100%) create mode 100644 scent.py delete mode 100644 tests/models/__init__.py diff --git a/Makefile b/Makefile index ffb0e32..75d49c9 100644 --- a/Makefile +++ b/Makefile @@ -10,19 +10,26 @@ ifndef TRAVIS PYTHON_MINOR := 4 endif -# Testake settings +# Test settings UNIT_TEST_COVERAGE := 82 INTEGRATION_TEST_COVERAGE := 82 +COMBINED_TEST_COVERAGE := 82 # System paths PLATFORM := $(shell python -c 'import sys; print(sys.platform)') ifneq ($(findstring win32, $(PLATFORM)), ) + WINDOWS := 1 SYS_PYTHON_DIR := C:\\Python$(PYTHON_MAJOR)$(PYTHON_MINOR) SYS_PYTHON := $(SYS_PYTHON_DIR)\\python.exe SYS_VIRTUALENV := $(SYS_PYTHON_DIR)\\Scripts\\virtualenv.exe # https://bugs.launchpad.net/virtualenv/+bug/449537 export TCL_LIBRARY=$(SYS_PYTHON_DIR)\\tcl\\tcl8.5 else + ifneq ($(findstring darwin, $(PLATFORM)), ) + MAC := 1 + else + LINUX := 1 + endif SYS_PYTHON := python$(PYTHON_MAJOR) ifdef PYTHON_MINOR SYS_PYTHON := $(SYS_PYTHON).$(PYTHON_MINOR) @@ -58,8 +65,10 @@ PYREVERSE := $(BIN)/pyreverse NOSE := $(BIN)/nosetests PYTEST := $(BIN)/py.test COVERAGE := $(BIN)/coverage +SNIFFER := $(BIN)/sniffer # Flags for PHONY targets +INSTALLED := $(ENV)/.installed DEPENDS_CI := $(ENV)/.depends-ci DEPENDS_DEV := $(ENV)/.depends-dev ALL := $(ENV)/.all @@ -102,10 +111,10 @@ launch-public: env # Development Installation ##################################################### .PHONY: env -env: .virtualenv $(EGG_INFO) -$(EGG_INFO): Makefile setup.py requirements.txt - VIRTUAL_ENV=$(ENV) $(PYTHON) setup.py develop - touch $(EGG_INFO) # flag to indicate package is installed +env: .virtualenv $(INSTALLED) +$(INSTALLED): Makefile requirements.txt + VIRTUAL_ENV=$(ENV) $(PIP) install -r requirements.txt + touch $(INSTALLED) # flag to indicate package is installed .PHONY: .virtualenv .virtualenv: $(PIP) @@ -125,7 +134,14 @@ $(DEPENDS_CI): Makefile .PHONY: depends-dev depends-dev: env Makefile $(DEPENDS_DEV) $(DEPENDS_DEV): Makefile - $(PIP) install --upgrade pip pep8radius pygments docutils pdoc wheel + $(PIP) install --upgrade pip pep8radius pygments docutils pdoc wheel readme sniffer +ifdef WINDOWS + $(PIP) install --upgrade pywin32 +else ifdef MAC + $(PIP) install --upgrade pync MacFSEvents +else ifdef LINUX + $(PIP) install --upgrade pyinotify +endif touch $(DEPENDS_DEV) # flag to indicate dependencies are installed # Documentation ################################################################ @@ -150,7 +166,7 @@ apidocs/$(PACKAGE)/index.html: $(SOURCES) .PHONY: uml uml: depends-dev docs/*.png docs/*.png: $(SOURCES) - $(PYREVERSE) $(PACKAGE) -p $(PACKAGE) -f ALL -o png --ignore test + $(PYREVERSE) $(PACKAGE) -p $(PACKAGE) -a 1 -f ALL -o png --ignore test - mv -f classes_$(PACKAGE).png docs/classes.png - mv -f packages_$(PACKAGE).png docs/packages.png @@ -194,20 +210,34 @@ PYTEST_COV_OPTS := --cov=$(PACKAGE) --cov-report=term-missing --cov-report=html PYTEST_CAPTURELOG_OPTS := --log-format="%(name)-25s %(lineno)4d %(levelname)8s: %(message)s" PYTEST_OPTS := $(PYTEST_CORE_OPTS) $(PYTEST_COV_OPTS) $(PYTEST_CAPTURELOG_OPTS) +.PHONY: test-unit +test-unit: test .PHONY: test test: depends-ci .clean-test - $(PYTEST) $(PYTEST_OPTS) tests + $(PYTEST) $(PYTEST_OPTS) $(PACKAGE) $(COVERAGE) report --fail-under=$(UNIT_TEST_COVERAGE) > /dev/null -.PHONY: tests -tests: depends-ci .clean-test +.PHONY: test-int +test-int: depends-ci .clean-test TEST_INTEGRATION=1 $(PYTEST) $(PYTEST_OPTS) tests $(COVERAGE) report --fail-under=$(INTEGRATION_TEST_COVERAGE) > /dev/null +.PHONY: test-all +test-all: tests +.PHONY: tests +tests: depends-ci .clean-test + TEST_INTEGRATION=1 $(PYTEST) $(PYTEST_OPTS) $(PACKAGE) tests + $(COVERAGE) report --fail-under=$(COMBINED_TEST_COVERAGE) > /dev/null + .PHONY: read-coverage read-coverage: $(OPEN) htmlcov/index.html +.PHONY: watch +watch: depends-dev + mkdir -p htmlcov && touch htmlcov/index.html && $(MAKE) read-coverage + $(SNIFFER) + # Cleanup ###################################################################### .PHONY: clean diff --git a/gridcommand/__init__.py b/gridcommand/__init__.py index 0c5d1e8..41b8982 100644 --- a/gridcommand/__init__.py +++ b/gridcommand/__init__.py @@ -14,7 +14,6 @@ try: from .routes import app - from . import data except (ImportError, AttributeError): # pragma: no cover (manual test) import logging logging.exception("dependencies:") diff --git a/gridcommand/data.py b/gridcommand/data.py deleted file mode 100644 index 68086af..0000000 --- a/gridcommand/data.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Data persistence for the application.""" - -import os - -from .models import Game, Games - - -def load(): - """Add previously stored data to the application.""" - _path = os.path.join("data", "games") # TODO: move this to settings? - if os.path.exists(_path): - for filename in os.listdir(_path): - _key = filename.split('.')[0] - games[_key] = Game(_key) - -games = Games() # pylint: disable=C0103 diff --git a/gridcommand/domain/__init__.py b/gridcommand/domain/__init__.py index 9380178..96cb901 100644 --- a/gridcommand/domain/__init__.py +++ b/gridcommand/domain/__init__.py @@ -3,4 +3,4 @@ from .move import Move, Moves from .turn import Turn, Turns from .player import Player, Players -from .game import Game, Games +from .game import Game diff --git a/gridcommand/domain/game.py b/gridcommand/domain/game.py index 37007c5..7af6ab9 100644 --- a/gridcommand/domain/game.py +++ b/gridcommand/domain/game.py @@ -59,19 +59,4 @@ def advance(self): player.turns.append(Turn()) -class Games(dict): - - """A collection of all games in the application.""" - - def create(self): - game = Game() - self[game.key] = game - return game - - def find(self, key, exc=ValueError): - try: - player = self[key] - except KeyError: - raise exc("The game '{}' does not exist.".format(key)) from None - else: - return player + diff --git a/gridcommand/models/__init__.py b/gridcommand/models/__init__.py deleted file mode 100644 index ddf3cf6..0000000 --- a/gridcommand/models/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Data models for the application.""" - -from .move import Move, Moves -from .turn import Turn, Turns -from .player import Player, Players -from .game import Game, Games diff --git a/gridcommand/models/game.py b/gridcommand/models/game.py deleted file mode 100644 index 68ecff6..0000000 --- a/gridcommand/models/game.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Classes representing games.""" - -import string -import random - -from flask import url_for # TODO: remove this import -import yorm - -from .. import common -from .player import Players -from .turn import Turn - -log = common.logger(__name__) - - -@yorm.attr(players=Players) -@yorm.attr(turn=yorm.converters.Integer) -@yorm.sync("data/games/{self.key}.yml") -class Game: - - """An individual game instance.""" - - KEY_CHARS = string.ascii_lowercase + string.digits - KEY_LENGTH = 8 - - def __init__(self, key=None): - self.key = key or self._generate_key() - self.players = Players() - self.turn = 0 - - def __repr__(self): - return "".format(self.key) - - @staticmethod - def _generate_key(): - return ''.join(random.choice(Game.KEY_CHARS) - for _ in range(Game.KEY_LENGTH)) - - def create_player(self, code, exc=ValueError): - if self.started: - raise exc("Game has already started.") - return self.players.create(code, exc=exc) - - def delete_player(self, color, exc=ValueError): - if self.started: - raise exc("Game has already started.") - self.players.delete(color) - - @property - def started(self): - return self.turn > 0 - - def start(self, exc=ValueError): - if len(self.players) < 2: - raise exc("At least 2 players are required.") - if self.turn == 0: - self.advance() - - def advance(self): - log.info("starting the next turn...") - self.turn += 1 - for player in self.players: - if player.turns.current: - player.turns.current.done = True - player.turns.append(Turn()) - - def serialize(self): - kwargs = dict(_external=True, key=self.key) - game_url = url_for('.games_detail', **kwargs) - players_url = url_for('.players_list', **kwargs) - start_url = url_for('.games_start', **kwargs) - return {'uri': game_url, - 'players': players_url, - 'start': start_url, - 'turn': self.turn} - - -class Games(dict): - - """A collection of all games in the application.""" - - def serialize(self): - return [url_for('.games_detail', - _external=True, key=key) for key in self] - - def create(self): - game = Game() - self[game.key] = game - return game - - def find(self, key, exc=ValueError): - try: - player = self[key] - except KeyError: - raise exc("The game '{}' does not exist.".format(key)) from None - else: - return player diff --git a/gridcommand/models/move.py b/gridcommand/models/move.py deleted file mode 100644 index 1ba1a90..0000000 --- a/gridcommand/models/move.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Classes representing player's moves.""" - -from flask import url_for # TODO: remove this import -import yorm - - -@yorm.attr(begin=yorm.converters.Integer) -@yorm.attr(end=yorm.converters.Integer) -@yorm.attr(count=yorm.converters.Integer) -class Move(yorm.converters.AttributeDictionary): - - """A planned transfer of tokens from one cell to another.""" - - def __init__(self, begin, end, count=0): - super().__init__() - self.begin = begin - self.end = end - self.count = count - - def __repr__(self): - return "".format(self.count, - self.begin, - self.end) - - def __eq__(self, other): - return self.begin == other.begin and self.end == other.end - - def __lt__(self, other): - if self.begin < other.begin: - return True - if self.begin > other.begin: - return False - return self.end < other.end - - def serialize(self): - return {'count': self.count} - - -@yorm.attr(all=Move) -class Moves(yorm.converters.SortedList): - - """A collection of moves for a player.""" - - def __repr__(self): - return "<{} move{}>".format(len(self), "" if len(self) == 1 else "s") - - def serialize(self, game, player): - return [url_for('.moves_detail', _external=True, - key=game.key, color=player.color, code=player.code, - begin=move.begin, end=move.end) for move in self] - - def get(self, begin, end): - move = Move(begin, end) - for move2 in self: - if move == move2: - return move2 - self.append(move) - return move - - def set(self, begin, end, count): - move = self.get(begin, end) - if count is not None: - move.count = count - if not move.count: - self.delete(begin, end) - return move - - def delete(self, begin, end): - move = Move(begin, end) - try: - self.remove(move) - except ValueError: - pass diff --git a/gridcommand/models/player.py b/gridcommand/models/player.py deleted file mode 100644 index 4f4d614..0000000 --- a/gridcommand/models/player.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Classes representing players in a game.""" - -from flask import url_for # TODO: remove this import -import yorm - -from ..common import logger -from .turn import Turns - - -log = logger(__name__) - - -@yorm.attr(color=yorm.converters.String) -@yorm.attr(code=yorm.converters.String) -@yorm.attr(turns=Turns) -class Player(yorm.converters.AttributeDictionary): - - """An entity that plans moves during a turn.""" - - def __init__(self, color, code=''): - super().__init__() - self.color = color - self.code = code - self.turns = Turns() - - def __repr__(self): - return "".format(self.color) - - def __eq__(self, other): - return self.color == other.color - - def authenticate(self, code, exc=ValueError): - if code != self.code: - raise exc("The code '{}' is invalid.".format(code)) - - def serialize(self, game, auth=False): - data = {'turn': len(self.turns)} - kwargs = dict(_external=True, key=game.key, color=self.color) - if auth: - kwargs.update(code=self.code) - player_url = url_for('.players_detail', **kwargs) - turns_url = url_for('.turns_list', **kwargs) - data['turns'] = turns_url - else: - player_url = url_for('.players_detail', **kwargs) - data['uri'] = player_url - return data - - -@yorm.attr(all=Player) -class Players(yorm.converters.List): - - """A collection players in a game.""" - - COLORS = ( - 'red', - 'blue', - 'teal', - 'purple', - 'yellow', - 'orange', - 'green', - 'pink', - ) - - def __repr__(self): - return "<{} player{}>".format(len(self), "" if len(self) == 1 else "s") - - @property - def maximum(self): - return len(self.COLORS) - - def serialize(self, game): - return [url_for('.players_detail', _external=True, - key=game.key, color=player.color) for player in self] - - def create(self, code='', exc=RuntimeError): - log.info("creating player with code %r...", code) - colors = [player.color for player in self] - for color in self.COLORS: - if color not in colors: - player = Player(color, code=code) - self.append(player) - return player - raise exc("The maximum number of players is {}.".format(self.maximum)) - - def find(self, color, exc=ValueError): - for player in self: - if player.color == color: - return player - if exc: - raise exc("The player '{}' does not exist.".format(color)) - - def delete(self, color): - player = self.find(color, exc=None) - if player: - self.remove(player) diff --git a/gridcommand/models/turn.py b/gridcommand/models/turn.py deleted file mode 100644 index b9ccc1a..0000000 --- a/gridcommand/models/turn.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Classes representing turns in a game.""" - -from flask import url_for # TODO: remove this import -import yorm - -from .move import Moves - - -@yorm.attr(moves=Moves) -@yorm.attr(done=yorm.converters.Boolean) -class Turn(yorm.converters.AttributeDictionary): - - """An individual turn for a player.""" - - def __init__(self): - super().__init__() - self.moves = Moves() - self.done = False - - def __repr__(self): - return "" - - def serialize(self, game, player, number): - kwargs = dict(_external=True, - key=game.key, - color=player.color, - code=player.code, - number=number) - turn_url = url_for('.turns_detail', **kwargs) - moves_url = url_for('.moves_list', **kwargs) - return {'uri': turn_url, - 'moves': moves_url, - 'done': self.done} - - -@yorm.attr(all=Turn) -class Turns(yorm.converters.List): - - """A list of turns in a game for each player.""" - - def __repr__(self): - return "<{} turn{}>".format(len(self), "" if len(self) == 1 else "s") - - @property - def current(self): - """Get the most recent turn.""" - try: - return self[-1] - except IndexError: - return None - - def find(self, number, exc=ValueError): - try: - return self[number - 1] - except IndexError: - raise exc("The turn '{}' does not exist.".format(number)) - - def serialize(self, game, player): - - return [url_for('.turns_detail', _external=True, - key=game.key, color=player.color, code=player.code, - number=index + 1) for index in range(len(self))] diff --git a/gridcommand/routes/game.py b/gridcommand/routes/game.py index 93a7090..c98387c 100644 --- a/gridcommand/routes/game.py +++ b/gridcommand/routes/game.py @@ -4,10 +4,11 @@ from flask import request from flask.ext.api import status, exceptions # pylint: disable=E0611,F0401 -from ..data import games +from ..services import GameService from . import app from .root import ROOT_URL +from .formatters import game_formatter as formatter GAMES_LIST_URL = ROOT_URL + "/games/" @@ -15,6 +16,10 @@ GAMES_START_URL = GAMES_DETAIL_URL + "/start" +service = GameService() +service.exceptions.missing = exceptions.NotFound + + @app.route(GAMES_LIST_URL, methods=['GET', 'POST']) def games_list(): """Create a new game.""" @@ -23,8 +28,8 @@ def games_list(): raise exceptions.PermissionDenied("Games list is hidden.") elif request.method == 'POST': - game = games.create() - return game.serialize(), status.HTTP_201_CREATED + game = service.create_game() + return formatter.format_single(game), status.HTTP_201_CREATED else: # pragma: no cover assert None @@ -35,8 +40,8 @@ def games_detail(key): """Retrieve a game's status.""" if request.method == 'GET': - game = games.find(key, exc=exceptions.NotFound) - return game.serialize() + game = service.find_game(key) + return formatter.format_single(game) else: # pragma: no cover assert None diff --git a/gridcommand/routes/move.py b/gridcommand/routes/move.py index 035c4fa..ce861cc 100644 --- a/gridcommand/routes/move.py +++ b/gridcommand/routes/move.py @@ -4,8 +4,6 @@ from flask import request from flask.ext.api import status, exceptions # pylint: disable=E0611,F0401 -from ..data import games - from . import app from .turn import TURNS_DETAIL_URL diff --git a/gridcommand/routes/player.py b/gridcommand/routes/player.py index cd29372..a69a94b 100644 --- a/gridcommand/routes/player.py +++ b/gridcommand/routes/player.py @@ -4,8 +4,6 @@ from flask import request from flask.ext.api import status, exceptions # pylint: disable=E0611,F0401 -from ..data import games - from . import app from .game import GAMES_DETAIL_URL diff --git a/gridcommand/routes/turn.py b/gridcommand/routes/turn.py index da48317..b1ace2d 100644 --- a/gridcommand/routes/turn.py +++ b/gridcommand/routes/turn.py @@ -4,8 +4,6 @@ from flask import request from flask.ext.api import exceptions # pylint: disable=E0611,F0401 -from ..data import games - from . import app from .player import PLAYERS_DETAIL_URL diff --git a/gridcommand/services/__init__.py b/gridcommand/services/__init__.py new file mode 100644 index 0000000..730c9a3 --- /dev/null +++ b/gridcommand/services/__init__.py @@ -0,0 +1 @@ +from .game import GameService diff --git a/gridcommand/services/base.py b/gridcommand/services/base.py new file mode 100644 index 0000000..f3e49dd --- /dev/null +++ b/gridcommand/services/base.py @@ -0,0 +1,13 @@ +from abc import ABCMeta + + +class Exceptions: + + duplicate = ValueError + missing = KeyError + + +class Service(metaclass=ABCMeta): + + def __init__(self, exceptions=None): + self.exceptions = exceptions or Exceptions() diff --git a/gridcommand/services/game.py b/gridcommand/services/game.py new file mode 100644 index 0000000..c40709d --- /dev/null +++ b/gridcommand/services/game.py @@ -0,0 +1,23 @@ +from ..domain import Game +from ..stores import GameStore + +from .base import Service + + +class GameService(Service): + + def __init__(self, game_store=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.game_store = game_store or GameStore() + + def create_game(self): + game = Game() + self.game_store.create(game) + return game + + def find_game(self, key): + game = self.game_store.read(key) + if game is None: + msg = "The game '{}' does not exist.".format(key) + raise self.exceptions.missing(msg) + return game diff --git a/gridcommand/stores/__init__.py b/gridcommand/stores/__init__.py new file mode 100644 index 0000000..574c204 --- /dev/null +++ b/gridcommand/stores/__init__.py @@ -0,0 +1,3 @@ +"""Persistence models for the application.""" + +from .game import GameStore diff --git a/gridcommand/stores/base.py b/gridcommand/stores/base.py new file mode 100644 index 0000000..45851b5 --- /dev/null +++ b/gridcommand/stores/base.py @@ -0,0 +1,20 @@ +from abc import ABCMeta, abstractmethod + + +class Store(metaclass=ABCMeta): + + @abstractmethod + def create(self, item): + raise NotImplementedError + + @abstractmethod + def read(self, item): + raise NotImplementedError + + @abstractmethod + def update(self, item): + raise NotImplementedError + + @abstractmethod + def delete(self, item): + raise NotImplementedError diff --git a/gridcommand/stores/game.py b/gridcommand/stores/game.py new file mode 100644 index 0000000..60814c5 --- /dev/null +++ b/gridcommand/stores/game.py @@ -0,0 +1,109 @@ +import os + +import yorm + +from .. import common +from ..domain import Game + +from .base import Store + + +log = common.logger(__name__) + + +@yorm.attr(begin=yorm.converters.Integer) +@yorm.attr(end=yorm.converters.Integer) +@yorm.attr(count=yorm.converters.Integer) +class Move(yorm.converters.AttributeDictionary): + pass + + +@yorm.attr(all=Move) +class Moves(yorm.converters.SortedList): + pass + + +@yorm.attr(moves=Moves) +@yorm.attr(done=yorm.converters.Boolean) +class Turn(yorm.converters.AttributeDictionary): + pass + + +@yorm.attr(all=Turn) +class Turns(yorm.converters.List): + pass + + +@yorm.attr(color=yorm.converters.String) +@yorm.attr(code=yorm.converters.String) +@yorm.attr(turns=Turns) +class Player(yorm.converters.AttributeDictionary): + pass + + +@yorm.attr(all=Player) +class Players(yorm.converters.List): + pass + + +@yorm.attr(players=Players) +@yorm.attr(turn=yorm.converters.Integer) +@yorm.sync("data/games/{self.key}.yml") +class GameModel: + + def __init__(self, key): + self.key = key + + +class GameStore(Store): + + def create(self, game): + model = GameModel(key=game.key) + model.players = game.players + model.turn = game.turn + + def read(self, key): + if key: + path = os.path.join("data", "games", key + ".yml") # TODO: move this to settings? + + if not os.path.exists(path): + return None + + model = GameModel(key) + + game = Game(key=model.key) + game.players = model.players + game.turn = model.turn + + return game + else: + games = [] + + path = os.path.join("data", "games") # TODO: move this to settings? + if os.path.exists(path): + for filename in os.listdir(path): + key = filename.split('.')[0] + + model = GameModel(key) + + game = Game(key=model.key) + game.players = model.players + game.turn = model.turn + + games.append(game) + + return games + + def update(self, game): + path = os.path.join("data", "games", game.key + ".yml") # TODO: move this to settings? + assert os.path.exists(path) + + model = GameModel(game.key) + model.players = game.players + model.turn = game.turn + + def delete(self, game): + path = os.path.join("data", "games", game.key + ".yml") # TODO: move this to settings? + if os.path.exists(path): + os.remove(path) + diff --git a/gridcommand/test/__init__.py b/gridcommand/test/__init__.py new file mode 100644 index 0000000..eecd5ed --- /dev/null +++ b/gridcommand/test/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the `gridcommand` package.""" diff --git a/tests/conftest.py b/gridcommand/test/conftest.py similarity index 86% rename from tests/conftest.py rename to gridcommand/test/conftest.py index 9b0e9c5..5872535 100644 --- a/tests/conftest.py +++ b/gridcommand/test/conftest.py @@ -10,8 +10,7 @@ from gridcommand.common import logger from gridcommand import app -from gridcommand import models -from gridcommand import data +from gridcommand import domain ENV = 'TEST_INTEGRATION' # environment variable to enable integration tests REASON = "'{0}' variable not set".format(ENV) @@ -41,7 +40,6 @@ def client(request): app.config['TESTING'] = True app.config['DEBUG'] = True test_client = app.test_client() - data.games.clear() return test_client @@ -49,8 +47,8 @@ def client(request): def game(): """Fixture to create an empty game.""" log.info("creating an empty game...") - game = models.Game(GAME_KEY) - data.games[game.key] = game + game = domain.Game(GAME_KEY) + os.system("touch data/games/my_game.yml") return game @@ -58,7 +56,7 @@ def game(): def game_player(game): """Fixture to create a game with one player.""" log.info("adding a player to a game...") - with patch.object(models.Players, 'COLORS', PLAYERS_COLORS): + with patch.object(domain.Players, 'COLORS', PLAYERS_COLORS): game.players.create(PLAYER_CODE) return game @@ -66,7 +64,7 @@ def game_player(game): @pytest.fixture def game_players(game): """Fixture to create a game with two players.""" - with patch.object(models.Players, 'COLORS', PLAYERS_COLORS): + with patch.object(domain.Players, 'COLORS', PLAYERS_COLORS): game.players.create(PLAYER_CODE) game.players.create(PLAYER_CODE) return game @@ -100,7 +98,7 @@ def players(game_players): def turn(game_player): """Fixture to create a turn for a player.""" log.info("adding a turn to a player...") - turn = models.Turn() + turn = domain.Turn() log.debug("appending turn...") game_player.players[0].turns.append(turn) return turn @@ -109,8 +107,8 @@ def turn(game_player): @pytest.fixture def turns(game_player): """Fixture to create turns for a player.""" - game_player.players[0].turns.append(models.Turn()) - game_player.players[0].turns.append(models.Turn()) + game_player.players[0].turns.append(domain.Turn()) + game_player.players[0].turns.append(domain.Turn()) return game_player.players[0].turns diff --git a/gridcommand/test/domain/__init__.py b/gridcommand/test/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/models/test_game.py b/gridcommand/test/domain/test_game.py similarity index 95% rename from tests/models/test_game.py rename to gridcommand/test/domain/test_game.py index 5518d91..2d42e06 100644 --- a/tests/models/test_game.py +++ b/gridcommand/test/domain/test_game.py @@ -1,9 +1,9 @@ -"""Unit tests for the `models.game` module.""" +"""Unit tests for the `domain.game` module.""" # pylint: disable=R0201,C0103,C0111 import pytest -from gridcommand.models.game import Games +from gridcommand.domain import Games class TestGame: diff --git a/tests/models/test_move.py b/gridcommand/test/domain/test_move.py similarity index 90% rename from tests/models/test_move.py rename to gridcommand/test/domain/test_move.py index feb9de0..f139287 100644 --- a/tests/models/test_move.py +++ b/gridcommand/test/domain/test_move.py @@ -1,7 +1,7 @@ -"""Unit tests for the `models.move` module.""" +"""Unit tests for the `domain.move` module.""" # pylint: disable=R0201,C0103,C0111 -from gridcommand.models.move import Move +from gridcommand.domain import Move class TestMove: diff --git a/tests/models/test_player.py b/gridcommand/test/domain/test_player.py similarity index 93% rename from tests/models/test_player.py rename to gridcommand/test/domain/test_player.py index 8af41b8..4e63be6 100644 --- a/tests/models/test_player.py +++ b/gridcommand/test/domain/test_player.py @@ -1,9 +1,9 @@ -"""Unit tests for the `models.player` module.""" +"""Unit tests for the `domain.player` module.""" # pylint: disable=R0201,C0103,C0111 import pytest -from gridcommand.models.player import Player, Players +from gridcommand.domain import Player, Players class TestPlayer: diff --git a/tests/models/test_turn.py b/gridcommand/test/domain/test_turn.py similarity index 84% rename from tests/models/test_turn.py rename to gridcommand/test/domain/test_turn.py index 776e0f5..0a95f9b 100644 --- a/tests/models/test_turn.py +++ b/gridcommand/test/domain/test_turn.py @@ -1,9 +1,9 @@ -"""Unit tests for the `models.turn` module.""" +"""Unit tests for the `domain.turn` module.""" # pylint: disable=R0201,C0103,C0111 import pytest -from gridcommand.models.turn import Turns +from gridcommand.domain import Turns class TestTurn: diff --git a/tests/views/__init__.py b/gridcommand/test/routes/__init__.py similarity index 50% rename from tests/views/__init__.py rename to gridcommand/test/routes/__init__.py index 70b6c2c..220fe62 100644 --- a/tests/views/__init__.py +++ b/gridcommand/test/routes/__init__.py @@ -1,3 +1 @@ -"""Tests for the `views` package.""" - GAMES = "http://localhost/api/games/" diff --git a/tests/views/test_game.py b/gridcommand/test/routes/test_game.py similarity index 97% rename from tests/views/test_game.py rename to gridcommand/test/routes/test_game.py index 427c7e3..04d8d9b 100644 --- a/tests/views/test_game.py +++ b/gridcommand/test/routes/test_game.py @@ -16,7 +16,7 @@ def test_get_games_list_hidden(self, client): assert {'message': "Games list is hidden."} == load(response) - @patch('gridcommand.models.game.Game._generate_key', Mock(return_value='x')) + @patch('gridcommand.domain.game.Game._generate_key', Mock(return_value='x')) def test_post_new_game(self, client): response = client.post('/api/games/') assert 201 == response.status_code diff --git a/tests/views/test_move.py b/gridcommand/test/routes/test_move.py similarity index 100% rename from tests/views/test_move.py rename to gridcommand/test/routes/test_move.py diff --git a/tests/views/test_player.py b/gridcommand/test/routes/test_player.py similarity index 100% rename from tests/views/test_player.py rename to gridcommand/test/routes/test_player.py diff --git a/tests/views/test_root.py b/gridcommand/test/routes/test_root.py similarity index 100% rename from tests/views/test_root.py rename to gridcommand/test/routes/test_root.py diff --git a/tests/views/test_turn.py b/gridcommand/test/routes/test_turn.py similarity index 100% rename from tests/views/test_turn.py rename to gridcommand/test/routes/test_turn.py diff --git a/main.py b/main.py index a116167..eef2307 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,7 @@ import sys -from gridcommand import app, data +from gridcommand import app def main(): @@ -19,8 +19,6 @@ def run(debug, public): if public: kwargs.update(host='0.0.0.0') - data.load() - app.run(**kwargs) diff --git a/scent.py b/scent.py new file mode 100644 index 0000000..6e62b37 --- /dev/null +++ b/scent.py @@ -0,0 +1,47 @@ +import os +import time +import subprocess + +from sniffer.api import select_runnable, file_validator, runnable +try: + from pync import Notifier +except ImportError: + notify = None +else: + notify = Notifier.notify + + +watch_paths = ['ddd/', 'tests/'] + + +@select_runnable('python_tests') +@file_validator +def py_files(filename): + return all((filename.endswith('.py'), + not os.path.basename(filename).startswith('.'))) + + +@runnable +def python_tests(*_): + + group = int(time.time()) # unique per run + + for count, (command, title) in enumerate(( + (('make', 'test-unit'), "Unit Tests"), + (('make', 'test-int'), "Integration Tests"), + (('make', 'test-all'), "Combined Tests"), + ), start=1): + + failure = subprocess.call(command) + + if failure: + if notify: + mark = "❌" * count + notify(mark + " [FAIL] " + mark, title=title, group=group) + return False + else: + if notify: + mark = "✅" * count + notify(mark + " [PASS] " + mark, title=title, group=group) + + return True diff --git a/tests/__init__.py b/tests/__init__.py index 8b0a003..1cab291 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Tests for the `gridcommand` package.""" +"""Integration tests for the `gridcommand` package.""" diff --git a/tests/models/__init__.py b/tests/models/__init__.py deleted file mode 100644 index b71015b..0000000 --- a/tests/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the `models` package.""" From d14f817822a1376227302fee4d81ad602e55c6b3 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Fri, 29 May 2015 17:43:13 -0400 Subject: [PATCH 03/14] Use game service in routes --- gridcommand/domain/game.py | 3 -- gridcommand/routes/__init__.py | 7 +++- gridcommand/routes/formatters.py | 8 ++--- gridcommand/routes/game.py | 12 ++----- gridcommand/routes/move.py | 13 +++---- gridcommand/routes/parsers.py | 48 +++++++++++++------------- gridcommand/routes/player.py | 11 +++--- gridcommand/routes/turn.py | 9 ++--- gridcommand/services/game.py | 11 +++--- gridcommand/stores/__init__.py | 2 +- gridcommand/stores/game.py | 29 ++++++++++++++-- gridcommand/test/conftest.py | 32 +++++++++++++---- gridcommand/test/domain/test_game.py | 16 --------- gridcommand/test/test_services_game.py | 15 ++++++++ tests/conftest.py | 27 +++++++++++++++ tests/test_all.py | 15 ++++++++ 16 files changed, 170 insertions(+), 88 deletions(-) create mode 100644 gridcommand/test/test_services_game.py create mode 100644 tests/conftest.py create mode 100644 tests/test_all.py diff --git a/gridcommand/domain/game.py b/gridcommand/domain/game.py index 7af6ab9..e287b31 100644 --- a/gridcommand/domain/game.py +++ b/gridcommand/domain/game.py @@ -57,6 +57,3 @@ def advance(self): if player.turns.current: player.turns.current.done = True player.turns.append(Turn()) - - - diff --git a/gridcommand/routes/__init__.py b/gridcommand/routes/__init__.py index 58bc672..85d44bd 100644 --- a/gridcommand/routes/__init__.py +++ b/gridcommand/routes/__init__.py @@ -1,7 +1,12 @@ """API views for the application.""" -from flask.ext.api import FlaskAPI # pylint: disable=E0611,F0401 +from flask_api import FlaskAPI, exceptions + +from ..services import GameService +from ..stores import GameFileStore app = FlaskAPI(__name__) # pylint: disable=C0103 +app.service = GameService(game_store=GameFileStore()) +app.service.exceptions.missing = exceptions.NotFound from . import root, game, player, turn, move # loads routes diff --git a/gridcommand/routes/formatters.py b/gridcommand/routes/formatters.py index d626068..b3c9a2f 100644 --- a/gridcommand/routes/formatters.py +++ b/gridcommand/routes/formatters.py @@ -28,7 +28,7 @@ class PlayerFormatter(Formatter): """Serializes players into dictionaries.""" - def format_single(self, player): + def format_single(self, player, game, auth): data = {'turn': len(player.turns)} kwargs = dict(_external=True, key=game.key, color=player.color) if auth: @@ -50,7 +50,7 @@ class TurnFormatter(Formatter): """Serializes turns into dictionaries.""" - def format_single(self, turn): + def format_single(self, turn, game, player, number): kwargs = dict(_external=True, key=game.key, color=player.color, @@ -62,7 +62,7 @@ def format_single(self, turn): 'moves': moves_url, 'done': turn.done} - def format_multiple(self, turns): + def format_multiple(self, turns, game, player): return [url_for('.turns_detail', _external=True, key=game.key, color=player.color, code=player.code, number=index + 1) for index in range(len(turns))] @@ -75,7 +75,7 @@ class MoveFormatter(Formatter): def format_single(self, move): return {'count': move.count} - def format_multiple(self, moves): + def format_multiple(self, moves, game, player): return [url_for('.moves_detail', _external=True, key=game.key, color=player.color, code=player.code, begin=move.begin, end=move.end) for move in self] diff --git a/gridcommand/routes/game.py b/gridcommand/routes/game.py index c98387c..d567515 100644 --- a/gridcommand/routes/game.py +++ b/gridcommand/routes/game.py @@ -4,8 +4,6 @@ from flask import request from flask.ext.api import status, exceptions # pylint: disable=E0611,F0401 -from ..services import GameService - from . import app from .root import ROOT_URL from .formatters import game_formatter as formatter @@ -16,10 +14,6 @@ GAMES_START_URL = GAMES_DETAIL_URL + "/start" -service = GameService() -service.exceptions.missing = exceptions.NotFound - - @app.route(GAMES_LIST_URL, methods=['GET', 'POST']) def games_list(): """Create a new game.""" @@ -28,7 +22,7 @@ def games_list(): raise exceptions.PermissionDenied("Games list is hidden.") elif request.method == 'POST': - game = service.create_game() + game = app.service.create_game() return formatter.format_single(game), status.HTTP_201_CREATED else: # pragma: no cover @@ -40,7 +34,7 @@ def games_detail(key): """Retrieve a game's status.""" if request.method == 'GET': - game = service.find_game(key) + game = app.service.find_game(key) return formatter.format_single(game) else: # pragma: no cover @@ -50,7 +44,7 @@ def games_detail(key): @app.route(GAMES_START_URL, methods=['GET', 'POST']) def games_start(key): """Start a game.""" - game = games.find(key, exc=exceptions.NotFound) + game = app.service.find_game(key) if request.method == 'GET': pass diff --git a/gridcommand/routes/move.py b/gridcommand/routes/move.py index ce861cc..1464bd3 100644 --- a/gridcommand/routes/move.py +++ b/gridcommand/routes/move.py @@ -6,6 +6,7 @@ from . import app from .turn import TURNS_DETAIL_URL +from .formatters import move_formatter as formatter MOVES_LIST_URL = TURNS_DETAIL_URL + "/moves/" @@ -15,20 +16,20 @@ @app.route(MOVES_LIST_URL, methods=['GET', 'POST']) def moves_list(key, color, number): """List or create moves for a player.""" - game = games.find(key, exc=exceptions.NotFound) + game = app.service.find_game(key) player = game.players.find(color, exc=exceptions.NotFound) code = request.args.get('code') player.authenticate(code, exc=exceptions.AuthenticationFailed) turn = player.turns.find(number, exc=exceptions.NotFound) if request.method == 'GET': - return turn.moves.serialize(game, player) + return formatter.format_multiple(turn.moves, game, player) elif request.method == 'POST': move = turn.moves.set(request.data.get('begin'), request.data.get('end'), request.data.get('count')) - return move.serialize() + return formatter.format_single(move) else: # pragma: no cover assert None @@ -37,7 +38,7 @@ def moves_list(key, color, number): @app.route(MOVES_DETAIL_URL, methods=['GET', 'PUT', 'DELETE']) def moves_detail(key, color, number, begin, end): """Retrieve, update or delete a players's move.""" - game = games.find(key, exc=exceptions.NotFound) + game = app.service.find_game(key) player = game.players.find(color, exc=exceptions.NotFound) code = request.args.get('code') player.authenticate(code, exc=exceptions.AuthenticationFailed) @@ -45,11 +46,11 @@ def moves_detail(key, color, number, begin, end): if request.method == 'GET': move = turn.moves.get(begin, end) - return move.serialize() + return formatter.format_single(move) elif request.method == 'PUT': move = turn.moves.set(begin, end, request.data.get('count')) - return move.serialize() + return formatter.format_single(move) elif request.method == 'DELETE': turn.moves.delete(begin, end) diff --git a/gridcommand/routes/parsers.py b/gridcommand/routes/parsers.py index 847dceb..4be07e2 100644 --- a/gridcommand/routes/parsers.py +++ b/gridcommand/routes/parsers.py @@ -1,26 +1,26 @@ """Parses requests coming into routes.""" -from flask_api import status, exceptions -from webargs import core -from webargs.flaskparser import FlaskParser - - -parser = FlaskParser(('query', 'form', 'json', 'data')) - - -@parser.location_handler('data') -def parse_data(req, name, arg): - data = req.data - if data: - return core.get_value(data, name, arg.multiple) - else: - return core.Missing - - -@parser.error_handler -def handle_error(error): - if error.status_code == status.HTTP_400_BAD_REQUEST: - message = str(error).replace('"', "'") - raise exceptions.ParseError(message) - else: - raise error +# from flask_api import status, exceptions +# from webargs import core +# from webargs.flaskparser import FlaskParser +# +# +# parser = FlaskParser(('query', 'form', 'json', 'data')) +# +# +# @parser.location_handler('data') +# def parse_data(req, name, arg): +# data = req.data +# if data: +# return core.get_value(data, name, arg.multiple) +# else: +# return core.Missing +# +# +# @parser.error_handler +# def handle_error(error): +# if error.status_code == status.HTTP_400_BAD_REQUEST: +# message = str(error).replace('"', "'") +# raise exceptions.ParseError(message) +# else: +# raise error diff --git a/gridcommand/routes/player.py b/gridcommand/routes/player.py index a69a94b..02f327e 100644 --- a/gridcommand/routes/player.py +++ b/gridcommand/routes/player.py @@ -6,6 +6,7 @@ from . import app from .game import GAMES_DETAIL_URL +from .formatters import player_formatter as formatter PLAYERS_LIST_URL = GAMES_DETAIL_URL + "/players/" @@ -15,17 +16,17 @@ @app.route(PLAYERS_LIST_URL, methods=['GET', 'POST']) def players_list(key): """List or create players.""" - game = games.find(key, exc=exceptions.NotFound) + game = app.service.find_game(key) if request.method == 'GET': - return game.players.serialize(game) + return formatter.format_multiple(game.players, game) elif request.method == 'POST': code = str(request.data.get('code', '')) if not code: raise exceptions.ParseError("Player 'code' must be specified.") player = game.create_player(code, exc=exceptions.PermissionDenied) - return player.serialize(game, auth=True), status.HTTP_201_CREATED + return formatter.format_single(player, game, auth=code), status.HTTP_201_CREATED else: # pragma: no cover assert None @@ -38,14 +39,14 @@ def players_detail(key, color): With authentication (code=?), retrieve full details or delete. """ - game = games.find(key, exc=exceptions.NotFound) + game = app.service.find_game(key) player = game.players.find(color, exc=exceptions.NotFound) code = request.args.get('code') if code: player.authenticate(code, exc=exceptions.AuthenticationFailed) if request.method == 'GET': - return player.serialize(game, auth=code) + return formatter.format_single(player, game, auth=code) elif request.method == 'DELETE': if not code: diff --git a/gridcommand/routes/turn.py b/gridcommand/routes/turn.py index b1ace2d..863ecd0 100644 --- a/gridcommand/routes/turn.py +++ b/gridcommand/routes/turn.py @@ -6,6 +6,7 @@ from . import app from .player import PLAYERS_DETAIL_URL +from .formatters import turn_formatter as formatter TUNRS_LIST_URL = PLAYERS_DETAIL_URL + "/turns/" @@ -15,13 +16,13 @@ @app.route(TUNRS_LIST_URL, methods=['GET']) def turns_list(key, color): """List turns for a player.""" - game = games.find(key, exc=exceptions.NotFound) + game = app.service.find_game(key) player = game.players.find(color, exc=exceptions.NotFound) code = request.args.get('code') player.authenticate(code, exc=exceptions.AuthenticationFailed) if request.method == 'GET': - return player.turns.serialize(game, player) + return formatter.format_multiple(player.turns, game, player) else: # pragma: no cover assert None @@ -30,14 +31,14 @@ def turns_list(key, color): @app.route(TURNS_DETAIL_URL, methods=['GET']) def turns_detail(key, color, number): """Retrieve a players's turn.""" - game = games.find(key, exc=exceptions.NotFound) + game = app.service.find_game(key) player = game.players.find(color, exc=exceptions.NotFound) code = request.args.get('code') player.authenticate(code, exc=exceptions.AuthenticationFailed) if request.method == 'GET': turn = player.turns.find(number, exc=exceptions.NotFound) - return turn.serialize(game, player, number) + return formatter.format_single(turn, game, player, number) else: # pragma: no cover assert None diff --git a/gridcommand/services/game.py b/gridcommand/services/game.py index c40709d..23a2357 100644 --- a/gridcommand/services/game.py +++ b/gridcommand/services/game.py @@ -1,17 +1,16 @@ from ..domain import Game -from ..stores import GameStore from .base import Service class GameService(Service): - def __init__(self, game_store=None, *args, **kwargs): - super().__init__(*args, **kwargs) - self.game_store = game_store or GameStore() + def __init__(self, game_store, **kwargs): + super().__init__(**kwargs) + self.game_store = game_store - def create_game(self): - game = Game() + def create_game(self, key=None): + game = Game(key=key) self.game_store.create(game) return game diff --git a/gridcommand/stores/__init__.py b/gridcommand/stores/__init__.py index 574c204..f86fb88 100644 --- a/gridcommand/stores/__init__.py +++ b/gridcommand/stores/__init__.py @@ -1,3 +1,3 @@ """Persistence models for the application.""" -from .game import GameStore +from .game import GameMemoryStore, GameFileStore diff --git a/gridcommand/stores/game.py b/gridcommand/stores/game.py index 60814c5..bb0f659 100644 --- a/gridcommand/stores/game.py +++ b/gridcommand/stores/game.py @@ -53,9 +53,35 @@ class GameModel: def __init__(self, key): self.key = key + self.players = Players() + self.turn = 0 -class GameStore(Store): +class GameMemoryStore(Store): + + def __init__(self): + self._games = {} + + def create(self, game): + self._games[game.key] = game + + def read(self, key): + try: + return self._games[key] + except KeyError: + return None + + def update(self, game): + self._games[game.key] = game + + def delete(self, game): + try: + del self._games[game.key] + except KeyError: + pass + + +class GameFileStore(Store): def create(self, game): model = GameModel(key=game.key) @@ -106,4 +132,3 @@ def delete(self, game): path = os.path.join("data", "games", game.key + ".yml") # TODO: move this to settings? if os.path.exists(path): os.remove(path) - diff --git a/gridcommand/test/conftest.py b/gridcommand/test/conftest.py index 5872535..fa80469 100644 --- a/gridcommand/test/conftest.py +++ b/gridcommand/test/conftest.py @@ -11,6 +11,8 @@ from gridcommand.common import logger from gridcommand import app from gridcommand import domain +from gridcommand import services +from gridcommand import stores ENV = 'TEST_INTEGRATION' # environment variable to enable integration tests REASON = "'{0}' variable not set".format(ENV) @@ -23,6 +25,13 @@ log = logger(__name__) +def load(response): + """Convert a response's binary data (JSON) to a dictionary.""" + text = response.data.decode('utf-8') + if text: + return json.loads(text) + + def pytest_runtest_setup(item): """pytest setup.""" if 'integration' in item.keywords: @@ -34,21 +43,27 @@ def pytest_runtest_setup(item): yorm.settings.fake = True +# Flask app fixtures + + @pytest.fixture def client(request): """Fixture to create a test client for the application.""" app.config['TESTING'] = True app.config['DEBUG'] = True + app.service.game_store = stores.GameMemoryStore() test_client = app.test_client() return test_client +# Domain model fixtures + + @pytest.fixture def game(): """Fixture to create an empty game.""" log.info("creating an empty game...") - game = domain.Game(GAME_KEY) - os.system("touch data/games/my_game.yml") + game = app.service.create_game(key=GAME_KEY) return game @@ -112,8 +127,11 @@ def turns(game_player): return game_player.players[0].turns -def load(response): - """Convert a response's binary data (JSON) to a dictionary.""" - text = response.data.decode('utf-8') - if text: - return json.loads(text) +# Service fixtures + + +@pytest.fixture +def game_service(): + game_store = stores.GameMemoryStore() + service = services.GameService(game_store=game_store) + return service diff --git a/gridcommand/test/domain/test_game.py b/gridcommand/test/domain/test_game.py index 2d42e06..2488164 100644 --- a/gridcommand/test/domain/test_game.py +++ b/gridcommand/test/domain/test_game.py @@ -3,8 +3,6 @@ import pytest -from gridcommand.domain import Games - class TestGame: @@ -44,17 +42,3 @@ def test_advance_adds_turn(self, game_started): game_started.advance() assert 2 == len(game_started.players[0].turns) assert 2 == len(game_started.players[1].turns) - - -class TestGames: - - def test_find_match(self): - games = Games() - game = games.create() - game2 = games.find(game.key) - assert game is game2 - - def test_find_missing(self): - games = Games() - with pytest.raises(ValueError): - games.find('abc123') diff --git a/gridcommand/test/test_services_game.py b/gridcommand/test/test_services_game.py new file mode 100644 index 0000000..0a136d2 --- /dev/null +++ b/gridcommand/test/test_services_game.py @@ -0,0 +1,15 @@ +# pylint: disable=R0201,C0103,C0111 + +import pytest + + +class TestGameService: + + def test_find_match(self, game_service): + game = game_service.create_game() + game2 = game_service.find_game(game.key) + assert game is game2 + + def test_find_missing(self, game_service): + with pytest.raises(KeyError): + game_service.find_game('abc123') diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5725793 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +"""Configuration for pytest.""" +# pylint: disable=W0613,W0621 +import json + +import pytest + +from gridcommand.common import logger +from gridcommand import app + + +log = logger(__name__) + + +def load(response): + """Convert a response's binary data (JSON) to a dictionary.""" + text = response.data.decode('utf-8') + if text: + return json.loads(text) + + +@pytest.fixture +def client(request): + """Fixture to create a test client for the application.""" + app.config['TESTING'] = True + app.config['DEBUG'] = True + test_client = app.test_client() + return test_client diff --git a/tests/test_all.py b/tests/test_all.py new file mode 100644 index 0000000..e2bee25 --- /dev/null +++ b/tests/test_all.py @@ -0,0 +1,15 @@ +"""Unit tests for the `views.game` module.""" +# pylint: disable=W0613,R0201,C0103,C0111 + + +from .conftest import load + + +def test_create_game(client): + + response = client.post('/api/games/') + assert 201 == response.status_code + url = load(response)['uri'] + + response = client.get(url) + assert 200 == response.status_code From bce7e1df4dee3bcaa2bc95235a11cde2643fcdef Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Fri, 29 May 2015 18:19:23 -0400 Subject: [PATCH 04/14] Add additional tests + cleanup --- Makefile | 6 +++--- gridcommand/domain/player.py | 4 +++- gridcommand/domain/types.py | 21 --------------------- gridcommand/routes/player.py | 3 +-- gridcommand/stores/base.py | 2 +- gridcommand/test/domain/test_game.py | 3 +++ gridcommand/test/domain/test_move.py | 6 +++++- gridcommand/test/domain/test_player.py | 8 ++++++++ gridcommand/test/domain/test_turn.py | 8 ++++++++ gridcommand/test/routes/test_player.py | 11 ++++++++++- gridcommand/test/test_stores_game.py | 6 ++++++ scent.py | 2 +- 12 files changed, 49 insertions(+), 31 deletions(-) delete mode 100644 gridcommand/domain/types.py create mode 100644 gridcommand/test/test_stores_game.py diff --git a/Makefile b/Makefile index 75d49c9..2ad07e7 100644 --- a/Makefile +++ b/Makefile @@ -11,9 +11,9 @@ ifndef TRAVIS endif # Test settings -UNIT_TEST_COVERAGE := 82 -INTEGRATION_TEST_COVERAGE := 82 -COMBINED_TEST_COVERAGE := 82 +UNIT_TEST_COVERAGE := 76 +INTEGRATION_TEST_COVERAGE := 45 +COMBINED_TEST_COVERAGE := 76 # System paths PLATFORM := $(shell python -c 'import sys; print(sys.platform)') diff --git a/gridcommand/domain/player.py b/gridcommand/domain/player.py index f25e875..e4710c7 100644 --- a/gridcommand/domain/player.py +++ b/gridcommand/domain/player.py @@ -24,8 +24,10 @@ def __eq__(self, other): return self.color == other.color def authenticate(self, code, exc=ValueError): + if not code: + raise exc("Player code required.") if code != self.code: - raise exc("The code '{}' is invalid.".format(code)) + raise exc("Player code '{}' is invalid.".format(code)) class Players(list): diff --git a/gridcommand/domain/types.py b/gridcommand/domain/types.py deleted file mode 100644 index bf01f20..0000000 --- a/gridcommand/domain/types.py +++ /dev/null @@ -1,21 +0,0 @@ -import time - - -class Widget: - - def __init__(self, number, name): - self.number = number - self.name = name - self.inspections = [] - - def inspect(self, status=True): - inspection = Inspection(status=status) - self.inspections.append(inspection) - return inspection - - -class Inspection: - - def __init__(self, status, stamp=None): - self.status = status - self.stamp = stamp or int(time.time()) diff --git a/gridcommand/routes/player.py b/gridcommand/routes/player.py index 02f327e..25897e9 100644 --- a/gridcommand/routes/player.py +++ b/gridcommand/routes/player.py @@ -49,8 +49,7 @@ def players_detail(key, color): return formatter.format_single(player, game, auth=code) elif request.method == 'DELETE': - if not code: - raise exceptions.AuthenticationFailed + player.authenticate(code, exc=exceptions.AuthenticationFailed) game.delete_player(color, exc=exceptions.PermissionDenied) return '', status.HTTP_204_NO_CONTENT diff --git a/gridcommand/stores/base.py b/gridcommand/stores/base.py index 45851b5..2f75525 100644 --- a/gridcommand/stores/base.py +++ b/gridcommand/stores/base.py @@ -1,7 +1,7 @@ from abc import ABCMeta, abstractmethod -class Store(metaclass=ABCMeta): +class Store(metaclass=ABCMeta): # pragma: no cover (abstract) @abstractmethod def create(self, item): diff --git a/gridcommand/test/domain/test_game.py b/gridcommand/test/domain/test_game.py index 2488164..2cb2504 100644 --- a/gridcommand/test/domain/test_game.py +++ b/gridcommand/test/domain/test_game.py @@ -6,6 +6,9 @@ class TestGame: + def test_repr(self, game): + assert "" == repr(game) + def test_start_triggers_turn_1(self, game_players): assert 0 == game_players.turn game_players.start() diff --git a/gridcommand/test/domain/test_move.py b/gridcommand/test/domain/test_move.py index f139287..8beb081 100644 --- a/gridcommand/test/domain/test_move.py +++ b/gridcommand/test/domain/test_move.py @@ -7,11 +7,15 @@ class TestMove: def test_init(self): - move = Move(1, 2) + move = Move(1, 2) # TODO: create fixture assert 1 == move.begin assert 2 == move.end assert 0 == move.count + def test_repr(self): + move = Move(1, 2) # TODO: create fixture + assert "" == repr(move) + def test_eq_if_begin_and_end_both_match(self): assert Move(1, 2) == Move(1, 2) diff --git a/gridcommand/test/domain/test_player.py b/gridcommand/test/domain/test_player.py index 4e63be6..afa118c 100644 --- a/gridcommand/test/domain/test_player.py +++ b/gridcommand/test/domain/test_player.py @@ -8,6 +8,9 @@ class TestPlayer: + def test_repr(self, player): + assert "" == repr(player) + def test_eq_if_colors_match(self): assert Player('red') == Player('red') assert Player('red') != Player('blue') @@ -20,6 +23,11 @@ def test_authentication(self, player): class TestPlayers: + def test_repr(self, players): + assert "<2 players>" == repr(players) + players.pop() + assert "<1 player>" == repr(players) + def test_create_unique_colors(self): players = Players() player1 = players.create('abc') diff --git a/gridcommand/test/domain/test_turn.py b/gridcommand/test/domain/test_turn.py index 0a95f9b..ef6ac0d 100644 --- a/gridcommand/test/domain/test_turn.py +++ b/gridcommand/test/domain/test_turn.py @@ -12,9 +12,17 @@ def test_init(self, turn): assert not turn.done assert not turn.moves + def test_repr(self, turn): + assert "" == repr(turn) + class TestTurns: + def test_repr(self, turns): + assert "<2 turns>" == repr(turns) + turns.pop() + assert "<1 turn>" == repr(turns) + def test_current(self, turns): assert turns.current diff --git a/gridcommand/test/routes/test_player.py b/gridcommand/test/routes/test_player.py index 0af0e23..fbd7859 100644 --- a/gridcommand/test/routes/test_player.py +++ b/gridcommand/test/routes/test_player.py @@ -50,6 +50,13 @@ def test_get_missing_player(self, client, game): assert {'message': "The player 'red' does not exist."} == load(response) + def test_delete_player(self, client, player): + response = client.delete('/api/games/my_game/players/red') + assert 401 == response.status_code + assert load(response) == { + 'message': "Player code required.", + } + class TestPlayerWithAuth: @@ -63,7 +70,9 @@ def test_get_existing_player(self, client, player): def test_get_existing_player_with_bad_auth(self, client, player): response = client.get('/api/games/my_game/players/red?code=invalid') assert 401 == response.status_code - assert {'message': "The code 'invalid' is invalid."} + assert { + 'message': "Player code 'invalid' is invalid." + } == load(response) def test_delete_player(self, client, player): response = client.delete('/api/games/my_game/players/red?code=my_code') diff --git a/gridcommand/test/test_stores_game.py b/gridcommand/test/test_stores_game.py new file mode 100644 index 0000000..7b3c41d --- /dev/null +++ b/gridcommand/test/test_stores_game.py @@ -0,0 +1,6 @@ +class TestGameMemoryStore: + pass + + +class TestGameFileStore: + pass diff --git a/scent.py b/scent.py index 6e62b37..2e23642 100644 --- a/scent.py +++ b/scent.py @@ -11,7 +11,7 @@ notify = Notifier.notify -watch_paths = ['ddd/', 'tests/'] +watch_paths = ['gridcommand/', 'tests/'] @select_runnable('python_tests') From edfac3a1a2abcd498e75f3a0304e6cb747f6a638 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Fri, 29 May 2015 18:57:28 -0700 Subject: [PATCH 05/14] Add tests for stores --- Makefile | 8 +-- gridcommand/domain/game.py | 6 ++ gridcommand/routes/formatters.py | 6 +- gridcommand/routes/move.py | 2 +- gridcommand/routes/player.py | 3 +- gridcommand/stores/game.py | 11 ++-- gridcommand/test/conftest.py | 14 +---- gridcommand/test/routes/test_turn.py | 14 +++-- gridcommand/test/test_stores_game.py | 86 ++++++++++++++++++++++++++-- 9 files changed, 118 insertions(+), 32 deletions(-) diff --git a/Makefile b/Makefile index 2ad07e7..bc7a107 100644 --- a/Makefile +++ b/Makefile @@ -11,9 +11,9 @@ ifndef TRAVIS endif # Test settings -UNIT_TEST_COVERAGE := 76 -INTEGRATION_TEST_COVERAGE := 45 -COMBINED_TEST_COVERAGE := 76 +UNIT_TEST_COVERAGE := 83 +INTEGRATION_TEST_COVERAGE := 44 +COMBINED_TEST_COVERAGE := 83 # System paths PLATFORM := $(shell python -c 'import sys; print(sys.platform)') @@ -191,7 +191,7 @@ pep8: depends-ci pep257: depends-ci # D102: docstring missing (checked by PyLint) # D202: No blank lines allowed *after* function docstring - $(PEP257) $(PACKAGE) --ignore=D102,D202 + $(PEP257) $(PACKAGE) --ignore=D100,D101,D102,D202 .PHONY: pylint pylint: depends-ci diff --git a/gridcommand/domain/game.py b/gridcommand/domain/game.py index e287b31..9939457 100644 --- a/gridcommand/domain/game.py +++ b/gridcommand/domain/game.py @@ -25,6 +25,12 @@ def __init__(self, key=None): def __repr__(self): return "".format(self.key) + def __eq__(self, other): + return self.key == other.key + + def __ne__(self, other): + return not self == other + @staticmethod def _generate_key(): return ''.join(random.choice(Game.KEY_CHARS) diff --git a/gridcommand/routes/formatters.py b/gridcommand/routes/formatters.py index b3c9a2f..dd8e088 100644 --- a/gridcommand/routes/formatters.py +++ b/gridcommand/routes/formatters.py @@ -5,6 +5,10 @@ from .base import Formatter +# TODO: figure out a better way to serialize objects without parent objects +# pylint: disable=W0221 + + class GameFormatter(Formatter): """Serializes games into dictionaries.""" @@ -75,7 +79,7 @@ class MoveFormatter(Formatter): def format_single(self, move): return {'count': move.count} - def format_multiple(self, moves, game, player): + def format_multiple(self, game, player): return [url_for('.moves_detail', _external=True, key=game.key, color=player.color, code=player.code, begin=move.begin, end=move.end) for move in self] diff --git a/gridcommand/routes/move.py b/gridcommand/routes/move.py index 1464bd3..0ac5e66 100644 --- a/gridcommand/routes/move.py +++ b/gridcommand/routes/move.py @@ -23,7 +23,7 @@ def moves_list(key, color, number): turn = player.turns.find(number, exc=exceptions.NotFound) if request.method == 'GET': - return formatter.format_multiple(turn.moves, game, player) + return formatter.format_multiple(game, player) elif request.method == 'POST': move = turn.moves.set(request.data.get('begin'), diff --git a/gridcommand/routes/player.py b/gridcommand/routes/player.py index 25897e9..2b80492 100644 --- a/gridcommand/routes/player.py +++ b/gridcommand/routes/player.py @@ -26,7 +26,8 @@ def players_list(key): if not code: raise exceptions.ParseError("Player 'code' must be specified.") player = game.create_player(code, exc=exceptions.PermissionDenied) - return formatter.format_single(player, game, auth=code), status.HTTP_201_CREATED + return formatter.format_single(player, game, auth=code), \ + status.HTTP_201_CREATED else: # pragma: no cover assert None diff --git a/gridcommand/stores/game.py b/gridcommand/stores/game.py index bb0f659..e0c85c1 100644 --- a/gridcommand/stores/game.py +++ b/gridcommand/stores/game.py @@ -66,10 +66,13 @@ def create(self, game): self._games[game.key] = game def read(self, key): - try: - return self._games[key] - except KeyError: - return None + if key: + try: + return self._games[key] + except KeyError: + return None + else: + return list(self._games.values()) def update(self, game): self._games[game.key] = game diff --git a/gridcommand/test/conftest.py b/gridcommand/test/conftest.py index fa80469..050ddb0 100644 --- a/gridcommand/test/conftest.py +++ b/gridcommand/test/conftest.py @@ -1,12 +1,10 @@ """Configuration for pytest.""" # pylint: disable=W0613,W0621 -import os import json from unittest.mock import patch import pytest -import yorm from gridcommand.common import logger from gridcommand import app @@ -32,17 +30,6 @@ def load(response): return json.loads(text) -def pytest_runtest_setup(item): - """pytest setup.""" - if 'integration' in item.keywords: - if not os.getenv(ENV): - pytest.skip(REASON) - else: - yorm.settings.fake = False - else: - yorm.settings.fake = True - - # Flask app fixtures @@ -132,6 +119,7 @@ def turns(game_player): @pytest.fixture def game_service(): + """Fixture to create a game service with memory store.""" game_store = stores.GameMemoryStore() service = services.GameService(game_store=game_store) return service diff --git a/gridcommand/test/routes/test_turn.py b/gridcommand/test/routes/test_turn.py index 810a42f..87c986b 100644 --- a/gridcommand/test/routes/test_turn.py +++ b/gridcommand/test/routes/test_turn.py @@ -6,12 +6,18 @@ from . import GAMES +TURNS = GAMES + "my_game/players/red/turns/" + + class TestTurns: def test_get_all_turns(self, client, turn): - response = client.get('/api/games/my_game/players/red/turns/?code=my_code') + response = client.get('/api/games/' + 'my_game/players/red/turns/?code=my_code') assert 200 == response.status_code - assert [GAMES + "my_game/players/red/turns/1?code=my_code"] == load(response) + assert load(response) == [ + GAMES + "my_game/players/red/turns/1?code=my_code", + ] class TestTurn: @@ -20,6 +26,6 @@ def test_get_existing_turn(self, client, turn): response = client.get('/api/games/' 'my_game/players/red/turns/1?code=my_code') assert 200 == response.status_code - assert {'uri': GAMES + "my_game/players/red/turns/1?code=my_code", - 'moves': GAMES + "my_game/players/red/turns/1/moves/?code=my_code", + assert {'uri': TURNS + "1?code=my_code", + 'moves': TURNS + "1/moves/?code=my_code", 'done': False} == load(response) diff --git a/gridcommand/test/test_stores_game.py b/gridcommand/test/test_stores_game.py index 7b3c41d..66be5c3 100644 --- a/gridcommand/test/test_stores_game.py +++ b/gridcommand/test/test_stores_game.py @@ -1,6 +1,84 @@ -class TestGameMemoryStore: - pass +# pylint: disable=W0613,R0201,C0111 +import os +import tempfile -class TestGameFileStore: - pass +import pytest + +from gridcommand.stores import GameMemoryStore, GameFileStore +from gridcommand import domain + + +@pytest.mark.parametrize("store_class", [GameMemoryStore, GameFileStore]) +class TestGameStore: + + def setup_method(self, method): + path = tempfile.mkdtemp() + os.chdir(path) + + def test_create_and_read(self, store_class): + store = store_class() + + game = domain.Game() + store.create(game) + + game2 = store.read(game.key) + assert game == game2 + + def test_read_single_unknown(self, store_class): + store = store_class() + + game = store.read('unknown_key') + assert game is None + + def test_read_multiple(self, store_class): + store = store_class() + + game = domain.Game() + store.create(game) + game = domain.Game() + store.create(game) + + games = store.read(key=None) + + assert len(games) == 2 + + def test_read_multiple_empty(self, store_class): + store = store_class() + + games = store.read(key=None) + + assert games == [] + + def test_update_existing(self, store_class): + store = store_class() + + game = domain.Game() + store.create(game) + + game.turn = 42 + store.update(game) + + game2 = store.read(game.key) + assert game2.turn == 42 + + def test_delete_existing(self, store_class): + store = store_class() + + game = domain.Game() + store.create(game) + + store.delete(game) + + game2 = store.read(game.key) + assert game2 is None + + def test_delete_missing(self, store_class): + store = store_class() + + game = domain.Game() + + store.delete(game) + + game2 = store.read(game.key) + assert game2 is None From 887afbde84a83a2c3d8f7f1a4e58a62908b295b6 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Mon, 1 Jun 2015 14:29:34 -0700 Subject: [PATCH 06/14] Fully map PM to DM --- gridcommand/routes/formatters.py | 4 +- gridcommand/routes/move.py | 2 +- gridcommand/stores/game.py | 84 +++++++++++++++++----------- gridcommand/test/domain/test_game.py | 9 +++ gridcommand/test/test_stores_game.py | 3 + tests/conftest.py | 1 + tests/test_all.py | 18 +++++- 7 files changed, 83 insertions(+), 38 deletions(-) diff --git a/gridcommand/routes/formatters.py b/gridcommand/routes/formatters.py index dd8e088..575b7c6 100644 --- a/gridcommand/routes/formatters.py +++ b/gridcommand/routes/formatters.py @@ -79,10 +79,10 @@ class MoveFormatter(Formatter): def format_single(self, move): return {'count': move.count} - def format_multiple(self, game, player): + def format_multiple(self, moves, game, player): return [url_for('.moves_detail', _external=True, key=game.key, color=player.color, code=player.code, - begin=move.begin, end=move.end) for move in self] + begin=move.begin, end=move.end) for move in moves] game_formatter = GameFormatter() diff --git a/gridcommand/routes/move.py b/gridcommand/routes/move.py index 0ac5e66..1464bd3 100644 --- a/gridcommand/routes/move.py +++ b/gridcommand/routes/move.py @@ -23,7 +23,7 @@ def moves_list(key, color, number): turn = player.turns.find(number, exc=exceptions.NotFound) if request.method == 'GET': - return formatter.format_multiple(game, player) + return formatter.format_multiple(turn.moves, game, player) elif request.method == 'POST': move = turn.moves.set(request.data.get('begin'), diff --git a/gridcommand/stores/game.py b/gridcommand/stores/game.py index e0c85c1..83f6e59 100644 --- a/gridcommand/stores/game.py +++ b/gridcommand/stores/game.py @@ -3,8 +3,7 @@ import yorm from .. import common -from ..domain import Game - +from .. import domain from .base import Store @@ -14,48 +13,77 @@ @yorm.attr(begin=yorm.converters.Integer) @yorm.attr(end=yorm.converters.Integer) @yorm.attr(count=yorm.converters.Integer) -class Move(yorm.converters.AttributeDictionary): +class MoveFileModel(yorm.converters.AttributeDictionary): pass -@yorm.attr(all=Move) -class Moves(yorm.converters.SortedList): +@yorm.attr(all=MoveFileModel) +class MovesFileModel(yorm.converters.SortedList): pass -@yorm.attr(moves=Moves) +@yorm.attr(moves=MovesFileModel) @yorm.attr(done=yorm.converters.Boolean) -class Turn(yorm.converters.AttributeDictionary): +class TurnFileModel(yorm.converters.AttributeDictionary): pass -@yorm.attr(all=Turn) -class Turns(yorm.converters.List): +@yorm.attr(all=TurnFileModel) +class TurnsFileModel(yorm.converters.List): pass @yorm.attr(color=yorm.converters.String) @yorm.attr(code=yorm.converters.String) -@yorm.attr(turns=Turns) -class Player(yorm.converters.AttributeDictionary): +@yorm.attr(turns=TurnsFileModel) +class PlayerFileModel(yorm.converters.AttributeDictionary): pass -@yorm.attr(all=Player) -class Players(yorm.converters.List): +@yorm.attr(all=PlayerFileModel) +class PlayersFileModel(yorm.converters.List): pass -@yorm.attr(players=Players) +@yorm.attr(players=PlayersFileModel) @yorm.attr(turn=yorm.converters.Integer) @yorm.sync("data/games/{self.key}.yml") -class GameModel: +class GameFileModel: def __init__(self, key): self.key = key - self.players = Players() + self.players = PlayersFileModel() self.turn = 0 + def from_domain(self, game): + self.players = game.players + self.turn = game.turn + + def to_domain(self): + game = domain.Game(key=self.key) + + for player_model in self.players: + player = domain.Player(color=player_model.color, + code=player_model.code) + + for turn_model in player_model.turns: + turn = domain.Turn() + + for move_model in turn_model.moves: + move = domain.Move(begin=move_model.begin, + end=move_model.end, + count=move_model.count) + + turn.moves.append(move) + + player.turns.append(turn) + + game.players.append(player) + + game.turn = self.turn + + return game + class GameMemoryStore(Store): @@ -87,9 +115,8 @@ def delete(self, game): class GameFileStore(Store): def create(self, game): - model = GameModel(key=game.key) - model.players = game.players - model.turn = game.turn + model = GameFileModel(key=game.key) + model.from_domain(game) def read(self, key): if key: @@ -98,11 +125,8 @@ def read(self, key): if not os.path.exists(path): return None - model = GameModel(key) - - game = Game(key=model.key) - game.players = model.players - game.turn = model.turn + model = GameFileModel(key) + game = model.to_domain() return game else: @@ -113,11 +137,8 @@ def read(self, key): for filename in os.listdir(path): key = filename.split('.')[0] - model = GameModel(key) - - game = Game(key=model.key) - game.players = model.players - game.turn = model.turn + model = GameFileModel(key) + game = model.to_domain() games.append(game) @@ -127,9 +148,8 @@ def update(self, game): path = os.path.join("data", "games", game.key + ".yml") # TODO: move this to settings? assert os.path.exists(path) - model = GameModel(game.key) - model.players = game.players - model.turn = game.turn + model = GameFileModel(game.key) + model.from_domain(game) def delete(self, game): path = os.path.join("data", "games", game.key + ".yml") # TODO: move this to settings? diff --git a/gridcommand/test/domain/test_game.py b/gridcommand/test/domain/test_game.py index 2cb2504..1b3ed7b 100644 --- a/gridcommand/test/domain/test_game.py +++ b/gridcommand/test/domain/test_game.py @@ -3,12 +3,21 @@ import pytest +from gridcommand.domain import Game + class TestGame: def test_repr(self, game): assert "" == repr(game) + def test_eq(self): + game1 = Game('abc123') + game2 = Game('abc123') + game3 = Game('def456') + assert game1 == game2 + assert game1 != game3 + def test_start_triggers_turn_1(self, game_players): assert 0 == game_players.turn game_players.start() diff --git a/gridcommand/test/test_stores_game.py b/gridcommand/test/test_stores_game.py index 66be5c3..bca1db6 100644 --- a/gridcommand/test/test_stores_game.py +++ b/gridcommand/test/test_stores_game.py @@ -20,6 +20,9 @@ def test_create_and_read(self, store_class): store = store_class() game = domain.Game() + game.players.append(domain.Player('red')) + game.players[0].turns.append(domain.Turn()) + game.players[0].turns[0].moves.append(domain.Move(0, 0)) store.create(game) game2 = store.read(game.key) diff --git a/tests/conftest.py b/tests/conftest.py index 5725793..de4c8fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ """Configuration for pytest.""" # pylint: disable=W0613,W0621 + import json import pytest diff --git a/tests/test_all.py b/tests/test_all.py index e2bee25..37de9e2 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -5,11 +5,23 @@ from .conftest import load -def test_create_game(client): +def test_create_game_and_players(client): + + # Create a game response = client.post('/api/games/') assert 201 == response.status_code - url = load(response)['uri'] + game_url = load(response)['uri'] - response = client.get(url) + response = client.get(game_url) assert 200 == response.status_code + players_url = load(response)['players'] + + # Create two players + + response = client.post(players_url, data={'code': '1'}) + assert 201 == response.status_code + player1_url = load(response)['uri'] + response = client.post(players_url, data={'code': '2'}) + assert 201 == response.status_code + player2_url = load(response)['uri'] From 5b58b0b3c9486fccfcbe15a645f03bb6219b76e5 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Mon, 1 Jun 2015 23:58:41 -0400 Subject: [PATCH 07/14] Fully map DM to PM --- Makefile | 6 +++--- gridcommand/routes/player.py | 1 + gridcommand/services/base.py | 1 + gridcommand/services/game.py | 3 +++ gridcommand/stores/game.py | 30 +++++++++++++++++++++++++--- gridcommand/test/conftest.py | 3 --- gridcommand/test/test_stores_game.py | 3 ++- tests/conftest.py | 7 ++++++- 8 files changed, 43 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index bc7a107..0622ec2 100644 --- a/Makefile +++ b/Makefile @@ -11,9 +11,9 @@ ifndef TRAVIS endif # Test settings -UNIT_TEST_COVERAGE := 83 -INTEGRATION_TEST_COVERAGE := 44 -COMBINED_TEST_COVERAGE := 83 +UNIT_TEST_COVERAGE := 84 +INTEGRATION_TEST_COVERAGE := 49 +COMBINED_TEST_COVERAGE := 84 # System paths PLATFORM := $(shell python -c 'import sys; print(sys.platform)') diff --git a/gridcommand/routes/player.py b/gridcommand/routes/player.py index 2b80492..e0936bf 100644 --- a/gridcommand/routes/player.py +++ b/gridcommand/routes/player.py @@ -23,6 +23,7 @@ def players_list(key): elif request.method == 'POST': code = str(request.data.get('code', '')) + # TODO: replace with a service call if not code: raise exceptions.ParseError("Player 'code' must be specified.") player = game.create_player(code, exc=exceptions.PermissionDenied) diff --git a/gridcommand/services/base.py b/gridcommand/services/base.py index f3e49dd..1cb9c4a 100644 --- a/gridcommand/services/base.py +++ b/gridcommand/services/base.py @@ -5,6 +5,7 @@ class Exceptions: duplicate = ValueError missing = KeyError + invalid = ValueError class Service(metaclass=ABCMeta): diff --git a/gridcommand/services/game.py b/gridcommand/services/game.py index 23a2357..c5c1553 100644 --- a/gridcommand/services/game.py +++ b/gridcommand/services/game.py @@ -20,3 +20,6 @@ def find_game(self, key): msg = "The game '{}' does not exist.".format(key) raise self.exceptions.missing(msg) return game + + def create_player(self, game, code): + raise NotImplementedError("TODO: implement method") diff --git a/gridcommand/stores/game.py b/gridcommand/stores/game.py index 83f6e59..88bd270 100644 --- a/gridcommand/stores/game.py +++ b/gridcommand/stores/game.py @@ -25,7 +25,10 @@ class MovesFileModel(yorm.converters.SortedList): @yorm.attr(moves=MovesFileModel) @yorm.attr(done=yorm.converters.Boolean) class TurnFileModel(yorm.converters.AttributeDictionary): - pass + + def __init__(self): + self.moves = [] + self.done = False @yorm.attr(all=TurnFileModel) @@ -37,7 +40,11 @@ class TurnsFileModel(yorm.converters.List): @yorm.attr(code=yorm.converters.String) @yorm.attr(turns=TurnsFileModel) class PlayerFileModel(yorm.converters.AttributeDictionary): - pass + + def __init__(self, color, code): + self.color = color + self.code = code + self.turns = [] @yorm.attr(all=PlayerFileModel) @@ -56,7 +63,24 @@ def __init__(self, key): self.turn = 0 def from_domain(self, game): - self.players = game.players + for player in game.players: + player_model = PlayerFileModel(color=player.color, + code=player.code) + + for turn in player.turns: + turn_model = TurnFileModel() + + for move in turn.moves: + move_model = MoveFileModel(begin=move.begin, + end=move.end, + count=move.count) + + turn_model.moves.append(move_model) + + player_model.turns.append(turn_model) + + self.players.append(player_model) + self.turn = game.turn def to_domain(self): diff --git a/gridcommand/test/conftest.py b/gridcommand/test/conftest.py index 050ddb0..610b51f 100644 --- a/gridcommand/test/conftest.py +++ b/gridcommand/test/conftest.py @@ -12,9 +12,6 @@ from gridcommand import services from gridcommand import stores -ENV = 'TEST_INTEGRATION' # environment variable to enable integration tests -REASON = "'{0}' variable not set".format(ENV) - GAME_KEY = 'my_game' PLAYER_CODE = 'my_code' PLAYERS_COLORS = ['red', 'blue'] diff --git a/gridcommand/test/test_stores_game.py b/gridcommand/test/test_stores_game.py index bca1db6..08bfbb3 100644 --- a/gridcommand/test/test_stores_game.py +++ b/gridcommand/test/test_stores_game.py @@ -19,7 +19,7 @@ def setup_method(self, method): def test_create_and_read(self, store_class): store = store_class() - game = domain.Game() + game = domain.Game('test_game') game.players.append(domain.Player('red')) game.players[0].turns.append(domain.Turn()) game.players[0].turns[0].moves.append(domain.Move(0, 0)) @@ -27,6 +27,7 @@ def test_create_and_read(self, store_class): game2 = store.read(game.key) assert game == game2 + assert game2.players[0].turns[0].moves[0].begin == 0 def test_read_single_unknown(self, store_class): store = store_class() diff --git a/tests/conftest.py b/tests/conftest.py index de4c8fe..99fa19b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ # pylint: disable=W0613,W0621 import json +import logging import pytest @@ -16,7 +17,11 @@ def load(response): """Convert a response's binary data (JSON) to a dictionary.""" text = response.data.decode('utf-8') if text: - return json.loads(text) + data = json.loads(text) + else: + data = None + logging.debug("response: %r", data) + return data @pytest.fixture From f85d84ac748336c8eea19466c0384c9de84f551a Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Mon, 1 Jun 2015 23:58:56 -0400 Subject: [PATCH 08/14] Add failing test for missing service calls --- tests/test_all.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/test_all.py b/tests/test_all.py index 37de9e2..75a8ef1 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -7,21 +7,43 @@ def test_create_game_and_players(client): + # Attempt to get the games list + + response = client.get('/api/games/') + assert 403 == response.status_code + # Create a game response = client.post('/api/games/') assert 201 == response.status_code game_url = load(response)['uri'] + game_start_url = load(response)['start'] response = client.get(game_url) assert 200 == response.status_code players_url = load(response)['players'] + # Attempt to start without players + + response = client.get(game_start_url) + assert False is load(response)['started'] + + response = client.post(game_start_url) + assert 403 == response.status_code + # Create two players response = client.post(players_url, data={'code': '1'}) assert 201 == response.status_code - player1_url = load(response)['uri'] + player_1_url = load(response)['uri'] response = client.post(players_url, data={'code': '2'}) assert 201 == response.status_code - player2_url = load(response)['uri'] + player_2_url = load(response)['uri'] + + response = client.get(player_1_url) + assert 0 == load(response)['turn'] + + # Start the game + + + From abaccb7f1233cf40104128dd64cd04444916b7a1 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Fri, 5 Jun 2015 00:37:33 -0400 Subject: [PATCH 09/14] Extend DM in PM --- Makefile | 4 +- data/games/_started_.yml | 2 +- gridcommand/routes/player.py | 2 + gridcommand/stores/game.py | 90 ++++++++-------------------- gridcommand/test/test_stores_game.py | 8 +++ requirements.txt | 2 +- tests/test_all.py | 3 +- 7 files changed, 41 insertions(+), 70 deletions(-) diff --git a/Makefile b/Makefile index 0622ec2..68862de 100644 --- a/Makefile +++ b/Makefile @@ -11,9 +11,9 @@ ifndef TRAVIS endif # Test settings -UNIT_TEST_COVERAGE := 84 +UNIT_TEST_COVERAGE := 82 INTEGRATION_TEST_COVERAGE := 49 -COMBINED_TEST_COVERAGE := 84 +COMBINED_TEST_COVERAGE := 82 # System paths PLATFORM := $(shell python -c 'import sys; print(sys.platform)') diff --git a/data/games/_started_.yml b/data/games/_started_.yml index 68f7786..0f11601 100644 --- a/data/games/_started_.yml +++ b/data/games/_started_.yml @@ -1,4 +1,3 @@ -turn: 1 players: - code: '1234' color: red @@ -10,3 +9,4 @@ players: turns: - done: false moves: [] +turn: 1 diff --git a/gridcommand/routes/player.py b/gridcommand/routes/player.py index e0936bf..0bd40ba 100644 --- a/gridcommand/routes/player.py +++ b/gridcommand/routes/player.py @@ -27,6 +27,8 @@ def players_list(key): if not code: raise exceptions.ParseError("Player 'code' must be specified.") player = game.create_player(code, exc=exceptions.PermissionDenied) + # TODO: move this to the service + app.service.game_store.update(game) return formatter.format_single(player, game, auth=code), \ status.HTTP_201_CREATED diff --git a/gridcommand/stores/game.py b/gridcommand/stores/game.py index 88bd270..6d85011 100644 --- a/gridcommand/stores/game.py +++ b/gridcommand/stores/game.py @@ -13,100 +13,57 @@ @yorm.attr(begin=yorm.converters.Integer) @yorm.attr(end=yorm.converters.Integer) @yorm.attr(count=yorm.converters.Integer) -class MoveFileModel(yorm.converters.AttributeDictionary): +class MoveFileModel(yorm.converters.AttributeDictionary, domain.Move): pass @yorm.attr(all=MoveFileModel) -class MovesFileModel(yorm.converters.SortedList): +class MovesFileModel(yorm.converters.SortedList, domain.Moves): pass @yorm.attr(moves=MovesFileModel) @yorm.attr(done=yorm.converters.Boolean) -class TurnFileModel(yorm.converters.AttributeDictionary): +class TurnFileModel(yorm.converters.AttributeDictionary, domain.Turn): def __init__(self): + super().__init__() self.moves = [] self.done = False @yorm.attr(all=TurnFileModel) -class TurnsFileModel(yorm.converters.List): +class TurnsFileModel(yorm.converters.List, domain.Turns): pass @yorm.attr(color=yorm.converters.String) @yorm.attr(code=yorm.converters.String) @yorm.attr(turns=TurnsFileModel) -class PlayerFileModel(yorm.converters.AttributeDictionary): +class PlayerFileModel(yorm.converters.AttributeDictionary, domain.Player): def __init__(self, color, code): + super().__init__() self.color = color self.code = code self.turns = [] @yorm.attr(all=PlayerFileModel) -class PlayersFileModel(yorm.converters.List): +class PlayersFileModel(yorm.converters.List, domain.Players): pass @yorm.attr(players=PlayersFileModel) @yorm.attr(turn=yorm.converters.Integer) -@yorm.sync("data/games/{self.key}.yml") -class GameFileModel: +@yorm.sync("data/games/{self.key}.yml", auto=False) +class GameFileModel(domain.Game): - def __init__(self, key): + def __init__(self, key, players=None, turn=0): + super().__init__() self.key = key - self.players = PlayersFileModel() - self.turn = 0 - - def from_domain(self, game): - for player in game.players: - player_model = PlayerFileModel(color=player.color, - code=player.code) - - for turn in player.turns: - turn_model = TurnFileModel() - - for move in turn.moves: - move_model = MoveFileModel(begin=move.begin, - end=move.end, - count=move.count) - - turn_model.moves.append(move_model) - - player_model.turns.append(turn_model) - - self.players.append(player_model) - - self.turn = game.turn - - def to_domain(self): - game = domain.Game(key=self.key) - - for player_model in self.players: - player = domain.Player(color=player_model.color, - code=player_model.code) - - for turn_model in player_model.turns: - turn = domain.Turn() - - for move_model in turn_model.moves: - move = domain.Move(begin=move_model.begin, - end=move_model.end, - count=move_model.count) - - turn.moves.append(move) - - player.turns.append(turn) - - game.players.append(player) - - game.turn = self.turn - - return game + self.players = players or PlayersFileModel() + self.turn = turn class GameMemoryStore(Store): @@ -139,8 +96,12 @@ def delete(self, game): class GameFileStore(Store): def create(self, game): - model = GameFileModel(key=game.key) - model.from_domain(game) + path = "data/games/{}.yml".format(game.key) + attrs = dict(players=PlayersFileModel, + turn=yorm.converters.Integer) + print(game.players) + yorm.sync(game, path, attrs, auto=False) + yorm.update_file(game) def read(self, key): if key: @@ -149,8 +110,8 @@ def read(self, key): if not os.path.exists(path): return None - model = GameFileModel(key) - game = model.to_domain() + game = GameFileModel(key) + yorm.update_object(game) return game else: @@ -161,8 +122,8 @@ def read(self, key): for filename in os.listdir(path): key = filename.split('.')[0] - model = GameFileModel(key) - game = model.to_domain() + game = GameFileModel(key) + yorm.update_object(game) games.append(game) @@ -172,8 +133,7 @@ def update(self, game): path = os.path.join("data", "games", game.key + ".yml") # TODO: move this to settings? assert os.path.exists(path) - model = GameFileModel(game.key) - model.from_domain(game) + yorm.update_file(game) def delete(self, game): path = os.path.join("data", "games", game.key + ".yml") # TODO: move this to settings? diff --git a/gridcommand/test/test_stores_game.py b/gridcommand/test/test_stores_game.py index 08bfbb3..000c242 100644 --- a/gridcommand/test/test_stores_game.py +++ b/gridcommand/test/test_stores_game.py @@ -2,6 +2,7 @@ import os import tempfile +import logging import pytest @@ -20,13 +21,19 @@ def test_create_and_read(self, store_class): store = store_class() game = domain.Game('test_game') + game.turn = 3 game.players.append(domain.Player('red')) game.players[0].turns.append(domain.Turn()) game.players[0].turns[0].moves.append(domain.Move(0, 0)) + logging.info("creating game...") store.create(game) + logging.info("reading game...") game2 = store.read(game.key) assert game == game2 + assert game2.turn == 3 + assert game2.players[0].color == 'red' + assert game2.players[0].turns[0].done is False assert game2.players[0].turns[0].moves[0].begin == 0 def test_read_single_unknown(self, store_class): @@ -61,6 +68,7 @@ def test_update_existing(self, store_class): store.create(game) game.turn = 42 + logging.info("updating game...") store.update(game) game2 = store.read(game.key) diff --git a/requirements.txt b/requirements.txt index 21841ee..b2b696e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ Flask ~= 0.10.1 Flask-API ~= 0.6.3 -YORM ~= 0.4 +YORM ~= 0.4.1rc1 diff --git a/tests/test_all.py b/tests/test_all.py index 75a8ef1..50f4a11 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -41,7 +41,8 @@ def test_create_game_and_players(client): player_2_url = load(response)['uri'] response = client.get(player_1_url) - assert 0 == load(response)['turn'] + assert 200 == response.status_code + turn = load(response)['turn'] # Start the game From e0af20406f8c6c10de3560b642b6d3fa2804091f Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Fri, 5 Jun 2015 21:38:23 -0400 Subject: [PATCH 10/14] Use service for games route --- gridcommand/routes/__init__.py | 1 + gridcommand/routes/game.py | 2 +- gridcommand/services/base.py | 1 + gridcommand/services/game.py | 4 ++++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/gridcommand/routes/__init__.py b/gridcommand/routes/__init__.py index 85d44bd..1d777ae 100644 --- a/gridcommand/routes/__init__.py +++ b/gridcommand/routes/__init__.py @@ -8,5 +8,6 @@ app = FlaskAPI(__name__) # pylint: disable=C0103 app.service = GameService(game_store=GameFileStore()) app.service.exceptions.missing = exceptions.NotFound +app.service.exceptions.denied = exceptions.PermissionDenied from . import root, game, player, turn, move # loads routes diff --git a/gridcommand/routes/game.py b/gridcommand/routes/game.py index d567515..8cffbc3 100644 --- a/gridcommand/routes/game.py +++ b/gridcommand/routes/game.py @@ -50,7 +50,7 @@ def games_start(key): pass elif request.method == 'POST': - game.start(exc=exceptions.PermissionDenied) + app.service.start_game(game) else: # pragma: no cover assert None diff --git a/gridcommand/services/base.py b/gridcommand/services/base.py index 1cb9c4a..6b1f937 100644 --- a/gridcommand/services/base.py +++ b/gridcommand/services/base.py @@ -6,6 +6,7 @@ class Exceptions: duplicate = ValueError missing = KeyError invalid = ValueError + denied = ValueError class Service(metaclass=ABCMeta): diff --git a/gridcommand/services/game.py b/gridcommand/services/game.py index c5c1553..ace69b3 100644 --- a/gridcommand/services/game.py +++ b/gridcommand/services/game.py @@ -23,3 +23,7 @@ def find_game(self, key): def create_player(self, game, code): raise NotImplementedError("TODO: implement method") + + def start_game(self, game): + game.start(exc=self.exceptions.denied) + self.game_store.update(game) From 18acac1f9fc4e5877e7d8d933c01205afeedce07 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Fri, 5 Jun 2015 22:00:17 -0400 Subject: [PATCH 11/14] Use service for players route --- gridcommand/routes/__init__.py | 6 ++++-- gridcommand/routes/player.py | 9 ++------- gridcommand/services/base.py | 9 +++++---- gridcommand/services/game.py | 15 ++++++++++++--- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/gridcommand/routes/__init__.py b/gridcommand/routes/__init__.py index 1d777ae..87ab8fe 100644 --- a/gridcommand/routes/__init__.py +++ b/gridcommand/routes/__init__.py @@ -7,7 +7,9 @@ app = FlaskAPI(__name__) # pylint: disable=C0103 app.service = GameService(game_store=GameFileStore()) -app.service.exceptions.missing = exceptions.NotFound -app.service.exceptions.denied = exceptions.PermissionDenied +app.service.exceptions.not_found = exceptions.NotFound +app.service.exceptions.permission_denied = exceptions.PermissionDenied +app.service.exceptions.missing_input = exceptions.ParseError +# TODO: replace imports with blueprints from . import root, game, player, turn, move # loads routes diff --git a/gridcommand/routes/player.py b/gridcommand/routes/player.py index 0bd40ba..be30e2d 100644 --- a/gridcommand/routes/player.py +++ b/gridcommand/routes/player.py @@ -23,12 +23,7 @@ def players_list(key): elif request.method == 'POST': code = str(request.data.get('code', '')) - # TODO: replace with a service call - if not code: - raise exceptions.ParseError("Player 'code' must be specified.") - player = game.create_player(code, exc=exceptions.PermissionDenied) - # TODO: move this to the service - app.service.game_store.update(game) + player = app.service.create_player(game, code) return formatter.format_single(player, game, auth=code), \ status.HTTP_201_CREATED @@ -54,7 +49,7 @@ def players_detail(key, color): elif request.method == 'DELETE': player.authenticate(code, exc=exceptions.AuthenticationFailed) - game.delete_player(color, exc=exceptions.PermissionDenied) + app.service.delete_player(game, player) return '', status.HTTP_204_NO_CONTENT else: # pragma: no cover diff --git a/gridcommand/services/base.py b/gridcommand/services/base.py index 6b1f937..e43dcc8 100644 --- a/gridcommand/services/base.py +++ b/gridcommand/services/base.py @@ -3,10 +3,11 @@ class Exceptions: - duplicate = ValueError - missing = KeyError - invalid = ValueError - denied = ValueError + duplicate_value = ValueError + not_found = KeyError + invalid_input = ValueError + permission_denied = ValueError + missing_input = ValueError class Service(metaclass=ABCMeta): diff --git a/gridcommand/services/game.py b/gridcommand/services/game.py index ace69b3..4c8c7f0 100644 --- a/gridcommand/services/game.py +++ b/gridcommand/services/game.py @@ -18,12 +18,21 @@ def find_game(self, key): game = self.game_store.read(key) if game is None: msg = "The game '{}' does not exist.".format(key) - raise self.exceptions.missing(msg) + raise self.exceptions.not_found(msg) return game def create_player(self, game, code): - raise NotImplementedError("TODO: implement method") + if not code: + msg = "Player 'code' must be specified." + raise self.exceptions.missing_input(msg) + player = game.create_player(code, exc=self.exceptions.permission_denied) + self.game_store.update(game) + return player + + def delete_player(self, game, player): + game.delete_player(player.color, exc=self.exceptions.permission_denied) + self.game_store.update(game) def start_game(self, game): - game.start(exc=self.exceptions.denied) + game.start(exc=self.exceptions.permission_denied) self.game_store.update(game) From acd158a8ab020acef157a87dc4cf1fbd597040e4 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Fri, 5 Jun 2015 22:31:01 -0400 Subject: [PATCH 12/14] Use service for turns and moves route --- .pylintrc | 3 ++- gridcommand/routes/move.py | 12 +++++++----- gridcommand/services/game.py | 9 +++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.pylintrc b/.pylintrc index 0ce85cf..93a3518 100644 --- a/.pylintrc +++ b/.pylintrc @@ -8,7 +8,8 @@ # R0401: Cyclic import # C0103: Invalid constant name # R0901: Too many ancestor -disable=I0011,W0142,W0511,R0903,R0904,R0401,C0103,R0901 +# R0913: Too many arguments +disable=I0011,W0142,W0511,R0903,R0904,R0401,C0103,R0901,R0913 [REPORTS] diff --git a/gridcommand/routes/move.py b/gridcommand/routes/move.py index 1464bd3..4c99381 100644 --- a/gridcommand/routes/move.py +++ b/gridcommand/routes/move.py @@ -26,9 +26,10 @@ def moves_list(key, color, number): return formatter.format_multiple(turn.moves, game, player) elif request.method == 'POST': - move = turn.moves.set(request.data.get('begin'), - request.data.get('end'), - request.data.get('count')) + begin = request.data.get('begin'), + end = request.data.get('end'), + count = request.data.get('count') + move = app.service.create_move(game, turn, begin, end, count) return formatter.format_single(move) else: # pragma: no cover @@ -49,11 +50,12 @@ def moves_detail(key, color, number, begin, end): return formatter.format_single(move) elif request.method == 'PUT': - move = turn.moves.set(begin, end, request.data.get('count')) + count = request.data.get('count') + move = app.service.create_move(game, turn, begin, end, count) return formatter.format_single(move) elif request.method == 'DELETE': - turn.moves.delete(begin, end) + app.service.delete_move(game, turn, begin, end) return '', status.HTTP_204_NO_CONTENT else: # pragma: no cover diff --git a/gridcommand/services/game.py b/gridcommand/services/game.py index 4c8c7f0..1b750a3 100644 --- a/gridcommand/services/game.py +++ b/gridcommand/services/game.py @@ -36,3 +36,12 @@ def delete_player(self, game, player): def start_game(self, game): game.start(exc=self.exceptions.permission_denied) self.game_store.update(game) + + def create_move(self, game, turn, begin, end, count): + move = turn.moves.set(begin, end, count) + self.game_store.update(game) + return move + + def delete_move(self, game, turn, begin, end): + turn.moves.delete(begin, end) + self.game_store.update(game) From c6d596380a1b63da96eba322046eda6295d7d4b0 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Sat, 6 Jun 2015 14:25:38 -0400 Subject: [PATCH 13/14] Use filter to read multiple store items --- gridcommand/stores/base.py | 11 +++++- gridcommand/stores/game.py | 50 +++++++++++++++------------- gridcommand/test/test_stores_game.py | 4 +-- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/gridcommand/stores/base.py b/gridcommand/stores/base.py index 2f75525..e558130 100644 --- a/gridcommand/stores/base.py +++ b/gridcommand/stores/base.py @@ -5,16 +5,25 @@ class Store(metaclass=ABCMeta): # pragma: no cover (abstract) @abstractmethod def create(self, item): + """Write the item to the store.""" raise NotImplementedError @abstractmethod - def read(self, item): + def read(self, key): + """Read a single item by it's key.""" + raise NotImplementedError + + @abstractmethod + def filter(self, **kwargs): + """Read all items matching filter options.""" raise NotImplementedError @abstractmethod def update(self, item): + """"Replace an item in the store.""" raise NotImplementedError @abstractmethod def delete(self, item): + """Remove an item from the store.""" raise NotImplementedError diff --git a/gridcommand/stores/game.py b/gridcommand/stores/game.py index 6d85011..0bc7dbe 100644 --- a/gridcommand/stores/game.py +++ b/gridcommand/stores/game.py @@ -75,13 +75,14 @@ def create(self, game): self._games[game.key] = game def read(self, key): - if key: - try: - return self._games[key] - except KeyError: - return None - else: - return list(self._games.values()) + assert key + try: + return self._games[key] + except KeyError: + return None + + def filter(self): + return list(self._games.values()) def update(self, game): self._games[game.key] = game @@ -104,30 +105,31 @@ def create(self, game): yorm.update_file(game) def read(self, key): - if key: - path = os.path.join("data", "games", key + ".yml") # TODO: move this to settings? + assert key + path = os.path.join("data", "games", key + ".yml") # TODO: move this to settings? - if not os.path.exists(path): - return None + if not os.path.exists(path): + return None - game = GameFileModel(key) - yorm.update_object(game) + game = GameFileModel(key) + yorm.update_object(game) - return game - else: - games = [] + return game - path = os.path.join("data", "games") # TODO: move this to settings? - if os.path.exists(path): - for filename in os.listdir(path): - key = filename.split('.')[0] + def filter(self): + games = [] + + path = os.path.join("data", "games") # TODO: move this to settings? + if os.path.exists(path): + for filename in os.listdir(path): + key = filename.split('.')[0] - game = GameFileModel(key) - yorm.update_object(game) + game = GameFileModel(key) + yorm.update_object(game) - games.append(game) + games.append(game) - return games + return games def update(self, game): path = os.path.join("data", "games", game.key + ".yml") # TODO: move this to settings? diff --git a/gridcommand/test/test_stores_game.py b/gridcommand/test/test_stores_game.py index 000c242..674bc20 100644 --- a/gridcommand/test/test_stores_game.py +++ b/gridcommand/test/test_stores_game.py @@ -50,14 +50,14 @@ def test_read_multiple(self, store_class): game = domain.Game() store.create(game) - games = store.read(key=None) + games = store.filter() assert len(games) == 2 def test_read_multiple_empty(self, store_class): store = store_class() - games = store.read(key=None) + games = store.filter() assert games == [] From e596db8f5a596045ff8f9e451817b257dd453846 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Sat, 6 Jun 2015 14:41:53 -0400 Subject: [PATCH 14/14] Start the game in the integration test --- tests/test_all.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_all.py b/tests/test_all.py index 50f4a11..cb08374 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -30,6 +30,8 @@ def test_create_game_and_players(client): response = client.post(game_start_url) assert 403 == response.status_code + response = client.get(game_start_url) + assert False is load(response)['started'] # Create two players @@ -42,9 +44,19 @@ def test_create_game_and_players(client): response = client.get(player_1_url) assert 200 == response.status_code - turn = load(response)['turn'] + assert 0 == load(response)['turn'] # Start the game + response = client.post(game_start_url) + assert 200 == response.status_code + assert True is load(response)['started'] + + response = client.get(player_1_url) + assert 200 == response.status_code + assert 1 == load(response)['turn'] + # Attempt to add another player + response = client.post(players_url, data={'code': '3'}) + assert 403 == response.status_code