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/Makefile b/Makefile index ffb0e32..68862de 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 +INTEGRATION_TEST_COVERAGE := 49 +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 @@ -175,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 @@ -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/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/__init__.py b/gridcommand/__init__.py index 0c0fc9c..41b8982 100644 --- a/gridcommand/__init__.py +++ b/gridcommand/__init__.py @@ -13,8 +13,7 @@ exit("Python {}.{}+ is required.".format(*PYTHON_VERSION)) try: - from .views import app - from . import data + from .routes import app 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/models/__init__.py b/gridcommand/domain/__init__.py similarity index 58% rename from gridcommand/models/__init__.py rename to gridcommand/domain/__init__.py index ddf3cf6..96cb901 100644 --- a/gridcommand/models/__init__.py +++ b/gridcommand/domain/__init__.py @@ -1,6 +1,6 @@ -"""Data models for the application.""" +"""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 +from .game import Game diff --git a/gridcommand/models/game.py b/gridcommand/domain/game.py similarity index 57% rename from gridcommand/models/game.py rename to gridcommand/domain/game.py index 68ecff6..9939457 100644 --- a/gridcommand/models/game.py +++ b/gridcommand/domain/game.py @@ -3,9 +3,6 @@ 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 @@ -13,9 +10,6 @@ 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.""" @@ -31,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) @@ -63,35 +63,3 @@ def advance(self): 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/domain/move.py similarity index 70% rename from gridcommand/models/move.py rename to gridcommand/domain/move.py index 1ba1a90..ac9f365 100644 --- a/gridcommand/models/move.py +++ b/gridcommand/domain/move.py @@ -1,13 +1,7 @@ """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): +class Move: """A planned transfer of tokens from one cell to another.""" @@ -32,23 +26,14 @@ def __lt__(self, other): return False return self.end < other.end - def serialize(self): - return {'count': self.count} - -@yorm.attr(all=Move) -class Moves(yorm.converters.SortedList): +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 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: diff --git a/gridcommand/models/player.py b/gridcommand/domain/player.py similarity index 62% rename from gridcommand/models/player.py rename to gridcommand/domain/player.py index 4f4d614..e4710c7 100644 --- a/gridcommand/models/player.py +++ b/gridcommand/domain/player.py @@ -1,8 +1,5 @@ """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 @@ -10,10 +7,7 @@ log = logger(__name__) -@yorm.attr(color=yorm.converters.String) -@yorm.attr(code=yorm.converters.String) -@yorm.attr(turns=Turns) -class Player(yorm.converters.AttributeDictionary): +class Player: """An entity that plans moves during a turn.""" @@ -30,25 +24,13 @@ 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)) - - 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 + raise exc("Player code '{}' is invalid.".format(code)) -@yorm.attr(all=Player) -class Players(yorm.converters.List): +class Players(list): """A collection players in a game.""" @@ -70,10 +52,6 @@ def __repr__(self): 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] 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/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/__init__.py b/gridcommand/routes/__init__.py new file mode 100644 index 0000000..87ab8fe --- /dev/null +++ b/gridcommand/routes/__init__.py @@ -0,0 +1,15 @@ +"""API views for the application.""" + +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.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/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..575b7c6 --- /dev/null +++ b/gridcommand/routes/formatters.py @@ -0,0 +1,91 @@ +"""Formats domain objects for route responses.""" + +from flask import url_for + +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.""" + + 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, game, auth): + 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, 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': turn.done} + + 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))] + + +class MoveFormatter(Formatter): + + """Serializes moves into dictionaries.""" + + def format_single(self, move): + return {'count': move.count} + + 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 moves] + + +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 76% rename from gridcommand/views/game.py rename to gridcommand/routes/game.py index 93a7090..8cffbc3 100644 --- a/gridcommand/views/game.py +++ b/gridcommand/routes/game.py @@ -4,10 +4,9 @@ from flask import request from flask.ext.api import status, exceptions # pylint: disable=E0611,F0401 -from ..data import games - from . import app from .root import ROOT_URL +from .formatters import game_formatter as formatter GAMES_LIST_URL = ROOT_URL + "/games/" @@ -23,8 +22,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 = app.service.create_game() + return formatter.format_single(game), status.HTTP_201_CREATED else: # pragma: no cover assert None @@ -35,8 +34,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 = app.service.find_game(key) + return formatter.format_single(game) else: # pragma: no cover assert None @@ -45,13 +44,13 @@ 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 elif request.method == 'POST': - game.start(exc=exceptions.PermissionDenied) + app.service.start_game(game) else: # pragma: no cover assert None diff --git a/gridcommand/views/move.py b/gridcommand/routes/move.py similarity index 67% rename from gridcommand/views/move.py rename to gridcommand/routes/move.py index 035c4fa..4c99381 100644 --- a/gridcommand/views/move.py +++ b/gridcommand/routes/move.py @@ -4,10 +4,9 @@ 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 +from .formatters import move_formatter as formatter MOVES_LIST_URL = TURNS_DETAIL_URL + "/moves/" @@ -17,20 +16,21 @@ @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() + 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 assert None @@ -39,7 +39,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) @@ -47,14 +47,15 @@ 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() + 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/routes/parsers.py b/gridcommand/routes/parsers.py new file mode 100644 index 0000000..4be07e2 --- /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 65% rename from gridcommand/views/player.py rename to gridcommand/routes/player.py index cd29372..be30e2d 100644 --- a/gridcommand/views/player.py +++ b/gridcommand/routes/player.py @@ -4,10 +4,9 @@ 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 +from .formatters import player_formatter as formatter PLAYERS_LIST_URL = GAMES_DETAIL_URL + "/players/" @@ -17,17 +16,16 @@ @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 + player = app.service.create_player(game, code) + return formatter.format_single(player, game, auth=code), \ + status.HTTP_201_CREATED else: # pragma: no cover assert None @@ -40,19 +38,18 @@ 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: - raise exceptions.AuthenticationFailed - game.delete_player(color, exc=exceptions.PermissionDenied) + player.authenticate(code, exc=exceptions.AuthenticationFailed) + app.service.delete_player(game, player) return '', status.HTTP_204_NO_CONTENT else: # pragma: no cover 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 80% rename from gridcommand/views/turn.py rename to gridcommand/routes/turn.py index da48317..863ecd0 100644 --- a/gridcommand/views/turn.py +++ b/gridcommand/routes/turn.py @@ -4,10 +4,9 @@ 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 +from .formatters import turn_formatter as formatter TUNRS_LIST_URL = PLAYERS_DETAIL_URL + "/turns/" @@ -17,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 @@ -32,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/__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..e43dcc8 --- /dev/null +++ b/gridcommand/services/base.py @@ -0,0 +1,16 @@ +from abc import ABCMeta + + +class Exceptions: + + duplicate_value = ValueError + not_found = KeyError + invalid_input = ValueError + permission_denied = ValueError + missing_input = ValueError + + +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..1b750a3 --- /dev/null +++ b/gridcommand/services/game.py @@ -0,0 +1,47 @@ +from ..domain import Game + +from .base import Service + + +class GameService(Service): + + def __init__(self, game_store, **kwargs): + super().__init__(**kwargs) + self.game_store = game_store + + def create_game(self, key=None): + game = Game(key=key) + 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.not_found(msg) + return game + + def create_player(self, game, code): + 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.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) diff --git a/gridcommand/stores/__init__.py b/gridcommand/stores/__init__.py new file mode 100644 index 0000000..f86fb88 --- /dev/null +++ b/gridcommand/stores/__init__.py @@ -0,0 +1,3 @@ +"""Persistence models for the application.""" + +from .game import GameMemoryStore, GameFileStore diff --git a/gridcommand/stores/base.py b/gridcommand/stores/base.py new file mode 100644 index 0000000..e558130 --- /dev/null +++ b/gridcommand/stores/base.py @@ -0,0 +1,29 @@ +from abc import ABCMeta, abstractmethod + + +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, 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 new file mode 100644 index 0000000..0bc7dbe --- /dev/null +++ b/gridcommand/stores/game.py @@ -0,0 +1,143 @@ +import os + +import yorm + +from .. import common +from .. import domain +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 MoveFileModel(yorm.converters.AttributeDictionary, domain.Move): + pass + + +@yorm.attr(all=MoveFileModel) +class MovesFileModel(yorm.converters.SortedList, domain.Moves): + pass + + +@yorm.attr(moves=MovesFileModel) +@yorm.attr(done=yorm.converters.Boolean) +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, domain.Turns): + pass + + +@yorm.attr(color=yorm.converters.String) +@yorm.attr(code=yorm.converters.String) +@yorm.attr(turns=TurnsFileModel) +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, domain.Players): + pass + + +@yorm.attr(players=PlayersFileModel) +@yorm.attr(turn=yorm.converters.Integer) +@yorm.sync("data/games/{self.key}.yml", auto=False) +class GameFileModel(domain.Game): + + def __init__(self, key, players=None, turn=0): + super().__init__() + self.key = key + self.players = players or PlayersFileModel() + self.turn = turn + + +class GameMemoryStore(Store): + + def __init__(self): + self._games = {} + + def create(self, game): + self._games[game.key] = game + + def read(self, key): + 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 + + def delete(self, game): + try: + del self._games[game.key] + except KeyError: + pass + + +class GameFileStore(Store): + + def create(self, 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): + assert key + path = os.path.join("data", "games", key + ".yml") # TODO: move this to settings? + + if not os.path.exists(path): + return None + + game = GameFileModel(key) + yorm.update_object(game) + + return game + + 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) + + 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) + + yorm.update_file(game) + + 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/gridcommand/test/conftest.py b/gridcommand/test/conftest.py new file mode 100644 index 0000000..610b51f --- /dev/null +++ b/gridcommand/test/conftest.py @@ -0,0 +1,122 @@ +"""Configuration for pytest.""" +# pylint: disable=W0613,W0621 + +import json +from unittest.mock import patch + +import pytest + +from gridcommand.common import logger +from gridcommand import app +from gridcommand import domain +from gridcommand import services +from gridcommand import stores + +GAME_KEY = 'my_game' +PLAYER_CODE = 'my_code' +PLAYERS_COLORS = ['red', 'blue'] + + +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) + + +# 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 = app.service.create_game(key=GAME_KEY) + return game + + +@pytest.fixture +def game_player(game): + """Fixture to create a game with one player.""" + log.info("adding a player to a game...") + with patch.object(domain.Players, 'COLORS', PLAYERS_COLORS): + game.players.create(PLAYER_CODE) + return game + + +@pytest.fixture +def game_players(game): + """Fixture to create a game with two players.""" + with patch.object(domain.Players, 'COLORS', PLAYERS_COLORS): + game.players.create(PLAYER_CODE) + game.players.create(PLAYER_CODE) + return game + + +@pytest.fixture +def game_started(game): + """Fixture to create a started game.""" + game.players.create(PLAYER_CODE) + game.players.create(PLAYER_CODE) + game.start() + return game + + +@pytest.fixture +def player(game): + """Fixture to create a player for a game.""" + log.info("adding a player to a game...") + player = game.players.create(PLAYER_CODE) + return player + + +@pytest.fixture +def players(game_players): + """Fixture to create two players for a game.""" + assert len(game_players.players) == 2 + return game_players.players + + +@pytest.fixture +def turn(game_player): + """Fixture to create a turn for a player.""" + log.info("adding a turn to a player...") + turn = domain.Turn() + log.debug("appending turn...") + game_player.players[0].turns.append(turn) + return turn + + +@pytest.fixture +def turns(game_player): + """Fixture to create turns for a player.""" + game_player.players[0].turns.append(domain.Turn()) + game_player.players[0].turns.append(domain.Turn()) + return game_player.players[0].turns + + +# Service fixtures + + +@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/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 79% rename from tests/models/test_game.py rename to gridcommand/test/domain/test_game.py index 5518d91..1b3ed7b 100644 --- a/tests/models/test_game.py +++ b/gridcommand/test/domain/test_game.py @@ -1,13 +1,23 @@ -"""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 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() @@ -44,17 +54,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/tests/models/test_move.py b/gridcommand/test/domain/test_move.py similarity index 75% rename from tests/models/test_move.py rename to gridcommand/test/domain/test_move.py index feb9de0..8beb081 100644 --- a/tests/models/test_move.py +++ b/gridcommand/test/domain/test_move.py @@ -1,17 +1,21 @@ -"""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: 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/tests/models/test_player.py b/gridcommand/test/domain/test_player.py similarity index 81% rename from tests/models/test_player.py rename to gridcommand/test/domain/test_player.py index 8af41b8..afa118c 100644 --- a/tests/models/test_player.py +++ b/gridcommand/test/domain/test_player.py @@ -1,13 +1,16 @@ -"""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: + 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/tests/models/test_turn.py b/gridcommand/test/domain/test_turn.py similarity index 63% rename from tests/models/test_turn.py rename to gridcommand/test/domain/test_turn.py index 776e0f5..ef6ac0d 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: @@ -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/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 89% rename from tests/views/test_player.py rename to gridcommand/test/routes/test_player.py index 0af0e23..fbd7859 100644 --- a/tests/views/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/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 58% rename from tests/views/test_turn.py rename to gridcommand/test/routes/test_turn.py index 810a42f..87c986b 100644 --- a/tests/views/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_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/gridcommand/test/test_stores_game.py b/gridcommand/test/test_stores_game.py new file mode 100644 index 0000000..674bc20 --- /dev/null +++ b/gridcommand/test/test_stores_game.py @@ -0,0 +1,96 @@ +# pylint: disable=W0613,R0201,C0111 + +import os +import tempfile +import logging + +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('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): + 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.filter() + + assert len(games) == 2 + + def test_read_multiple_empty(self, store_class): + store = store_class() + + games = store.filter() + + assert games == [] + + def test_update_existing(self, store_class): + store = store_class() + + game = domain.Game() + store.create(game) + + game.turn = 42 + logging.info("updating game...") + 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 diff --git a/gridcommand/views/__init__.py b/gridcommand/views/__init__.py deleted file mode 100644 index 58bc672..0000000 --- a/gridcommand/views/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""API views for the application.""" - -from flask.ext.api import FlaskAPI # pylint: disable=E0611,F0401 - -app = FlaskAPI(__name__) # pylint: disable=C0103 - -from . import root, game, player, turn, move # loads routes 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/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/scent.py b/scent.py new file mode 100644 index 0000000..2e23642 --- /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 = ['gridcommand/', '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/conftest.py b/tests/conftest.py index 9b0e9c5..99fa19b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,38 +1,27 @@ """Configuration for pytest.""" # pylint: disable=W0613,W0621 -import os import json -from unittest.mock import patch +import logging import pytest -import yorm from gridcommand.common import logger from gridcommand import app -from gridcommand import models -from gridcommand import data - -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'] log = logger(__name__) -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 +def load(response): + """Convert a response's binary data (JSON) to a dictionary.""" + text = response.data.decode('utf-8') + if text: + data = json.loads(text) else: - yorm.settings.fake = True + data = None + logging.debug("response: %r", data) + return data @pytest.fixture @@ -41,81 +30,4 @@ def client(request): app.config['TESTING'] = True app.config['DEBUG'] = True test_client = app.test_client() - data.games.clear() return test_client - - -@pytest.fixture -def game(): - """Fixture to create an empty game.""" - log.info("creating an empty game...") - game = models.Game(GAME_KEY) - data.games[game.key] = game - return game - - -@pytest.fixture -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): - game.players.create(PLAYER_CODE) - return game - - -@pytest.fixture -def game_players(game): - """Fixture to create a game with two players.""" - with patch.object(models.Players, 'COLORS', PLAYERS_COLORS): - game.players.create(PLAYER_CODE) - game.players.create(PLAYER_CODE) - return game - - -@pytest.fixture -def game_started(game): - """Fixture to create a started game.""" - game.players.create(PLAYER_CODE) - game.players.create(PLAYER_CODE) - game.start() - return game - - -@pytest.fixture -def player(game): - """Fixture to create a player for a game.""" - log.info("adding a player to a game...") - player = game.players.create(PLAYER_CODE) - return player - - -@pytest.fixture -def players(game_players): - """Fixture to create two players for a game.""" - assert len(game_players.players) == 2 - return game_players.players - - -@pytest.fixture -def turn(game_player): - """Fixture to create a turn for a player.""" - log.info("adding a turn to a player...") - turn = models.Turn() - log.debug("appending turn...") - game_player.players[0].turns.append(turn) - return turn - - -@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()) - 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) 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.""" diff --git a/tests/test_all.py b/tests/test_all.py new file mode 100644 index 0000000..cb08374 --- /dev/null +++ b/tests/test_all.py @@ -0,0 +1,62 @@ +"""Unit tests for the `views.game` module.""" +# pylint: disable=W0613,R0201,C0103,C0111 + + +from .conftest import load + + +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 + response = client.get(game_start_url) + assert False is load(response)['started'] + + # Create two players + + response = client.post(players_url, data={'code': '1'}) + assert 201 == response.status_code + player_1_url = load(response)['uri'] + response = client.post(players_url, data={'code': '2'}) + assert 201 == response.status_code + player_2_url = load(response)['uri'] + + response = client.get(player_1_url) + assert 200 == response.status_code + 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