Skip to content

Commit

Permalink
Merge pull request #62 from pyapp-org/develop
Browse files Browse the repository at this point in the history
Release 0.16.0
  • Loading branch information
timsavage authored Oct 9, 2023
2 parents 47b8ef5 + 1c93262 commit ada8b16
Show file tree
Hide file tree
Showing 7 changed files with 286 additions and 197 deletions.
20 changes: 20 additions & 0 deletions HISTORY
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
0.16.0
======

Additions
---------

- Add ``and_finally`` option to the Group (and derived) nodes. This provides a way
to execute nodes to clean up after a workflow has completed successfully or
an exception was raised.

Changes
-------

- testing.call_node now accepts a workflow context. This allows for inspection
of the context if an exception is raised.
- Fix the inheritance of exceptions. All RunTime exceptions are now based off
of ``WorkflowRuntimeError``.
- ``TryExcept`` now resolves nodes called after an exception taking subclasses
into account. This matches the behaviour of Python itself.

0.15.0
======

Expand Down
372 changes: 191 additions & 181 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "pyapp-flow"
version = "0.15"
version = "0.16"
description = "Application workflow framework"
authors = ["Tim Savage <[email protected]>"]
license = "BSD-3-Clause"
Expand Down
6 changes: 3 additions & 3 deletions src/pyapp_flow/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ class WorkflowRuntimeError(WorkflowException, RuntimeError):
"""Error within the workflow runtime"""


class StepFailedError(WorkflowException):
class StepFailedError(WorkflowRuntimeError):
"""Error occurred within a step"""


class FatalError(WorkflowException):
class FatalError(WorkflowRuntimeError):
"""Fatal error occurred terminate the workflow."""


class SkipStep(WorkflowException):
class SkipStep(WorkflowRuntimeError):
"""Skip the current step."""
31 changes: 25 additions & 6 deletions src/pyapp_flow/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,12 @@ class Group(Navigable):
Useful for creating composable blocks, that don't require variable scope."""

__slots__ = ("_nodes", "_log_level")
__slots__ = ("_nodes", "_finally_nodes", "_log_level")

def __init__(self, *nodes_: Node, log_level: Union[str, int, None] = None):
"""Initialise nodes."""
self._nodes = list(nodes_)
self._finally_nodes = None
self._log_level = log_level

def __call__(self, context: WorkflowContext):
Expand All @@ -167,13 +168,27 @@ def __call__(self, context: WorkflowContext):
def name(self) -> str:
return f"🔽 {type(self).__name__}"

def and_finally(self, *nodes) -> Self:
"""Nodes that are always called even if an exception is raised.
This is analogous to a ``finally`` block."""
self._finally_nodes = nodes
return self

def branches(self) -> Optional[Branches]:
"""Return branches for this node."""
return {"": self._nodes}
if self._finally_nodes:
return {"": self._nodes + self._finally_nodes}
else:
return {"": self._nodes}

def _execute(self, context: WorkflowContext):
with change_log_level(self._log_level):
call_nodes(context, self._nodes)
try:
call_nodes(context, self._nodes)
finally:
if self._finally_nodes:
call_nodes(context, self._finally_nodes)


class SetVar(Navigable):
Expand Down Expand Up @@ -712,10 +727,14 @@ def __call__(self, context: WorkflowContext):
call_nodes(context, self._nodes)

except tuple(self._exceptions.keys()) as exception:
exception_type = type(exception)
context.info("🚨 Caught %s", exception_type.__name__)
context.info("🚨 Caught %s", type(exception).__name__)
context.state.exception = exception
call_nodes(context, self._exceptions[exception_type])

# Call first matching set of nodes
for exception_type, nodes in self._exceptions.items():
if isinstance(exception, exception_type):
call_nodes(context, self._exceptions[exception_type])
break

@property
def name(self) -> str:
Expand Down
14 changes: 10 additions & 4 deletions src/pyapp_flow/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@


def call_node(
node: Callable[[WorkflowContext], Any], **context_vars: Any
node: Callable[[WorkflowContext], Any],
*,
workflow_context: WorkflowContext = None,
**context_vars: Any
) -> WorkflowContext:
"""Simplifies the testing of any node.
Expand All @@ -25,6 +28,9 @@ def test_find_isbn__with_known_title():
assert actual == "978-0553283686"
"""
context = WorkflowContext(**context_vars)
functions.call_node(context, node)
return context
if workflow_context:
workflow_context.state.update(context_vars)
else:
workflow_context = WorkflowContext(**context_vars)
functions.call_node(workflow_context, node)
return workflow_context
38 changes: 36 additions & 2 deletions tests/unit/test_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,21 @@ def test_call__where_log_level_is_changed(self, caplog):

assert len(caplog.records) == 0

def test_call__where_an_error_is_raised_with_always_nodes(self):
context = WorkflowContext()
target = nodes.Group(
nodes.Append("messages", "foo"),
nodes.inline(valid_raise_exception),
nodes.Append("messages", "eek"),
).and_finally(
nodes.Append("messages", "bar"),
)

with pytest.raises(KeyError):
call_node(target, workflow_context=context)

assert context.state.messages == ["foo", "bar"]


class TestAppend:
def test_call__with_existing_variable(self):
Expand Down Expand Up @@ -417,11 +432,13 @@ def test_call__default_behaviour(self, target):
context = call_node(target)

assert context.state.message == ["False"]
def test_call__default_behaviour(self, target, enable_feature):

def test_call__default_behaviour__where_feature_enabled(self, target, enable_feature):
context = call_node(target)

assert context.state.message == ["True"]
def test_call__default_behaviour(self, target, disable_feature):

def test_call__default_behaviour__where_feature_disabled(self, target, disable_feature):
context = call_node(target)

assert context.state.message == ["False"]
Expand Down Expand Up @@ -574,6 +591,23 @@ def test_call__where_exception_is_caught(self, target):

assert context.state.track == ["a", "b", "except_on"]

def test_call__where_exception_is_subclass(self):
target = (
nodes.TryExcept(
track_step("a", "b", "c"),
track_step("b", "a"),
track_step("c", "a"),
)
.except_on(
WorkflowRuntimeError,
nodes.Append("track", "except_on"),
)
)

context = call_node(target, track=[], var_a="c")

assert context.state.track == ["a", "b", "except_on"]


class TestTryUntil:
@pytest.fixture
Expand Down

0 comments on commit ada8b16

Please sign in to comment.