diff --git a/aiofiledol/__init__.py b/aiofiledol/__init__.py index 4c3a88f..ea20f42 100644 --- a/aiofiledol/__init__.py +++ b/aiofiledol/__init__.py @@ -6,7 +6,9 @@ import asyncio import os +from dol import wrapped_self from dol.base import KvReader, KvPersister +from dol.dig import inner_most_key from dol.paths import mk_relative_path_store from dol.filesys import ( FileCollection, @@ -22,6 +24,22 @@ _dflt_not_found_error_msg = "Key not found: {}" +def _resolve_key(store, k): + """The leaf-level filepath for ``k``, resolved through any wrapping layers. + + ``mk_relative_path_store`` (and dol's wrapping machinery generally) wraps by + delegation: a method defined on the wrapped class is reached bound to the INNER + store, but with the OUTER, un-transformed key. Handing that key straight to + ``AIOFile`` would resolve it against the process CWD instead of ``rootdir``. + Walking the wrapper chain with ``inner_most_key`` recovers the real filepath. + + This REPLACES ``store._id_of_key(k)`` -- it walks the whole chain including the + leaf, so the two must never be composed. For an un-wrapped store no layer defines + ``_id_of_key`` and ``k`` is returned verbatim. + """ + return inner_most_key(wrapped_self(store), k, default=k) + + class AioFileBytesReader(FileCollection, KvReader): _read_open_kwargs = dict(mode="rb") @@ -46,7 +64,7 @@ async def aget(self, k): # noqa b'hello world' """ - async with AIOFile(k, **self._read_open_kwargs) as fp: + async with AIOFile(_resolve_key(self, k), **self._read_open_kwargs) as fp: v = await fp.read() # Question: Is it faster if we just did `return await fp.read(), instead of assign? return v # with open(k, **self._read_open_kwargs) as fp: @@ -102,7 +120,7 @@ async def asetitem(self, k, v): >>> asyncio.run(s.aget(k)) # read it back (async reader; __getitem__ is disabled) b'bar' """ - async with AIOFile(k, **self._write_open_kwargs) as fp: + async with AIOFile(_resolve_key(self, k), **self._write_open_kwargs) as fp: await fp.write(v) await fp.fsync() diff --git a/aiofiledol/tests/__init__.py b/aiofiledol/tests/__init__.py new file mode 100644 index 0000000..139ad60 --- /dev/null +++ b/aiofiledol/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for aiofiledol""" diff --git a/aiofiledol/tests/test_relative_path_keys.py b/aiofiledol/tests/test_relative_path_keys.py new file mode 100644 index 0000000..5946d75 --- /dev/null +++ b/aiofiledol/tests/test_relative_path_keys.py @@ -0,0 +1,75 @@ +"""Tests that relativized stores read the file they advertise, not a CWD lookalike. + +``mk_relative_path_store`` wraps by delegation, so a method defined on the wrapped +class (such as ``aget``) is reached bound to the INNER store while still receiving +the OUTER, un-transformed key. Passing that relative key straight to ``AIOFile`` +resolves it against the process CWD instead of the store's ``rootdir`` -- silently +returning the wrong bytes when a same-named file happens to sit in the CWD, and +raising ``FileNotFoundError`` for a key the store just enumerated when it does not. +""" + +import asyncio +import os + +from aiofiledol import ( + AioFileBytesReader, + RelPathAioFileBytesReader, + RelPathFileStringReader, +) + +KEY = "greeting" +REAL = b"REAL STORE CONTENT" +DECOY = b"DECOY FROM CWD" + + +def _store_dir(tmp_path, name, content=None): + """Make a directory under ``tmp_path``, optionally holding a ``KEY`` file. + + Returns the path with a trailing separator -- what the relativized stores want, + so that the keys they yield carry no leading separator. + """ + d = tmp_path / name + d.mkdir() + if content is not None: + mode = "wb" if isinstance(content, bytes) else "wt" + with open(d / KEY, mode) as fp: + fp.write(content) + return str(d) + os.sep + + +def test_relpath_aget_reads_the_store_not_the_cwd(tmp_path, monkeypatch): + """A relative key must resolve against ``rootdir``, never against the CWD.""" + rootdir = _store_dir(tmp_path, "realstore", REAL) + monkeypatch.chdir(_store_dir(tmp_path, "decoy", DECOY)) + + s = RelPathAioFileBytesReader(rootdir) + assert asyncio.run(s.aget(KEY)) == REAL + + +def test_relpath_aget_finds_every_key_it_enumerates(tmp_path, monkeypatch): + """Every key the store yields must be readable -- no ``FileNotFoundError``.""" + rootdir = _store_dir(tmp_path, "realstore", REAL) + monkeypatch.chdir(_store_dir(tmp_path, "elsewhere")) + + s = RelPathAioFileBytesReader(rootdir) + assert list(s) == [KEY] + for k in s: + assert asyncio.run(s.aget(k)) == REAL + + +def test_relpath_string_reader_too(tmp_path, monkeypatch): + """The text-mode relativized reader inherits the same ``aget``, so same rule.""" + rootdir = _store_dir(tmp_path, "realstore", REAL.decode()) + monkeypatch.chdir(_store_dir(tmp_path, "decoy", DECOY.decode())) + + s = RelPathFileStringReader(rootdir) + assert asyncio.run(s.aget(KEY)) == REAL.decode() + + +def test_bare_reader_still_takes_absolute_keys(tmp_path, monkeypatch): + """Regression guard: an un-wrapped store keeps its absolute-key behaviour.""" + rootdir = _store_dir(tmp_path, "realstore", REAL) + monkeypatch.chdir(_store_dir(tmp_path, "elsewhere")) + + s = AioFileBytesReader(rootdir, max_levels=0) + assert asyncio.run(s.aget(os.path.join(rootdir, KEY))) == REAL