Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/just_bash/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
...
Expand All @@ -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."""
...
Expand Down
73 changes: 73 additions & 0 deletions tests/test_fs/test_ifilesystem_conformance.py
Original file line number Diff line number Diff line change
@@ -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)}"