Skip to content

Commit

Permalink
base: Implement (im)mutable config properties
Browse files Browse the repository at this point in the history
As defined by arch/0003-Config-Property-Mutable-vs-Immutable.rst

Related: ipython/ipython#1456

Signed-off-by: John Andersen <johnandersenpdx@gmail.com>
  • Loading branch information
pdxjohnny committed Aug 4, 2021
1 parent 2ef9945 commit 0e24573
Show file tree
Hide file tree
Showing 7 changed files with 160 additions and 14 deletions.
145 changes: 140 additions & 5 deletions dffml/base.py
Expand Up @@ -14,6 +14,7 @@
from argparse import ArgumentParser
from typing import Dict, Any, Type, Optional, Union

from .util.python import within_method
from .util.data import get_args, get_origin
from .util.cli.arg import Arg
from .util.data import (
Expand Down Expand Up @@ -291,6 +292,7 @@ def field(
action=None,
required: bool = False,
labeled: bool = False,
mutable: bool = False,
metadata: Optional[dict] = None,
**kwargs,
):
Expand All @@ -305,26 +307,157 @@ def field(
metadata["required"] = required
metadata["labeled"] = labeled
metadata["action"] = action
metadata["mutable"] = mutable
return dataclasses.field(*args, metadata=metadata, **kwargs)


def config_asdict(self, *args, **kwargs):
return export_dict(**dataclasses.asdict(self, *args, **kwargs))


def config(cls):
def config_ensure_immutable_init(self):
"""
Decorator to create a dataclass
Initialize immutable support config instance local variables.
We can't call this on ``__init__`` so we call it whenever we are about to
use it.
"""
if hasattr(self, "_mutable_callbacks"):
return
self._mutable_callbacks = set()
self._data = {}
# Only enforce mutable/immutable checks if _enforce_immutable == True
self._enforce_immutable = True


def config_add_mutable_callback(self, func):
config_ensure_immutable_init(self)
self._mutable_callbacks.add(func)


@contextlib.contextmanager
def config_no_enforce_immutable(self):
"""
By default, all properties of a config object are immutable. If you would
like to mutate immutable properties, you must explicitly call this method
using it as a context manager.
Examples
--------
>>> from dffml import config
>>>
>>> @config
... class MyConfig:
... C: int
>>>
>>> config = MyConfig(C=2)
>>> with config.no_enforce_immutable():
... config.C = 1
"""
config_ensure_immutable_init(self)
self._enforce_immutable = False
try:
yield
finally:
self._enforce_immutable = True


def config_make_getter(key):
"""
Create a getter function for use with :py:func:`property` on config objects.
"""

def getter_mutable(self):
if not key in self._data:
raise AttributeError(key)
return self._data[key]

return getter_mutable


class ImmutableConfigPropertyError(Exception):
"""
Raised when config property was changed but was not marked as mutable.
"""


class NoMutableCallbacksError(Exception):
"""
Raised when a config property is mutated but there are not mutable callbacks
present to handle it's update.
"""


def config_make_setter(key, immutable):
"""
datacls = dataclasses.dataclass(eq=True, init=True)(cls)
Create a setter function for use with :py:func:`property` on config objects.
"""

def setter_immutable(self, value):
config_ensure_immutable_init(self)
# Reach into caller's stack frame to check if we are in the
# __init__ function of the dataclass. If we are in the __init__
# method we should not enforce immutability. Set max_depth to 4 in
# case of __post_init__. No point in searching farther.
if within_method(self, "__init__", max_depth=4):
# Mutate without checks if we are within the __init__ code of
# the class. Then bail out, we're done here.
self._data[key] = value
return
# Raise if the property is immutable and we're in enforcing mode
if self._enforce_immutable:
if immutable:
raise ImmutableConfigPropertyError(
f"Attempted to mutate immutable property {self.__class__.__qualname__}.{key}"
)
# Ensure we have callbacks if we're mutating
if self._mutable_callbacks:
raise NoMutableCallbacksError(
"Config instance has no mutable_callbacks registered but a mutable property was updated"
)
# Call callbacks to notify we've mutated
for func in self._mutable_callbacks:
func(key, value)
# Mutate property
self._data[key] = value

return setter_immutable


def _config(datacls):
datacls._fromdict = classmethod(_fromdict)
datacls._replace = lambda self, *args, **kwargs: dataclasses.replace(
self, *args, **kwargs
)
datacls._asdict = config_asdict
datacls.add_mutable_callback = config_add_mutable_callback
datacls.no_enforce_immutable = config_no_enforce_immutable

for field in dataclasses.fields(datacls):
# Make deleter None so it raises AttributeError: can't delete attribute
setattr(
datacls,
field.name,
property(
config_make_getter(field.name),
config_make_setter(
field.name, field.metadata.get("mutable", False)
),
None,
),
)

return datacls


def config(cls):
"""
Decorator to create a dataclass
"""
return _config(dataclasses.dataclass(eq=True, init=True)(cls))


def make_config(cls_name: str, fields, *args, namespace=None, **kwargs):
"""
Function to create a dataclass
Expand Down Expand Up @@ -354,8 +487,10 @@ def make_config(cls_name: str, fields, *args, namespace=None, **kwargs):
fields_non_default.append((name, cls, field))
fields = fields_non_default + fields_default
# Create dataclass
return dataclasses.make_dataclass(
cls_name, fields, *args, namespace=namespace, **kwargs
return _config(
dataclasses.make_dataclass(
cls_name, fields, *args, namespace=namespace, **kwargs
)
)


Expand Down
3 changes: 2 additions & 1 deletion dffml/df/memory.py
Expand Up @@ -1549,7 +1549,8 @@ async def run(
if self.config.max_ctxs is not None and self.config.max_ctxs > len(
ctxs
):
self.config.max_ctxs = None
with self.config.no_enforce_immutable():
self.config.max_ctxs = None
# Create tasks to wait on the results of each of the contexts submitted
for ctxs_index in range(0, len(ctxs)):
if (
Expand Down
5 changes: 4 additions & 1 deletion dffml/model/model.py
Expand Up @@ -78,7 +78,10 @@ def __init__(self, config):
if isinstance(location, pathlib.Path):
# to treat "~" as the the home location rather than a literal
location = location.expanduser().resolve()
self.config.location = location
# TODO Change all model configs to make them support mutable
# location config properties
with self.config.no_enforce_immutable():
self.config.location = location

def __call__(self) -> ModelContext:
self._make_config_location()
Expand Down
5 changes: 4 additions & 1 deletion dffml/source/df.py
Expand Up @@ -164,7 +164,10 @@ async def __aenter__(self) -> "DataFlowSourceContext":
async with config_cls.withconfig({}) as configloader:
async with configloader() as loader:
exported = await loader.loadb(dataflow_path.read_bytes())
self.parent.config.dataflow = DataFlow._fromdict(**exported)
with self.parent.config.no_enforce_immutable():
self.parent.config.dataflow = DataFlow._fromdict(
**exported
)

self.octx = await self.parent.orchestrator(
self.parent.config.dataflow
Expand Down
10 changes: 6 additions & 4 deletions dffml/source/dir.py
Expand Up @@ -43,7 +43,8 @@ class DirectorySource(MemorySource):
def __init__(self, config):
super().__init__(config)
if isinstance(getattr(self.config, "foldername", None), str):
self.config.foldername = pathlib.Path(self.config.foldername)
with self.config.no_enforce_immutable():
self.config.foldername = pathlib.Path(self.config.foldername)

async def __aenter__(self) -> "BaseSourceContext":
await self._open()
Expand All @@ -64,9 +65,10 @@ async def _open(self):
):
if os.path.isfile(self.config.labels[0]):
# Update labels with list read from the file
self.config.labels = pathlib.Path.read_text(
pathlib.Path(self.config.labels[0])
).split(",")
with self.config.no_enforce_immutable():
self.config.labels = pathlib.Path.read_text(
pathlib.Path(self.config.labels[0])
).split(",")

elif self.config.labels != ["unlabelled"]:
label_folders = [
Expand Down
3 changes: 2 additions & 1 deletion dffml/source/file.py
Expand Up @@ -40,7 +40,8 @@ def __init__(self, config):
super().__init__(config)

if isinstance(getattr(self.config, "filename", None), str):
self.config.filename = pathlib.Path(self.config.filename)
with self.config.no_enforce_immutable():
self.config.filename = pathlib.Path(self.config.filename)

async def __aenter__(self) -> "BaseSourceContext":
await self._open()
Expand Down
3 changes: 2 additions & 1 deletion model/vowpalWabbit/dffml_model_vowpalWabbit/vw_base.py
Expand Up @@ -212,7 +212,8 @@ def _save_model(self):
return

async def __aenter__(self):
self.parent.config.vwcmd = self.modify_config()
with self.parent.config.no_enforce_immutable():
self.parent.config.vwcmd = self.modify_config()
self.clf = self._load_model()
return self

Expand Down

0 comments on commit 0e24573

Please sign in to comment.