diff --git a/.pylint.ini b/.pylint.ini index da7fdefe..b3cd7d41 100644 --- a/.pylint.ini +++ b/.pylint.ini @@ -26,11 +26,10 @@ disable= protected-access, # Disabling these helps to write more expressive tests: expression-not-assigned, - singleton-comparison, - bad-continuation, blacklisted-name, # Handled by automatic formatting: line-too-long, + bad-continuation, # False positives: no-member, unsupported-membership-test, diff --git a/.travis.yml b/.travis.yml index 08565a64..b612faba 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,6 +21,7 @@ install: script: - make test-repeat - make check + - make notebooks - make mkdocs after_success: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd37bc2..360441da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 0.9 (2020-04-13) + +- Fixed serialization of optional nested dataclasses with a value of `None`. +- Fixed preservation of comments on nested dataclass attributes. +- Added support for using `enum.Enum` subclasses as type annotations. + # 0.8.1 (2020-03-30) - Fixed loading of `Missing` nested dataclasses attributes. diff --git a/Makefile b/Makefile index 258f43a0..66c6e20b 100644 --- a/Makefile +++ b/Makefile @@ -89,7 +89,7 @@ test-profile: install MKDOCS_INDEX := site/index.html .PHONY: docs -docs: mkdocs uml papermill ## Generate documentation and UML +docs: mkdocs uml notebooks ## Generate documentation and UML .PHONY: mkdocs mkdocs: install $(MKDOCS_INDEX) @@ -116,12 +116,12 @@ docs/*.png: $(MODULES) - mv -f classes_$(PACKAGE).png docs/classes.png - mv -f packages_$(PACKAGE).png docs/packages.png -.PHONY: papermill -papermill: install +.PHONY: notebooks +notebooks: install @ cd notebooks; for filename in *.ipynb; do \ poetry run papermill $$filename $$filename; \ done - git config filter.nbstripout.extrakeys 'metadata.papermill cell.metadata.papermill' + git config filter.nbstripout.extrakeys 'cell.metadata.execution cell.metadata.papermill metadata.papermill' poetry run nbstripout --keep-output notebooks/*.ipynb # RELEASE ##################################################################### diff --git a/datafiles/converters/__init__.py b/datafiles/converters/__init__.py index 95d61abc..ab4f7d00 100644 --- a/datafiles/converters/__init__.py +++ b/datafiles/converters/__init__.py @@ -1,4 +1,5 @@ import dataclasses +from enum import Enum from inspect import isclass from typing import Any, Dict, Optional, Union @@ -9,6 +10,7 @@ from ._bases import Converter from .builtins import Boolean, Float, Integer, String from .containers import Dataclass, Dictionary, List +from .enumerations import Enumeration from .extensions import * # pylint: disable=unused-wildcard-import @@ -46,7 +48,7 @@ def map_type(cls, *, name: str = '', item_cls: Optional[type] = None): converters = {} for field in dataclasses.fields(cls): converters[field.name] = map_type(field.type, name=field.name) - converter = Dataclass.subclass(cls, converters) + converter = Dataclass.of_mappings(cls, converters) log.debug(f'Mapped {cls!r} to new converter: {converter}') return converter @@ -57,11 +59,10 @@ def map_type(cls, *, name: str = '', item_cls: Optional[type] = None): try: converter = map_type(item_cls or cls.__args__[0]) except TypeError as e: - if '~T' in str(e): - e = TypeError(f"Type is required with 'List' annotation") - raise e from None + assert '~T' in str(e), f'Unhandled error: ' + str(e) + raise TypeError("Type is required with 'List' annotation") from None else: - converter = List.subclass(converter) + converter = List.of_type(converter) elif cls.__origin__ == dict: if item_cls: @@ -72,7 +73,7 @@ def map_type(cls, *, name: str = '', item_cls: Optional[type] = None): key = map_type(cls.__args__[0]) value = map_type(cls.__args__[1]) - converter = Dictionary.subclass(key, value) + converter = Dictionary.of_mapping(key, value) elif cls.__origin__ == Union: converter = map_type(cls.__args__[0]) @@ -101,4 +102,7 @@ def map_type(cls, *, name: str = '', item_cls: Optional[type] = None): log.debug(f'Mapped {cls!r} to existing converter (itself)') return cls + if issubclass(cls, Enum): + return Enumeration.of_type(cls) + raise TypeError(f'Could not map type: {cls}') diff --git a/datafiles/converters/_bases.py b/datafiles/converters/_bases.py index ba80a2c4..5879f5be 100644 --- a/datafiles/converters/_bases.py +++ b/datafiles/converters/_bases.py @@ -7,7 +7,7 @@ class Converter: """Base class for immutable attribute conversion.""" TYPE: type = object - DEFAULT: Any = None + DEFAULT: Any = NotImplemented @classmethod def as_optional(cls): diff --git a/datafiles/converters/containers.py b/datafiles/converters/containers.py index 1e6099e2..89e20520 100644 --- a/datafiles/converters/containers.py +++ b/datafiles/converters/containers.py @@ -11,10 +11,10 @@ class List(Converter): """Base converter for homogeneous lists of another converter.""" - CONVERTER: Converter = None # type: ignore + CONVERTER: Converter = NotImplemented @classmethod - def subclass(cls, converter: type): + def of_type(cls, converter: type): name = f'{converter.__name__}List' bases = (cls,) attributes = {'CONVERTER': converter} @@ -87,7 +87,7 @@ class Dictionary(Converter): """Base converter for raw dictionaries.""" @classmethod - def subclass(cls, key: type, value: type): + def of_mapping(cls, key: type, value: type): name = f'{key.__name__}{value.__name__}Dict' bases = (cls,) return type(name, bases, {}) @@ -121,11 +121,11 @@ def to_preserialization_data(cls, python_value, *, default_to_skip=None): class Dataclass(Converter): """Base converter for dataclasses.""" - DATACLASS: Callable = None # type: ignore - CONVERTERS: Dict = None # type: ignore + DATACLASS: Callable = NotImplemented + CONVERTERS: Dict = NotImplemented @classmethod - def subclass(cls, dataclass, converters: Dict[str, type]): + def of_mappings(cls, dataclass, converters: Dict[str, type]): name = f'{dataclass.__name__}Converter' bases = (cls,) attributes = {'DATACLASS': dataclass, 'CONVERTERS': converters} @@ -138,6 +138,9 @@ def to_python_value(cls, deserialized_data, *, target_object): else: data = {} + if deserialized_data is None and cls.DEFAULT is None: + return None + for name, value in list(data.items()): if name not in cls.CONVERTERS: log.debug(f'Removed unmapped nested file attribute: {name}') @@ -171,19 +174,22 @@ def to_python_value(cls, deserialized_data, *, target_object): def to_preserialization_data(cls, python_value, *, default_to_skip=None): data = {} + if python_value is None and cls.DEFAULT is None: + return None + for name, converter in cls.CONVERTERS.items(): if isinstance(python_value, dict): try: value = python_value[name] - except KeyError as e: - log.debug(e) + except KeyError: + log.debug(f'Added missing nested attribute: {name}') value = None else: try: value = getattr(python_value, name) - except AttributeError as e: - log.debug(e) + except AttributeError: + log.debug(f'Added missing nested attribute: {name}') value = None with suppress(AttributeError): diff --git a/datafiles/converters/enumerations.py b/datafiles/converters/enumerations.py new file mode 100644 index 00000000..c4a67461 --- /dev/null +++ b/datafiles/converters/enumerations.py @@ -0,0 +1,23 @@ +# pylint: disable=unused-argument,not-callable + +from ._bases import Converter + + +class Enumeration(Converter): + + ENUM: type = NotImplemented + + @classmethod + def of_type(cls, enum: type): + name = f'{enum.__name__}Converter' + bases = (cls,) + attributes = {'ENUM': enum} + return type(name, bases, attributes) + + @classmethod + def to_python_value(cls, deserialized_data, *, target_object=None): + return cls.ENUM(deserialized_data) + + @classmethod + def to_preserialization_data(cls, python_value, *, default_to_skip=None): + return python_value.value diff --git a/datafiles/formats.py b/datafiles/formats.py index 704ec364..20c87e91 100644 --- a/datafiles/formats.py +++ b/datafiles/formats.py @@ -9,7 +9,7 @@ import log -from . import settings +from . import settings, types _REGISTRY: Dict[str, type] = {} @@ -87,15 +87,24 @@ def deserialize(cls, file_object): from ruamel import yaml try: - return yaml.round_trip_load(file_object, preserve_quotes=True) or {} + data = yaml.round_trip_load(file_object, preserve_quotes=True) except NotImplementedError as e: log.error(str(e)) return {} + else: + return data or {} @classmethod def serialize(cls, data): from ruamel import yaml + yaml.representer.RoundTripRepresenter.add_representer( + types.List, yaml.representer.RoundTripRepresenter.represent_list + ) + yaml.representer.RoundTripRepresenter.add_representer( + types.Dict, yaml.representer.RoundTripRepresenter.represent_dict + ) + if settings.INDENT_YAML_BLOCKS: f = StringIO() y = yaml.YAML() @@ -104,8 +113,11 @@ def serialize(cls, data): text = f.getvalue().strip() + '\n' else: text = yaml.round_trip_dump(data) or "" - text = text.replace('- \n', '-\n') - return "" if text == "{}\n" else text + + if text == "{}\n": + return "" + + return text.replace('- \n', '-\n') class PyYAML(Formatter): @@ -144,19 +156,21 @@ def increase_indent(self, flow=False, indentless=False): return text -def deserialize(path: Path, extension: str) -> Dict: - formatter = _get_formatter(extension) +def deserialize(path: Path, extension: str, *, formatter=None) -> Dict: + if formatter is None: + formatter = _get_formatter(extension) with path.open('r') as file_object: return formatter.deserialize(file_object) -def serialize(data: Dict, extension: str = '.yml') -> str: - formatter = _get_formatter(extension) +def serialize(data: Dict, extension: str = '.yml', *, formatter=None) -> str: + if formatter is None: + formatter = _get_formatter(extension) return formatter.serialize(data) def _get_formatter(extension: str): - if settings.YAML_LIBRARY == 'PyYAML': + if settings.YAML_LIBRARY == 'PyYAML': # pragma: no cover register('.yml', PyYAML) with suppress(KeyError): diff --git a/datafiles/hooks.py b/datafiles/hooks.py index dcf44454..61c7f0da 100644 --- a/datafiles/hooks.py +++ b/datafiles/hooks.py @@ -4,7 +4,7 @@ import log -from . import settings +from . import settings, types from .mapper import create_mapper @@ -27,14 +27,6 @@ FLAG = '_patched' -class List(list): - """Patchable `list` type.""" - - -class Dict(dict): - """Patchable `dict` type.""" - - def apply(instance, mapper): """Path methods that get or set attributes.""" cls = instance.__class__ @@ -56,10 +48,10 @@ def apply(instance, mapper): for attr_name in instance.datafile.attrs: attr = getattr(instance, attr_name) if isinstance(attr, list): - attr = List(attr) + attr = types.List(attr) setattr(instance, attr_name, attr) elif isinstance(attr, dict): - attr = Dict(attr) + attr = types.Dict(attr) setattr(instance, attr_name, attr) elif not is_dataclass(attr): continue diff --git a/datafiles/mapper.py b/datafiles/mapper.py index e8d19bd4..3bd41c9f 100644 --- a/datafiles/mapper.py +++ b/datafiles/mapper.py @@ -13,14 +13,8 @@ from . import config, formats, hooks from .converters import Converter, List, map_type -from .utils import ( - Missing, - Trilean, - display, - get_default_field_value, - recursive_update, - write, -) +from .types import Missing, Trilean +from .utils import display, get_default_field_value, recursive_update, write class Mapper: @@ -138,20 +132,13 @@ def _get_data(self, include_default_values: Trilean = None) -> Dict: if getattr(converter, 'DATACLASS', None): log.debug(f"Converting '{name}' dataclass with {converter}") - if value is None: - value = {} - - for field in dataclasses.fields(converter.DATACLASS): - if field.name not in value: - log.debug(f'Added missing nested attribute: {field.name}') - value[field.name] = None - - data[name] = converter.to_preserialization_data( + new_value = converter.to_preserialization_data( value, default_to_skip=Missing if include_default_values else get_default_field_value(self._instance, name), ) + data[name] = recursive_update(value, new_value) elif ( value == get_default_field_value(self._instance, name) @@ -171,16 +158,16 @@ def _get_data(self, include_default_values: Trilean = None) -> Dict: def text(self) -> str: return self._get_text() + @text.setter + def text(self, value: str): + write(self.path, value.strip() + '\n', display=True) + def _get_text(self, **kwargs) -> str: data = self._get_data(**kwargs) if self.path and self.path.suffix: return formats.serialize(data, self.path.suffix) return formats.serialize(data) - @text.setter # type: ignore - def text(self, value: str): - write(self.path, value.strip() + '\n') - def load(self, *, _log=True, _first_load=False) -> None: if self._root: self._root.load(_log=_log, _first_load=_first_load) @@ -282,7 +269,7 @@ def save(self, *, include_default_values: Trilean = None, _log=True) -> None: with hooks.disabled(): text = self._get_text(include_default_values=include_default_values) - write(self.path, text) + write(self.path, text, display=True) self.modified = False diff --git a/datafiles/tests/test_converters.py b/datafiles/tests/test_converters.py index 2901ceb0..d2a045b1 100644 --- a/datafiles/tests/test_converters.py +++ b/datafiles/tests/test_converters.py @@ -1,6 +1,7 @@ # pylint: disable=unused-variable from dataclasses import dataclass +from enum import Enum from typing import ByteString, Dict, List, Optional import pytest @@ -29,12 +30,17 @@ class MyCustomString: pass -IntegerList = converters.List.subclass(converters.Integer) -StringList = converters.List.subclass(converters.String) -MyDict = converters.Dictionary.subclass(converters.String, converters.Integer) +class Color(Enum): + RED = 1 + GREEN = 2 + BLUE = 3 + + +IntegerList = converters.List.of_type(converters.Integer) +StringList = converters.List.of_type(converters.String) +MyDict = converters.Dictionary.of_mapping(converters.String, converters.Integer) MyDataclassConverter = converters.map_type(MyDataclass) MyDataclassConverterList = converters.map_type(List[MyDataclass]) -MyNestedDataclassConverter = converters.map_type(MyNestedDataclass) def describe_map_type(): @@ -68,11 +74,15 @@ def it_handles_dataclasses(expect): 'flag': converters.Boolean, } + def it_handles_enums(expect): + converter = converters.map_type(Color) + expect(converter.__name__) == 'ColorConverter' + def it_handles_optionals(expect): converter = converters.map_type(Optional[str]) expect(converter.__name__) == 'OptionalString' expect(converter.TYPE) == str - expect(converter.DEFAULT) == None + expect(converter.DEFAULT).is_(None) def it_handles_string_type_annotations(expect): converter = converters.map_type('float') @@ -146,11 +156,6 @@ def when_immutable(expect, converter, data, value): (MyDataclassConverter, None, MyDataclass(foobar=0)), (MyDataclassConverterList, None, []), (MyDataclassConverterList, 42, [MyDataclass(foobar=0)]), - ( - MyNestedDataclassConverter, - None, - MyNestedDataclass(name='', dc=MyDataclass(foobar=0, flag=False)), - ), ], ) def when_mutable(expect, converter, data, value): @@ -167,6 +172,12 @@ def when_text(expect): expect(convert("Hello, world!")) == "Hello, world!" expect(convert("Line 1\nLine 2\n")) == "Line 1\nLine 2\n" + def when_enum(expect): + convert = converters.map_type(Color).to_python_value + expect(convert(1)).is_(Color.RED) + with expect.raises(ValueError): + convert(42) + def when_invalid(expect): message = "invalid literal for int() with base 10: 'a'" with expect.raises(ValueError, message): @@ -245,6 +256,10 @@ def when_text(expect): expect(convert("Line 1\nLine 2")) == "Line 1\nLine 2\n" expect(convert("Line 1\nLine 2")).isinstance(LiteralScalarString) + def when_enum(expect): + convert = converters.map_type(Color).to_preserialization_data + expect(convert(Color.RED)) == 1 + def when_invalid(expect): message = "invalid literal for int() with base 10: 'a'" with expect.raises(ValueError, message): diff --git a/datafiles/tests/test_decorators.py b/datafiles/tests/test_decorators.py index 2f4707aa..8cefeafe 100644 --- a/datafiles/tests/test_decorators.py +++ b/datafiles/tests/test_decorators.py @@ -12,7 +12,7 @@ class Normal: cls = decorators.datafile("")(Normal) - expect(is_dataclass(cls)) == True + expect(is_dataclass(cls)).is_(True) def it_can_reuse_existing_dataclass(expect): @dataclass @@ -29,7 +29,7 @@ class Sample: cls = decorators.datafile(Sample) - expect(is_dataclass(cls)) == True + expect(is_dataclass(cls)).is_(True) def it_forwards_arguments_dataclass_decorator(expect): class Sample: @@ -37,4 +37,4 @@ class Sample: cls = decorators.datafile(order=True)(Sample) - expect(is_dataclass(cls)) == True + expect(is_dataclass(cls)).is_(True) diff --git a/datafiles/tests/test_formats.py b/datafiles/tests/test_formats.py index 6cac2472..94228db5 100644 --- a/datafiles/tests/test_formats.py +++ b/datafiles/tests/test_formats.py @@ -13,7 +13,7 @@ def data(): def describe_ruamel_yaml(): def it_indents_blocks_by_default(expect, data): - text = formats.RuamelYAML.serialize(data) + text = formats.serialize(data, '.yaml') expect(text) == dedent( """ key: value @@ -26,7 +26,7 @@ def it_indents_blocks_by_default(expect, data): def it_can_render_lists_inline(expect, data, monkeypatch): monkeypatch.setattr(settings, 'INDENT_YAML_BLOCKS', False) - text = formats.RuamelYAML.serialize(data) + text = formats.serialize(data, '.yaml') expect(text) == dedent( """ key: value @@ -39,7 +39,7 @@ def it_can_render_lists_inline(expect, data, monkeypatch): def describe_pyyaml(): def it_indents_blocks_by_default(expect, data): - text = formats.PyYAML.serialize(data) + text = formats.serialize(data, '.yaml', formatter=formats.PyYAML) expect(text) == dedent( """ key: value @@ -52,7 +52,7 @@ def it_indents_blocks_by_default(expect, data): def it_can_render_lists_inline(expect, data, monkeypatch): monkeypatch.setattr(settings, 'INDENT_YAML_BLOCKS', False) - text = formats.PyYAML.serialize(data) + text = formats.serialize(data, '.yaml', formatter=formats.PyYAML) expect(text) == dedent( """ key: value @@ -71,18 +71,26 @@ def path(tmp_path): path.write_text("") return path - def with_empty_yaml_file(expect, path): - data = formats.deserialize(path, '.yaml') - expect(data) == {} + def describe_ruamel_yaml(): + def with_empty_file(expect, path): + data = formats.deserialize(path, '.yaml') + expect(data) == {} + + def describe_pyyaml(): + def with_empty_file(expect, path): + data = formats.deserialize(path, '.yaml', formatter=formats.PyYAML) + expect(data) == {} - def with_empty_json_file(expect, path): - path.write_text("{}") - data = formats.deserialize(path, '.json') - expect(data) == {} + def describe_json(): + def with_empty_file(expect, path): + path.write_text("{}") + data = formats.deserialize(path, '.json') + expect(data) == {} - def with_empty_toml_file(expect, path): - data = formats.deserialize(path, '.toml') - expect(data) == {} + def describe_toml(): + def with_empty_file(expect, path): + data = formats.deserialize(path, '.toml') + expect(data) == {} def with_unknown_extension(expect, path): with expect.raises(ValueError): diff --git a/datafiles/tests/test_hooks.py b/datafiles/tests/test_hooks.py index 903046a4..9fca5b19 100644 --- a/datafiles/tests/test_hooks.py +++ b/datafiles/tests/test_hooks.py @@ -22,29 +22,29 @@ def it_can_be_called_twice(expect, mocker): setattr(instance, 'datafile', mocker.MagicMock(attrs=['key', 'items'])) hooks.apply(instance, None) - expect(hasattr(instance.__setattr__, '_patched')) == True + expect(hasattr(instance.__setattr__, '_patched')).is_(True) hooks.apply(instance, None) - expect(hasattr(instance.__setattr__, '_patched')) == True + expect(hasattr(instance.__setattr__, '_patched')).is_(True) def it_patches_list_elements(expect, mocker): instance = Sample(items=[Item('a'), Item('b')]) setattr(instance, 'datafile', mocker.MagicMock(attrs=['key', 'items'])) hooks.apply(instance, None) - expect(hasattr(instance.items[0].__setattr__, '_patched')) == True + expect(hasattr(instance.items[0].__setattr__, '_patched')).is_(True) def describe_disabled(): def when_nested(expect): - expect(settings.HOOKS_ENABLED) == True + expect(settings.HOOKS_ENABLED).is_(True) with hooks.disabled(): - expect(settings.HOOKS_ENABLED) == False + expect(settings.HOOKS_ENABLED).is_(False) with hooks.disabled(): - expect(settings.HOOKS_ENABLED) == False + expect(settings.HOOKS_ENABLED).is_(False) - expect(settings.HOOKS_ENABLED) == False + expect(settings.HOOKS_ENABLED).is_(False) - expect(settings.HOOKS_ENABLED) == True + expect(settings.HOOKS_ENABLED).is_(True) diff --git a/datafiles/tests/test_manager.py b/datafiles/tests/test_manager.py index 22a4465e..c19a836b 100644 --- a/datafiles/tests/test_manager.py +++ b/datafiles/tests/test_manager.py @@ -27,11 +27,11 @@ def describe_get_or_none(): @patch('datafiles.mapper.Mapper.modified', False) def when_file_exists(mock_load, expect, manager): expect(manager.get_or_none(foo=1, bar=2)) == MyClass(foo=1, bar=2) - expect(mock_load.called) == True + expect(mock_load.called).is_(True) @patch('datafiles.mapper.Mapper.exists', False) def when_file_missing(expect, manager): - expect(manager.get_or_none(foo=3, bar=4)) == None + expect(manager.get_or_none(foo=3, bar=4)).is_(None) def describe_get_or_create(): @patch('datafiles.mapper.Mapper.load') @@ -40,14 +40,14 @@ def describe_get_or_create(): @patch('datafiles.mapper.Mapper.modified', False) def when_file_exists(mock_load, mock_save, expect, manager): expect(manager.get_or_create(foo=1, bar=2)) == MyClass(foo=1, bar=2) - expect(mock_load.called) == False - expect(mock_save.called) == True + expect(mock_load.called).is_(False) + expect(mock_save.called).is_(True) @patch('datafiles.mapper.Mapper.save') @patch('datafiles.mapper.Mapper.exists', False) def when_file_missing(mock_save, expect, manager): expect(manager.get_or_create(foo=1, bar=2)) == MyClass(foo=1, bar=2) - expect(mock_save.called) == True + expect(mock_save.called).is_(True) def describe_all(): @patch('datafiles.mapper.Mapper.exists', False) diff --git a/datafiles/tests/test_mapper.py b/datafiles/tests/test_mapper.py index 236a6059..2f5cfb83 100644 --- a/datafiles/tests/test_mapper.py +++ b/datafiles/tests/test_mapper.py @@ -37,7 +37,7 @@ def mapper(): def describe_path(): def is_none_when_no_pattern(expect, mapper): - expect(mapper.path) == None + expect(mapper.path).is_(None) def is_relative_to_file_by_default(expect, mapper): mapper._pattern = '../../tmp/sample.yml' diff --git a/datafiles/tests/test_utils.py b/datafiles/tests/test_utils.py index 868f63d8..dd4e5a49 100644 --- a/datafiles/tests/test_utils.py +++ b/datafiles/tests/test_utils.py @@ -6,48 +6,96 @@ def describe_recursive_update(): - def it_preserves_root_id(expect): - old: Dict = {} - new = {'a': 1} - id_ = id(old) + def describe_id_preservation(): + def with_dict(expect): + old = {'my_dict': {'a': 1}} + new = {'my_dict': {'a': 2}} + previous_id = id(old['my_dict']) - old = recursive_update(old, new) + recursive_update(old, new) - expect(old) == new - expect(id(old)) == id_ + expect(old) == new + expect(id(old['my_dict'])) == previous_id - def it_preserves_nested_dict_id(expect): - old = {'a': {'b': 1}} - new = {'a': {'b': 2}} - id_ = id(old['a']) + def with_dict_value(expect): + old = {'my_dict': {'my_nested_dict': {'a': 1}}} + new = {'my_dict': {'my_nested_dict': {'a': 2}}} + previous_id = id(old['my_dict']['my_nested_dict']) - old = recursive_update(old, new) + recursive_update(old, new) - expect(old) == new - expect(id(old['a'])) == id_ + expect(old) == new + expect(id(old['my_dict']['my_nested_dict'])) == previous_id - def it_preserves_nested_list_id(expect): - old = {'a': [1]} - new = {'a': [2]} - id_ = id(old['a']) + def with_list(expect): + old = {'my_list': [1]} + new = {'my_list': [2]} + previous_id = id(old['my_list']) - old = recursive_update(old, new) + recursive_update(old, new) - expect(old) == new - expect(id(old['a'])) == id_ + expect(old) == new + expect(id(old['my_list'])) == previous_id - def it_adds_missing_dict(expect): - old: Dict = {} - new = {'a': {'b': 2}} + def with_list_item(expect): + old = {'my_list': [{'name': "John"}]} + new = {'my_list': [{'name': "Jane"}]} + previous_id = id(old['my_list'][0]) - old = recursive_update(old, new) + recursive_update(old, new) - expect(old) == new + expect(old) == new + expect(id(old['my_list'][0])) == previous_id - def it_adds_missing_list(expect): - old: Dict = {} - new = {'a': [1]} + def with_nested_list(expect): + old = {'my_dict': {'my_list': [{'name': "John"}]}} + new = {'my_dict': {'my_list': [{'name': "Jane"}]}} + previous_id = id(old['my_dict']['my_list'][0]) - old = recursive_update(old, new) + recursive_update(old, new) - expect(old) == new + expect(old) == new + expect(id(old['my_dict']['my_list'][0])) == previous_id + + def describe_merge(): + def with_shoreter_list_into_longer(expect): + old = {'my_list': [1, 2, 3]} + new = {'my_list': [5, 6]} + + recursive_update(old, new) + + expect(old) == new + + def with_longer_list_into_shorter(expect): + old = {'my_list': [1, 2]} + new = {'my_list': [3, 4, 5]} + + recursive_update(old, new) + + expect(old) == new + + def describe_missing(): + def with_dict(expect): + old: Dict = {} + new = {'my_dict': {'a': 1}} + + recursive_update(old, new) + + expect(old) == new + + def with_list(expect): + old: Dict = {} + new = {'my_list': [1]} + + recursive_update(old, new) + + expect(old) == new + + def describe_extra(): + def with_dict(expect): + old = {'my_dict': {'a': 1, 'b': 2}} + new = {'my_dict': {'a': 1}} + + recursive_update(old, new) + + expect(old) == new diff --git a/datafiles/types.py b/datafiles/types.py new file mode 100644 index 00000000..dcd1b100 --- /dev/null +++ b/datafiles/types.py @@ -0,0 +1,14 @@ +import dataclasses +from typing import Optional + + +Trilean = Optional[bool] +Missing = dataclasses._MISSING_TYPE + + +class List(list): + """Patchable `list` type.""" + + +class Dict(dict): + """Patchable `dict` type.""" diff --git a/datafiles/utils.py b/datafiles/utils.py index 7b4ed1d4..94e55c9b 100644 --- a/datafiles/utils.py +++ b/datafiles/utils.py @@ -6,13 +6,11 @@ from pathlib import Path from pprint import pformat from shutil import get_terminal_size -from typing import Dict, Optional, Union +from typing import Any, Dict, Union import log - -Trilean = Optional[bool] -Missing = dataclasses._MISSING_TYPE +from .types import Missing cached = lru_cache() @@ -52,24 +50,40 @@ def dictify(value): return value -def recursive_update(old: Dict, new: Dict): - """Recursively update a dictionary.""" +def recursive_update(old: Dict, new: Dict) -> Dict: + """Recursively update a dictionary, keeping equivalent objects.""" + return _merge(old, new) + + +def _merge(old: Any, new: Any) -> Any: + if old is None: + return new + + if isinstance(new, dict): + for key, value in new.items(): + old[key] = _merge(old.get(key), value) + + for key in list(old.keys()): + if key not in new: + old.pop(key) - for key, value in new.items(): - if isinstance(value, dict): - if key in old: - recursive_update(old[key], value) - else: - old[key] = value - elif isinstance(value, list): - if key in old: - old[key][:] = value - else: - old[key] = value - else: - old[key] = value + return old - return old + if isinstance(new, list): + for index, new_item in enumerate(new): + try: + old_item = old[index] + except IndexError: + old_item = None + old.append(old_item) + old[index] = _merge(old_item, new_item) + + while len(old) > len(new): + old.pop() + + return old + + return new def dedent(text: str) -> str: @@ -79,8 +93,8 @@ def dedent(text: str) -> str: return text.replace(' ' * indent, '') -def write(filename_or_path: Union[str, Path], text: str) -> None: - """Write text to a given file with logging.""" +def write(filename_or_path: Union[str, Path], text: str, *, display=False) -> None: + """Write text to a given file and optionally log it.""" if isinstance(filename_or_path, Path): path = filename_or_path else: @@ -93,14 +107,17 @@ def write(filename_or_path: Union[str, Path], text: str) -> None: content = text.replace(' \n', '␠\n') else: content = '∅\n' - log.debug(message + '\n' + line + '\n' + content + line) + if display: + log.debug(message + '\n' + line + '\n' + content + line) + else: + log.critical(message) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text) -def read(filename: str) -> str: - """Read text from a file with logging.""" +def read(filename: str, *, display=False) -> str: + """Read text from a file and optionally log it.""" path = Path(filename).resolve() message = f'Reading file: {path}' line = '=' * (31 + len(message)) @@ -109,7 +126,10 @@ def read(filename: str) -> str: content = text.replace(' \n', '␠\n') else: content = '∅\n' - log.debug(message + '\n' + line + '\n' + content + line) + if display: + log.debug(message + '\n' + line + '\n' + content + line) + else: + log.critical(message) return text diff --git a/docs/types/builtins.md b/docs/types/builtins.md index 41b468d0..6382457c 100644 --- a/docs/types/builtins.md +++ b/docs/types/builtins.md @@ -8,36 +8,53 @@ from typing import Optional ## Booleans -| Type Annotation | Python Value | YAML Content | -| --- | --- | --- | -| `foobar: bool` | `foobar = True` | `foobar: true` | -| `foobar: bool` | `foobar = False` | `foobar: false` | -| `foobar: bool` | `foobar = None` | `foobar: false` | -| `foobar: Optional[bool]` | `foobar = False` | `foobar: ` | +| Type Annotation | Python Value | YAML Content | +| ------------------------ | ---------------- | --------------- | +| `foobar: bool` | `foobar = True` | `foobar: true` | +| `foobar: bool` | `foobar = False` | `foobar: false` | +| `foobar: bool` | `foobar = None` | `foobar: false` | +| `foobar: Optional[bool]` | `foobar = False` | `foobar:` | ## Integers -| Type Annotation | Python Value | YAML Content | -| --- | --- | --- | -| `foobar: int` | `foobar = 42` | `foobar: 42` | -| `foobar: int` | `foobar = 1.23` | `foobar: 1` | -| `foobar: int` | `foobar = None` | `foobar: 0` | -| `foobar: Optional[int]` | `foobar = None` | `foobar: ` | +| Type Annotation | Python Value | YAML Content | +| ----------------------- | --------------- | ------------ | +| `foobar: int` | `foobar = 42` | `foobar: 42` | +| `foobar: int` | `foobar = 1.23` | `foobar: 1` | +| `foobar: int` | `foobar = None` | `foobar: 0` | +| `foobar: Optional[int]` | `foobar = None` | `foobar:` | ## Floats -| Type Annotation | Python Value | YAML Content | -| --- | --- | --- | -| `foobar: float` | `foobar = 1.23` | `foobar: 1.23` | -| `foobar: float` | `foobar = 42` | `foobar: 42.0` | -| `foobar: float` | `foobar = None` | `foobar: 0.0` | -| `foobar: Optional[float]` | `foobar = None` | `foobar: ` | +| Type Annotation | Python Value | YAML Content | +| ------------------------- | --------------- | -------------- | +| `foobar: float` | `foobar = 1.23` | `foobar: 1.23` | +| `foobar: float` | `foobar = 42` | `foobar: 42.0` | +| `foobar: float` | `foobar = None` | `foobar: 0.0` | +| `foobar: Optional[float]` | `foobar = None` | `foobar:` | ## Strings -| Type Annotation | Python Value | YAML Content | -| --- | --- | --- | -| `foobar: str` | `foobar = "Hello, world!"` | `foobar: Hello, world!` | -| `foobar: str` | `foobar = 42` | `foobar: '42'` | -| `foobar: str` | `foobar = None` | `foobar: ''` | -| `foobar: Optional[str]` | `foobar = None` | `foobar: ` | +| Type Annotation | Python Value | YAML Content | +| ----------------------- | -------------------------- | ----------------------- | +| `foobar: str` | `foobar = "Hello, world!"` | `foobar: Hello, world!` | +| `foobar: str` | `foobar = 42` | `foobar: '42'` | +| `foobar: str` | `foobar = None` | `foobar: ''` | +| `foobar: Optional[str]` | `foobar = None` | `foobar:` | + +## Enumerations + +Subclasses of `enum.Enum` can also be used as type annotations: + +```python +from enum import Enum + +class Color: + RED = 1 + GREEN = 2 + BLUE = 3 +``` + +| Type Annotation | Python Value | YAML Content | +| --------------- | -------------------- | ------------ | +| `color: Color` | `color = Color.BLUE` | `color: 3` | diff --git a/docs/types/extensions.md b/docs/types/extensions.md index 72680f31..f96340e1 100644 --- a/docs/types/extensions.md +++ b/docs/types/extensions.md @@ -12,12 +12,12 @@ from typing import Optional from datafiles.converters import Number ``` -| Type Annotation | Python Value | YAML Content | -| --- | --- | --- | -| `foobar: Number` | `foobar = 42` | `foobar: 42` | -| `foobar: Number` | `foobar = 1.23` | `foobar: 1.23` | -| `foobar: Number` | `foobar = None` | `foobar: 0.0` | -| `foobar: Optional[Number]` | `foobar = None` | `foobar: ` | +| Type Annotation | Python Value | YAML Content | +| -------------------------- | --------------- | -------------- | +| `foobar: Number` | `foobar = 42` | `foobar: 42` | +| `foobar: Number` | `foobar = 1.23` | `foobar: 1.23` | +| `foobar: Number` | `foobar = None` | `foobar: 0.0` | +| `foobar: Optional[Number]` | `foobar = None` | `foobar:` | ## Text @@ -29,9 +29,9 @@ from typing import Optional from datafiles.converters import Text ``` -| Type Annotation | Python Value | YAML Content | -| --- | --- | --- | -| `foobar: Text` | `foobar = "Hello, world!"` | `foobar: Hello, world!` | -| `foobar: Text` | `foobar = "First\nSecond\n"` | `foobar: | `
    `First`
    `Second` | -| `foobar: Text` | `foobar = None` | `foobar: ''` | -| `foobar: Optional[Text]` | `foobar = None` | `foobar: ` | +| Type Annotation | Python Value | YAML Content | +| ------------------------ | ---------------------------- | ---------------------------------------------------------------------------------- | +| `foobar: Text` | `foobar = "Hello, world!"` | `foobar: Hello, world!` | +| `foobar: Text` | `foobar = "First\nSecond\n"` | `foobar: |`
    `First`
    `Second` | +| `foobar: Text` | `foobar = None` | `foobar: ''` | +| `foobar: Optional[Text]` | `foobar = None` | `foobar:` | diff --git a/mkdocs.yml b/mkdocs.yml index 6699505b..7e40a349 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,8 +18,8 @@ nav: - Mapper: api/mapper.md - Supported Types: - Builtins: types/builtins.md - - Extensions: types/extensions.md - Containers: types/containers.md + - Extended Types: types/extensions.md - Custom Types: types/custom.md - File Formats: formats.md - Utilities: utilities.md diff --git a/notebooks/file_inference.ipynb b/notebooks/file_inference.ipynb index 58cedf7f..fca59460 100644 --- a/notebooks/file_inference.ipynb +++ b/notebooks/file_inference.ipynb @@ -157,7 +157,7 @@ "script:\n", " - make test-repeat\n", " - make check\n", - " - make mkdocs\n", + " - make docs\n", "\n", "after_success:\n", " - pip install coveralls\n", @@ -237,7 +237,9 @@ " - 3.8\n", "cache:\n", " pip: true\n", - " directories: []\n", + " directories:\n", + " - ${VIRTUAL_ENV}\n", + "\n", "before_install:\n", " - curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py\n", " | python\n", @@ -249,7 +251,7 @@ "script:\n", " - make test-repeat\n", " - make check\n", - " - make mkdocs\n", + " - make docs\n", "after_success:\n", " - pip install coveralls\n", " - coveralls\n", @@ -283,7 +285,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.2" + "version": "3.8.2" } }, "nbformat": 4, diff --git a/notebooks/format_options.ipynb b/notebooks/format_options.ipynb index 2a820d46..59af66c2 100644 --- a/notebooks/format_options.ipynb +++ b/notebooks/format_options.ipynb @@ -256,7 +256,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.2" + "version": "3.8.2" } }, "nbformat": 4, diff --git a/notebooks/manager_api.ipynb b/notebooks/manager_api.ipynb index d6496bbc..0648d764 100644 --- a/notebooks/manager_api.ipynb +++ b/notebooks/manager_api.ipynb @@ -331,7 +331,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.2" + "version": "3.8.2" } }, "nbformat": 4, diff --git a/notebooks/mapper_api.ipynb b/notebooks/mapper_api.ipynb index db04dfea..3ec4df3d 100644 --- a/notebooks/mapper_api.ipynb +++ b/notebooks/mapper_api.ipynb @@ -325,7 +325,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.2" + "version": "3.8.2" } }, "nbformat": 4, diff --git a/notebooks/nested_dataclass.ipynb b/notebooks/nested_dataclass.ipynb index b069e54b..b4db7fb2 100644 --- a/notebooks/nested_dataclass.ipynb +++ b/notebooks/nested_dataclass.ipynb @@ -19,7 +19,7 @@ "source": [ "%%sh\n", "\n", - "rm -f files/nested_values.yml" + "rm -f files/nested_dataclass.yml" ] }, { @@ -65,7 +65,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "INFO: datafiles.mapper: Loading 'Sample' object from 'files/nested_dataclass.yml'\n" + "INFO: datafiles.mapper: Saving 'Sample' object to 'files/nested_dataclass.yml'\n" ] } ], @@ -87,7 +87,7 @@ "foo: 42\n", "bar:\n", " alpha: true\n", - " beta: true\n" + " beta: false\n" ] } ], @@ -157,6 +157,15 @@ "\n", "cat files/nested_dataclass.yml" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [] } ], "metadata": { @@ -175,7 +184,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.2" + "version": "3.8.2" } }, "nbformat": 4, diff --git a/notebooks/patched_containers.ipynb b/notebooks/patched_containers.ipynb index b2af098c..f4be6de8 100644 --- a/notebooks/patched_containers.ipynb +++ b/notebooks/patched_containers.ipynb @@ -10,7 +10,7 @@ "source": [ "%%sh\n", "\n", - "rm -f files/patched_containers.*" + "rm -f files/patched_containers.yml" ] }, { @@ -337,7 +337,6 @@ " - 2.3\n", "data:\n", " a: 1\n", - " b: 2\n", " c: 3\n" ] } @@ -365,7 +364,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.2" + "version": "3.8.2" } }, "nbformat": 4, diff --git a/notebooks/readme.ipynb b/notebooks/readme.ipynb index db613cff..191500b0 100644 --- a/notebooks/readme.ipynb +++ b/notebooks/readme.ipynb @@ -244,7 +244,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.2" + "version": "3.8.2" } }, "nbformat": 4, diff --git a/notebooks/roundtrip_comments.ipynb b/notebooks/roundtrip_comments.ipynb index bacb777d..8b925cc3 100644 --- a/notebooks/roundtrip_comments.ipynb +++ b/notebooks/roundtrip_comments.ipynb @@ -239,7 +239,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.2" + "version": "3.8.2" } }, "nbformat": 4, diff --git a/poetry.lock b/poetry.lock index ec8668ad..b1b0e45d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -43,6 +43,14 @@ wrapt = ">=1.11.0,<1.12.0" python = "<3.8" version = ">=1.4.0,<1.5" +[[package]] +category = "dev" +description = "Async generators and context managers for Python 3.5+" +name = "async-generator" +optional = false +python-versions = ">=3.5" +version = "1.10" + [[package]] category = "dev" description = "Atomic file writes." @@ -117,7 +125,7 @@ description = "Python package for providing Mozilla's CA Bundle." name = "certifi" optional = false python-versions = "*" -version = "2019.11.28" +version = "2020.4.5.1" [[package]] category = "dev" @@ -157,7 +165,7 @@ description = "Code coverage measurement for Python" name = "coverage" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "5.0.4" +version = "5.1" [package.extras] toml = ["toml"] @@ -539,7 +547,7 @@ description = "Optional static typing for Python" name = "mypy" optional = false python-versions = ">=3.5" -version = "0.761" +version = "0.770" [package.dependencies] mypy-extensions = ">=0.4.3,<0.5.0" @@ -562,12 +570,14 @@ category = "dev" description = "A client library for executing notebooks. Formally nbconvert's ExecutePreprocessor." name = "nbclient" optional = false -python-versions = ">=3.5" -version = "0.1.0" +python-versions = ">=3.6" +version = "0.2.0" [package.dependencies] -jupyter-client = ">=5.3.4" +async-generator = "*" +jupyter-client = ">=6.1.0" nbformat = ">=5.0" +nest-asyncio = "*" traitlets = ">=4.2" [package.extras] @@ -608,7 +618,7 @@ description = "The Jupyter Notebook format" name = "nbformat" optional = false python-versions = ">=3.5" -version = "5.0.4" +version = "5.0.5" [package.dependencies] ipython-genutils = "*" @@ -630,6 +640,14 @@ version = "0.3.7" [package.dependencies] nbformat = "*" +[[package]] +category = "dev" +description = "Patch asyncio to allow nested event loops" +name = "nest-asyncio" +optional = false +python-versions = ">=3.5" +version = "1.3.2" + [[package]] category = "dev" description = "A web-based notebook environment for interactive computing" @@ -681,25 +699,22 @@ category = "dev" description = "Parametrize and run Jupyter and nteract Notebooks" name = "papermill" optional = false -python-versions = ">=3.5" -version = "2.0.0" +python-versions = ">=3.6" +version = "2.1.0" [package.dependencies] ansiwrap = "*" +black = "*" click = "*" entrypoints = "*" jupyter-client = "*" -nbclient = "*" +nbclient = ">=0.2.0" nbformat = "*" pyyaml = "*" requests = "*" tenacity = "*" tqdm = ">=4.32.2" -[package.dependencies.black] -python = ">=3.6" -version = "*" - [package.extras] all = ["boto3", "azure-datalake-store (>=0.0.30)", "azure-storage-blob (>=12.1.0)", "requests (>=2.21.0)", "gcsfs (>=0.2.0)", "pyarrow"] azure = ["azure-datalake-store (>=0.0.30)", "azure-storage-blob (>=12.1.0)", "requests (>=2.21.0)"] @@ -854,7 +869,7 @@ description = "Python parsing module" name = "pyparsing" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.6" +version = "2.4.7" [[package]] category = "dev" @@ -919,6 +934,10 @@ version = "0.12.0" [package.dependencies] pytest = ">=2.6.0" +[package.source] +reference = "43245ecfc3c432c4bfb5b12f8868790c7f2ebe5d" +type = "git" +url = "https://github.com/jacebrowning/pytest-describe" [[package]] category = "dev" description = "Better testing with expecter and pytest." @@ -929,17 +948,17 @@ version = "2.1" [[package]] category = "dev" -description = "Thin-wrapper around the mock package for easier use with py.test" +description = "Thin-wrapper around the mock package for easier use with pytest" name = "pytest-mock" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.0.0" +python-versions = ">=3.5" +version = "3.0.0" [package.dependencies] pytest = ">=2.7" [package.extras] -dev = ["pre-commit", "tox"] +dev = ["pre-commit", "tox", "pytest-asyncio"] [[package]] category = "dev" @@ -1212,7 +1231,7 @@ description = "Fast, Extensible Progress Meter" name = "tqdm" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*" -version = "4.44.1" +version = "4.45.0" [package.extras] dev = ["py-make (>=0.1.0)", "twine", "argopt", "pydoc-markdown"] @@ -1247,7 +1266,7 @@ description = "Backported and Experimental Type Hints for Python 3.5+" name = "typing-extensions" optional = false python-versions = "*" -version = "3.7.4.1" +version = "3.7.4.2" [[package]] category = "dev" @@ -1325,7 +1344,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "98f1afc1fb2314c5d660a2d8fa7f0e25c7f6af2bde1459dafe39283d5a692d1c" +content-hash = "3aa5a06bdedb79daee99cea522a995dd9a9c2d17f2d549194198adfb663f1d41" python-versions = "^3.7" [metadata.files] @@ -1345,6 +1364,10 @@ astroid = [ {file = "astroid-2.3.3-py3-none-any.whl", hash = "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42"}, {file = "astroid-2.3.3.tar.gz", hash = "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a"}, ] +async-generator = [ + {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"}, + {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"}, +] atomicwrites = [ {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"}, {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"}, @@ -1370,8 +1393,8 @@ cached-property = [ {file = "cached_property-1.5.1-py2.py3-none-any.whl", hash = "sha256:3a026f1a54135677e7da5ce819b0c690f156f37976f3e30c5430740725203d7f"}, ] certifi = [ - {file = "certifi-2019.11.28-py2.py3-none-any.whl", hash = "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3"}, - {file = "certifi-2019.11.28.tar.gz", hash = "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"}, + {file = "certifi-2020.4.5.1-py2.py3-none-any.whl", hash = "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304"}, + {file = "certifi-2020.4.5.1.tar.gz", hash = "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"}, ] chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, @@ -1389,37 +1412,37 @@ colorama = [ {file = "colorama-0.3.9.tar.gz", hash = "sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1"}, ] coverage = [ - {file = "coverage-5.0.4-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:8a620767b8209f3446197c0e29ba895d75a1e272a36af0786ec70fe7834e4307"}, - {file = "coverage-5.0.4-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:73aa6e86034dad9f00f4bbf5a666a889d17d79db73bc5af04abd6c20a014d9c8"}, - {file = "coverage-5.0.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:408ce64078398b2ee2ec08199ea3fcf382828d2f8a19c5a5ba2946fe5ddc6c31"}, - {file = "coverage-5.0.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:cda33311cb9fb9323958a69499a667bd728a39a7aa4718d7622597a44c4f1441"}, - {file = "coverage-5.0.4-cp27-cp27m-win32.whl", hash = "sha256:5f587dfd83cb669933186661a351ad6fc7166273bc3e3a1531ec5c783d997aac"}, - {file = "coverage-5.0.4-cp27-cp27m-win_amd64.whl", hash = "sha256:9fad78c13e71546a76c2f8789623eec8e499f8d2d799f4b4547162ce0a4df435"}, - {file = "coverage-5.0.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:2e08c32cbede4a29e2a701822291ae2bc9b5220a971bba9d1e7615312efd3037"}, - {file = "coverage-5.0.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:922fb9ef2c67c3ab20e22948dcfd783397e4c043a5c5fa5ff5e9df5529074b0a"}, - {file = "coverage-5.0.4-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:c3fc325ce4cbf902d05a80daa47b645d07e796a80682c1c5800d6ac5045193e5"}, - {file = "coverage-5.0.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:046a1a742e66d065d16fb564a26c2a15867f17695e7f3d358d7b1ad8a61bca30"}, - {file = "coverage-5.0.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6ad6ca45e9e92c05295f638e78cd42bfaaf8ee07878c9ed73e93190b26c125f7"}, - {file = "coverage-5.0.4-cp35-cp35m-win32.whl", hash = "sha256:eda55e6e9ea258f5e4add23bcf33dc53b2c319e70806e180aecbff8d90ea24de"}, - {file = "coverage-5.0.4-cp35-cp35m-win_amd64.whl", hash = "sha256:4a8a259bf990044351baf69d3b23e575699dd60b18460c71e81dc565f5819ac1"}, - {file = "coverage-5.0.4-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:f372cdbb240e09ee855735b9d85e7f50730dcfb6296b74b95a3e5dea0615c4c1"}, - {file = "coverage-5.0.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a37c6233b28e5bc340054cf6170e7090a4e85069513320275a4dc929144dccf0"}, - {file = "coverage-5.0.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:443be7602c790960b9514567917af538cac7807a7c0c0727c4d2bbd4014920fd"}, - {file = "coverage-5.0.4-cp36-cp36m-win32.whl", hash = "sha256:165a48268bfb5a77e2d9dbb80de7ea917332a79c7adb747bd005b3a07ff8caf0"}, - {file = "coverage-5.0.4-cp36-cp36m-win_amd64.whl", hash = "sha256:0a907199566269e1cfa304325cc3b45c72ae341fbb3253ddde19fa820ded7a8b"}, - {file = "coverage-5.0.4-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:513e6526e0082c59a984448f4104c9bf346c2da9961779ede1fc458e8e8a1f78"}, - {file = "coverage-5.0.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:3844c3dab800ca8536f75ae89f3cf566848a3eb2af4d9f7b1103b4f4f7a5dad6"}, - {file = "coverage-5.0.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:641e329e7f2c01531c45c687efcec8aeca2a78a4ff26d49184dce3d53fc35014"}, - {file = "coverage-5.0.4-cp37-cp37m-win32.whl", hash = "sha256:db1d4e38c9b15be1521722e946ee24f6db95b189d1447fa9ff18dd16ba89f732"}, - {file = "coverage-5.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:62061e87071497951155cbccee487980524d7abea647a1b2a6eb6b9647df9006"}, - {file = "coverage-5.0.4-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:65a7e00c00472cd0f59ae09d2fb8a8aaae7f4a0cf54b2b74f3138d9f9ceb9cb2"}, - {file = "coverage-5.0.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1f66cf263ec77af5b8fe14ef14c5e46e2eb4a795ac495ad7c03adc72ae43fafe"}, - {file = "coverage-5.0.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:85596aa5d9aac1bf39fe39d9fa1051b0f00823982a1de5766e35d495b4a36ca9"}, - {file = "coverage-5.0.4-cp38-cp38-win32.whl", hash = "sha256:86a0ea78fd851b313b2e712266f663e13b6bc78c2fb260b079e8b67d970474b1"}, - {file = "coverage-5.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:03f630aba2b9b0d69871c2e8d23a69b7fe94a1e2f5f10df5049c0df99db639a0"}, - {file = "coverage-5.0.4-cp39-cp39-win32.whl", hash = "sha256:7c9762f80a25d8d0e4ab3cb1af5d9dffbddb3ee5d21c43e3474c84bf5ff941f7"}, - {file = "coverage-5.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:4482f69e0701139d0f2c44f3c395d1d1d37abd81bfafbf9b6efbe2542679d892"}, - {file = "coverage-5.0.4.tar.gz", hash = "sha256:1b60a95fc995649464e0cd48cecc8288bac5f4198f21d04b8229dc4097d76823"}, + {file = "coverage-5.1-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65"}, + {file = "coverage-5.1-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2"}, + {file = "coverage-5.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04"}, + {file = "coverage-5.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6"}, + {file = "coverage-5.1-cp27-cp27m-win32.whl", hash = "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796"}, + {file = "coverage-5.1-cp27-cp27m-win_amd64.whl", hash = "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730"}, + {file = "coverage-5.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0"}, + {file = "coverage-5.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a"}, + {file = "coverage-5.1-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf"}, + {file = "coverage-5.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9"}, + {file = "coverage-5.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768"}, + {file = "coverage-5.1-cp35-cp35m-win32.whl", hash = "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2"}, + {file = "coverage-5.1-cp35-cp35m-win_amd64.whl", hash = "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7"}, + {file = "coverage-5.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0"}, + {file = "coverage-5.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019"}, + {file = "coverage-5.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c"}, + {file = "coverage-5.1-cp36-cp36m-win32.whl", hash = "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1"}, + {file = "coverage-5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7"}, + {file = "coverage-5.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355"}, + {file = "coverage-5.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489"}, + {file = "coverage-5.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd"}, + {file = "coverage-5.1-cp37-cp37m-win32.whl", hash = "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e"}, + {file = "coverage-5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a"}, + {file = "coverage-5.1-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55"}, + {file = "coverage-5.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c"}, + {file = "coverage-5.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef"}, + {file = "coverage-5.1-cp38-cp38-win32.whl", hash = "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24"}, + {file = "coverage-5.1-cp38-cp38-win_amd64.whl", hash = "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0"}, + {file = "coverage-5.1-cp39-cp39-win32.whl", hash = "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4"}, + {file = "coverage-5.1-cp39-cp39-win_amd64.whl", hash = "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e"}, + {file = "coverage-5.1.tar.gz", hash = "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052"}, ] coveragespace = [ {file = "coveragespace-3.1.1-py3-none-any.whl", hash = "sha256:cc62bf4f2feb419032920270a0c16f1a379b80ac9fc6bd3cefce918bfc90ba27"}, @@ -1588,41 +1611,45 @@ more-itertools = [ {file = "more_itertools-8.2.0-py3-none-any.whl", hash = "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c"}, ] mypy = [ - {file = "mypy-0.761-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6"}, - {file = "mypy-0.761-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36"}, - {file = "mypy-0.761-cp35-cp35m-win_amd64.whl", hash = "sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72"}, - {file = "mypy-0.761-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2"}, - {file = "mypy-0.761-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0"}, - {file = "mypy-0.761-cp36-cp36m-win_amd64.whl", hash = "sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474"}, - {file = "mypy-0.761-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a"}, - {file = "mypy-0.761-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749"}, - {file = "mypy-0.761-cp37-cp37m-win_amd64.whl", hash = "sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1"}, - {file = "mypy-0.761-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7"}, - {file = "mypy-0.761-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"}, - {file = "mypy-0.761-cp38-cp38-win_amd64.whl", hash = "sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b"}, - {file = "mypy-0.761-py3-none-any.whl", hash = "sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217"}, - {file = "mypy-0.761.tar.gz", hash = "sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf"}, + {file = "mypy-0.770-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:a34b577cdf6313bf24755f7a0e3f3c326d5c1f4fe7422d1d06498eb25ad0c600"}, + {file = "mypy-0.770-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:86c857510a9b7c3104cf4cde1568f4921762c8f9842e987bc03ed4f160925754"}, + {file = "mypy-0.770-cp35-cp35m-win_amd64.whl", hash = "sha256:a8ffcd53cb5dfc131850851cc09f1c44689c2812d0beb954d8138d4f5fc17f65"}, + {file = "mypy-0.770-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:7687f6455ec3ed7649d1ae574136835a4272b65b3ddcf01ab8704ac65616c5ce"}, + {file = "mypy-0.770-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3beff56b453b6ef94ecb2996bea101a08f1f8a9771d3cbf4988a61e4d9973761"}, + {file = "mypy-0.770-cp36-cp36m-win_amd64.whl", hash = "sha256:15b948e1302682e3682f11f50208b726a246ab4e6c1b39f9264a8796bb416aa2"}, + {file = "mypy-0.770-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:b90928f2d9eb2f33162405f32dde9f6dcead63a0971ca8a1b50eb4ca3e35ceb8"}, + {file = "mypy-0.770-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c56ffe22faa2e51054c5f7a3bc70a370939c2ed4de308c690e7949230c995913"}, + {file = "mypy-0.770-cp37-cp37m-win_amd64.whl", hash = "sha256:8dfb69fbf9f3aeed18afffb15e319ca7f8da9642336348ddd6cab2713ddcf8f9"}, + {file = "mypy-0.770-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:219a3116ecd015f8dca7b5d2c366c973509dfb9a8fc97ef044a36e3da66144a1"}, + {file = "mypy-0.770-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7ec45a70d40ede1ec7ad7f95b3c94c9cf4c186a32f6bacb1795b60abd2f9ef27"}, + {file = "mypy-0.770-cp38-cp38-win_amd64.whl", hash = "sha256:f91c7ae919bbc3f96cd5e5b2e786b2b108343d1d7972ea130f7de27fdd547cf3"}, + {file = "mypy-0.770-py3-none-any.whl", hash = "sha256:3b1fc683fb204c6b4403a1ef23f0b1fac8e4477091585e0c8c54cbdf7d7bb164"}, + {file = "mypy-0.770.tar.gz", hash = "sha256:8a627507ef9b307b46a1fea9513d5c98680ba09591253082b4c48697ba05a4ae"}, ] mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] nbclient = [ - {file = "nbclient-0.1.0-py3-none-any.whl", hash = "sha256:02af062077ae0ec00201f52e207942a03a49e7dbb2dd7589c1713a3f40bea0df"}, - {file = "nbclient-0.1.0.tar.gz", hash = "sha256:39fa0b3b24cb597827f6b104718dc952c679a8f5ba2d6f1a4113a3bd7f1528c6"}, + {file = "nbclient-0.2.0-py3-none-any.whl", hash = "sha256:193731fd5039061dbd7d6d3f765a2fa59d23012594d68b1798deea9c3eae4a01"}, + {file = "nbclient-0.2.0.tar.gz", hash = "sha256:44dde0356def1d9345908c8f58dc604a434f2fe61c49ac13fac6e2da2ae429de"}, ] nbconvert = [ {file = "nbconvert-5.6.1-py2.py3-none-any.whl", hash = "sha256:f0d6ec03875f96df45aa13e21fd9b8450c42d7e1830418cccc008c0df725fcee"}, {file = "nbconvert-5.6.1.tar.gz", hash = "sha256:21fb48e700b43e82ba0e3142421a659d7739b65568cc832a13976a77be16b523"}, ] nbformat = [ - {file = "nbformat-5.0.4-py3-none-any.whl", hash = "sha256:f4bbbd8089bd346488f00af4ce2efb7f8310a74b2058040d075895429924678c"}, - {file = "nbformat-5.0.4.tar.gz", hash = "sha256:562de41fc7f4f481b79ab5d683279bf3a168858268d4387b489b7b02be0b324a"}, + {file = "nbformat-5.0.5-py3-none-any.whl", hash = "sha256:65a79936a128fd85aef392b7fea520166364037118b6fe3ed52de742d06c4558"}, + {file = "nbformat-5.0.5.tar.gz", hash = "sha256:f0c47cf93c505cb943e2f131ef32b8ae869292b5f9f279db2bafb35867923f69"}, ] nbstripout = [ {file = "nbstripout-0.3.7-py2.py3-none-any.whl", hash = "sha256:cf745ae8c49fccdb3068b73fc3b783898d5d62ee929429e9af37a6dfefba34b7"}, {file = "nbstripout-0.3.7.tar.gz", hash = "sha256:62f1b1fe9c7c298061089fd9bd5d297eb6209f7fbef0758631dbe58d38fc828f"}, ] +nest-asyncio = [ + {file = "nest_asyncio-1.3.2-py3-none-any.whl", hash = "sha256:b4cdd08655e2848098d204a26590cbfa39fcbc4ad1811c568678ffc8a0c8e279"}, + {file = "nest_asyncio-1.3.2.tar.gz", hash = "sha256:14e194b72144052a82173ca9109bd07c57813a320f42c7acfad1e4d329988350"}, +] notebook = [ {file = "notebook-6.0.3-py3-none-any.whl", hash = "sha256:3edc616c684214292994a3af05eaea4cc043f6b4247d830f3a2f209fa7639a80"}, {file = "notebook-6.0.3.tar.gz", hash = "sha256:47a9092975c9e7965ada00b9a20f0cf637d001db60d241d479f53c0be117ad48"}, @@ -1635,8 +1662,8 @@ pandocfilters = [ {file = "pandocfilters-1.4.2.tar.gz", hash = "sha256:b3dd70e169bb5449e6bc6ff96aea89c5eea8c5f6ab5e207fc2f521a2cf4a0da9"}, ] papermill = [ - {file = "papermill-2.0.0-py3-none-any.whl", hash = "sha256:7ad0dd5bce86df5e973a88e29457be99662099d365b99d640acb27119b1b7812"}, - {file = "papermill-2.0.0.tar.gz", hash = "sha256:83459eeb378b2a2f885fc2b36890306a99fda160d483d28847aa112454311bab"}, + {file = "papermill-2.1.0-py3-none-any.whl", hash = "sha256:10f86551cd28d09acea1f63b04a61d061ed4c3d3e82aa675b6a593c4451860c6"}, + {file = "papermill-2.1.0.tar.gz", hash = "sha256:1ff390a2bea5ea1538c1fcb5e2abb08c2a261d4427286905bc803416c72c26b0"}, ] parse = [ {file = "parse-1.15.0.tar.gz", hash = "sha256:a6d4e2c2f1fbde6717d28084a191a052950f758c0cbd83805357e6575c2b95c0"}, @@ -1685,8 +1712,8 @@ pygments = [ ] pylint = [] pyparsing = [ - {file = "pyparsing-2.4.6-py2.py3-none-any.whl", hash = "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"}, - {file = "pyparsing-2.4.6.tar.gz", hash = "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f"}, + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] pyrsistent = [ {file = "pyrsistent-0.16.0.tar.gz", hash = "sha256:28669905fe725965daa16184933676547c5bb40a5153055a8dee2a4bd7933ad3"}, @@ -1699,16 +1726,14 @@ pytest-cov = [ {file = "pytest-cov-2.8.1.tar.gz", hash = "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b"}, {file = "pytest_cov-2.8.1-py2.py3-none-any.whl", hash = "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"}, ] -pytest-describe = [ - {file = "pytest-describe-0.12.0.tar.gz", hash = "sha256:569bda96401fe512f4f345f33fd23fa4d718639d42afac62bc03254b5f2b3fdf"}, -] +pytest-describe = [] pytest-expecter = [ {file = "pytest-expecter-2.1.tar.gz", hash = "sha256:70b05fec2a9ae5ec6f15d5c5467c1e2cb9aa2fd85ec9c4783b26e2cf68e66f96"}, {file = "pytest_expecter-2.1-py3-none-any.whl", hash = "sha256:ab66120671a22be41f7df4c29d76c02d62e3420b8277b1455d2e683a042a0608"}, ] pytest-mock = [ - {file = "pytest-mock-2.0.0.tar.gz", hash = "sha256:b35eb281e93aafed138db25c8772b95d3756108b601947f89af503f8c629413f"}, - {file = "pytest_mock-2.0.0-py2.py3-none-any.whl", hash = "sha256:cb67402d87d5f53c579263d37971a164743dc33c159dfb4fb4a86f37c5552307"}, + {file = "pytest-mock-3.0.0.tar.gz", hash = "sha256:a4494016753a30231f8519bfd160242a0f3c8fb82ca36e7b6f82a7fb602ac6b8"}, + {file = "pytest_mock-3.0.0-py2.py3-none-any.whl", hash = "sha256:98e02534f170e4f37d7e1abdfc5973fd4207aa609582291717f643764e71c925"}, ] pytest-profiling = [ {file = "pytest-profiling-1.7.0.tar.gz", hash = "sha256:93938f147662225d2b8bd5af89587b979652426a8a6ffd7e73ec4a23e24b7f29"}, @@ -1885,8 +1910,8 @@ tornado = [ {file = "tornado-6.0.4.tar.gz", hash = "sha256:0fe2d45ba43b00a41cd73f8be321a44936dc1aba233dee979f17a042b83eb6dc"}, ] tqdm = [ - {file = "tqdm-4.44.1-py2.py3-none-any.whl", hash = "sha256:be5ddeec77d78ba781ea41eacb2358a77f74cc2407f54b82222d7ee7dc8c8ccf"}, - {file = "tqdm-4.44.1.tar.gz", hash = "sha256:03d2366c64d44c7f61e74c700d9b202d57e9efe355ea5c28814c52bfe7a50b8c"}, + {file = "tqdm-4.45.0-py2.py3-none-any.whl", hash = "sha256:ea9e3fd6bd9a37e8783d75bfc4c1faf3c6813da6bd1c3e776488b41ec683af94"}, + {file = "tqdm-4.45.0.tar.gz", hash = "sha256:00339634a22c10a7a22476ee946bbde2dbe48d042ded784e4d88e0236eca5d81"}, ] traitlets = [ {file = "traitlets-4.3.3-py2.py3-none-any.whl", hash = "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44"}, @@ -1916,9 +1941,9 @@ typed-ast = [ {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] typing-extensions = [ - {file = "typing_extensions-3.7.4.1-py2-none-any.whl", hash = "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d"}, - {file = "typing_extensions-3.7.4.1-py3-none-any.whl", hash = "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"}, - {file = "typing_extensions-3.7.4.1.tar.gz", hash = "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2"}, + {file = "typing_extensions-3.7.4.2-py2-none-any.whl", hash = "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"}, + {file = "typing_extensions-3.7.4.2-py3-none-any.whl", hash = "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5"}, + {file = "typing_extensions-3.7.4.2.tar.gz", hash = "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae"}, ] urllib3 = [ {file = "urllib3-1.25.8-py2.py3-none-any.whl", hash = "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc"}, diff --git a/pyproject.toml b/pyproject.toml index 9cd72c71..5f9b6e5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "datafiles" -version = "0.8.1" +version = "0.9" description = "File-based ORM for dataclasses." license = "MIT" @@ -43,8 +43,8 @@ python = "^3.7" # Formats PyYAML = "^5.2" -"ruamel.yaml" = "^0.16.7" -tomlkit = "^0.5.3" +"ruamel.yaml" = "~0.16.10" +tomlkit = "~0.5.3" # ORM parse = "^1.12" @@ -64,14 +64,14 @@ black = "=19.3b0" isort = "=4.3.4" # Linters -mypy = "^0.761" +mypy = "~0.770" pylint = { git = "https://github.com/PyCQA/pylint", rev = "e169e83e52fedc6624235d45e8e8737294a0fedf" } # 2.4.5 has not yet been released pydocstyle = "*" # Testing pytest = "^5.3.2" -pytest-describe = "*" -pytest-expecter = "^2.0" +pytest-describe = { git = "https://github.com/jacebrowning/pytest-describe", branch = "pytest-5.4-support" } +pytest-expecter = "^2.1" pytest-mock = "*" pytest-random = "*" pytest-repeat = "*" diff --git a/tests/test_file_inference.py b/tests/test_file_inference.py index 92cc7fbd..3d2caf29 100644 --- a/tests/test_file_inference.py +++ b/tests/test_file_inference.py @@ -6,17 +6,13 @@ def test_auto_with_sample_file(expect): write( 'tmp/sample.yml', """ - homogeneous_list: - 1 - 2 - heterogeneous_list: - 1 - 'abc' - empty_list: [] - """, ) @@ -36,7 +32,6 @@ def test_auto_with_sample_file(expect): logbreak("Reading file") expect(read('tmp/sample.yml')) == dedent( """ - homogeneous_list: - 1 - 2 diff --git a/tests/test_instantiation.py b/tests/test_instantiation.py index 58ebb54b..79ef3514 100644 --- a/tests/test_instantiation.py +++ b/tests/test_instantiation.py @@ -115,12 +115,12 @@ class SampleAutomatic: def it_is_created_automatically_by_default(expect): sample = SampleAutomatic() - expect(sample.datafile.exists) == True + expect(sample.datafile.exists).is_(True) def it_is_not_created_automatically_when_manual(expect): sample = SampleManual() - expect(sample.datafile.exists) == False + expect(sample.datafile.exists).is_(False) def describe_factory_defaults(): diff --git a/tests/test_loading.py b/tests/test_loading.py index 22bc85b7..e6bf7f6a 100644 --- a/tests/test_loading.py +++ b/tests/test_loading.py @@ -39,7 +39,7 @@ def with_matching_types(sample, expect): sample.datafile.load() - expect(sample.bool_) == True + expect(sample.bool_).is_(True) expect(sample.int_) == 1 expect(sample.float_) == 2.3 expect(sample.str_) == 'foobar' @@ -57,7 +57,7 @@ def with_convertable_types(sample, expect): sample.datafile.load() - expect(sample.bool_) == True + expect(sample.bool_).is_(True) expect(sample.int_) == 2 expect(sample.float_) == 3.0 expect(sample.str_) == '4' @@ -77,7 +77,7 @@ def with_extra_fields(sample, expect): sample.datafile.load() - expect(hasattr(sample, 'extra')) == False + expect(hasattr(sample, 'extra')).is_(False) def describe_alternate_formats(): @@ -100,7 +100,7 @@ def with_json(sample, expect): sample.datafile.load() - expect(sample.bool_) == True + expect(sample.bool_).is_(True) expect(sample.int_) == 1 expect(sample.float_) == 2.3 expect(sample.str_) == 'foobar' @@ -230,7 +230,7 @@ def with_extra_attributes(sample, expect): expect(sample.score) == 1.2 expect(sample.nested.name) == 'b' expect(sample.nested.score) == 3.4 - expect(hasattr(sample.nested, 'extra')) == False + expect(hasattr(sample.nested, 'extra')).is_(False) @pytest.mark.flaky def with_multiple_levels(expect): diff --git a/tests/test_orm_usage.py b/tests/test_orm_usage.py index aaba1842..adf9ff1c 100644 --- a/tests/test_orm_usage.py +++ b/tests/test_orm_usage.py @@ -1,11 +1,11 @@ """Tests that represent usage as an ORM.""" -from dataclasses import dataclass +from typing import List, Optional import pytest from datafiles import datafile -from datafiles.utils import logbreak +from datafiles.utils import logbreak, write # This model is based on the example dataclass from: @@ -48,7 +48,7 @@ def test_multiple_instances_are_distinct(expect): def test_classes_can_share_a_nested_dataclass(expect): - @dataclass + @datafile class Nested: value: int @@ -78,3 +78,58 @@ def test_values_are_filled_from_disk(expect): items = list(InventoryItem.objects.all()) expect(items[0]) == InventoryItem(42, "Things", 0.99) + + +def test_missing_optional_fields_are_loaded(expect): + @datafile + class Name: + value: str + + @datafile("../tmp/samples/{self.key}.json") + class Sample: + + key: int + name: Optional[Name] + value: float = 0.0 + + sample = Sample(42, None) + + logbreak("get key=42") + sample2 = Sample.objects.get(42) + expect(sample2.name) == sample.name + + +def test_comments_in_matched_files(expect): + @datafile("../tmp/templates/{self.key}/config.yml") + class LegacyTemplate: + key: str + name: str + link: str + default: List[str] + aliases: List[str] + + write( + 'tmp/templates/foo/config.yml', + """ + link: # placeholder + default: + - # placeholder + - # placeholder + aliases: + - # placeholder + """, + ) + write( + 'tmp/templates/bar/config.yml', + """ + link: http://example.com + default: + - abc + - def + aliases: + - qux + """, + ) + + items = list(LegacyTemplate.objects.all()) + expect(len(items)) == 2 diff --git a/tests/test_saving.py b/tests/test_saving.py index bb4585d4..e127b77f 100644 --- a/tests/test_saving.py +++ b/tests/test_saving.py @@ -1,9 +1,12 @@ """Tests for saving to a file.""" -# pylint: disable=unused-variable,assigning-non-slot +# pylint: disable=unused-variable,assigning-non-slot,unsubscriptable-object + +from typing import Optional import pytest +from datafiles import datafile from datafiles.utils import dedent, logbreak, read, write from .samples import ( @@ -224,6 +227,26 @@ def with_nones(expect): """ ) + def when_nested_dataclass_is_none(expect): + @datafile + class Name: + value: str + + @datafile("../tmp/samples/{self.key}.yml") + class Sample: + + key: int + name: Optional[Name] + value: float = 0.0 + + sample = Sample(42, None) + + expect(read('tmp/samples/42.yml')) == dedent( + """ + name: + """ + ) + def describe_defaults(): def with_custom_values(expect): @@ -293,8 +316,8 @@ def with_comments(expect): write( 'tmp/sample.yml', """ - # Heading - required: 1.0 # Line + # Header + required: 1.0 # Line optional: 2.0 """, ) @@ -305,8 +328,8 @@ def with_comments(expect): expect(read('tmp/sample.yml')) == dedent( """ - # Heading - required: 3.0 # Line + # Header + required: 3.0 # Line optional: 2.0 """ ) @@ -317,76 +340,112 @@ def with_comments_in_nested_objects(expect): write( 'tmp/sample.yml', """ - # Heading + # Header name: a - score: 1.0 # Line + score: 1.0 # Line nested: - # Nested heading + # Nested header name: n score: 2 """, ) - logbreak("Loading") sample.datafile.load() - sample.score = 3 sample.nested.score = 4 - - logbreak("Saving") sample.datafile.save() expect(read('tmp/sample.yml')) == dedent( """ - # Heading + # Header name: a - score: 3.0 # Line + score: 3.0 # Line nested: - # Nested heading + # Nested header name: n score: 4.0 """ ) - @pytest.mark.xfail(reason="Unknown ruamel.yaml bug") def with_comments_on_nested_lines(expect): sample = SampleWithNestingAndDefaults(None) write( 'tmp/sample.yml', """ - # Heading + # Header name: a - score: 1 # Line + score: 1 # Line nested: - # Nested heading + # Nested header name: n - score: 2 # Nested line + score: 2 # Nested line """, ) - logbreak("Loading") sample.datafile.load() - sample.score = 3 sample.nested.score = 4 - - logbreak("Saving") sample.datafile.save() expect(read('tmp/sample.yml')) == dedent( """ - # Heading + # Header name: a - score: 3.0 # Line + score: 3.0 # Line nested: - # Nested heading + # Nested header name: n - score: 4.0 # Nested line + score: 4.0 # Nested line + """ + ) + + @pytest.mark.xfail(reason="https://github.com/jacebrowning/datafiles/issues/171") + def with_comments_on_list_items(expect): + sample = SampleWithListOfDataclasses() + + write( + 'tmp/sample.yml', + """ + # Header + + items: # Subheader + + # Section + + - name: a # Item + score: 1 + + # Section + + - name: b # Item + score: 2 + """, + ) + + sample.datafile.load() + sample.items[1].name = 'c' + sample.datafile.save() + + expect(read('tmp/sample.yml')) == dedent( + """ + # Header + + items: # Subheader + + # Section + + - name: a # Item + score: 1 + + # Section + + - name: c # Item + score: 2 """ ) @@ -401,6 +460,7 @@ def with_quotations(expect): ) sample.datafile.load() + sample.float_ = 1 sample.datafile.save() expect(read('tmp/sample.yml')) == dedent( @@ -408,6 +468,6 @@ def with_quotations(expect): str_: "42" bool_: false int_: 0 - float_: 0.0 + float_: 1.0 """ ) diff --git a/tests/test_setup.py b/tests/test_setup.py index 879d338e..17f85a06 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -133,7 +133,7 @@ def it_inferrs_attrs(expect, sample): } def it_has_no_path(expect, sample): - expect(sample.datafile.path) == None + expect(sample.datafile.path).is_(None) def it_converts_attributes(expect, sample): expect(sample.key) == 2 @@ -163,7 +163,7 @@ def it_uses_attrs_from_meta(expect, sample): expect(sample.datafile.attrs) == {'name': datafiles.converters.String} def it_has_no_path(expect, sample): - expect(sample.datafile.path) == None + expect(sample.datafile.path).is_(None) def it_converts_attributes(expect, sample): expect(sample.datafile.data) == {'name': "c"}