Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Data objects #12

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions configurator/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .config import Config
from .merge import default_mergers
from .mapping import source, target, convert, required, if_supplied, value
from .proxy import Proxy

__all__ = (
'Config',
Expand Down
7 changes: 7 additions & 0 deletions configurator/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class DataValue:

def __init__(self, value):
self.value = value

def get(self):
return self.value
9 changes: 6 additions & 3 deletions configurator/mapping.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
from .path import (
Path, parse_text, ConvertOp, RequiredOp, NotPresent, IfSuppliedOp, ValueOp
)
from .data import DataValue
from .path import Path, parse_text, ConvertOp, RequiredOp, NotPresent, IfSuppliedOp, ValueOp


def load(data, path):
path = parse_text(path)
if isinstance(data, DataValue):
data = data.get()
for op in path.ops:
if isinstance(data, NotPresent):
op.not_present(data)
else:
data = op.get(data)
if isinstance(data, DataValue):
data = data.get()
return data


Expand Down
7 changes: 6 additions & 1 deletion configurator/node.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pprint import pformat

from .data import DataValue
from .path import parse_text, NotPresent


Expand All @@ -23,6 +24,8 @@ def __init__(self, data=None, container=None, accessor=None):
self._accessor = accessor

def _wrap(self, accessor, value):
if isinstance(value, DataValue):
value = value.get()
if isinstance(value, (dict, list)):
value = ConfigNode(value, self.data, accessor)
return value
Expand Down Expand Up @@ -82,7 +85,7 @@ def get(self, name=None, default=None):
for simple values.
"""
if name is None:
return self.data
return self.data.get() if isinstance(self.data, DataValue) else self.data
try:
return self._get(name)
except KeyError:
Expand Down Expand Up @@ -135,6 +138,8 @@ def node(self, path=None, create=False):

data = self.data
for op in path.ops:
if isinstance(data, DataValue):
data = data.get()
if isinstance(data, NotPresent):
op.not_present(data)
else:
Expand Down
35 changes: 35 additions & 0 deletions configurator/proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
class Proxy:
"""
A proxy is an object that can be passed around to present another object
that needs to be referenced during configuration parsing but cannot be used
until configuration parsing is completed.
"""

_object = None

def __init__(self, name='proxy'):
self._name = name

def __deepcopy__(self, memo):
# Merging and cloning make use of deepcopy. Proxies should not be
# copied as part of this process, so this is disabled here:
return self

def set(self, obj):
self._object = obj

def get(self):
if self._object is None:
raise RuntimeError(f'Cannot use {self._name} before it is configured')
return self._object

def clone(self):
proxy = type(self)(self._name)
proxy.set(self._object)
return proxy

def __getattr__(self, item):
return getattr(self.get(), item)

def __call__(self, *args, **kw):
return self.__getattr__('__call__')(*args, **kw)
46 changes: 46 additions & 0 deletions tests/test_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from testfixtures import compare, ShouldRaise, generator

from configurator.data import DataValue
from configurator.mapping import load, source
from configurator.node import ConfigNode


class TestDataValue:

def test_load_root(self):
compare(load(DataValue('foo'), source), expected='foo')

def test_load_nested(self):
compare(load([DataValue('foo')], source[0]), expected='foo')

def test_load_traverse_simple(self):
with ShouldRaise(TypeError('string indices must be integers')):
load(DataValue('foo'), source['foo'])

def test_load_traverse_container(self):
compare(load([DataValue({'bar': ['foo']})], source[0]['bar'][0]), expected='foo')

def test_item_get_container(self):
compare(ConfigNode({'a': DataValue({'b': 'c'})})['a'], expected=ConfigNode({'b': 'c'}))

def test_attr_get_simple(self):
compare(ConfigNode({'a': DataValue('b')}).a, expected='b')

def test_items(self):
compare(ConfigNode({'a': DataValue('b')}).items(), expected=generator(('a', 'b')))

def test_node_get(self):
compare(ConfigNode(DataValue('b')).get(), expected='b')

def test_node_get_name(self):
compare(ConfigNode({'a': DataValue('b')}).get('a'), expected='b')

def test_node_get_wrapped(self):
compare(ConfigNode(DataValue({'b': 'c'})).get(), expected={'b': 'c'})

def test_node_get_wrapped_name(self):
compare(ConfigNode({'a': DataValue({'b': 'c'})}).get('a'), expected=ConfigNode({'b': 'c'}))

def test_node_node_traverse(self):
node = ConfigNode({'b': DataValue({'c': {'d': 'e'}})})
compare(node.node('b.c.d').get(), expected='e')
130 changes: 129 additions & 1 deletion tests/test_functional.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from argparse import ArgumentParser

from testfixtures import compare
from testfixtures import compare, ShouldRaise
from configurator import Config
from configurator.data import DataValue
from configurator.mapping import target, convert
import pytest

from configurator.proxy import Proxy


class TestFunctional(object):

Expand Down Expand Up @@ -87,6 +90,131 @@ def test_overlay(self, dir):
compare(config.user, expected=2)
compare(config.file, expected=3)

def test_lazy_load(self, dir):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RonnyPfannschmidt - okay, try this now. I think formalising the Proxy object gives a really nice clean outcome, but interested to hear what you think. If you want, I think you should be fine to stick the proxy object on the config root too :-)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from my pov this is basically a drunk identity copied contextvar,

hence the desire to access the config root and path of the dynamic lookup

the tricky point about the client landing in data however is a valid concern, however i would currently consider it less of a problem than having the loader proxy object be integrated differently

how about the concept of a Sidechannel value

it would be a Contant Data Value that one could update to a object for within a Config, but uppon copying a config, it would
"loose" the Value

then it would no longer be a contextvar, but rather a symblic reference one could map to other objects

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"from my pov this is basically a drunk identity copied contextvar," comes across quite badly, I'm trying to help here. I don't need this functionality myself, and I'm struggling to understand the resistance to the suggestions I'm making.

I haven't used ContextVar so maybe this is a good opportunity for me to learn.

I can't get my head around your Con(s?)tant Data Value idea, but it seems to be related to your fixation with storing vault_loader on the Config object. That's not something I really like, but I'd like to understand why it's important to you so that we can find something that keeps both of us happy :-)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would be fine with not storing the vault loader on the config,
a key consideration for me is how to make it available to the loaded value (which ideally doesn't need a extra magical object

having a proxy object thats tricky to manage and weaseling it into a dynamic loader is something i would like to avoid

im under the impression we have a very different understanding of how to pass the context in (my preference would be to just pass the root config object)

your preference seems to be to pass in something like a context variable, however the proposed variant looks to me like it will carry context over into new copies of the configuration (which seems to be a bug NO)

the idea of the "standin values" would be to have a way to have a sidechannel for the configruation

so that if one had a config like

symbol = Symbol("vault-loader")
config = Config({"password": VaultLoader(loader = symbol, key="password"))
symbol.set_for(config, VaultClient.for_config(config))
assert config.password = "abc123#supersecure"

Copy link
Member Author

@cjw296 cjw296 Jul 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RonnyPfannschmidt - I hope it's not intentional, but your tone is still coming across quite negatively... "magical", "weaseling" and "bug NO" are not things that make this fun for me to work on.

I just read up on ContextVar, and while the API may appear similar, for me, the Proxy here is more like a Twisted Deferred. It's a proxy for an object because we don't have that object when we need to pass around a reference to it. They only need to be referenced in the config parsing code, so not sure how that equates to being tricky to manage. The dynamic loader needs a reference to the vault client before the vault client can be instanted, so having a proxy reference to it which case be filled in later felt quite natural to me.

Unfortunately, the notion of when to carry over a Proxy into a new config is a tricky one. I'm interested in where that would cause a problem?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most likely the same way as in symbol, which is just a name for proxy

Copy link
Member Author

@cjw296 cjw296 Jul 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_context in your example appears to only be needed if you require path traversal, which I think we both agreed was a bad thing? (The path can be out of sync with where the node is in the config).

I'm trying to figure out a way the Proxy can have a value per config without needing a direct reference to the config, since I'm still not sure that can be plumbed through: configurator has always tried to not get involved with the underlying config data (although I think #9 may have to change that...), so .data never has access to a node, it's always the ConfigNode that refers down to the .data, and the nodes are basically ephemeral.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it cannot be done with get - get_context is intended to pass the config trough to each constructed node, so it can be used in the lazy resolving

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What, specifically, cannot be done with get?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obtaining the config object that was in use for the lookup

yaml = pytest.importorskip("yaml")

class VaultClient:

def __init__(self, host, token):
self.host = host
self.token = token

def load(self, key):
return {'name': key.capitalize(), 'password': '...'}

class VaultValue(DataValue):

def __init__(self, client, key):
self.client = client
self.key = key

def get(self):
return self.client.load(self.key)

client_ = Proxy()

def parser(client):

class Loader(yaml.Loader):
pass

def value_from_yaml(loader, node):
return VaultValue(client, loader.construct_mapping(node)['key'])

Loader.add_constructor('!from_vault', value_from_yaml)

def parser_(path):
return yaml.load(path, Loader)

return parser_

parser_ = parser(client_)

path1 = dir.write('default.yml', '''
vault:
host: localhost
token: foo
users:
- !from_vault {key: someuser}
''')

path2 = dir.write('testing.yml', '''
users:
- !from_vault {key: testuser}
''')

default = Config.from_path(path1, parser=parser_)
testing = Config.from_path(path2, parser=parser_)

# confirm the proxy isn't configured at this point, and so the values can't
# be resolved without raising an exception:
with ShouldRaise(RuntimeError('Cannot use proxy before it is configured')):
default.users[0].name
with ShouldRaise(RuntimeError('Cannot use proxy before it is configured')):
testing.users[0].name

config = default + testing

client_.set(VaultClient(**config.vault.data))
compare(config.users[0].data, expected={'name': 'Someuser', 'password': '...'})

# make sure iteration works:
compare([user.name for user in config.users], expected=['Someuser', 'Testuser'])

# make sure clone still works:
config.clone()
compare(config.users.data[0], expected=VaultValue(client_, 'someuser'))

def test_change_proxy_after_clone(self):
user = Proxy()
config = Config({'user': user})
with ShouldRaise(RuntimeError('Cannot use proxy before it is configured')):
config.user.get()

config.set_proxy(user, 'a')
compare(config.user.get(), expected='a')

config_ = config.clone()
config_.set_proxy(user, 'b')

compare(config.user.get(), expected='a')
compare(config_.user.get(), expected='b')

def test_proxy_setting_with_merge(self):
db = Proxy()

class User(DataValue):

def __init__(self, db, name):
self.db = db
self.name = name

def get(self):
db_ = self.db.get()
return f'{self.name} from {db_}'

config1 = Config({'users': [User(db, 'a')]})
config2 = Config({'users': [User(db, 'b')]})

db.set(0)
compare(config1.users, expected=['a from 0'])
compare(config2.users, expected=['b from 0'])

config1.set_proxy(db, 1)
config2.set_proxy(db, 2)
compare(config1.users, expected=['a from 1'])
compare(config2.users, expected=['b from 2'])

config3 = config1 + config2
compare(config1.users, expected=['a from 1'])
compare(config2.users, expected=['b from 2'])
compare(config3.users, expected=['a from 1', 'b from 2'])

config3.set_proxy(db, 3)
compare(config1.users, expected=['a from 1'])
compare(config2.users, expected=['b from 2'])
compare(config3.users, expected=['a from 3', 'b from 3'])


def test_fake_fs(fs):
fs.create_file('/foo/bar.yml', contents='foo: 1\n')
Expand Down
43 changes: 43 additions & 0 deletions tests/test_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from configurator.proxy import Proxy
from testfixtures import ShouldRaise, compare
from testfixtures.mock import Mock


def test_unnamed_unconfigured():
proxy = Proxy()
with ShouldRaise(RuntimeError('Cannot use proxy before it is configured')):
proxy.foo


def test_named_unconfigured():
proxy = Proxy('My Foo')
with ShouldRaise(RuntimeError('Cannot use My Foo before it is configured')):
proxy.foo


def test_configured_attr():
proxy = Proxy()
foo = Mock()
proxy.set(foo)
assert proxy.bar is foo.bar


def test_configured_function():

def foo(n):
return n+1

proxy = Proxy()
proxy.set(foo)

compare(proxy(1), expected=2)


def test_clone():
proxy = Proxy('foo')
proxy.set(1)
proxy_ = proxy.clone()
proxy_.set(2)
compare(proxy.get(), expected=1)
compare(proxy_.get(), expected=2)
compare(proxy_._name, expected='foo')