Skip to content

Commit

Permalink
issue #6 Split puzzle decorators from Puzzle class
Browse files Browse the repository at this point in the history
  • Loading branch information
Nicals committed Oct 5, 2023
1 parent 989dc70 commit 8538103
Show file tree
Hide file tree
Showing 8 changed files with 117 additions and 81 deletions.
7 changes: 7 additions & 0 deletions saulve/puzzle/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""TODO: Document this
"""

from .core import Puzzle
from .decorators import solved, with_input

__all__ = ['Puzzle', 'solved', 'with_input']
9 changes: 9 additions & 0 deletions saulve/puzzle/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Some common declarations for puzzles.
"""


# The response of a puzzle step.
PuzzleStepResponse = int | str

# Return value of a puzzle step. None indicates an unsolved step.
PuzzleStepResult = PuzzleStepResponse | None
73 changes: 9 additions & 64 deletions saulve/puzzle.py → saulve/puzzle/core.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Declare puzzle and their associated solution.
"""

from functools import wraps
from typing import Callable, Concatenate, NamedTuple, ParamSpec, TypeVar
from typing import Callable, NamedTuple

from .errors import PuzzleHasNoSolution, WrongStepSolution
from ..errors import PuzzleHasNoSolution, WrongStepSolution
from .common import PuzzleStepResult

__all__ = ['Puzzle']

Expand All @@ -21,26 +21,17 @@ def is_solved(self) -> bool:
return self.solution is not None


# The response of a puzzle step.
PuzzleStepResponse = int | str
# Return value of a puzzle step. None indicates an unsolved step.
PuzzleStepResult = PuzzleStepResponse | None


T = TypeVar('T')
U = TypeVar('U')

P = ParamSpec('P')


class PuzzleStep:
"""A linked linked of solution step functions.
The solution function can either return a response or None. A None return
value is considered a solution without implemented response.
"""
def __init__(self, fn: Callable[P, PuzzleStepResult]):
def __init__(self, fn: Callable[[], PuzzleStepResult]):
self.fn = fn
self._next: PuzzleStep | None = None

def push_step(self, fn: Callable[P, PuzzleStepResult]) -> 'PuzzleStep':
def push_step(self, fn: Callable[[], PuzzleStepResult]) -> 'PuzzleStep':
"""Add a new solution function step at the end of the structure.
"""
if self._next is not None:
Expand Down Expand Up @@ -77,72 +68,26 @@ def run(self) -> list[PuzzleSolution]:
class Puzzle:
"""Holds solutions of a puzzle and its input data.
A puzzle is first defined by its name and input.
Next, some solution functions steps are registered by decorating them with
Some solution functions steps are registered by decorating them with
the `solution` decorator.
Arguments:
name: A verbose name for this puzzle
Attributes:
name: A verbose title for the current puzzle
steps: A list of callable implementing puzzle solutions
"""

def __init__(self, name: str) -> None:
self.name = name
self._steps: PuzzleStep | None = None
self.steps: list[Callable[[], PuzzleStepResult]] = [] # XXX: Remove this

def __repr__(self) -> str:
return f'<{self.__class__.__name__}: {self.name}>'

def __str__(self) -> str:
return self.name

def with_input(self, puzzle_input: U) -> Callable[
[Callable[Concatenate[U, P], PuzzleStepResult]],
Callable[P, PuzzleStepResult],
]:
"""Injects a value as first argument of the solution step function.
"""
def decorator(
fn: Callable[Concatenate[U, P], PuzzleStepResult],
) -> Callable[P, PuzzleStepResult]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> PuzzleStepResult:
return fn(puzzle_input, *args, **kwargs)

return wrapper

return decorator # type: ignore[return-value] # pending issue 9


def solved(self, solution: PuzzleStepResponse) -> Callable[
[Callable[P, PuzzleStepResult]],
Callable[P, PuzzleStepResult],
]:
"""Mark a solution function as solved. The return value of the solution
function must match the passed argument to considere the response as
correct.
"""
def decorator(
fn: Callable[P, PuzzleStepResult],
) -> Callable[P, PuzzleStepResult]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> PuzzleStepResult:
step_solution = fn(*args, **kwargs)

if step_solution is not None and step_solution != solution:
raise WrongStepSolution()

return step_solution

return wrapper

return decorator


def solution(
self,
fn: Callable[[], PuzzleStepResult],
Expand Down
55 changes: 55 additions & 0 deletions saulve/puzzle/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Puzzle step function decorators.
"""

from functools import wraps
from typing import Callable, Concatenate, ParamSpec, TypeVar

from ..errors import WrongStepSolution
from .common import PuzzleStepResponse, PuzzleStepResult

U = TypeVar('U')

P = ParamSpec('P')


def with_input(puzzle_input: U) -> Callable[
[Callable[Concatenate[U, P], PuzzleStepResult]],
Callable[P, PuzzleStepResult],
]:
"""Injects a value as first argument of the solution step function.
"""
def decorator(
fn: Callable[Concatenate[U, P], PuzzleStepResult],
) -> Callable[P, PuzzleStepResult]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> PuzzleStepResult:
return fn(puzzle_input, *args, **kwargs)

return wrapper

return decorator # type: ignore[return-value] # pending issue 9


def solved(solution: PuzzleStepResponse) -> Callable[
[Callable[P, PuzzleStepResult]],
Callable[P, PuzzleStepResult],
]:
"""Mark a solution function as solved. The return value of the solution
function must match the passed argument to considere the response as
correct.
"""
def decorator(
fn: Callable[P, PuzzleStepResult],
) -> Callable[P, PuzzleStepResult]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> PuzzleStepResult:
step_solution = fn(*args, **kwargs)

if step_solution is not None and step_solution != solution:
raise WrongStepSolution()

return step_solution

return wrapper

return decorator
Empty file added tests/puzzle/__init__.py
Empty file.
17 changes: 1 addition & 16 deletions tests/test_puzzle.py → tests/puzzle/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import pytest

from saulve.errors import PuzzleHasNoSolution, WrongStepSolution
from saulve.puzzle import Puzzle, PuzzleStep
from saulve.puzzle.core import Puzzle, PuzzleStep


def _raise_wrong_step_solution() -> None:
Expand Down Expand Up @@ -68,18 +68,3 @@ def test_solve_puzzle_steps() -> None:
assert len(solutions) == 2
assert solutions[0].solution == '12'
assert solutions[1].solution == 'second one'


@pytest.mark.skip(reason='Should only test the decorator withour the Puzzle')
def test_injects_puzzle_input() -> None:
puzzle = Puzzle(name='Test puzzle')

@puzzle.solution
@puzzle.with_input('foobar')
def solution(puzzle_input) -> str:
return puzzle_input

solutions = puzzle.solve()

assert len(solutions) == 1
assert solutions[0].solution == 'foobar'
35 changes: 35 additions & 0 deletions tests/puzzle/test_decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import pytest

from saulve.errors import WrongStepSolution
from saulve.puzzle.decorators import solved, with_input


@pytest.mark.parametrize('decorate', [
solved(12),
with_input(12),
])
def test_decorated_functions_are_wrapped(decorate) -> None: # type: ignore
def under_test() -> None:
"""Docstring"""

decorated = decorate(under_test)

assert decorated.__doc__ == 'Docstring'

def test_step_input_is_injected_as_first_argument() -> None:
fn = with_input('Ministry of ')(lambda s: s + 'silly walks')

assert fn() == 'Ministry of silly walks'


def test_solved_wont_do_anything_with_correct_solution() -> None:
fn = solved('Tis but a scratch')(lambda: 'Tis but a scratch')

assert fn() == 'Tis but a scratch'


def test_solved_raises_exception_on_wrong_solution() -> None:
fn = solved('This parrot is dead.')(lambda: 'No, it\'s resting.')

with pytest.raises(WrongStepSolution):
fn()
2 changes: 1 addition & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from saulve.cli import cli

puzzle = Puzzle(name='Test puzzle')
puzzle.solution(lambda _: 'bar')
puzzle.solution(lambda: 'bar')


app = App()
Expand Down

0 comments on commit 8538103

Please sign in to comment.