From cda9e210daa41e154939603edf6c0b23a1ff08f4 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Fri, 9 Oct 2026 10:48:17 +0200 Subject: [PATCH] lib.FcWrapper: Add a delegating file context wrapper Add a file context that sits in front of another one and delegates every primitive call to it. The wrapper adopts the identity of the wrapped context: uri, root, chroot flag, and the chroot path composition are the wrapped context's, so a path composed by the wrapper composes identically in the wrapped context. Every primitive is forwarded to the wrapped context's primitives, which receive the already-composed path; open() and close() are forwarded to the wrapped context's public methods, so the open count is kept in one place. The point is to give derived contexts a stable base: a caching context, or any other filter, overrides the primitives it cares about and inherits the rest, instead of re-deriving the chroot bookkeeping of every method. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v1.1.0 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/FcWrapper.py | 122 +++++++++++++ .../unit/python/jw/pkg/lib/FcWrapper/Makefile | 7 + test/unit/python/jw/pkg/lib/FcWrapper/test.py | 161 ++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 src/python/jw/pkg/lib/FcWrapper.py create mode 100644 test/unit/python/jw/pkg/lib/FcWrapper/Makefile create mode 100644 test/unit/python/jw/pkg/lib/FcWrapper/test.py diff --git a/src/python/jw/pkg/lib/FcWrapper.py b/src/python/jw/pkg/lib/FcWrapper.py new file mode 100644 index 00000000..1973870a --- /dev/null +++ b/src/python/jw/pkg/lib/FcWrapper.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterable, override + +if TYPE_CHECKING: + from .base import Result, StatResult + from .file_types import FileType + +from .FileContext import FileContext + +class FcWrapper(FileContext): # export + """A file context that delegates to a wrapped context. + + The wrapper adopts the identity of the wrapped context: uri, + root, chroot flag, and the chroot path composition are all the + wrapped context's, so a path composed by the wrapper composes + identically in the wrapped context. Every primitive call is + forwarded to the wrapped context's primitives, which receive + the already-composed path; open and close are forwarded to the + wrapped context's public methods, so the open count is kept in + one place. + """ + + def __init__(self, wrapped: FileContext) -> None: + super().__init__( + wrapped.uri, + interactive = wrapped.interactive, + verbose_default = wrapped.verbose_default, + chroot = wrapped.chroot, + ) + self.__wrapped = wrapped + + @property + def wrapped(self) -> FileContext: + return self.__wrapped + + @override + async def _open(self) -> None: + await self.__wrapped.open() + + @override + async def _close(self) -> None: + await self.__wrapped.close() + + @override + async def _get( + self, path: str, wd: str | None, throw: bool, verbose: bool | None, title: str + ) -> Result: + return await self.__wrapped._get(path, wd, throw, verbose, title) + + @override + async def _put( + self, + path: str, + content: bytes, + wd: str | None, + throw: bool, + verbose: bool | None, + title: str, + owner: str | None, + group: str | None, + mode: str | None, + atomic: bool, + ftype: FileType, + ) -> Result: + return await self.__wrapped._put( + path, + content, + wd, + throw, + verbose, + title, + owner, + group, + mode, + atomic, + ftype, + ) + + @override + async def _unlink(self, path: str) -> None: + await self.__wrapped._unlink(path) + + @override + async def _erase(self, path: str) -> None: + await self.__wrapped._erase(path) + + @override + async def _rename(self, src: str, dst: str) -> None: + await self.__wrapped._rename(src, dst) + + @override + async def _mkdir(self, path: str, mode: int, ftype: FileType) -> None: + await self.__wrapped._mkdir(path, mode, ftype) + + @override + async def _mktemp(self, tmpl: str, directory: bool) -> str: + return await self.__wrapped._mktemp(tmpl, directory) + + @override + async def _chown(self, path: str, owner: str | None, group: str | None) -> None: + await self.__wrapped._chown(path, owner, group) + + @override + async def _chmod(self, path: str, mode: int) -> None: + await self.__wrapped._chmod(path, mode) + + @override + async def _stat(self, path: str, follow_symlinks: bool) -> StatResult: + return await self.__wrapped._stat(path, follow_symlinks) + + @override + async def _file_exists(self, path: str) -> bool: + return await self.__wrapped._file_exists(path) + + @override + async def _is_dir(self, path: str, follow_symlinks: bool) -> bool: + return await self.__wrapped._is_dir(path, follow_symlinks) + + @override + async def _readdir(self, path: str) -> Iterable[str]: + return await self.__wrapped._readdir(path) diff --git a/test/unit/python/jw/pkg/lib/FcWrapper/Makefile b/test/unit/python/jw/pkg/lib/FcWrapper/Makefile new file mode 100644 index 00000000..de693650 --- /dev/null +++ b/test/unit/python/jw/pkg/lib/FcWrapper/Makefile @@ -0,0 +1,7 @@ +TOPDIR = ../../../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/py-run.mk + +all: +test: run diff --git a/test/unit/python/jw/pkg/lib/FcWrapper/test.py b/test/unit/python/jw/pkg/lib/FcWrapper/test.py new file mode 100644 index 00000000..6e158013 --- /dev/null +++ b/test/unit/python/jw/pkg/lib/FcWrapper/test.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import asyncio + +from typing import TYPE_CHECKING, Iterable, override + +if TYPE_CHECKING: + from jw.pkg.lib.file_types import FileType + +from jw.pkg.lib.base import StatResult +from jw.pkg.lib.FcWrapper import FcWrapper +from jw.pkg.lib.file_types import FT_DIR, FT_REG +from jw.pkg.lib.FileContext import FileContext +from jw.pkg.lib.Result import Result + +class FakeCtx(FileContext): + """An in-memory FileContext that counts its primitive calls""" + + def __init__(self, uri: str, chroot: bool = False) -> None: + super().__init__(uri, chroot = chroot) + self.files: dict[str, bytes] = {} + self.dirs: set[str] = set() + self.counts: dict[str, int] = {} + self.opens = 0 + + def count(self, op: str) -> None: + self.counts[op] = self.counts.get(op, 0) + 1 + + def _stat_of(self, path: str) -> StatResult: + if path in self.dirs: + return StatResult(0o40755, '0', '0', 40, 0.0, 0.0, 0.0) + if path in self.files: + return StatResult(0o100644, '0', '0', len(self.files[path]), 0.0, 0.0, 0.0) + raise FileNotFoundError(path) + + @override + async def _open(self) -> None: + self.opens += 1 + + @override + async def _close(self) -> None: + pass + + @override + async def _get( + self, path: str, wd: str | None, throw: bool, verbose: bool | None, title: str + ) -> Result: + self.count('get') + if wd is not None: + path = wd + '/' + path + if path not in self.files: + raise FileNotFoundError(path) + return Result(stdout = self.files[path]) + + @override + async def _put( + self, + path: str, + content: bytes, + wd: str | None, + throw: bool, + verbose: bool | None, + title: str, + owner: str | None, + group: str | None, + mode: str | None, + atomic: bool, + ftype: FileType, + ) -> Result: + self.count('put') + if wd is not None: + path = wd + '/' + path + self.files[path] = content + return Result(stdout = b'') + + @override + async def _mkdir(self, path: str, mode: int, ftype: FileType) -> None: + self.count('mkdir') + self.dirs.add(path) + + @override + async def _unlink(self, path: str) -> None: + self.count('unlink') + self.files.pop(path, None) + self.dirs.discard(path) + + @override + async def _stat(self, path: str, follow_symlinks: bool) -> StatResult: + self.count('stat') + return self._stat_of(path) + + @override + async def _file_exists(self, path: str) -> bool: + self.count('file_exists') + try: + self._stat_of(path) + return True + except FileNotFoundError: + return False + + @override + async def _is_dir(self, path: str, follow_symlinks: bool) -> bool: + self.count('is_dir') + try: + st = self._stat_of(path) + return (st.mode & 0o170000) == 0o40000 + except FileNotFoundError: + return False + + @override + async def _readdir(self, path: str) -> Iterable[str]: + self.count('readdir') + prefix = path.rstrip('/') + '/' + return [ + p[len(prefix):] for p in sorted(self.files.keys() | self.dirs) + if p.startswith(prefix) + ] + +async def main() -> None: + fake = FakeCtx('local:///tmp/fc-wrapper-fake', chroot = True) + fc = FcWrapper(fake) + + # -- The wrapper adopts the identity of the wrapped context + assert fc.wrapped is fake + assert fc.uri is fake.uri + assert fc.root == fake.root + assert fc.chroot == fake.chroot + assert fc.username == fake.username + assert fc.interactive == fake.interactive + assert fc.verbose_default == fake.verbose_default + + # -- Put and get are forwarded; paths are composed once + await fc.put('etc/a.conf', b'x=1\n', ftype = FT_REG) + assert fake.files == {'/tmp/fc-wrapper-fake/etc/a.conf': b'x=1\n'} + assert fake.counts['put'] == 1 + + ret = await fc.get('etc/a.conf') + assert ret.stdout == b'x=1\n' + assert fake.counts['get'] == 1 + + # -- stat, file_exists, is_dir, and readdir are forwarded + st = await fc.stat('etc/a.conf') + assert st.size == 4 + assert await fc.file_exists('etc/a.conf') is True + assert await fc.file_exists('etc/missing.conf') is False + await fc.mkdir('etc/sub', 0o755, FT_DIR) + assert await fc.is_dir('etc/sub') is True + names = await fc.readdir('etc') + assert 'a.conf' in names and 'sub' in names + + # -- Unlink is forwarded + await fc.unlink('etc/a.conf') + assert '/tmp/fc-wrapper-fake/etc/a.conf' not in fake.files + assert fake.counts['unlink'] == 1 + + # -- Open and close reach the wrapped context's public methods + await fc.open() + await fc.close() + assert fake.opens == 1 + +asyncio.run(main())