Skip to content

Commit

Permalink
actionpack#65: "adds pipeline abstraction" (#77)
Browse files Browse the repository at this point in the history
* introduces ActionType metaclass

* introduces the Pipeline abstraction

* Pipeline made into an Action

* rectifies Serialization.validate return value issue

* updates WriteBytes.instruction the handle "append" mode without decoding bytes

* WriteBytes.instruction returns absolute filepath where bytes were successfully written

* finalizes the Pipeline implementation
  • Loading branch information
withtwoemms authored Oct 18, 2021
1 parent ca5c9ab commit 6148eaf
Show file tree
Hide file tree
Showing 9 changed files with 198 additions and 17 deletions.
6 changes: 5 additions & 1 deletion actionpack/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ class OutcomeMustBeOfTypeEither(Exception):
pass


class Action(Generic[Name, Outcome]):
class ActionType(type):
pass


class Action(Generic[Name, Outcome], metaclass=ActionType):

_name: Optional[Name] = None

Expand Down
3 changes: 2 additions & 1 deletion actionpack/actions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from actionpack.actions.call import Call
from actionpack.actions.pipeline import Pipeline
from actionpack.actions.make_request import MakeRequest
from actionpack.actions.read_bytes import ReadBytes
from actionpack.actions.read_input import ReadInput
Expand All @@ -10,10 +11,10 @@
__all__ = [
'Call',
'MakeRequest',
'Pipeline',
'ReadBytes',
'ReadInput',
'RetryPolicy',
'Serialization',
'WriteBytes'
]

91 changes: 91 additions & 0 deletions actionpack/actions/pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from collections import OrderedDict
from inspect import signature
from typing import Optional

from actionpack import Action
from actionpack.action import ActionType
from actionpack.actions import Call


class Pipeline(Action):

def __init__(self, action: Action, *action_types: ActionType, should_raise: bool = False):
self.action = action
self.should_raise = should_raise

for action_type in action_types:
if not isinstance(action_type, ActionType):
raise TypeError(f'Must be an {ActionType.__name__}: {action_type}')

self.action_types = action_types
self._action_types = iter(self.action_types)

def instruction(self):
return self.flush(self.action).perform(should_raise=self.should_raise).value

# recursive
def flush(self, given_action: Optional[Action] = None) -> Action:
next_action_type = next(self)

if next_action_type:
params_dict = OrderedDict(signature(next_action_type.__init__).parameters.items())
params_dict.pop('self', None)
params = list(params_dict.keys())
keyed_result = dict(zip(params, [given_action.perform(should_raise=self.should_raise).value]))
if next_action_type.__name__ == Pipeline.Fitting.__name__:
params_dict = OrderedDict(signature(next_action_type.action.__init__).parameters.items())
params_dict.pop('self', None)
params = list(params_dict.keys())
keyed_result = dict(zip(params, [keyed_result['action']]))
next_action_type.kwargs.update(keyed_result)
next_action = next_action_type.action(**next_action_type.kwargs)
else:
next_action = next_action_type(**keyed_result)

return self.flush(next_action)

return given_action

def __iter__(self):
return self._action_types

def __next__(self):
try:
return next(self._action_types)
except StopIteration:
self._action_types = iter(self._action_types)

class Fitting(type):

@staticmethod
def init(
action: Action,
should_raise: bool = False,
reaction: Call = None,
*args, **kwargs
):
pass

@staticmethod
def instruction(instance):
action_performance = instance.action.perform(should_raise=instance.should_raise)
if action_performance.successful and instance.reaction:
return instance.reaction.perform(should_raise=instance.should_raise)
return action_performance

def __new__(
mcs,
action: Action,
should_raise: bool = False,
reaction: Call = None,
**kwargs
):
dct = dict()
dct['__init__'] = Pipeline.Fitting.init
dct['instruction'] = Pipeline.Fitting.instruction
dct['action'] = action
dct['should_raise'] = should_raise
dct['reaction'] = reaction
dct['kwargs'] = kwargs
cls = type(Pipeline.Fitting.__name__, (Action,), dct)
return cls
17 changes: 16 additions & 1 deletion actionpack/actions/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,25 @@


class Serialization(Action[Name, str]):
def __init__(self, schema, data, inverse=False):
def __init__(self, schema=None, data=None, inverse=False):
self.schema = schema
self.data = data
self.validate()
self.inverse = inverse

def validate(self):
if not self.data:
raise self.NoDataGiven()
if not self.schema:
raise self.NoSchemaGiven()
return self

def instruction(self) -> str:
return self.schema.loads(self.data) if self.inverse else self.schema.dumps(self.data)

class NoDataGiven(Exception):
pass

class NoSchemaGiven(Exception):
pass

10 changes: 4 additions & 6 deletions actionpack/actions/write_bytes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,10 @@ def __init__(self, filename: str, bytes_to_write: bytes, overwrite: bool = False

def instruction(self) -> int:
if self.append:
appendable_path = self.path.open('a')
# NOTE: Byte decoding is fixed as UTF-8.
# In the future, a character encoding option should be provided to the user
return appendable_path.write(f'{self.bytes_to_write.decode("utf-8")}\n')

return self.path.write_bytes(self.bytes_to_write)
self.path.open('ab').write(self.bytes_to_write + b'\n')
else:
self.path.write_bytes(self.bytes_to_write)
return str(self.path.absolute())

def validate(self) -> WriteBytes[Name, int]:
if self.overwrite and self.append:
Expand Down
10 changes: 9 additions & 1 deletion actionpack/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pickle

from functools import wraps
from typing import Callable
from typing import Callable, Iterable
from typing import Generic
from typing import Optional
from typing import TypeVar
Expand Down Expand Up @@ -56,3 +56,11 @@ def newFunction(*args, **kw):
return f(*args, **kw)
return newFunction
return wrap


def first(iterable: Iterable):
return iterable[0]


def last(iterable: Iterable):
return iterable[-1]
2 changes: 1 addition & 1 deletion tests/actionpack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def validate(self):

class FakeFile:
def __init__(self, contents: bytes = bytes(), mode: str = None):
self.buffer = BytesIO(contents) if mode not in ['a', 'a+'] else StringIO(contents.decode())
self.buffer = BytesIO(contents)
self.buffer.read()
self.mode = mode

Expand Down
62 changes: 62 additions & 0 deletions tests/actionpack/actions/test_pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from unittest import TestCase
from unittest.mock import patch

from actionpack.action import Result
from actionpack.actions import Pipeline
from actionpack.actions import ReadInput
from actionpack.actions import ReadBytes
from actionpack.actions import WriteBytes
from actionpack.actions.pipeline import Call
from actionpack.utils import Closure
from tests.actionpack import FakeFile


class PipelineTest(TestCase):

@patch('pathlib.Path.open')
@patch('pathlib.Path.exists')
@patch('builtins.input')
def test_Pipeline(self, mock_input, mock_exists, mock_file):
filename = 'this/file.txt'
contents = b"What's wrong with him? ...My first thought would be, 'a lot'."
file = FakeFile(contents)

mock_file.return_value = file
mock_exists.return_value = True
mock_input.return_value = filename

pipeline = Pipeline(ReadInput('Which file?'), ReadBytes)
result = pipeline.perform()

self.assertIsInstance(result, Result)
self.assertEqual(result.value, contents)

@patch('pathlib.Path.open')
@patch('pathlib.Path.exists')
@patch('builtins.input')
def test_Pipeline_FittingType(self, mock_input, mock_exists, mock_file):
filename = 'this/file.txt'
file = FakeFile()
question = b'How are you?'
reply = 'I\'m fine.'

mock_file.return_value = file
mock_exists.return_value = True
mock_input.side_effect = [filename, reply]

read_input = ReadInput('Which file?')
action_types = [
Pipeline.Fitting(
action=WriteBytes,
reaction=Call(Closure(bytes.decode)),
**{'append': True, 'bytes_to_write': question},
),
ReadBytes, # retrieve question from FakeFile
ReadInput # pose question to user
]
pipeline = Pipeline(read_input, *action_types, should_raise=True)
result = pipeline.perform(should_raise=True)

self.assertEqual(file.read(), question + b'\n')
self.assertIsInstance(result, Result)
self.assertEqual(result.value, reply)
14 changes: 8 additions & 6 deletions tests/actionpack/actions/test_write_bytes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pickle

from os import getcwd as cwd
from unittest import TestCase
from unittest.mock import patch

Expand All @@ -14,7 +15,8 @@ class WriteBytesTest(TestCase):
def setUp(self):
self.salutation = 'Hello.'.encode()
self.question = ' How are you?'.encode()
self.action = WriteBytes('valid/path/to/file', self.question)
self.absfilepath = f'{cwd()}/valid/path/to/file'
self.action = WriteBytes(self.absfilepath, self.question)

@patch('pathlib.Path.open')
def test_can_WriteBytes(self, mock_output):
Expand All @@ -24,7 +26,7 @@ def test_can_WriteBytes(self, mock_output):

self.assertEqual(file.read(), self.salutation + self.question)
self.assertIsInstance(result, Result)
self.assertEqual(result.value, len(self.question))
self.assertEqual(result.value, self.absfilepath)

@patch('pathlib.Path.open')
def test_can_overWriteBytes(self, mock_output):
Expand All @@ -33,27 +35,27 @@ def test_can_overWriteBytes(self, mock_output):
result = self.action.perform()

self.assertEqual(file.read(), self.question)
self.assertEqual(result.value, len(self.question))
self.assertEqual(result.value, self.absfilepath)

@patch('pathlib.Path.open')
def test_can_WriteBytes_in_append_mode(self, mock_output):
file = FakeFile(self.salutation, mode='a')
mock_output.return_value = file
question = b' How are you?'
action = WriteBytes('valid/path/to/file', question, append=True)
action = WriteBytes(self.absfilepath, question, append=True)
action.perform()
action.perform()

self.assertEqual(
f'{self.salutation.decode()}{question.decode()}\n{question.decode()}\n',
self.salutation + question + b'\n' + question + b'\n',
file.read()
)

@patch('pathlib.Path.open')
def test_cannot_overwrite_and_append(self, mock_output):
file = FakeFile(self.salutation)
mock_output.return_value = file
action = WriteBytes('valid/path/to/file', b'bytes to write', overwrite=True, append=True)
action = WriteBytes(self.absfilepath, b'bytes to write', overwrite=True, append=True)

with self.assertRaises(ValueError):
action.validate()
Expand Down

0 comments on commit 6148eaf

Please sign in to comment.