From 5ca55040bda6c28130c8b054c7ac3f4335820a23 Mon Sep 17 00:00:00 2001 From: ed cuss Date: Sat, 16 May 2026 12:27:27 +0100 Subject: [PATCH 1/2] feat: add decorator handling --- src/io_adapters/_adapters.py | 25 ++++++++++++++++++++++--- tests/test_adapters.py | 21 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/io_adapters/_adapters.py b/src/io_adapters/_adapters.py index 1200854..7a2d0a4 100644 --- a/src/io_adapters/_adapters.py +++ b/src/io_adapters/_adapters.py @@ -11,7 +11,7 @@ from types import MappingProxyType import attrs -from attrs.validators import deep_mapping, instance_of, is_callable, optional +from attrs.validators import deep_iterable, deep_mapping, instance_of, is_callable, optional from io_adapters._clock import default_datetime, default_guid, fake_datetime, fake_guid from io_adapters._registries import READ_FNS, WRITE_FNS, Data, ReadFn, WriteFn, standardise_key @@ -203,10 +203,21 @@ class FakeAdapter(IoAdapter): ), converter=_convert_file_mapping, ) + read_decs: tuple[Callable[..., ReadFn]] = attrs.field( + factory=tuple, validator=deep_iterable(is_callable(), instance_of(tuple)), converter=tuple + ) + write_decs: tuple[Callable[..., WriteFn]] = attrs.field( + factory=tuple, validator=deep_iterable(is_callable(), instance_of(tuple)), converter=tuple + ) def __attrs_post_init__(self) -> None: - self.read_fns = MappingProxyType(dict.fromkeys(self.read_fns.keys(), self._read_fn)) - self.write_fns = MappingProxyType(dict.fromkeys(self.write_fns.keys(), self._write_fn)) + self.read_fns = MappingProxyType( + dict.fromkeys(self.read_fns.keys(), _apply_decs(self._read_fn, self.read_decs)) + ) + self.write_fns = MappingProxyType( + dict.fromkeys(self.write_fns.keys(), _apply_decs(self._write_fn, self.write_decs)) + ) + self.guid_fn = self.guid_fn or fake_guid self.datetime_fn = self.datetime_fn or fake_datetime @@ -244,3 +255,11 @@ def delete_file(self, path: str | Path, *, missing_ok: bool = True) -> None: def exists(self, path: str | Path) -> bool: return Path(path).resolve() in map(Path, self.files) + + +def _apply_decs( + fn: ReadFn | WriteFn, decs: tuple[Callable[..., ReadFn | WriteFn]] +) -> ReadFn | WriteFn: + for dec in reversed(decs): + fn = dec(fn) + return fn diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 32f0b3a..8ec292d 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -6,6 +6,7 @@ import pytest from src.io_adapters import FakeAdapter, RealAdapter +from src.io_adapters._adapters import _apply_decs REPO_ROOT = Path(__file__).parents[1] MOCK_DATA_PATH = f"{REPO_ROOT}/tests/mock_data/mock.json" @@ -191,3 +192,23 @@ def test_write_then_list(adapter): assert adapter.list_files(f"{TMP_ROOT}/pending") == [ Path(f"{TMP_ROOT}/pending/20260425_211300_000.json") ] + + +def test_apply_decs() -> None: + def append(lst: list[int], value: int): + def wrapper(fn): + lst.append(value) + return fn + + return wrapper + + dec_lst, fn_lst = [], [] + + @append(dec_lst, 1) + @append(dec_lst, 2) + def blah(): + return None + + _apply_decs(lambda x: x, [append(fn_lst, 1), append(fn_lst, 2)]) + + assert dec_lst == fn_lst From f7378f04fc1309f4eba0df73b2bfe7473cc8efa6 Mon Sep 17 00:00:00 2001 From: ed cuss Date: Sat, 16 May 2026 16:38:19 +0100 Subject: [PATCH 2/2] test: some better tests for monad returning io fns --- pyproject.toml | 1 + tests/test_adapters.py | 52 +++++++++++++++++++++++++++++++++++++++++- uv.lock | 14 ++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2bcad08..5357482 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dev = [ "pytest>=9.0.2", "pytest-cov>=7.0.0", "repo-mapper-rs>=0.3.0", + "returns>=0.27.0", "ruff>=0.14.9", "sphinx>=9.0.4", "ty>=0.0.9", diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 8ec292d..04af65d 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +from returns.result import Success, safe from src.io_adapters import FakeAdapter, RealAdapter from src.io_adapters._adapters import _apply_decs @@ -209,6 +210,55 @@ def wrapper(fn): def blah(): return None - _apply_decs(lambda x: x, [append(fn_lst, 1), append(fn_lst, 2)]) + _apply_decs(fn=lambda x: x, decs=(append(fn_lst, 1), append(fn_lst, 2))) assert dec_lst == fn_lst + + +@safe +def safe_read_py_file(path: str) -> str: + return Path(path).read_text() + + +def read_py_file(path: str) -> str: + return Path(path).read_text() + + +SAFE_FNS = {"safe_py": safe_read_py_file, "py": read_py_file} + + +@pytest.mark.parametrize( + ("adapter", "file_type", "expected_result"), + [ + pytest.param( + RealAdapter(read_fns=SAFE_FNS), + "safe_py", + Success(""), + id="reads file with monad using a RealAdapter", + ), + pytest.param( + RealAdapter(read_fns=SAFE_FNS), "py", "", id="reads file normally using a RealAdapter" + ), + pytest.param( + FakeAdapter( + files=dict.fromkeys(map(str, INITIAL_FILES), ""), + read_fns=SAFE_FNS, + read_decs=[safe], + ), + "safe_py", + Success(""), + id="reads file with monad using a FakeAdapter", + ), + pytest.param( + FakeAdapter( + files=dict.fromkeys(map(str, INITIAL_FILES), ""), + read_fns=SAFE_FNS, + ), + "py", + "", + id="reads file normally using a FakeAdapter", + ), + ], +) +def test_adapters_with_decs(adapter, file_type, expected_result): + assert adapter.read(INITIAL_FILES[0], file_type) == expected_result diff --git a/uv.lock b/uv.lock index a925ddc..32ee33f 100644 --- a/uv.lock +++ b/uv.lock @@ -443,6 +443,7 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "repo-mapper-rs" }, + { name = "returns" }, { name = "ruff" }, { name = "sphinx" }, { name = "ty" }, @@ -458,6 +459,7 @@ dev = [ { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "repo-mapper-rs", specifier = ">=0.3.0" }, + { name = "returns", specifier = ">=0.27.0" }, { name = "ruff", specifier = ">=0.14.9" }, { name = "sphinx", specifier = ">=9.0.4" }, { name = "ty", specifier = ">=0.0.9" }, @@ -1003,6 +1005,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "returns" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d5/a3208e3193848ecfec2adb689f474cc6c66c4b7e1711c31528c5b3cfbc93/returns-0.27.0.tar.gz", hash = "sha256:f70a452dd81e6d024c97523683aba85076b15e00874e723afb23bcf3aa4ecea2", size = 105261, upload-time = "2026-04-14T07:11:21.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/9c/1db56b3a26b56abde214833556c83af7e9817999bc05163c9e1bf200952d/returns-0.27.0-py3-none-any.whl", hash = "sha256:a84f243b9a17e9b96c16b709f5cca819550c38a2fc4db725ee2539354956af12", size = 160133, upload-time = "2026-04-14T07:11:23.187Z" }, +] + [[package]] name = "roman-numerals" version = "4.1.0"