Skip to content

Commit 121b3b9

Browse files
authored
Merge pull request #316 from jacebrowning/default-pattern-args
Handle pattern arguments with default values in Manager.get()
2 parents fb701ee + c362c62 commit 121b3b9

File tree

4 files changed

+72
-54
lines changed

4 files changed

+72
-54
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Release Notes
22

3+
## 2.2.1 (2024-01-04)
4+
5+
- Updated `Manager.get()` to handle default values in pattern arguments.
6+
37
## 2.2 (2023-10-14)
48

59
- Added a `sync()` utility to map arbitrary objects to the filesystem.

datafiles/manager.py

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121

2222

2323
Trilean = Optional[bool]
24-
Missing = dataclasses._MISSING_TYPE
25-
_NOT_PASSED = object() # sentinel
24+
Missing = dataclasses._MISSING_TYPE # sentinel value for arguments to be loaded
25+
Absent = object() # sentinel value for required arguments not passed
2626

2727

2828
class Splats:
@@ -38,31 +38,28 @@ def get(self, *args, **kwargs) -> Model:
3838
with hooks.disabled():
3939
instance = self.model.__new__(self.model)
4040

41-
# We **must** initialize with a value all fields on the uninitialized
42-
# instance which play a role in loading, e.g., those with placeholders
43-
# in the pattern. Other init fields of the instance which are not passed
44-
# as args or kwargs will be set to `dataclasses._MISSING_TYPE` and their
45-
# values will be loaded over.
41+
# Set initial values for all passed arguments
4642
fields = [field for field in dataclasses.fields(self.model) if field.init]
4743
pattern = self.model.Meta.datafile_pattern
4844
args_iter = iter(args)
4945
for field in fields:
5046
placeholder = f"{{self.{field.name}}}"
5147

5248
try:
53-
# we always need to consume an arg if it exists,
54-
# even if it's not one with a placeholder
5549
value = next(args_iter)
5650
except StopIteration:
57-
value = kwargs.get(field.name, _NOT_PASSED)
58-
59-
if placeholder in pattern:
60-
if value is _NOT_PASSED:
61-
raise TypeError(
62-
f"Manager.get() missing required placeholder field argument: '{field.name}'"
63-
)
64-
65-
if value is _NOT_PASSED:
51+
value = kwargs.get(field.name, Absent)
52+
53+
if (
54+
placeholder in pattern
55+
and value is Absent
56+
and isinstance(field.default, Missing)
57+
):
58+
raise TypeError(
59+
f"Manager.get() missing required placeholder field argument: '{field.name}'"
60+
)
61+
62+
if value is Absent:
6663
if not isinstance(field.default, Missing):
6764
value = field.default
6865
elif not isinstance(field.default_factory, Missing):
@@ -71,7 +68,7 @@ def get(self, *args, **kwargs) -> Model:
7168
value = Missing
7269
object.__setattr__(instance, field.name, value)
7370

74-
# NOTE: the following doesn't call instance.datafile.load because hooks are disabled currently
71+
# Bypass calling load() because hooks are disabled currently
7572
model.Model.__post_init__(instance)
7673

7774
try:
@@ -129,7 +126,6 @@ def all(self, *, _exclude: str = "") -> Iterator[Model]:
129126

130127
log.info(f"Finding files matching pattern: {splatted}")
131128
for index, filename in enumerate(iglob(splatted, recursive=True)):
132-
133129
if Path(filename).is_dir():
134130
log.debug(f"Skipped matching directory {index + 1}: {filename}")
135131
continue

datafiles/tests/test_manager.py

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# pylint: disable=unused-variable
1+
# pylint: disable=unused-variable,unused-argument
22

33
import os
44
import shutil
@@ -25,35 +25,49 @@ class MyClass:
2525
nested: Optional[Nested] = None
2626

2727

28+
@dataclass
29+
class MyClassDefaults:
30+
foo: int
31+
bar: int = 2
32+
33+
2834
def describe_manager():
2935
@pytest.fixture
30-
def manager():
31-
shutil.rmtree(Path(__file__).parent / "files", ignore_errors=True)
36+
def files():
37+
return Path(__file__).parent / "files"
38+
39+
@pytest.fixture
40+
def manager(files: Path):
41+
shutil.rmtree(files, ignore_errors=True)
3242
model = create_model(MyClass, pattern="files/{self.foo}.yml")
3343
return Manager(model)
3444

3545
@pytest.fixture
36-
def manager_home():
46+
def manager_at_home():
3747
model = create_model(Nested, pattern="~/.{self.name}.json")
3848
return Manager(model)
3949

40-
@pytest.fixture()
41-
def manager_with_files():
42-
files_dir = Path(__file__).parent / "files"
43-
shutil.rmtree(files_dir, ignore_errors=True)
44-
files_dir.mkdir(exist_ok=True)
50+
@pytest.fixture
51+
def manager_with_files(files: Path):
52+
files.mkdir(exist_ok=True)
4553
model = create_model(MyClass, pattern="files/{self.foo}.yml")
4654
model(foo=1, bar=2).datafile.save()
4755
return Manager(model)
4856

57+
@pytest.fixture
58+
def manager_with_defaults(files: Path):
59+
shutil.rmtree(files, ignore_errors=True)
60+
model = create_model(MyClassDefaults, pattern="files/{self.foo}/{self.bar}.yml")
61+
return Manager(model)
62+
4963
def describe_get():
5064
@patch("datafiles.mapper.Mapper.load")
5165
@patch("datafiles.mapper.Mapper.exists", True)
5266
@patch("datafiles.mapper.Mapper.modified", False)
53-
def when_partial_args_passed_init_args_missing(mock_load, expect, manager):
54-
got = manager.get(1)
55-
expect(got.foo) == 1
56-
expect(got.bar) is Missing
67+
def when_absent_pattern_arg(mock_load, expect, manager):
68+
instance = manager.get(1)
69+
expect(instance.foo) == 1
70+
expect(instance.bar) is Missing
5771
expect(mock_load.called).is_(True)
5872

5973
with expect.raises(
@@ -62,35 +76,39 @@ def when_partial_args_passed_init_args_missing(mock_load, expect, manager):
6276
):
6377
manager.get(bar=2)
6478

79+
@patch("datafiles.mapper.Mapper.load")
6580
@patch("datafiles.mapper.Mapper.exists", True)
6681
@patch("datafiles.mapper.Mapper.modified", False)
67-
def when_partial_args_passed_init_arg_missing_file_exists(
68-
expect, manager_with_files
82+
def when_absent_pattern_arg_has_default_value(
83+
mock_load, expect, manager_with_defaults: Manager
6984
):
70-
# demonstrates that `Manager.get` loads the value for bar, when it is not passed
85+
expect(manager_with_defaults.get(0, 1)) == MyClassDefaults(foo=0, bar=1)
86+
expect(manager_with_defaults.get(1)) == MyClassDefaults(foo=1, bar=2)
87+
expect(mock_load.called).is_(True)
88+
89+
@patch("datafiles.mapper.Mapper.exists", True)
90+
@patch("datafiles.mapper.Mapper.modified", False)
91+
def when_partial_args_match_file(expect, manager_with_files: Manager):
7192
expect(manager_with_files.get(1)) == MyClass(foo=1, bar=2)
7293

7394
@patch("datafiles.mapper.Mapper.exists", True)
7495
@patch("datafiles.mapper.Mapper.modified", False)
75-
def when_partial_kwargs_passed_init_arg_missing_file_exists(
76-
expect, manager_with_files
77-
):
78-
# demonstrates that `Manager.get` loads the value for bar, when it is not passed
96+
def when_partial_kwargs_match_file(expect, manager_with_files: Manager):
7997
expect(manager_with_files.get(foo=1)) == MyClass(foo=1, bar=2)
8098

8199
def describe_get_or_none():
82100
@patch("datafiles.mapper.Mapper.load")
83101
@patch("datafiles.mapper.Mapper.exists", True)
84102
@patch("datafiles.mapper.Mapper.modified", False)
85-
def when_file_exists(mock_load, expect, manager):
103+
def when_file_exists(mock_load, expect, manager: Manager):
86104
expect(manager.get_or_none(foo=1, bar=2)) == MyClass(foo=1, bar=2)
87105
expect(mock_load.called).is_(True)
88106

89107
@patch("datafiles.mapper.Mapper.exists", False)
90-
def when_file_missing(expect, manager):
108+
def when_file_missing(expect, manager: Manager):
91109
expect(manager.get_or_none(foo=3, bar=4)).is_(None)
92110

93-
def when_file_corrupt(expect, manager):
111+
def when_file_corrupt(expect, manager: Manager):
94112
instance = manager.get_or_create(foo=2, bar=1)
95113
instance.datafile.path.write_text("{")
96114
instance2 = manager.get_or_none(foo=2, bar=2)
@@ -102,48 +120,48 @@ def describe_get_or_create():
102120
@patch("datafiles.mapper.Mapper.load")
103121
@patch("datafiles.mapper.Mapper.exists", True)
104122
@patch("datafiles.mapper.Mapper.modified", False)
105-
def when_file_exists(mock_save, mock_load, expect, manager):
123+
def when_file_exists(mock_save, mock_load, expect, manager: Manager):
106124
expect(manager.get_or_create(foo=1, bar=2)) == MyClass(foo=1, bar=2)
107125
expect(mock_save.called).is_(True)
108126
expect(mock_load.called).is_(False)
109127

110128
@patch("datafiles.mapper.Mapper.save")
111129
@patch("datafiles.mapper.Mapper.load")
112130
@patch("datafiles.mapper.Mapper.exists", False)
113-
def when_file_missing(mock_save, mock_load, expect, manager):
131+
def when_file_missing(mock_save, mock_load, expect, manager: Manager):
114132
expect(manager.get_or_create(foo=1, bar=2)) == MyClass(foo=1, bar=2)
115133
expect(mock_save.called).is_(True)
116134
expect(mock_load.called).is_(True)
117135

118-
def when_file_corrupt(expect, manager):
136+
def when_file_corrupt(expect, manager: Manager):
119137
instance = manager.get_or_create(foo=2, bar=1)
120138
instance.datafile.path.write_text("{")
121139
instance2 = manager.get_or_create(foo=2, bar=2)
122-
expect(instance2.bar) == 2
140+
expect(instance2.bar) == 2 # type: ignore[attr-defined]
123141

124142
def describe_all():
125143
@patch("datafiles.mapper.Mapper.exists", False)
126-
def when_no_files_exist(expect, manager):
144+
def when_no_files_exist(expect, manager: Manager):
127145
items = list(manager.all())
128146
expect(items) == []
129147

130-
def with_home_directory(expect, manager_home):
131-
items = list(manager_home.all())
148+
def with_home_directory(expect, manager_at_home: Manager):
149+
items = list(manager_at_home.all())
132150
if "CI" not in os.environ:
133151
expect(len(items)) > 0
134152

135153
def describe_filter():
136154
@patch("datafiles.mapper.Mapper.exists", False)
137-
def when_no_files_exist(expect, manager):
155+
def when_no_files_exist(expect, manager: Manager):
138156
items = list(manager.filter())
139157
expect(items) == []
140158

141159
@patch("datafiles.mapper.Mapper.exists", False)
142-
def with_partial_positional_arguments(expect, manager):
160+
def with_partial_positional_arguments(expect, manager: Manager):
143161
items = list(manager.filter(foo=1))
144162
expect(items) == []
145163

146164
@patch("datafiles.mapper.Mapper.exists", False)
147-
def with_nested_key_query(expect, manager):
165+
def with_nested_key_query(expect, manager: Manager):
148166
items = list(manager.filter(nested__name="John Doe"))
149167
expect(items) == []

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[tool.poetry]
22

33
name = "datafiles"
4-
version = "2.2"
4+
version = "2.2.1"
55
description = "File-based ORM for dataclasses."
66

77
license = "MIT"

0 commit comments

Comments
 (0)