Skip to content

Commit

Permalink
Merge pull request #52 from ameily/issue-51
Browse files Browse the repository at this point in the history
Track Default Values
  • Loading branch information
ameily authored Sep 16, 2021
2 parents 6f4aeb8 + 40c9fa2 commit e777610
Show file tree
Hide file tree
Showing 14 changed files with 155 additions and 25 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased
### Added
- New support method, `is_value_defined` to check if a config field value is defined by the user,
through either a loaded config file or the API.
- New support method, `reset_value` to reset a configuration value back to the default.
- New Config method, `Config._set_default_value`, to set a field default value. This was added so
that `__setdefault__` methods wouldn't need to access the `_data` dictionary directly.


## [v0.8.0](https://github.com/ameily/cincoconfig/releases/tag/v0.8.0) - 2021-07-17
### Added
- Improved full reference path detection for both field and configuration objects.
Expand Down
3 changes: 2 additions & 1 deletion cincoconfig/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
'''
from .core import Config, Field, Schema, ValidationError, AnyField, ConfigFormat, ConfigType
from .support import (make_type, validator, get_all_fields, generate_argparse_parser,
item_ref_path, cmdline_args_override, asdict, get_fields)
item_ref_path, cmdline_args_override, asdict, get_fields, reset_value,
is_value_defined)
from .fields import *
from .encryption import KeyFile
from .stubs import generate_stub
Expand Down
34 changes: 25 additions & 9 deletions cincoconfig/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import inspect
from collections import OrderedDict
from functools import partial
from typing import Union, Any, Optional, Dict, Iterator, Tuple, List, Callable, Type
from typing import Union, Any, Optional, Dict, Iterator, Tuple, List, Callable, Type, Set
from argparse import ArgumentParser, Namespace
import warnings

Expand Down Expand Up @@ -182,12 +182,12 @@ def __getval__(self, cfg: 'Config') -> Any:
def __setdefault__(self, cfg: 'Config') -> None:
'''
Set the default value in the configuration. Subclasses should set the field's default value
directly in the ``cfg._data`` dictionary. For example:
using the :meth:`Config._set_deafult_value` method. For example:
.. code-block:: python
def __setdefault__(self, cfg: 'Config') -> None:
cfg._data[self._key] = "Hello, world!"
cfg._set_default_value(self._key, "Hello, world!")
'''

@property
Expand Down Expand Up @@ -467,7 +467,7 @@ def __setdefault__(self, cfg: 'Config') -> None:
if value is None:
value = self.default

cfg._data[self._key] = value
cfg._set_default_value(self._key, value)

def to_python(self, cfg: 'Config', value: Any) -> Any:
'''
Expand Down Expand Up @@ -525,7 +525,7 @@ def __init__(self, config_type: Type["ConfigType"], key: str = None, name: str =
self.config_type = config_type

def __setdefault__(self, cfg: 'Config') -> None:
cfg._data[self._key] = self.config_type(cfg)
cfg._set_default_value(self._key, self.config_type(cfg))

def __call__(self, cfg: 'Config' = None) -> 'ConfigType':
'''
Expand Down Expand Up @@ -605,7 +605,7 @@ def __setkey__(self, schema: 'Schema', key: str) -> None:
self._env_prefix = prefix + self._key.upper()

def __setdefault__(self, cfg: 'Config') -> None:
cfg._data[self._key] = Config(self, cfg)
cfg._set_default_value(self._key, Config(self, cfg))

def _get_field(self, key: str) -> Optional[BaseField]:
'''
Expand Down Expand Up @@ -881,6 +881,7 @@ def __init__(self, schema: Schema, parent: 'Config' = None, key_filename: str =
self._fields: Dict[str, BaseField] = OrderedDict()
self._key = schema._key
self.__keyfile = None # type: Optional[KeyFile]
self._default_value_keys: Set[str] = set()

if key_filename:
self._key_filename = key_filename
Expand Down Expand Up @@ -936,6 +937,17 @@ def _get_field(self, key: str) -> Optional[BaseField]:
'''
return self._schema._get_field(key) or self._fields.get(key)

def _set_default_value(self, key: str, value: Any) -> None:
'''
Set a default value without performing any validation. The value is set and the field is
marked as having the initial default value.
:param key: field key
:param value: field default value
'''
self._data[key] = value
self._default_value_keys.add(key)

def _set_value(self, key: str, value: Any) -> Any:
'''
Set a configuration value. This method passes the value through the field validation chain
Expand Down Expand Up @@ -965,6 +977,7 @@ def _set_value(self, key: str, value: Any) -> Any:
raise ValidationError(self, field, err) from err
else:
field.__setval__(self, value)
self._default_value_keys.discard(key)
return value

if isinstance(value, Config):
Expand All @@ -981,6 +994,7 @@ def _set_value(self, key: str, value: Any) -> Any:
"Unable to coerce %s to Config" % type(value).__name__)

self._data[key] = value
self._default_value_keys.discard(key)
return value

def __setattr__(self, name: str, value: Any) -> Any:
Expand Down Expand Up @@ -1028,6 +1042,7 @@ def __getitem__(self, key: str) -> Any:
return value.__getitem__(subkey) if subkey else value

def __iter__(self) -> Iterator[Tuple[str, Any]]:
# pylint: disable=superfluous-parens
return ((key, self._get_value(key)) for key in self._data)

def __setitem__(self, key: str, value: Any) -> Any:
Expand Down Expand Up @@ -1110,14 +1125,15 @@ def _ref_path(self) -> str:

return path

def save(self, filename: str, format: str):
def save(self, filename: str, format: str, **kwargs):
'''
Save the configuration to a file.
Save the configuration to a file. Additional keyword arguments are passed to :meth:`dumps`.
:param filename: destination file path
:param format: output format
:param: additional keyword arguments for :meth:`dumps`
'''
content = self.dumps(format)
content = self.dumps(format, **kwargs)
filename = os.path.expanduser(filename)
with open(filename, 'wb') as file:
file.write(content)
Expand Down
2 changes: 1 addition & 1 deletion cincoconfig/fields/list_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def __setdefault__(self, cfg: Config) -> None:
if isinstance(default, list) and self.field:
default = ListProxy(cfg, self, default)

cfg._data[self._key] = default
cfg._set_default_value(self._key, default)

def _validate(self, cfg: Config, value: list) -> Union[list, ListProxy]:
'''
Expand Down
2 changes: 1 addition & 1 deletion cincoconfig/fields/secure_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ def __setdefault__(self, cfg: Config) -> None:
val = self.default
else:
raise TypeError('invalid default value: %r' % self.default)
cfg._data[self._key] = val
cfg._set_default_value(self._key, val)

def _validate(self, cfg: Config, value: Any) -> DigestValue:
'''
Expand Down
34 changes: 34 additions & 0 deletions cincoconfig/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,37 @@ def asdict(config: Config, virtual: bool = False) -> dict:
data.update({key: field.__getval__(config) for key, field in fields})

return data


def is_value_defined(config: Config, key: str) -> bool:
'''
Check if the given field has been set by the user through either loading a configuration file
or using the API to set the field value.
:param config: configuration object
:param key: field key
:returns: the field is set by the user
'''
path, _, key = key.rpartition('.')
if path:
config = config[path]

return key not in config._default_value_keys


def reset_value(config: Config, key: str) -> None:
'''
Reset a config value back to the default.
:param config: configuration object
:param key: field key
'''
path, _, key = key.rpartition('.')
if path:
config = config[path]

field = config._get_field(key)
if not field:
raise AttributeError(key)

field.__setdefault__(config)
1 change: 1 addition & 0 deletions docs/configs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ configs

.. automethod:: _get_field
.. automethod:: _set_value
.. automethod:: _set_default_value
.. automethod:: _get_value
.. automethod:: __setattr__
.. automethod:: __getattr__
Expand Down
4 changes: 4 additions & 0 deletions docs/support.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ support
.. autofunction:: cincoconfig.get_fields

.. autofunction:: cincoconfig.asdict

.. autofunction:: cincoconfig.is_value_defined

.. autofunction:: cincoconfig.reset_value
29 changes: 29 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,3 +727,32 @@ def test_load_tree_no_validate(self):
config = schema()
assert config.load_tree({}, validate=False) is None

def test_default_keys(self):
schema = Schema()
schema.a.b = Field(default=0)
schema.x = Field(default=1)
schema.y = Field(default=2)
schema.z = Field(default=3)
config = schema(y=2, z=4)
assert config._default_value_keys == set(['a', 'x'])

def test_default_keys_override(self):
schema = Schema()
schema.x = Field(default=1)
config = schema()
config.x = 1
assert config._default_value_keys == set()

def test_default_keys_config_override(self):
schema = Schema()
schema.x.y = Field(default=1)
config = schema()
config.x = {'y': 2}
assert config._default_value_keys == set()

def test_set_default_value(self):
schema = Schema()
config = schema()
config._set_default_value('x', 'asdf')
assert config._data == {'x': 'asdf'}
assert config._default_value_keys == set(['x'])
2 changes: 1 addition & 1 deletion tests/test_fields/test_configtype_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def test_setdefault(self):
cfg._data = {}
field = ConfigTypeField(mock_ct, key='x')
field.__setdefault__(cfg)
assert cfg._data == {'x': retval}
cfg._set_default_value.assert_called_once_with('x', retval)
mock_ct.assert_called_once_with(cfg)

def test_call(self):
Expand Down
3 changes: 2 additions & 1 deletion tests/test_fields/test_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def __init__(self, data=None, parent=None, key=None):
self._parent = parent
self._key = key
self._schema = Schema()
self._set_default_value = MagicMock()

def _full_path(self):
return ''
Expand Down Expand Up @@ -58,7 +59,7 @@ def test_setkey(self):
def test_setdefault(self):
field = Field(key='key', default='hello')
field.__setdefault__(self.cfg)
assert self.cfg._data['key'] == 'hello'
self.cfg._set_default_value.assert_called_once_with('key', 'hello')

def test_to_python(self):
field = Field()
Expand Down
8 changes: 5 additions & 3 deletions tests/test_fields/test_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# this source code package.
#
from typing import List
from unittest.mock import patch, call
from unittest.mock import patch, call, MagicMock
import pytest
from cincoconfig.fields import ListField, ListProxy, IntField
from cincoconfig.core import Schema, Config, ValidationError
Expand All @@ -15,6 +15,7 @@ class MockConfig:

def __init__(self):
self._data = {}
self._set_default_value = MagicMock()


class TestListProxy:
Expand Down Expand Up @@ -298,8 +299,9 @@ def test_default_list_wrap(self):
cfg = MockConfig()
field = ListField(IntField(), default=lambda: [1, 2, 3], key='asdf')
field.__setdefault__(cfg)
assert isinstance(cfg._data['asdf'], ListProxy)
assert cfg._data['asdf'] == ListProxy(cfg, field, [1, 2, 3])
cfg._set_default_value.assert_called_once_with('asdf', ListProxy(cfg, field, [1, 2, 3]))
# assert isinstance(cfg._data['asdf'], ListProxy)
# assert cfg._data['asdf'] == ListProxy(cfg, field, [1, 2, 3])

def test_to_basic_none(self):
field = ListField(IntField(), default=None, key='asdf')
Expand Down
6 changes: 3 additions & 3 deletions tests/test_fields/test_net.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class TestIPv4AddressField:

def test_valid_ipv4(self):
field = IPv4AddressField()
assert field.validate(MockConfig(), '192.168.001.1') == '192.168.1.1'
assert field.validate(MockConfig(), '192.168.1.1') == '192.168.1.1'

def test_invalid_ipv4(self):
field = IPv4AddressField()
Expand All @@ -34,7 +34,7 @@ class TestIPv4NetworkField:

def test_valid_net(self):
field = IPv4NetworkField()
assert field.validate(MockConfig(), '192.168.001.0/24') == '192.168.1.0/24'
assert field.validate(MockConfig(), '192.168.1.0/24') == '192.168.1.0/24'

def test_invalid_net(self):
field = IPv4NetworkField()
Expand Down Expand Up @@ -64,7 +64,7 @@ class TestHostnameField:

def test_valid_ipv4(self):
field = HostnameField(allow_ipv4=True)
assert field.validate(MockConfig(), '192.168.001.1') == '192.168.1.1'
assert field.validate(MockConfig(), '192.168.1.1') == '192.168.1.1'

def test_no_ipv4(self):
field = HostnameField(allow_ipv4=False)
Expand Down
43 changes: 38 additions & 5 deletions tests/test_support.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@

from cincoconfig.fields.virtual_field import VirtualField
from unittest.mock import patch, MagicMock, call
import argparse

from cincoconfig.fields import StringField, IntField, BoolField, FloatField
import pytest

from cincoconfig.fields import StringField, IntField, BoolField, FloatField, VirtualField
from cincoconfig.core import Schema, Field, Config
from cincoconfig.support import (generate_argparse_parser, get_fields, make_type, get_all_fields,
cmdline_args_override, validator, item_ref_path, asdict,
_list_asdict)
from cincoconfig.support import (generate_argparse_parser, get_fields, is_value_defined, make_type,
get_all_fields, cmdline_args_override, reset_value, validator,
item_ref_path, asdict, _list_asdict)


class TestSupportFuncs:
Expand Down Expand Up @@ -232,3 +233,35 @@ def test_list_asdict_copy(self):
assert result is not y
assert result[0] == x
assert result[0] is not x

def test_is_value_defined(self):
config = MagicMock(_default_value_keys=set(['x']))
assert is_value_defined(config, 'x') is False
assert is_value_defined(config, 'y') is True

def test_is_value_defined_nested(self):
config = {
'sub': MagicMock(_default_value_keys=set(['x']))
}
assert is_value_defined(config, 'sub.x') is False
assert is_value_defined(config, 'sub.y') is True

def test_reset_value(self):
config = MagicMock()
field = config._get_field.return_value = MagicMock(__setdefault__=MagicMock())
reset_value(config, 'x')
config._get_field.assert_called_once_with('x')
field.__setdefault__.assert_called_once_with(config)

def test_reset_value_nested(self):
config = MagicMock()
field = config._get_field.return_value = MagicMock(__setdefault__=MagicMock())
reset_value({'sub': config}, 'sub.x')
config._get_field.assert_called_once_with('x')
field.__setdefault__.assert_called_once_with(config)

def test_reset_value_attribute_error(self):
config = MagicMock()
config._get_field.return_value = None
with pytest.raises(AttributeError):
reset_value(config, 'x')

0 comments on commit e777610

Please sign in to comment.