From da11c0c1bd38dc12de5c29871e1ef39a6e5d97ff Mon Sep 17 00:00:00 2001 From: Nino Walker Date: Sun, 23 Aug 2026 14:00:50 +0200 Subject: [PATCH] Add the de-facto required methods to the IFileSystem Protocol cp, mv, link and lstat are called by the cp/mv/ln/readlink commands and the test builtin without a hasattr guard, so a backend implementing exactly the Protocol type-checks and then raises AttributeError at runtime. Declare them, with signatures matching all four built-in backends. Add a conformance test that reads the method list off the Protocol and checks every built-in backend implements it. --- src/just_bash/types.py | 16 ++++ tests/test_fs/test_ifilesystem_conformance.py | 73 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/test_fs/test_ifilesystem_conformance.py diff --git a/src/just_bash/types.py b/src/just_bash/types.py index 6f37751..43e30a6 100644 --- a/src/just_bash/types.py +++ b/src/just_bash/types.py @@ -113,10 +113,22 @@ async def rm(self, path: str, recursive: bool = False, force: bool = False) -> N """Remove file or directory.""" ... + async def cp(self, src: str, dest: str, recursive: bool = False) -> None: + """Copy file or directory.""" + ... + + async def mv(self, src: str, dest: str) -> None: + """Move file or directory.""" + ... + async def stat(self, path: str) -> "FsStat": """Get file/directory stats.""" ... + async def lstat(self, path: str) -> "FsStat": + """Get file/directory stats (does not follow the final symlink).""" + ... + async def chmod(self, path: str, mode: int) -> None: """Change file mode.""" ... @@ -125,6 +137,10 @@ async def symlink(self, target: str, link_path: str) -> None: """Create symbolic link.""" ... + async def link(self, existing_path: str, new_path: str) -> None: + """Create hard link.""" + ... + async def readlink(self, path: str) -> str: """Read symbolic link target.""" ... diff --git a/tests/test_fs/test_ifilesystem_conformance.py b/tests/test_fs/test_ifilesystem_conformance.py new file mode 100644 index 0000000..eb780dc --- /dev/null +++ b/tests/test_fs/test_ifilesystem_conformance.py @@ -0,0 +1,73 @@ +"""Tests that the built-in backends conform to the IFileSystem Protocol. + +The Protocol is what a custom backend is written against, so anything the +interpreter or a command calls unconditionally has to be declared on it, and +every built-in backend has to implement everything it declares. +""" + +import inspect +import pytest + +from just_bash.fs import InMemoryFs, MountableFs, OverlayFs, ReadWriteFs +from just_bash.types import IFileSystem + +BACKENDS = [InMemoryFs, MountableFs, OverlayFs, ReadWriteFs] + +PROTOCOL_METHODS = sorted( + name + for name, member in vars(IFileSystem).items() + if not name.startswith("_") and inspect.isfunction(member) +) + +# Gaps that predate this test, listed so it can still guard everything else. +# realpath() is declared on IFileSystem but only InMemoryFs implements it, so +# `pwd -P` and `readlink -f` raise AttributeError on the other three backends. +# Removing an entry here once the backend implements it keeps the test passing. +KNOWN_GAPS = { + (MountableFs, "realpath"), + (OverlayFs, "realpath"), + (ReadWriteFs, "realpath"), +} + + +def _expected(backend) -> list[str]: + """Protocol methods the backend is expected to have.""" + return [n for n in PROTOCOL_METHODS if (backend, n) not in KNOWN_GAPS] + + +class TestProtocolCoverage: + """Test that the Protocol declares what the commands call.""" + + @pytest.mark.parametrize( + "name,caller", + [ + ("cp", "cp"), + ("mv", "mv"), + ("link", "ln"), + ("lstat", "readlink, test -L"), + ], + ) + def test_declares_method_used_by_commands(self, name, caller): + """Methods called without a hasattr guard should be declared.""" + assert name in PROTOCOL_METHODS, f"{caller} calls fs.{name}() but IFileSystem omits it" + + +class TestBackendConformance: + """Test that every built-in backend implements the Protocol.""" + + @pytest.mark.parametrize("backend", BACKENDS, ids=lambda b: b.__name__) + def test_implements_every_method(self, backend): + """Backend should have every method the Protocol declares.""" + missing = [n for n in _expected(backend) if not callable(getattr(backend, n, None))] + assert not missing, f"{backend.__name__} is missing: {', '.join(missing)}" + + @pytest.mark.parametrize("backend", BACKENDS, ids=lambda b: b.__name__) + def test_async_methods_are_coroutines(self, backend): + """A method declared async should be a coroutine function on the backend.""" + mismatched = [ + n + for n in _expected(backend) + if inspect.iscoroutinefunction(getattr(IFileSystem, n)) + != inspect.iscoroutinefunction(getattr(backend, n, None)) + ] + assert not mismatched, f"{backend.__name__} does not match on: {', '.join(mismatched)}"