Skip to content

Commit 907dbf5

Browse files
authored
v0.3.0 (#8)
* fix: update to use param spec * fix: update typehint for Data * fix: update typehint for register fns * fix: update typehint for domain register fns * feat: file ops for adapter * bump: => 0.3.0
1 parent 90bfcf5 commit 907dbf5

File tree

6 files changed

+182
-21
lines changed

6 files changed

+182
-21
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "io-adapters"
3-
version = "0.2.2"
3+
version = "0.3.0"
44
description = "Dependency Injection Adapters"
55
readme = "README.md"
66
authors = [

src/io_adapters/_adapters.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
import datetime
44
import logging
5+
import shutil
6+
from abc import ABC, abstractmethod
57
from collections.abc import Callable, Hashable
8+
from copy import deepcopy
9+
from fnmatch import fnmatch
610
from pathlib import Path
711
from types import MappingProxyType
812

@@ -16,7 +20,7 @@
1620

1721

1822
@attrs.define
19-
class IoAdapter:
23+
class IoAdapter(ABC):
2024
read_fns: MappingProxyType[Hashable, ReadFn] = attrs.field(
2125
default=READ_FNS,
2226
validator=[
@@ -147,13 +151,44 @@ def get_guid(self) -> str:
147151
def get_datetime(self) -> datetime.datetime:
148152
return self.datetime_fn()
149153

154+
@abstractmethod
155+
def list_files(self, path: str | Path, glob_pattern: str = "*") -> list[str]: ...
156+
157+
@abstractmethod
158+
def copy_file(self, old: str | Path, new: str | Path) -> None: ...
159+
160+
@abstractmethod
161+
def delete_file(self, path: str | Path, *, missing_ok: bool = True) -> None: ...
162+
163+
def move_file(self, old: str | Path, new: str | Path) -> None:
164+
self.copy_file(old, new)
165+
self.delete_file(old)
166+
167+
@abstractmethod
168+
def exists(self, path: str | Path) -> bool: ...
169+
150170

151171
@attrs.define
152172
class RealAdapter(IoAdapter):
153173
def __attrs_post_init__(self) -> None:
154174
self.guid_fn = self.guid_fn or default_guid
155175
self.datetime_fn = self.datetime_fn or default_datetime
156176

177+
def list_files(self, path: str | Path, glob_pattern: str = "*") -> list[str]:
178+
return sorted(map(str, Path(path).rglob(glob_pattern)))
179+
180+
def copy_file(self, old: str | Path, new: str | Path) -> None:
181+
src = Path(old)
182+
dst = Path(new)
183+
dst.parent.mkdir(parents=True, exist_ok=True)
184+
shutil.copy2(src, dst)
185+
186+
def delete_file(self, path: str | Path, *, missing_ok: bool = True) -> None:
187+
Path(path).unlink(missing_ok=missing_ok)
188+
189+
def exists(self, path: str | Path) -> bool:
190+
return Path(path).exists()
191+
157192

158193
@attrs.define
159194
class FakeAdapter(IoAdapter):
@@ -174,8 +209,26 @@ def _read_fn(self, path: str | Path) -> Data:
174209
def _write_fn(self, data: Data, path: str | Path) -> None:
175210
self.files[str(path)] = data
176211

177-
def get_guid(self) -> str:
178-
return self.guid_fn()
212+
def list_files(self, path: str | Path, glob_pattern: str = "*") -> list[str]:
213+
return sorted(
214+
[
215+
str(p)
216+
for p in self.files
217+
if Path(p).is_relative_to(Path(path)) and fnmatch(Path(p).name, glob_pattern)
218+
]
219+
)
179220

180-
def get_datetime(self) -> datetime.datetime:
181-
return self.datetime_fn()
221+
def copy_file(self, old: str | Path, new: str | Path) -> None:
222+
self.files[str(new)] = deepcopy(self.files[str(old)])
223+
224+
def delete_file(self, path: str | Path, *, missing_ok: bool = True) -> None:
225+
path = str(Path(path))
226+
227+
try:
228+
self.files.pop(path)
229+
except KeyError as e:
230+
if not missing_ok:
231+
raise FileNotFoundError(path) from e
232+
233+
def exists(self, path: str | Path) -> bool:
234+
return str(path) in map(str, self.files)

src/io_adapters/_container.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from attrs.validators import deep_iterable, instance_of
1010

1111
from io_adapters import FakeAdapter, RealAdapter
12-
from io_adapters._registries import standardise_key
12+
from io_adapters._registries import ReadFn, WriteFn, standardise_key
1313

1414
logger = logging.getLogger(__name__)
1515

@@ -19,7 +19,7 @@ class _FnType(Enum):
1919
WRITE = auto()
2020

2121

22-
DomainFns: TypeAlias = dict[Hashable, dict[Hashable, Callable]]
22+
DomainFns: TypeAlias = dict[Hashable, dict[Hashable, ReadFn | WriteFn]]
2323

2424

2525
@attrs.define
@@ -150,7 +150,7 @@ def read_json(path: str | Path, **kwargs: dict) -> dict:
150150
domain = standardise_key(domain)
151151
key = standardise_key(key)
152152

153-
def wrapper(func: Callable) -> Callable:
153+
def wrapper(func: ReadFn) -> ReadFn:
154154
logger.info(f"registering read fn {key = } {func = }")
155155
self.domain_fns[domain][_FnType.READ][key] = func
156156
return func
@@ -182,7 +182,7 @@ def write_json(data: dict, path: str | Path, **kwargs: dict) -> None:
182182
domain = standardise_key(domain)
183183
key = standardise_key(key)
184184

185-
def wrapper(func: Callable) -> Callable:
185+
def wrapper(func: WriteFn) -> WriteFn:
186186
logger.info(f"registering read fn {key = } {func = }")
187187
self.domain_fns[domain][_FnType.WRITE][key] = func
188188
return func
@@ -265,7 +265,7 @@ def add_domain(domain: Hashable) -> None:
265265
return DEFAULT_CONTAINER.add_domain(domain)
266266

267267

268-
def register_domain_read_fn(domain: Hashable, key: Hashable) -> Callable:
268+
def register_domain_read_fn(domain: Hashable, key: Hashable) -> ReadFn:
269269
"""Register a read function to a domain in the default ``Container``.
270270
271271
Decorators can be stacked to register the same function to multiple domains.
@@ -290,7 +290,7 @@ def read_json(path: str | Path, **kwargs: dict) -> dict:
290290
return DEFAULT_CONTAINER.register_domain_read_fn(domain, key)
291291

292292

293-
def register_domain_write_fn(domain: Hashable, key: Hashable) -> Callable:
293+
def register_domain_write_fn(domain: Hashable, key: Hashable) -> WriteFn:
294294
"""Register a write function to a domain in the default ``Container``.
295295
296296
Decorators can be stacked to register the same function to multiple domains.

src/io_adapters/_registries.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,22 @@
33
import logging
44
from collections.abc import Callable, Hashable
55
from pathlib import Path
6-
from typing import Concatenate, ParamSpec, TypeAlias
6+
from typing import Concatenate, ParamSpec, TypeAlias, TypeVar
77

88
logger = logging.getLogger(__name__)
99

10-
Data: TypeAlias = "Data"
10+
Data = TypeVar("Data")
1111
P = ParamSpec("P")
1212

13-
ReadFn = Callable[Concatenate[str | Path, P], Data]
14-
WriteFn = Callable[Concatenate[Data, str | Path, P], None]
13+
ReadFn: TypeAlias = Callable[Concatenate[str | Path, P], Data]
14+
WriteFn: TypeAlias = Callable[Concatenate[Data, str | Path, P], None]
1515

1616

1717
READ_FNS: dict[Hashable, ReadFn] = {}
1818
WRITE_FNS: dict[Hashable, WriteFn] = {}
1919

2020

21-
def register_read_fn(key: Hashable) -> Callable:
21+
def register_read_fn(key: Hashable) -> Callable[[ReadFn], ReadFn]:
2222
"""Register a read function to the read functions constant.
2323
2424
This is useful for smaller projects where domain isolation isn't required.
@@ -36,15 +36,15 @@ def read_json(path: str | Path, **kwargs: dict) -> dict:
3636
"""
3737
key = standardise_key(key)
3838

39-
def wrapper(func: Callable) -> Callable:
39+
def wrapper(func: ReadFn) -> ReadFn:
4040
logger.info(f"registering read fn {key = } {func = }")
4141
READ_FNS[key] = func
4242
return func
4343

4444
return wrapper
4545

4646

47-
def register_write_fn(key: Hashable) -> Callable:
47+
def register_write_fn(key: Hashable) -> Callable[[WriteFn], WriteFn]:
4848
"""Register a write function to the write functions constant.
4949
5050
This is useful for smaller projects where domain isolation isn't required.
@@ -66,7 +66,7 @@ def write_json(data: dict, path: str | Path, **kwargs: dict) -> None:
6666
"""
6767
key = standardise_key(key)
6868

69-
def wrapper(func: Callable) -> Callable:
69+
def wrapper(func: WriteFn) -> WriteFn:
7070
logger.info(f"registering write fn {key = } {func = }")
7171
WRITE_FNS[key] = func
7272
return func

tests/test_adapters.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import operator
2+
import shutil
23
from contextlib import nullcontext
34
from pathlib import Path
45

@@ -8,6 +9,28 @@
89

910
REPO_ROOT = Path(__file__).parents[1]
1011
MOCK_DATA_PATH = f"{REPO_ROOT}/tests/mock_data/mock.json"
12+
FILES = ("__init__.py", "mock.csv", "reqs.txt", "main.py")
13+
TMP_ROOT = REPO_ROOT.joinpath("tests", "tmp_mock_data")
14+
INITIAL_FILES = [TMP_ROOT.joinpath(x) for x in FILES]
15+
16+
17+
@pytest.fixture(autouse=True)
18+
def setup():
19+
for f in INITIAL_FILES:
20+
f.parent.mkdir(parents=True, exist_ok=True)
21+
f.joinpath(f).touch()
22+
23+
yield
24+
25+
shutil.rmtree(TMP_ROOT)
26+
27+
28+
ADAPTERS = [
29+
pytest.param(RealAdapter(), id="using a RealAdapter"),
30+
pytest.param(
31+
FakeAdapter(files=dict.fromkeys(map(str, INITIAL_FILES), "")), id="using a FakeAdapter"
32+
),
33+
]
1134

1235

1336
@pytest.mark.parametrize(
@@ -73,3 +96,88 @@ def test_guid(adapter, op):
7396
)
7497
def test_datetime(adapter, op):
7598
assert op(adapter().get_datetime(), adapter().get_datetime())
99+
100+
101+
@pytest.mark.parametrize("adapter", ADAPTERS)
102+
@pytest.mark.parametrize(
103+
("path", "glob_pattern", "expected_result"),
104+
[
105+
pytest.param(
106+
TMP_ROOT,
107+
"*",
108+
[
109+
f"{TMP_ROOT}/__init__.py",
110+
f"{TMP_ROOT}/main.py",
111+
f"{TMP_ROOT}/mock.csv",
112+
f"{TMP_ROOT}/reqs.txt",
113+
],
114+
id="get all files",
115+
),
116+
pytest.param(
117+
TMP_ROOT,
118+
"*.py",
119+
[f"{TMP_ROOT}/__init__.py", f"{TMP_ROOT}/main.py"],
120+
id="glob .py files",
121+
),
122+
],
123+
)
124+
def test_list_files(adapter, path, glob_pattern, expected_result):
125+
assert adapter.list_files(path, glob_pattern) == expected_result
126+
127+
128+
@pytest.mark.parametrize("adapter", ADAPTERS)
129+
@pytest.mark.parametrize(
130+
("old", "new"),
131+
[
132+
pytest.param(
133+
f"{TMP_ROOT}/__init__.py",
134+
f"{TMP_ROOT}/new_init.py",
135+
id="both old and new exist after copy",
136+
),
137+
],
138+
)
139+
def test_copy_file(adapter, old, new):
140+
adapter.copy_file(old, new)
141+
assert adapter.exists(old)
142+
assert adapter.exists(new)
143+
144+
145+
@pytest.mark.parametrize("adapter", ADAPTERS)
146+
@pytest.mark.parametrize(
147+
("old", "new"),
148+
[
149+
pytest.param(
150+
f"{TMP_ROOT}/__init__.py",
151+
f"{TMP_ROOT}/new_init.py",
152+
id="old not exists and new exists after move",
153+
),
154+
],
155+
)
156+
def test_move_file(adapter, old, new):
157+
adapter.move_file(old, new)
158+
assert not adapter.exists(old)
159+
assert adapter.exists(new)
160+
161+
162+
@pytest.mark.parametrize("adapter", ADAPTERS)
163+
@pytest.mark.parametrize(
164+
("path", "missing_ok", "expected_context"),
165+
[
166+
pytest.param(
167+
f"{TMP_ROOT}/__init__.py",
168+
True,
169+
nullcontext(),
170+
id="file is deleted after delete",
171+
),
172+
pytest.param(
173+
f"{TMP_ROOT}/INVALID_FILE.py",
174+
False,
175+
pytest.raises(FileNotFoundError),
176+
id="file is deleted after delete",
177+
),
178+
],
179+
)
180+
def test_delete_file(adapter, path, missing_ok, expected_context):
181+
with expected_context:
182+
adapter.delete_file(path, missing_ok=missing_ok)
183+
assert not adapter.exists(path)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)