From 0c732672ed5f86ef584e8539387807682c3e4a3b Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Mon, 28 Sep 2026 16:48:48 +0200 Subject: [PATCH] lib.FileContext: Add readdir() method FileContext offers get(), put(), unlink(), mkdir(), and stat(), but no way to list the entries of a directory, so consumers that need the names of the entries under a path in a context have no portable means to obtain them. Add readdir(), returning Iterable[str], following the _readdir() -> readdir() pattern used by the other operations: the public method chroots the path via _chroot() and delegates to the _readdir() hook, which raises NotImplementedError by default. Implement _readdir() in the context families: - Local._readdir(): os.listdir() - ExecContext._readdir(): runs ls -1A and splits the output into one entry per line; inherited by SSHClient and the other command-based contexts - Curl._readdir(): raises OSError with errno.ENOSYS Add unit tests covering Local (listing, hidden entries, missing directory) and Curl (ENOSYS). Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.87.1 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/ExecContext.py | 9 ++++- src/python/jw/pkg/lib/FileContext.py | 10 +++++- src/python/jw/pkg/lib/ec/Curl.py | 9 ++++- src/python/jw/pkg/lib/ec/Local.py | 6 +++- test/unit/python/jw/pkg/lib/ec/Curl/Makefile | 7 ++++ test/unit/python/jw/pkg/lib/ec/Curl/test.py | 15 +++++++++ test/unit/python/jw/pkg/lib/ec/Local/Makefile | 7 ++++ test/unit/python/jw/pkg/lib/ec/Local/test.py | 33 +++++++++++++++++++ 8 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 test/unit/python/jw/pkg/lib/ec/Curl/Makefile create mode 100644 test/unit/python/jw/pkg/lib/ec/Curl/test.py create mode 100644 test/unit/python/jw/pkg/lib/ec/Local/Makefile create mode 100644 test/unit/python/jw/pkg/lib/ec/Local/test.py diff --git a/src/python/jw/pkg/lib/ExecContext.py b/src/python/jw/pkg/lib/ExecContext.py index 55faf2c7..1e5d6f30 100644 --- a/src/python/jw/pkg/lib/ExecContext.py +++ b/src/python/jw/pkg/lib/ExecContext.py @@ -5,7 +5,7 @@ import errno import sys from decimal import ROUND_FLOOR, Decimal -from typing import TYPE_CHECKING, Any, NamedTuple, override +from typing import TYPE_CHECKING, Any, Iterable, NamedTuple, override if TYPE_CHECKING: from typing import Type @@ -688,3 +688,10 @@ class ExecContext(Base): ['chmod', self.__mode_str(mode), path], cmd_input = InputMode.NonInteractive ) + + @override + async def _readdir(self, path: str) -> Iterable[str]: + result = await self.run( + ['ls', '-1A', path], cmd_input = InputMode.NonInteractive + ) + return result.stdout_str.splitlines() diff --git a/src/python/jw/pkg/lib/FileContext.py b/src/python/jw/pkg/lib/FileContext.py index 08d2e89f..c16a5b69 100644 --- a/src/python/jw/pkg/lib/FileContext.py +++ b/src/python/jw/pkg/lib/FileContext.py @@ -4,7 +4,7 @@ import abc from enum import Enum, auto from functools import cached_property -from typing import TYPE_CHECKING, Any, override +from typing import TYPE_CHECKING, Any, Iterable, override from .log import DEBUG, ERR, log from .ProcFilter import ProcPipeline @@ -301,6 +301,14 @@ class FileContext(abc.ABC): async def is_dir(self, path: str, follow_symlinks: bool = True) -> bool: return await self._is_dir(self._chroot(path), follow_symlinks = follow_symlinks) + async def _readdir(self, path: str) -> Iterable[str]: + raise NotImplementedError( + f'{self.log_name}: readdir("{path}") is not implemented' + ) + + async def readdir(self, path: str) -> Iterable[str]: + return await self._readdir(self._chroot(path)) + @classmethod def create(cls, uri: str | Uri, *args: Any, **kwargs: Any) -> 'FileContext': uri = Uri.pimp(uri) diff --git a/src/python/jw/pkg/lib/ec/Curl.py b/src/python/jw/pkg/lib/ec/Curl.py index 2637cf15..aae38ae1 100644 --- a/src/python/jw/pkg/lib/ec/Curl.py +++ b/src/python/jw/pkg/lib/ec/Curl.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, override +import errno + +from typing import TYPE_CHECKING, Any, Iterable, override from ..FileContext import FileContext as Base @@ -48,3 +50,8 @@ class Curl(Base): path = '/' + path cmd.append(self.uri.to_string + self._chroot(path)) return await self.__ec.run(cmd, throw = throw, verbose = verbose) + + @override + async def _readdir(self, path: str) -> Iterable[str]: + msg = f'{self.log_name}: readdir("{path}") is not implemented' + raise OSError(errno.ENOSYS, msg) diff --git a/src/python/jw/pkg/lib/ec/Local.py b/src/python/jw/pkg/lib/ec/Local.py index 9eca6817..b4c530ff 100644 --- a/src/python/jw/pkg/lib/ec/Local.py +++ b/src/python/jw/pkg/lib/ec/Local.py @@ -7,7 +7,7 @@ import pwd import sys from functools import cache -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, Iterable, override from ..base import Result, StatResult from ..ExecContext import ExecContext as Base @@ -206,3 +206,7 @@ class Local(Base): if (not follow_symlinks) and os.path.islink(path): return False return os.path.isdir(path) + + @override + async def _readdir(self, path: str) -> Iterable[str]: + return os.listdir(path) diff --git a/test/unit/python/jw/pkg/lib/ec/Curl/Makefile b/test/unit/python/jw/pkg/lib/ec/Curl/Makefile new file mode 100644 index 00000000..4801122b --- /dev/null +++ b/test/unit/python/jw/pkg/lib/ec/Curl/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/ec/Curl/test.py b/test/unit/python/jw/pkg/lib/ec/Curl/test.py new file mode 100644 index 00000000..b220a4b7 --- /dev/null +++ b/test/unit/python/jw/pkg/lib/ec/Curl/test.py @@ -0,0 +1,15 @@ +import asyncio +import errno + +from jw.pkg.lib.ec.Curl import Curl + +async def run_tests() -> None: + curl = Curl('https://example.com/') + try: + await curl.readdir('some/path') + raise AssertionError('expected OSError with errno ENOSYS') + except OSError as e: + assert e.errno == errno.ENOSYS, e.errno + +asyncio.run(run_tests()) +print('All Curl readdir tests passed') diff --git a/test/unit/python/jw/pkg/lib/ec/Local/Makefile b/test/unit/python/jw/pkg/lib/ec/Local/Makefile new file mode 100644 index 00000000..4801122b --- /dev/null +++ b/test/unit/python/jw/pkg/lib/ec/Local/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/ec/Local/test.py b/test/unit/python/jw/pkg/lib/ec/Local/test.py new file mode 100644 index 00000000..5ddf0af6 --- /dev/null +++ b/test/unit/python/jw/pkg/lib/ec/Local/test.py @@ -0,0 +1,33 @@ +import asyncio +import os +import tempfile + +from jw.pkg.lib.ec.Local import Local + +async def run_tests() -> None: + with tempfile.TemporaryDirectory() as tmp: + for name in ['a', 'b', '.hidden']: + with open(os.path.join(tmp, name), 'w') as f: + f.write(name) + os.mkdir(os.path.join(tmp, 'subdir')) + + local = Local() + entries = sorted(await local.readdir(tmp)) + assert entries == ['.hidden', 'a', 'b', 'subdir'], entries + + # -- readdir() of a missing directory raises FileNotFoundError + try: + await local.readdir(os.path.join(tmp, 'missing')) + raise AssertionError('expected FileNotFoundError') + except FileNotFoundError: + pass + + # -- mkdir() in a chrooted context creates below the context root + root = os.path.join(tmp, 'root') + os.mkdir(root) + chrooted = Local('local://localhost' + root, chroot = True) + await chrooted.mkdir('sub') + assert os.path.isdir(os.path.join(root, 'sub')) + +asyncio.run(run_tests()) +print('All Local readdir tests passed')