Skip to content

Commit

Permalink
Merge pull request #294 from bnorick/fronzen-dataclasses
Browse files Browse the repository at this point in the history
Adds support for frozen dataclasses with some tests
  • Loading branch information
jacebrowning committed Feb 11, 2023
2 parents 1850aec + 68a4e03 commit 0e4b357
Show file tree
Hide file tree
Showing 7 changed files with 86 additions and 5 deletions.
2 changes: 1 addition & 1 deletion datafiles/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def get(self, *args, **kwargs) -> Model:
value = field.default_factory()
else:
value = Missing
setattr(instance, field.name, value)
object.__setattr__(instance, field.name, value)

# NOTE: the following doesn't call instance.datafile.load because hooks are disabled currently
model.Model.__post_init__(instance)
Expand Down
16 changes: 15 additions & 1 deletion datafiles/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def __init__(
assert manual is not None
assert defaults is not None
self._instance = instance
self._frozen = (
dataclasses.is_dataclass(self._instance)
and self._instance.__dataclass_params__.frozen
)
self.attrs = attrs
self._pattern = pattern
self._manual = manual
Expand Down Expand Up @@ -162,6 +166,11 @@ def _get_text(self, **kwargs) -> str:
return formats.serialize(data)

def load(self, *, _log=True, _first_load=False) -> None:
if self._frozen and not _first_load:
raise dataclasses.FrozenInstanceError(
"Cannot load frozen dataclass instances more than once."
)

if self._root:
self._root.load(_log=_log, _first_load=_first_load)
return
Expand Down Expand Up @@ -248,14 +257,19 @@ def _set_value(instance, name, converter, data, first_load):
value = converter.to_python_value(file_value, target_object=init_value)

log.debug(f"Setting '{name}' value: {value!r}")
setattr(instance, name, value)
object.__setattr__(instance, name, value)

def save(self, *, include_default_values: Trilean = None, _log=True) -> None:
if self._root:
self._root.save(include_default_values=include_default_values, _log=_log)
return

if self.path:
if self.exists and self._frozen:
raise dataclasses.FrozenInstanceError(
f"Cannot save frozen dataclass instances which already exist, "
f"delete '{self.path}' before saving."
)
if _log:
log.info(f"Saving '{self.classname}' object to '{self.relpath}'")
else:
Expand Down
6 changes: 4 additions & 2 deletions datafiles/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@

from . import config, hooks, settings
from .manager import Manager
from .mapper import create_mapper
from .mapper import Mapper, create_mapper


class Model:

Meta: config.Meta = config.Meta()
datafile: Mapper

def __post_init__(self):
log.debug(f"Initializing {self.__class__} object")

self.datafile = create_mapper(self)
# Using object.__setattr__ in case of frozen dataclasses
object.__setattr__(self, "datafile", create_mapper(self))

if settings.HOOKS_ENABLED:
with hooks.disabled():
Expand Down
10 changes: 10 additions & 0 deletions datafiles/tests/test_model.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# pylint: disable=unused-variable
import dataclasses

from datafiles import model

Expand All @@ -10,3 +11,12 @@ class NonDataclass:

with expect.raises(ValueError):
model.create_model(NonDataclass)

def it_is_compatible_with_frozen_dataclass(expect):
@dataclasses.dataclass(frozen=True)
class FrozenDataclass:
a: int

frozen_model = model.create_model(FrozenDataclass)
expect(hasattr(frozen_model, "Meta")).is_(True)
expect(hasattr(frozen_model, "objects")).is_(True)
8 changes: 8 additions & 0 deletions tests/samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ class Sample:
str_: str


@datafile("../tmp/sample.yml", manual=True, frozen=True)
class SampleFrozen:
bool_: bool
int_: int
float_: float
str_: str


@datafile("../tmp/sample.json", manual=True)
class SampleAsJSON:
bool_: bool
Expand Down
31 changes: 30 additions & 1 deletion tests/test_loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# pylint: disable=unused-variable

from dataclasses import dataclass, field
from dataclasses import FrozenInstanceError, dataclass, field

import pytest

Expand All @@ -13,6 +13,7 @@
from .samples import (
Sample,
SampleAsJSON,
SampleFrozen,
SampleWithDefaults,
SampleWithList,
SampleWithListAndDefaults,
Expand Down Expand Up @@ -98,6 +99,34 @@ def with_invalid_data(sample, expect):
expect(sample.int_) == 0


def describe_frozen():
def with_already_loaded(expect):
write(
"tmp/sample.yml",
"""
bool_: true
int_: 1
float_: 2.3
str_: 'foobar'
""",
)
# NOTE: To trigger a load with frozen dataclasses we can't simply
# use sample.datafile.load() because this will have _first_load=False
# and raise, and if we set _first_load=True then the None values
# would be taken as non-default init values and override the data
# from the file. Here, we use Manager.get to trigger the appropriate
# call to Mapper.load with no non-default init arguments.
sample = SampleFrozen.objects.get()

expect(sample.bool_).is_(True)
expect(sample.int_) == 1
expect(sample.float_) == 2.3
expect(sample.str_) == "foobar"

with expect.raises(FrozenInstanceError):
sample.datafile.load()


def describe_alternate_formats():
@pytest.fixture
def sample():
Expand Down
18 changes: 18 additions & 0 deletions tests/test_saving.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# pylint: disable=unused-variable,assigning-non-slot,unsubscriptable-object

from dataclasses import FrozenInstanceError
from typing import Optional

import pytest
Expand All @@ -12,6 +13,7 @@
from . import xfail_with_pep_563
from .samples import (
Sample,
SampleFrozen,
SampleWithCustomFields,
SampleWithDefaults,
SampleWithList,
Expand Down Expand Up @@ -80,6 +82,22 @@ def with_custom_fields(expect):
)


def describe_frozen():
def with_already_existing(expect):
write(
"tmp/sample.yml",
"""
bool_: true
int_: 1
float_: 2.3
str_: 'foobar'
""",
)
sample = SampleFrozen(True, 1, 2.3, "foobar")
with expect.raises(FrozenInstanceError):
sample.datafile.save()


def describe_lists():
def when_empty(expect):
sample = SampleWithList([])
Expand Down

0 comments on commit 0e4b357

Please sign in to comment.