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 <jan@janware.com>
This commit is contained in:
Jan Lindemann 2026-09-28 16:48:48 +02:00
commit 0c732672ed
Signed by: Jan Lindemann
GPG key ID: 3750640C9E25DD61
4 changed files with 92 additions and 4 deletions

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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)