diff --git a/modules/data/Variable.py b/modules/data/Variable.py index 62208a6..51413a5 100644 --- a/modules/data/Variable.py +++ b/modules/data/Variable.py @@ -76,12 +76,19 @@ def default(self) -> Option[A]: @abstractmethod def bind(self, val: A) -> 'BoundVariable[A]': ''' Bind a value to this Variable. - - This only works for unbound Variables; otherwise, a TypeError will - be raised. ''' ... # pragma: no cover + def __eq__(self, other: 'Variable') -> bool: + return all([ + self.name == other.name, + self.is_bound == other.is_bound, + self.desc == other.desc, + (self.val == other.val) if self.is_bound else True, + self.var_type == other.var_type, + self.default == other.default + ]) + class BoundVariable[A](Variable): ''' A Variable that has already been given a value. ''' @@ -116,11 +123,26 @@ def var_type(self) -> Option[type]: def default(self) -> Option[A]: return self.__default + def bind(self, val: A) -> 'BoundVariable[A]': + return BoundVariable( + self.name, val, self.var_type, self.desc, self.default + ) + + def __str__(self) -> str: + return ''.join([ + 'BoundVariable', + f'[{self.var_type.val.__name__}]' if self.var_type != Nothing() else '', + f' {self.name}', + f' = {self.val}', + f' (default {self.default.val})' if self.default != Nothing() else '', + f': {self.desc.val}' if self.desc != Nothing() else '' + ]) + class UnboundVariable[A](Variable): ''' A Variable which has not yet been given a value. ''' - def __init__(self, name: str, var_type: Option[type], desc: Option[str] = Nothing(), default: Option[A] = Nothing()): + def __init__(self, name: str, var_type: Option[type] = Nothing(), desc: Option[str] = Nothing(), default: Option[A] = Nothing()): self.__name = name self.__type = var_type self.__desc = desc @@ -148,4 +170,18 @@ def var_type(self) -> Option[type]: @property def default(self) -> Option[A]: - return self.__default \ No newline at end of file + return self.__default + + def bind(self, val: A) -> BoundVariable[A]: + return BoundVariable( + self.name, val, self.var_type, self.desc, self.default + ) + + def __str__(self) -> str: + return ''.join([ + 'UnboundVariable', + f'[{self.var_type.val.__name__}]' if self.var_type != Nothing() else '', + f' {self.name}', + f' (default {self.default.val})' if self.default != Nothing() else '', + f': {self.desc.val}' if self.desc != Nothing() else '' + ]) \ No newline at end of file diff --git a/modules/data/__init__.py b/modules/data/__init__.py deleted file mode 100644 index 3b87fb2..0000000 --- a/modules/data/__init__.py +++ /dev/null @@ -1,65 +0,0 @@ -''' - Chicory ML Workflow Manager - Copyright (C) 2024 Alexis Maya-Isabelle Shuping - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -''' - -from abc import ABC, abstractmethod - -class DataStore[T](ABC): - ''' - A write-once data-store. - ''' - @abstractmethod - def __init__(self, label: str, prev: DataStore = None): ... - - @property - @abstractmethod - def label(self) -> str: ... - - @property - @abstractmethod - def data(self) -> T: ... - - @property - @abstractmethod - def prev(self) -> DataStore|None: ... - - def __iter__(self): - cur = self - while(cur is not None): - yield cur - cur = cur.prev - - - Nothing = NoneDataStore('Nothing') - -class NoneDataStore(DataStore): - def __init__(self, label: str, prev: DataStore = None): - self.__label = label - self.__prev = prev - - @property - def label(self) -> str: - return self.__label - - @property - def data(self) -> None: - return None - - @property - def prev(self) -> DataStore|None: - return self.__prev - diff --git a/modules/types/ComparableError.py b/modules/types/ComparableError.py index 6e036ac..d80b6a5 100644 --- a/modules/types/ComparableError.py +++ b/modules/types/ComparableError.py @@ -58,6 +58,10 @@ def __eq__(self, other): - The two `args` attributes evaluate to equal. ''' other = ComparableError.encapsulate(other) # ensure `other` is comparable + + if type(other) != ComparableError: + return False + if type(self.exc) != type(other.exc): return False diff --git a/modules/types/Either.py b/modules/types/Either.py index 286aac3..51290d4 100644 --- a/modules/types/Either.py +++ b/modules/types/Either.py @@ -79,6 +79,9 @@ def __eq__(self, other) -> bool: ''' Returns True iff both Eithers being compared are the same type (i.e. both Left or both Right) AND the relevant inner values are the same. ''' + if not issubclass(type(other), Either): + return False + if self.is_right: if not other.is_right: return False diff --git a/modules/types/Option.py b/modules/types/Option.py index 3f03beb..926d6d1 100644 --- a/modules/types/Option.py +++ b/modules/types/Option.py @@ -53,6 +53,9 @@ def map(self, f: Callable[[A], B]) -> 'Option[B]': of the result. If it is `Nothing`, do not call `f` and just return `Nothing`. + + :param: f: A function that takes an `A` and converts it to a `B` + :returns: `Some(f(a))` if this Option is `Some(a)`, else `Nothing()` ''' ... # pragma: no cover @@ -60,11 +63,16 @@ def map(self, f: Callable[[A], B]) -> 'Option[B]': def flat_map(self, f: Callable[[A], 'Option[B]']) -> 'Option[B]': ''' Similar to Map, except that `f` should convert `A`'s directly into `Option[B]`'s. - + + :param f: A function that takes an `A` and converts it to an + `Option[B]` + :returns: `f(a)` if this `Option` is `Some(a)`, else `Nothing()` ''' ... # pragma: no cover def __eq__(self, other): + if not issubclass(type(other), Option): + return False if self.has_val: if other.has_val: return ComparableError.encapsulate(self.val) == ComparableError.encapsulate(other.val) @@ -77,6 +85,7 @@ def __eq__(self, other): return True class Nothing(Option): + ''' Represents an empty `Option`.''' @property def has_val(self) -> bool: return False @@ -95,6 +104,7 @@ def __str__(self): return f'Nothing' class Some[A](Option): + ''' Represents an `Option` that contains a concrete value. ''' def __init__(self, val: A): self.__val = val diff --git a/modules/types/Result.py b/modules/types/Result.py index d3eec8e..a0e46db 100644 --- a/modules/types/Result.py +++ b/modules/types/Result.py @@ -104,6 +104,9 @@ def flat_map(self, f: Callable[[C], 'Result[A, B, D]']) -> 'Result[A, B, D]': ... # pragma: no cover def __eq__(self, other): + if not issubclass(type(other), Result): + return False + if self.is_okay: if not other.is_okay: return False diff --git a/modules/workflow/Workflow.py b/modules/workflow/Workflow.py new file mode 100644 index 0000000..0c9e41d --- /dev/null +++ b/modules/workflow/Workflow.py @@ -0,0 +1,100 @@ +''' + Chicory ML Workflow Manager + Copyright (C) 2024 Alexis Maya-Isabelle Shuping + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +''' + +import logging +import random + +from modules.data.Variable import Variable, UnboundVariable +from modules.types.Option import Nothing, Some + +log = logging.get_logger(__name__) + +class Workflow(): + ''' Base class for Workflows. + + Instead of instantiating this directly, use `MakeWorkflow`. + ''' + def __init__(self, stages, inputs=[], outputs=[]): + pass + +class MakeWorkflow(): + ''' Workflow factory. Use this class to construct a Workflow that can then + be instantiated. + ''' + + def __init__(self, name=None): + if name is None: + name = f'Unnamed Workflow {random.randrange(0, 999999):06}' + + self.name = name + self.stages = [] + self.inputs = [] + self.outputs = [] + + def given(self, *args, **kwargs): + ''' Add one or more inputs to the Workflow. + + Arguments should either be `Variable`s or strings. In the latter + case, appropriate variables are created for the strings. + + Keyword arguments may be provided - these are interpreted as default + values for the variable. For example, providing `my_var=1` creates a + new `UnboundVariable` called `my_var` with a default value of `1`. + + Keyword argument names must, of course, be strings. To provide a + pre-existing `Variable` with a default value, the default must be + provided in the `Variable`'s instantiation + ''' + for arg in args: + if issubclass(type(arg), Variable): + self.inputs.append(arg) + elif type(arg) is str: + self.inputs.append(UnboundVariable(arg)) + else: + raise TypeError('Workflow inputs must be `Variable`s or strings.') + + for kwarg, kwdefault in kwargs.items(): + self.inputs.append(UnboundVariable( + arg, + default=Some(kwdefault) + )) + + def output(self, *args): + ''' Add one or more outputs to the Workflow. + + Arguments should either be `Variable`s or strings. In the latter + case, wiring will search for the latest `Variable` with the provided + name. + ''' + for arg in args: + if issubclass(type(arg), Variable): + self.outputs.append(arg) + elif type(arg) is str: + self.outputs.append(UnboundVariable(arg)) + else: + raise TypeError('Workflow outputs must be `Variable`s or strings.') + + def from_stages(self, *args): + ''' Add stages to the Workflow. + ''' + self.stages = args + + def __call__(self, *args, **kwargs): + ''' Instantiate the Workflow and bind the provided arguments to its + inputs. + ''' \ No newline at end of file diff --git a/modules/workflow/stage/Stage.py b/modules/workflow/stage/Stage.py new file mode 100644 index 0000000..ed1bd15 --- /dev/null +++ b/modules/workflow/stage/Stage.py @@ -0,0 +1,140 @@ +''' + Chicory ML Workflow Manager + Copyright (C) 2024 Alexis Maya-Isabelle Shuping + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +''' + +from modules.data.Variable import Variable, UnboundVariable +from modules.types.Option import Option, Some, Nothing + +from dataclasses import dataclass +from typing import Callable +from abc import ABC, abstractmethod + +def _process_stage_io( + dex: int, + i: Variable, + args: list[Variable|str], + kwargs: dict[str, Variable|str] +) -> Option[Variable]: + ''' Match a single input (or output) from our variable list to the provided + parameters. + + :param dex: Offset in our input `Variable` list for `i` + :param i: Input `Variable` to match + :param args: Positional function args to match against + :param kwargs: Keyword function args to match against + :returns: The matching `Variable` if found, or `Nothing` if not. + ''' + def __atov(a: Variable|str) -> Variable: + if type(a) == str: + return UnboundVariable(a) + else: + return a + + if args and len(args) > dex: + return Some(__atov(args[dex])) + elif kwargs and i.name in kwargs.keys(): + return Some(__atov(kwargs[i.name])) + else: + return Nothing() + +class Stage(ABC): + ''' Represents a Stage - something that can be used as a step in a Workflow. + + Stages are immutable - their inputs (and outputs, if applicable), do not + change once set. Functions modifying Stages will return a new Stage with + the desired modifications, rather than modifying the existing Stage + in-place. + ''' + + input_vars: tuple[UnboundVariable] = () + output_vars: tuple[UnboundVariable] = () + + def __init__(self, + *args: list[str|Variable], + _to: tuple[list[str|Variable], dict[str, str|Variable]]=([], {}), + **kwargs: dict[str, str|Variable] + ): + ''' Create a Stage and bind its input variables. + + All arguments should be either `Variable`s or strings. Strings are + automatically converted to `UnboundVariable`s. Keyword arguments are + matched by name; positional arguments are matched by position. + + :param _to: Internal-use constructor for supplying output variable + information. + ''' + self.__inputs = tuple([ + _process_stage_io(dex, i, args, kwargs) + for dex, i in enumerate(self.input_vars) + ]) + + self.__outputs = tuple([ + _process_stage_io(dex, i, _to[0], _to[1]) + for dex, i in enumerate(self.output_vars) + ]) + + self.__args = tuple(args) + self.__kwargs = tuple(kwargs) + self.__to = _to + + @abstractmethod + def run(self, inputs: list[Variable]) -> 'StageResult': + ''' Run this stage, returning a StageResult containing output + information. + + :param inputs: A list of all `Variables` needed for this `Stage` to + run. + :returns: A `StageResult` containing output `Variable`s for this + `Stage`. + ''' + ... + + @property + def _input_bindings(self) -> list[Variable]: + ''' A list of Input bindings for this stage. Used for internal wiring. + ''' + return self.__inputs + + @property + def _output_bindings(self) -> Option[list[Variable]]: + ''' An `Option`al list of Output bindings for this stage. Used for + internal wiring. + ''' + return self.__outputs + + def to(self, *args, **kwargs) -> 'Stage': + ''' Bind the outputs of the `Stage` to a `Variable` or name. + + :param args: Outputs to be bound by position + :params kwargs: Outputs to be bound by name + :returns: A copy of this `Stage` with the outputs bound. + ''' + _to = (args, kwargs) + + return type(self)( + _to=_to, + *self.__args, + **self.__kwargs + ) + + +@dataclass(frozen=True) +class StageResult(): + ''' Holds the result of an executed Stage. + ''' + stage: Stage + outputs: tuple[Variable] \ No newline at end of file diff --git a/modules/workflow/stage/StageFromFunction.py b/modules/workflow/stage/StageFromFunction.py new file mode 100644 index 0000000..ad7ba72 --- /dev/null +++ b/modules/workflow/stage/StageFromFunction.py @@ -0,0 +1,106 @@ +''' + Chicory ML Workflow Manager + Copyright (C) 2024 Alexis Maya-Isabelle Shuping + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +''' + +from modules.data.Variable import Variable, UnboundVariable +from modules.types.Option import Option, Some, Nothing +from modules.workflow.stage.Stage import Stage, StageResult + +from abc import ABC, abstractmethod +import inspect +from inspect import Parameter, Signature +from typing import Callable + +def _input_list_to_kwargs(input_list: tuple[Variable]) -> dict[str, any]: + kwargs = {} + for v in input_list: + kwargs[v.name] = v.val + + return kwargs + +class StageFromFunction(Stage): + ''' A `Stage` constructed automatically from a function. + + To construct a `StageFromFunction`, use `make_StageFromFunction`. + ''' + + @abstractmethod + def inner_function(self, *args, **kwargs): + ''' The actual function encapsulated in a StageFromFunction. + ''' + ... #pragma: no cover + + def run(self, inputs: tuple[Variable]) -> StageResult: + rval = self.inner_function(**_input_list_to_kwargs(inputs)) + + return StageResult( + self, + (self.output_vars[0].bind(rval),) if len(self.output_vars) > 0 else () + ) + + +def _extract_func_args_and_ret(fn: Callable) -> tuple[list[Variable], Option[Variable]]: + ''' Extract the arguments and (if present) return-type annotation from a + function. + + :param fn: The function to extract data from. + :returns: A tuple whose first element is the list of input `Variable`s + and the second element is an `Option`al return-type `Variable` + ''' + sig = inspect.signature(fn) + + args = [ + UnboundVariable( + param.name, + Some(param.annotation) if param.annotation != Parameter.empty else Nothing(), + default=Some(param.default) if param.default != Parameter.empty else Nothing() + ) for param in sig.parameters.values() + ] + + ret = Some(UnboundVariable( + 'return', + Some(sig.return_annotation) + )) if sig.return_annotation != Signature.empty else Nothing() + + return (args, ret) + + +def make_StageFromFunction(fn: Callable) -> type[StageFromFunction]: + ''' Return a concrete subclass of `StageFromFunction`, with its inner + function set to the provided `fn`. Unlike `StageFromFunction` itself, + these subclasses can be directly instantiated. + + :param fn: The function to convert. + :returns: A concrete subclass of `StageFromFunction` + ''' + args, ret = _extract_func_args_and_ret(fn) + + inputs = tuple(args) + outputs = (ret.val,) if ret != Nothing() else () + + print(f'inputs: {inputs}') + print(f'outputs: {outputs}') + + return type( + f'StageFromFunction_{fn.__name__}_{hash(fn)}', + (StageFromFunction,), + { + 'input_vars': inputs, + 'output_vars': outputs, + 'inner_function': lambda self, *args, **kwargs: fn(*args, **kwargs) + } + ) \ No newline at end of file diff --git a/test/types/test_ComparableError.py b/test/types/test_ComparableError.py index 2f90deb..8966aab 100644 --- a/test/types/test_ComparableError.py +++ b/test/types/test_ComparableError.py @@ -21,6 +21,7 @@ def test_eq(): assert ComparableError(TypeError("test")) == ComparableError(TypeError("test")) assert ComparableError(TypeError("test")) != ComparableError(TypeError("test2")) assert ComparableError(TypeError("test")) != ComparableError(ArithmeticError("test")) + assert ComparableError(TypeError("test")) != "test" def test_auto_encapsulate(): assert ComparableError(TypeError("test")) == TypeError("test") diff --git a/test/types/test_Either.py b/test/types/test_Either.py index 52ee904..ab90c7a 100644 --- a/test/types/test_Either.py +++ b/test/types/test_Either.py @@ -20,10 +20,12 @@ def test_equals(): assert Left(12) == Left(12) assert Left(12) != Left(13) assert Left(12) != Left("12") + assert Left(12) != 12 assert Right(12) == Right(12) assert Right(12) != Right(13) assert Right(12) != Right("12") + assert Right(12) != 12 assert Right(12) != Left(12) assert Left(12) != Right(12) diff --git a/test/types/test_Option.py b/test/types/test_Option.py index afa4740..af05d47 100644 --- a/test/types/test_Option.py +++ b/test/types/test_Option.py @@ -29,6 +29,7 @@ def test_eq(): assert Some(None) == Some(None) assert Some(37) != Some(36) + assert Some(37) != 37 assert Some("test") != Some("tes") assert Some(37) != Some("37") @@ -39,6 +40,7 @@ def test_eq(): assert Nothing() != Some(37) assert Nothing() != Some("test") assert Nothing() != Some(None) + assert Nothing() != None assert Nothing() == Nothing() diff --git a/test/types/test_Result.py b/test/types/test_Result.py index 49ccc44..22c46e9 100644 --- a/test/types/test_Result.py +++ b/test/types/test_Result.py @@ -51,17 +51,27 @@ def test_eq(): assert Okay(37) != Okay(36) assert Okay(37) != Okay("37") + assert Okay(37) != 37 assert Warning([ArithmeticError(6)], 37) != Warning([ArithmeticError(6)], 36) assert Warning([ArithmeticError(6)], 37) != Warning([ArithmeticError(6)], "37") assert Warning([ArithmeticError(6)], 37) != Warning([SyntaxError(6)], 37) assert Warning([ArithmeticError(6)], 37) != Warning([ArithmeticError(5)], 37) + assert Warning([ArithmeticError(6)], 37) != 37 + assert Warning([], 37) != 37 + assert Warning([ArithmeticError(6)], 37) != ArithmeticError(6) assert Error([SyntaxError("test")], [ArithmeticError(6)]) != Error([SyntaxError("test")], [ArithmeticError(5)]) assert Error([SyntaxError("test")], [ArithmeticError(6)]) != Error([ArithmeticError(6)], [SyntaxError("test")]) assert Error([SyntaxError("test")], [ArithmeticError(6)]) != Error([SyntaxError("test")], [ArithmeticError(5)]) assert Error([SyntaxError("test")], [ArithmeticError(6)]) != Error([SyntaxError("test")], [ArithmeticError("6")]) assert Error([SyntaxError("test")], [ArithmeticError(6)]) != Error([SyntaxError("test2")], [ArithmeticError(6)]) + assert Error([SyntaxError("test")], [ArithmeticError(6)]) != SyntaxError("test") + assert Error([SyntaxError("test")], [ArithmeticError(6)]) != [SyntaxError("test")] + assert Error([SyntaxError("test")], [ArithmeticError(6)]) != "test" + assert Error([SyntaxError("test")], [ArithmeticError(6)]) != ArithmeticError(6) + assert Error([SyntaxError("test")], [ArithmeticError(6)]) != [ArithmeticError(6)] + assert Error([SyntaxError("test")], [ArithmeticError(6)]) != 6 assert Okay(37) != Warning([ArithmeticError(6)], 37) assert Okay(37) != Error([SyntaxError()], [ArithmeticError(6)]) diff --git a/test/workflow/stage/test_StageFromFunction.py b/test/workflow/stage/test_StageFromFunction.py new file mode 100644 index 0000000..1ad3f9b --- /dev/null +++ b/test/workflow/stage/test_StageFromFunction.py @@ -0,0 +1,54 @@ + +from modules.data.Variable import BoundVariable, UnboundVariable +from modules.workflow.stage.StageFromFunction import StageFromFunction, make_StageFromFunction +from modules.types.Option import Some + +def test_basic_function(): + def fn(x: int) -> int: + print('calling inner function') + return 2*x + + stage = make_StageFromFunction(fn)( + UnboundVariable('x') + ) + + res = stage.run( + (BoundVariable('x', 2),) + ) + + assert len(res.outputs) == 1 + assert res.outputs[0].val == 4 + +def test_input_types(): + def fn(x: int, y: str, z = 'test') -> bool: + return True + + stage_cls = make_StageFromFunction(fn) + + print(f'input_vars: {stage_cls.input_vars}') + + [print(var) for var in stage_cls.input_vars] + + assert stage_cls.input_vars == ( + UnboundVariable('x', Some(int)), + UnboundVariable('y', Some(str)), + UnboundVariable('z', default=Some('test')) + ) + + assert stage_cls.output_vars == (UnboundVariable('return', Some(bool)),) + +def test_side_effecting_function(): + global fn_run_count + fn_run_count = 0 + def fn(): + global fn_run_count + fn_run_count += 1 + + + stage_cls = make_StageFromFunction(fn) + + assert stage_cls.input_vars == () + assert stage_cls.output_vars == () + + assert len(stage_cls().run(()).outputs) == 0 + assert fn_run_count == 1 \ No newline at end of file