Skip to content

Commit

Permalink
Evolve Functionality (#211)
Browse files Browse the repository at this point in the history
* added evolve functionality similar to the underlying attrs lib that allows you to evolve the underlying Spockspace and return a new Spockspace object with the requested deltas. The evolve interface takes in instantiated @spock decorated classes and uses the deltas to evolve the underling attrs classes

* linted
  • Loading branch information
ncilfone committed Jan 25, 2022
1 parent 0e3ec7f commit 8ed2f75
Show file tree
Hide file tree
Showing 33 changed files with 348 additions and 4,245 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Expand Up @@ -4,6 +4,9 @@
# Debugging folder
debug/

# Auto-gen Reference Docs
website/docs/reference

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
10 changes: 10 additions & 0 deletions spock/backend/builder.py
Expand Up @@ -62,6 +62,16 @@ def input_classes(self):
"""Returns the graph of dependencies between spock classes"""
return self._input_classes

@property
def dag(self):
"""Returns the underlying graph DAG"""
return self._graph.dag

@property
def graph(self):
"""Returns the underlying graph object"""
return self._graph

@staticmethod
@abstractmethod
def _make_group_override_parser(parser, class_obj, class_name):
Expand Down
157 changes: 156 additions & 1 deletion spock/builder.py
Expand Up @@ -8,6 +8,8 @@
import argparse
import sys
import typing
from collections import Counter
from copy import deepcopy
from pathlib import Path
from uuid import uuid4

Expand All @@ -17,7 +19,10 @@
from spock.backend.payload import AttrPayload
from spock.backend.saver import AttrSaver
from spock.backend.wrappers import Spockspace
from spock.utils import check_payload_overwrite, deep_payload_update
from spock.exceptions import _SpockEvolveError, _SpockUndecoratedClass
from spock.utils import _is_spock_instance, check_payload_overwrite, deep_payload_update

_CLS = typing.TypeVar("_CLS", bound=type)


class ConfigArgBuilder:
Expand Down Expand Up @@ -567,3 +572,153 @@ def save_best(
def config_2_dict(self):
"""Dictionary representation of the arg payload"""
return self._saver_obj.dict_payload(self._arg_namespace)

def evolve(self, *args: typing.Type[_CLS]):
"""Function that allows a user to evolve the underlying spock classes with instantiated spock objects
This will map the differences between the passed in instantiated objects and the underlying class definitions
to the underlying namespace -- this essentially allows you to 'evolve' the Spockspace similar to how attrs
allows for class evolution -- returns a new Spockspace object
Args:
*args: variable number of @spock decorated classes to evolve parameters with
Returns:
new_arg_namespace: Spockspace evolved with *arg @spock decorated classes
"""
# First check that all instances are in the underlying set of input_classes and that there are no dupes
arg_counts = Counter([type(v).__name__ for v in args])
for k, v in arg_counts.items():
if v > 1:
raise _SpockEvolveError(
f"Passed multiple instances (count: {v}) of class `{k}` into `evolve()` -- please pass only a "
f"single instance of the class in order to evolve the underlying Spockspace"
)
elif k not in self._builder_obj.graph.node_names:
raise _SpockEvolveError(
f"Passed class `{k}` into `evolve()` but that class in not within the set of input "
f"classes {repr(self._builder_obj.graph.node_names)}"
)
# Create a new copy of the object
new_arg_namespace = deepcopy(self._arg_namespace)
# Determine the order of overwrite ops -- based on the topological order
topo_idx = sorted(
zip(
[
self._builder_obj.graph.topological_order.index(type(v).__name__)
for v in args
],
args,
)
)
args = {type(v).__name__: v for _, v in topo_idx}
# Walk through the now sorted set of evolve classes
for k, v in args.items():
# Get the class name from the object
cls_name = type(v).__name__
# Swap in the value to the new object
# Note: we don't need to evolve here as it's a fully new obj
setattr(new_arg_namespace, cls_name, v)
# Recurse upwards through the deps stack and evolve all the necessary classes
new_arg_namespace, all_cls = self._recurse_upwards(
new_arg_namespace, cls_name, args
)
return new_arg_namespace

def _recurse_upwards(
self, new_arg_namespace: Spockspace, current_cls: str, all_cls: typing.Dict
):
"""Using the underlying graph work recurse upwards through the parents and swap in the correct values
Args:
new_arg_namespace: new Spockspace object
current_cls: current name of the cls
all_cls: dict of the variable number of @spock decorated classes to evolve parameters with
Returns:
modified new_arg_namespace and the updated evolve class dict
"""
# Get the parent deps from the graph
parents = self._builder_obj.dag[current_cls]
if len(parents) > 0:
for parent_cls in parents:
parent_name = parent_cls.__name__
# Change the parent classes in the Spockspace
new_arg_namespace = self._set_matching_attrs_by_name(
new_arg_namespace, current_cls, parent_name
)
# if the parent is in the evolve classes then morph them too
if parent_name in all_cls.keys():
all_cls = self._set_matching_attrs_by_name_args(
current_cls, parent_name, all_cls
)
# then recurse to the parents
new_arg_namespace, all_cls = self._recurse_upwards(
new_arg_namespace, parent_name, all_cls
)
return new_arg_namespace, all_cls

@staticmethod
def _set_matching_attrs_by_name_args(
current_cls_name: str, parent_cls_name: str, all_cls: typing.Dict
):
"""Sets the value of an attribute by matching it to a spock class name
Args:
current_cls_name: current name of the changed class
parent_cls_name: name of the parent class that contains a reference to the current class
all_cls: dict of the variable number of @spock decorated classes to evolve parameters with
Returns:
modified all_cls dictionary
"""
new_arg_namespace = all_cls[parent_cls_name]
names = attr.fields_dict(type(new_arg_namespace)).keys()
for v in names:
if type(getattr(new_arg_namespace, v)).__name__ == current_cls_name:
# Some evolution magic -- attr library wants kwargs so trick it by unrolling the dict
# This creates a new object
new_obj = attr.evolve(
new_arg_namespace,
**{v: all_cls[current_cls_name]},
)
all_cls[parent_cls_name] = new_obj
print(
f"Evolved CLS Dependency: Parent = {parent_cls_name}, Child = {current_cls_name}, Value = {v}"
)
return all_cls

@staticmethod
def _set_matching_attrs_by_name(
new_arg_namespace: Spockspace, current_cls_name: str, parent_cls_name: str
):
"""Sets the value of an attribute by matching it to a spock class name
Args:
new_arg_namespace: new Spockspace object
current_cls_name: current name of the changed class
parent_cls_name: name of the parent class that contains a reference to the current class
Returns:
modified new_arg_namespace
"""
parent_attr = getattr(new_arg_namespace, parent_cls_name)
names = attr.fields_dict(type(parent_attr)).keys()
for v in names:
if type(getattr(parent_attr, v)).__name__ == current_cls_name:
# Some evolution magic -- attr library wants kwargs so trick it by unrolling the dict
# This creates a new object
new_obj = attr.evolve(
getattr(new_arg_namespace, parent_cls_name),
**{v: getattr(new_arg_namespace, current_cls_name)},
)
# Swap the new object into the existing attribute slot
setattr(new_arg_namespace, parent_cls_name, new_obj)
print(
f"Evolved: Parent = {parent_cls_name}, Child = {current_cls_name}, Value = {v}"
)
return new_arg_namespace
4 changes: 4 additions & 0 deletions spock/exceptions.py
Expand Up @@ -21,3 +21,7 @@ class _SpockDuplicateArgumentError(Exception):
"""Custom exception type for duplicated values"""

pass


class _SpockEvolveError(Exception):
"""Custom exception for when evolve errors occur"""
25 changes: 25 additions & 0 deletions spock/graph.py
Expand Up @@ -76,6 +76,10 @@ def roots(self):
"""Returns the roots of the dependency graph"""
return [self.node_map[k] for k, v in self.dag.items() if len(v) == 0]

@property
def topological_order(self):
return self._topological_sort()

@staticmethod
def _yield_class_deps(classes):
"""Generator to iterate through nodes and find dependencies
Expand Down Expand Up @@ -219,3 +223,24 @@ def _cycle_dfs(self, node: Type, visited: dict, recursion_stack: dict):
# Reset the stack for the current node if we've completed the DFS from this node
recursion_stack.update({node: False})
return False

def _topological_sort(self):
# DFS for topological sort
# https://en.wikipedia.org/wiki/Topological_sorting
visited = {key: False for key in self.node_names}
all_nodes = list(visited.keys())
stack = []
for node in all_nodes:
if visited.get(node) is False:
self._topological_sort_dfs(node, visited, stack)
stack.reverse()
return stack

def _topological_sort_dfs(self, node, visited, stack):
# Update the visited dict
visited.update({node: True})
# Recur for all edges
for val in self._dag.get(node):
if visited.get(val.__name__) is False:
self._topological_sort_dfs(val.__name__, visited, stack)
stack.append(node)
150 changes: 150 additions & 0 deletions tests/base/test_evolve.py
@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
import sys

import attr
import pytest

from spock import spock
from spock import SpockBuilder
from spock.exceptions import _SpockEvolveError, _SpockUndecoratedClass

from tests.base.attr_configs_test import *

@attr.s(auto_attribs=True)
class FailedClass:
one: int = 30


@spock
class NotEvolved:
one: int = 10


@spock
class EvolveNestedStuff:
one: int = 10
two: str = 'hello'


@spock
class EvolveNestedListStuff:
one: int = 10
two: str = 'hello'


class EvolveClassChoice(Enum):
class_nested_stuff = EvolveNestedStuff
class_nested_list_stuff = EvolveNestedListStuff


@spock
class TypeThinDefaultConfig:
"""This creates a test Spock config of all supported variable types as required parameters and falls back
to defaults
"""

# Boolean - Set
bool_p_set_def: bool = True
# Required Int
int_p_def: int = 10
# Required Float
float_p_def: float = 10.0
# Required String
string_p_def: str = "Spock"
# Required List -- Float
list_p_float_def: List[float] = [10.0, 20.0]
# Required List -- Int
list_p_int_def: List[int] = [10, 20]
# Required List -- Str
list_p_str_def: List[str] = ["Spock", "Package"]
# Required List -- Bool
list_p_bool_def: List[bool] = [True, False]
# Required Tuple -- Float
tuple_p_float_def: Tuple[float] = (10.0, 20.0)
# Required Tuple -- Int
tuple_p_int_def: Tuple[int] = (10, 20)
# Required Tuple -- Str
tuple_p_str_def: Tuple[str] = ("Spock", "Package")
# Required Tuple -- Bool
tuple_p_bool_def: Tuple[bool] = (True, False)
# Required choice
choice_p_str_def: StrChoice = "option_2"
# Required list of choice -- Str
list_choice_p_str_def: List[StrChoice] = ["option_1"]
# Required list of list of choice -- Str
list_list_choice_p_str_def: List[List[StrChoice]] = [["option_1"], ["option_1"]]
# Class Enum
class_enum_def: EvolveClassChoice = EvolveNestedStuff()


class TestEvolve:
"""Testing evolve functionality"""

@staticmethod
@pytest.fixture
def arg_builder(monkeypatch):
with monkeypatch.context() as m:
m.setattr(sys, "argv", [""])
config = SpockBuilder(EvolveNestedStuff, EvolveNestedListStuff, TypeThinDefaultConfig)
return config

def test_evolve(self, arg_builder):

evolve_nested_stuff = EvolveNestedStuff(
one=12345, two='abcdef'
)
evolve_type_config = TypeThinDefaultConfig(
bool_p_set_def=False,
int_p_def=16,
float_p_def=16.0,
string_p_def="Spocked",
list_p_float_def=[16.0, 26.0],
list_p_int_def=[16, 26],
list_p_str_def=["Spocked", "Packaged"],
list_p_bool_def=[False, True],
tuple_p_float_def=(16.0, 26.0),
tuple_p_int_def=(16, 26),
tuple_p_str_def=("Spocked", "Packaged"),
tuple_p_bool_def=(False, True),
choice_p_str_def="option_1",
list_choice_p_str_def=["option_2"],
list_list_choice_p_str_def=[["option_2"], ["option_2"]]
)
# Evolve the class
new_class = arg_builder.evolve(evolve_nested_stuff, evolve_type_config)
# Assert based on evolution
assert new_class.TypeThinDefaultConfig.bool_p_set_def is False
assert new_class.TypeThinDefaultConfig.int_p_def == 16
assert new_class.TypeThinDefaultConfig.float_p_def == 16.0
assert new_class.TypeThinDefaultConfig.string_p_def == "Spocked"
assert new_class.TypeThinDefaultConfig.list_p_float_def == [16.0, 26.0]
assert new_class.TypeThinDefaultConfig.list_p_int_def == [16, 26]
assert new_class.TypeThinDefaultConfig.list_p_str_def == ["Spocked", "Packaged"]
assert new_class.TypeThinDefaultConfig.list_p_bool_def == [False, True]
assert new_class.TypeThinDefaultConfig.tuple_p_float_def == (16.0, 26.0)
assert new_class.TypeThinDefaultConfig.tuple_p_int_def == (16, 26)
assert new_class.TypeThinDefaultConfig.tuple_p_str_def == ("Spocked", "Packaged")
assert new_class.TypeThinDefaultConfig.tuple_p_bool_def == (False, True)
assert new_class.TypeThinDefaultConfig.choice_p_str_def == "option_1"
assert new_class.TypeThinDefaultConfig.list_choice_p_str_def == ["option_2"]
assert new_class.TypeThinDefaultConfig.list_list_choice_p_str_def == [
["option_2"],
["option_2"],
]
assert new_class.TypeThinDefaultConfig.class_enum_def.one == 12345
assert new_class.TypeThinDefaultConfig.class_enum_def.two == 'abcdef'

def test_raise_multiples(self, arg_builder):
evolve_nested_stuff = EvolveNestedStuff(
one=12345, two='abcdef'
)
evolve_nested_stuff_2 = EvolveNestedStuff(
one=123456, two='abcdefg'
)
with pytest.raises(_SpockEvolveError):
new_class = arg_builder.evolve(evolve_nested_stuff, evolve_nested_stuff_2)

def test_raise_not_input(self, arg_builder):
evolve_not_evolved = NotEvolved(one=100)
with pytest.raises(_SpockEvolveError):
new_class = arg_builder.evolve(evolve_not_evolved)

0 comments on commit 8ed2f75

Please sign in to comment.