Skip to content

Commit

Permalink
feat: support coroutine functions as story step definitions
Browse files Browse the repository at this point in the history
You can use async def syntax to define story steps. Story objects
become available, so you can apply in your aiohttp views. All story
steps should be either coroutines or functions.

Most part of the work in this PR was done by @supadrupa and @thedrow
  • Loading branch information
dry-python-bot authored and dry-python-bot committed Mar 6, 2020
1 parent a4da2e3 commit 55cbfda
Show file tree
Hide file tree
Showing 14 changed files with 271 additions and 37 deletions.
3 changes: 3 additions & 0 deletions src/_stories/collect.py
@@ -1,8 +1,11 @@
# -*- coding: utf-8 -*-
from _stories.compat import iscoroutinefunction
from _stories.exceptions import StoryDefinitionError


def collect_story(f):
if iscoroutinefunction(f):
raise StoryDefinitionError("Story should be a regular function")

calls = []

Expand Down
8 changes: 8 additions & 0 deletions src/_stories/compat.py
Expand Up @@ -59,3 +59,11 @@ def indent(text, prefix): # type: ignore
except ImportError:
# Prettyprinter package is not installed.
from pprint import pformat # noqa: F401


try:
from asyncio import iscoroutinefunction
except ImportError:
# We are on Python 2.7
def iscoroutinefunction(func):
return False
3 changes: 3 additions & 0 deletions src/_stories/compat.pyi
@@ -0,0 +1,3 @@
from typing import Callable

def iscoroutinefunction(func: Callable) -> bool: ...
40 changes: 40 additions & 0 deletions src/_stories/execute/__init__.py
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from _stories.compat import iscoroutinefunction
from _stories.exceptions import StoryDefinitionError
from _stories.execute import function

try:
from _stories.execute import coroutine
except SyntaxError:
pass


def get_executor(method, previous, cls_name, story_name):
if iscoroutinefunction(method):
executor = coroutine.execute
other_kind = "function"
else:
executor = function.execute
other_kind = "coroutine"

if previous is not executor and previous is not None:
message = mixed_method_template.format(
other_kind=other_kind,
cls=cls_name,
method=method.__name__,
story_name=story_name,
)
raise StoryDefinitionError(message)
return executor


# Messages.


mixed_method_template = """
Coroutines and functions can not be used together in story definition.
This method should be a {other_kind}: {cls}.{method}
Story method: {cls}.{story_name}
""".strip()
15 changes: 15 additions & 0 deletions src/_stories/execute/__init__.pyi
@@ -0,0 +1,15 @@
from typing import Callable
from typing import Optional
from typing import Union

from _stories.returned import Failure
from _stories.returned import Result
from _stories.returned import Skip
from _stories.returned import Success

def get_executor(
method: Callable[[object], Union[Result, Success, Failure, Skip]],
previous: Optional[Callable],
cls_name: str,
story_name: str,
) -> Callable: ...
78 changes: 78 additions & 0 deletions src/_stories/execute/coroutine.py
@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
from _stories.context import assign_namespace
from _stories.marker import BeginningOfStory
from _stories.marker import EndOfStory
from _stories.returned import Failure
from _stories.returned import Result
from _stories.returned import Skip
from _stories.returned import Success


async def execute(runner, ctx, ns, lines, history, methods):
__tracebackhide__ = True

skipped = 0

for method, contract, protocol in methods:

method_type = type(method)

if skipped > 0:
if method_type is EndOfStory:
skipped -= 1
elif method_type is BeginningOfStory:
skipped += 1
continue

if method_type is BeginningOfStory:
history.on_substory_start(method.story_name)
try:
contract.check_substory_call(ctx, ns)
except Exception as error:
history.on_error(error.__class__.__name__)
raise
continue

if method_type is EndOfStory:
history.on_substory_end()
continue

history.before_call(method.__name__)

try:
result = await method(ctx)
except Exception as error:
history.on_error(error.__class__.__name__)
raise

restype = type(result)
if restype not in (Result, Success, Failure, Skip):
raise AssertionError

if restype is Failure:
try:
protocol.check_return_statement(method, result.reason)
except Exception as error:
history.on_error(error.__class__.__name__)
raise
history.on_failure(result.reason)
return runner.got_failure(ctx, method.__name__, result.reason)

if restype is Result:
history.on_result(result.value)
return runner.got_result(result.value)

if restype is Skip:
history.on_skip()
skipped = 1
continue

try:
kwargs = contract.check_success_statement(method, ctx, ns, result.kwargs)
except Exception as error:
history.on_error(error.__class__.__name__)
raise

assign_namespace(ns, lines, method, kwargs)

return runner.finished()
55 changes: 55 additions & 0 deletions src/_stories/execute/coroutine.pyi
@@ -0,0 +1,55 @@
from typing import Any
from typing import Callable
from typing import List
from typing import overload
from typing import Tuple
from typing import Union

from _stories.contract import NullContract
from _stories.contract import SpecContract
from _stories.failures import DisabledNullExecProtocol
from _stories.failures import NotNullExecProtocol
from _stories.failures import NullExecProtocol
from _stories.history import History
from _stories.marker import BeginningOfStory
from _stories.marker import EndOfStory
from _stories.returned import Failure
from _stories.returned import Result
from _stories.returned import Skip
from _stories.returned import Success
from _stories.run import Call
from _stories.run import Run
@overload
async def execute(
runner: Call,
ctx: object,
history: History,
methods: List[
Tuple[
Union[
BeginningOfStory,
Callable[[object], Union[Result, Success, Failure, Skip]],
EndOfStory,
],
Union[NullContract, SpecContract],
Union[NullExecProtocol, DisabledNullExecProtocol, NotNullExecProtocol],
],
],
) -> Any: ...
@overload
async def execute(
runner: Run,
ctx: object,
history: History,
methods: List[
Tuple[
Union[
BeginningOfStory,
Callable[[object], Union[Result, Success, Failure, Skip]],
EndOfStory,
],
Union[NullContract, SpecContract],
Union[NullExecProtocol, DisabledNullExecProtocol, NotNullExecProtocol],
],
],
) -> object: ...
26 changes: 13 additions & 13 deletions src/_stories/execute/function.py
Expand Up @@ -24,6 +24,19 @@ def execute(runner, ctx, ns, lines, history, methods):
skipped += 1
continue

if method_type is BeginningOfStory:
history.on_substory_start(method.story_name)
try:
contract.check_substory_call(ctx, ns)
except Exception as error:
history.on_error(error.__class__.__name__)
raise
continue

if method_type is EndOfStory:
history.on_substory_end()
continue

history.before_call(method.__name__)

try:
Expand Down Expand Up @@ -54,19 +67,6 @@ def execute(runner, ctx, ns, lines, history, methods):
skipped = 1
continue

if method_type is BeginningOfStory:
try:
contract.check_substory_call(ctx, ns)
except Exception as error:
history.on_error(error.__class__.__name__)
raise
history.on_substory_start()
continue

if method_type is EndOfStory:
history.on_substory_end()
continue

try:
kwargs = contract.check_success_statement(method, ctx, ns, result.kwargs)
except Exception as error:
Expand Down
4 changes: 2 additions & 2 deletions src/_stories/history.py
Expand Up @@ -25,9 +25,9 @@ def on_skip(self):
def on_error(self, error_name):
self.lines[-1] += " (errored: " + error_name + ")"

def on_substory_start(self):
def on_substory_start(self, story_name):
self.before_call(story_name)
self.indent += 1

def on_substory_end(self):
self.lines.pop()
self.indent -= 1
11 changes: 2 additions & 9 deletions src/_stories/marker.py
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
from _stories.returned import Success


class BeginningOfStory(object):
Expand All @@ -9,11 +8,8 @@ def __init__(self, cls_name, name):
self.parent_name = None
self.same_object = None

def __call__(self, ctx):
return Success()

@property
def __name__(self):
def story_name(self):
if self.parent_name is None:
return self.cls_name + "." + self.name
elif self.same_object:
Expand All @@ -27,7 +23,4 @@ def set_parent(self, parent_name, same_object):


class EndOfStory(object):
def __call__(self, ctx):
return Success()

__name__ = "end_of_story"
pass
6 changes: 3 additions & 3 deletions src/_stories/marker.pyi
Expand Up @@ -2,8 +2,8 @@ from _stories.returned import Success

class BeginningOfStory:
def __init__(self, cls_name: str, name: str) -> None: ...
def __call__(self, ctx: object) -> Success: ...
@property
def story_name(self) -> str: ...
def set_parent(self, parent_name: str, same_object: bool) -> None: ...

class EndOfStory:
def __call__(self, ctx: object) -> Success: ...
class EndOfStory: ...
17 changes: 10 additions & 7 deletions src/_stories/mounted.py
@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
from _stories.context import make_context
from _stories.execute import function
from _stories.failures import make_run_protocol
from _stories.history import History
from _stories.marker import BeginningOfStory
Expand Down Expand Up @@ -31,39 +30,43 @@ def __repr__(self):


class MountedStory(object):
def __init__(self, obj, cls_name, name, arguments, methods, contract, failures):
def __init__(
self, obj, cls_name, name, arguments, methods, contract, failures, executor
):
self.obj = obj
self.cls_name = cls_name
self.name = name
self.arguments = arguments
self.methods = methods
self.contract = contract
self.failures = failures
self.executor = executor

def __call__(self, **kwargs):
__tracebackhide__ = True
history = History()
ctx, ns, lines = make_context(self.methods[0][1], kwargs, history)
runner = Call()
return function.execute(runner, ctx, ns, lines, history, self.methods)
return self.executor(runner, ctx, ns, lines, history, self.methods)

def run(self, **kwargs):
__tracebackhide__ = True
history = History()
ctx, ns, lines = make_context(self.methods[0][1], kwargs, history)
run_protocol = make_run_protocol(self.failures, self.cls_name, self.name)
runner = Run(run_protocol)
return function.execute(runner, ctx, ns, lines, history, self.methods)
return self.executor(runner, ctx, ns, lines, history, self.methods)

def __repr__(self):
result = []
indent = 0
for method, _contract, _protocol in self.methods:
method_type = type(method)
if method_type is EndOfStory:
if method_type is BeginningOfStory:
result.append(" " * indent + method.story_name)
indent += 1
elif method_type is EndOfStory:
indent -= 1
else:
result.append(" " * indent + method.__name__)
if method_type is BeginningOfStory:
indent += 1
return "\n".join(result)
11 changes: 9 additions & 2 deletions src/_stories/story.py
Expand Up @@ -31,7 +31,7 @@ def get_method(self, obj, cls):
cls, name, collected, contract_method, failures_method
)
else:
methods, contract, failures = wrap_story(
methods, contract, failures, executor = wrap_story(
arguments,
collected,
cls.__name__,
Expand All @@ -41,7 +41,14 @@ def get_method(self, obj, cls):
this["failures"],
)
return MountedStory(
obj, cls.__name__, name, arguments, methods, contract, failures
obj,
cls.__name__,
name,
arguments,
methods,
contract,
failures,
executor,
)

return type(
Expand Down

0 comments on commit 55cbfda

Please sign in to comment.