Mostly implemented Vfs using system file system.
This commit is contained in:
18
tests/test_bsv/__init__.py
Normal file
18
tests/test_bsv/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# pybsv - Backup, Synchronization, Versioning.
|
||||
# Copyright (C) 2025 Simon Boyé
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""pybsv test module."""
|
||||
|
||||
from __future__ import annotations
|
||||
270
tests/test_bsv/test_cli.py
Normal file
270
tests/test_bsv/test_cli.py
Normal file
@@ -0,0 +1,270 @@
|
||||
# pybsv - Backup, Synchronization, Versioning.
|
||||
# Copyright (C) 2025 Simon Boyé
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import TYPE_CHECKING, Literal, NamedTuple
|
||||
|
||||
from click.testing import CliRunner
|
||||
from hypothesis import given
|
||||
import hypothesis.strategies as st
|
||||
import pytest
|
||||
|
||||
from bsv import cli
|
||||
from bsv.vfs import Permissions
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(tmp_path: Path) -> CliRunner:
|
||||
runner = CliRunner(env={"BSV_REPO": str(tmp_path)})
|
||||
return runner
|
||||
|
||||
|
||||
@contextmanager
|
||||
def make_runner() -> Generator[CliRunner, None, None]:
|
||||
with TemporaryDirectory(prefix="test_vfs_") as tmp:
|
||||
runner = CliRunner(env={"BSV_REPO": tmp})
|
||||
yield runner
|
||||
|
||||
|
||||
########################################################################################
|
||||
# mkdir
|
||||
|
||||
|
||||
def test_mkdir_fails_with_relative_path(tmp_path: Path, runner: CliRunner):
|
||||
assert not (tmp_path / "test").exists()
|
||||
result = runner.invoke(cli.cli, ["mkdir", "test"])
|
||||
assert result.exit_code == 2
|
||||
assert "test is not an absolute path" in result.stderr
|
||||
|
||||
|
||||
def test_mkdir_default(tmp_path: Path, runner: CliRunner):
|
||||
assert not (tmp_path / "test").exists()
|
||||
result = runner.invoke(cli.cli, ["mkdir", "/test"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stderr == ""
|
||||
assert result.stdout == ""
|
||||
assert (tmp_path / "test").is_dir()
|
||||
|
||||
|
||||
def test_mkdir_multiple_dirs(tmp_path: Path, runner: CliRunner):
|
||||
assert not (tmp_path / "foo").exists()
|
||||
assert not (tmp_path / "bar").exists()
|
||||
result = runner.invoke(cli.cli, ["mkdir", "/foo", "/bar"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stderr == ""
|
||||
assert result.stdout == ""
|
||||
assert (tmp_path / "foo").is_dir()
|
||||
assert (tmp_path / "bar").is_dir()
|
||||
|
||||
|
||||
def test_mkdir_nested_fails_without_parents(tmp_path: Path, runner: CliRunner):
|
||||
assert not (tmp_path / "foo").exists()
|
||||
result = runner.invoke(cli.cli, ["mkdir", "/foo/bar"])
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr == "/foo does not exist\n"
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
def test_mkdir_nested(tmp_path: Path, runner: CliRunner):
|
||||
assert not (tmp_path / "foo/bar").exists()
|
||||
result = runner.invoke(cli.cli, ["mkdir", "--parents", "/foo/bar"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stderr == ""
|
||||
assert result.stdout == ""
|
||||
assert (tmp_path / "foo/bar").is_dir()
|
||||
|
||||
|
||||
def test_mkdir_message_if_exists(tmp_path: Path, runner: CliRunner):
|
||||
result = runner.invoke(cli.cli, ["mkdir", "/test"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stderr == ""
|
||||
assert result.stdout == ""
|
||||
assert (tmp_path / "test").is_dir()
|
||||
|
||||
result = runner.invoke(cli.cli, ["mkdir", "/test"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stderr == "/test already exists\n"
|
||||
assert result.stdout == ""
|
||||
assert (tmp_path / "test").is_dir()
|
||||
|
||||
|
||||
def test_mkdir_mode(tmp_path: Path, runner: CliRunner):
|
||||
assert not (tmp_path / "test").exists()
|
||||
result = runner.invoke(cli.cli, ["mkdir", "/test", "--mode=741"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stderr == ""
|
||||
assert result.stdout == ""
|
||||
assert (tmp_path / "test").is_dir()
|
||||
assert (tmp_path / "test").stat().st_mode & 0o7777 == 0o741
|
||||
|
||||
|
||||
def test_mkdir_verbose(tmp_path: Path, runner: CliRunner):
|
||||
result = runner.invoke(cli.cli, ["mkdir", "/foo"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stderr == ""
|
||||
assert result.stdout == ""
|
||||
assert (tmp_path / "foo").is_dir()
|
||||
assert not (tmp_path / "bar").exists()
|
||||
result = runner.invoke(cli.cli, ["mkdir", "--verbose", "/foo", "/bar"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stderr == "/foo already exists\n"
|
||||
assert result.stdout == "Created /bar\n"
|
||||
assert (tmp_path / "foo").is_dir()
|
||||
assert (tmp_path / "bar").is_dir()
|
||||
|
||||
|
||||
########################################################################################
|
||||
# ls
|
||||
|
||||
|
||||
def permissions(target: Literal["file", "dir"] = "file"):
|
||||
return st.builds(
|
||||
Permissions,
|
||||
st.sampled_from(
|
||||
[
|
||||
0o0400,
|
||||
0o0440,
|
||||
0o0444,
|
||||
0o0600,
|
||||
0o0640,
|
||||
0o0644,
|
||||
0o0664,
|
||||
0o0750,
|
||||
0o0755,
|
||||
0o0777,
|
||||
]
|
||||
if target == "file"
|
||||
else [
|
||||
0o0400,
|
||||
0o0600,
|
||||
0o0640,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Tree(NamedTuple):
|
||||
type: Literal["file", "dir"]
|
||||
name: str
|
||||
perms: Permissions
|
||||
time: datetime
|
||||
content: bytes | list[Tree]
|
||||
|
||||
@property
|
||||
def type_prefix(self) -> str:
|
||||
if self.type == "dir":
|
||||
return "d"
|
||||
return "-"
|
||||
|
||||
def build(self, parent: Path) -> None:
|
||||
path = parent / self.name
|
||||
if isinstance(self.content, list):
|
||||
path.mkdir(mode=self.perms.unix_perms)
|
||||
for child in self.content:
|
||||
child.build(path)
|
||||
else:
|
||||
path.write_bytes(self.content)
|
||||
path.chmod(self.perms.unix_perms)
|
||||
ts = self.time.timestamp()
|
||||
os.utime(path, (ts, ts))
|
||||
|
||||
|
||||
def filenames() -> st.SearchStrategy:
|
||||
return st.text(
|
||||
st.characters(exclude_categories=["Cc", "Cs"], exclude_characters='<>:"/\\|!*'),
|
||||
min_size=1,
|
||||
max_size=255,
|
||||
).filter(lambda t: len(t.encode()) < 256 and t not in (".", ".."))
|
||||
|
||||
|
||||
@st.composite
|
||||
def trees(draw: st.DrawFn, max_depth: int = 3) -> Tree:
|
||||
file_type = draw(st.sampled_from(["file", "dir"]))
|
||||
content = (
|
||||
st.binary()
|
||||
if file_type == "file"
|
||||
else st.lists(
|
||||
trees(max_depth - 1),
|
||||
unique_by=lambda t: t.name,
|
||||
max_size=0 if max_depth == 0 else 10,
|
||||
)
|
||||
)
|
||||
return Tree(
|
||||
file_type, # type: ignore
|
||||
draw(filenames()),
|
||||
draw(permissions(file_type)), # type: ignore
|
||||
draw(
|
||||
st.datetimes(
|
||||
min_value=datetime(1902, 1, 1),
|
||||
max_value=datetime(2100, 1, 1),
|
||||
timezones=st.just(UTC),
|
||||
)
|
||||
),
|
||||
draw(content),
|
||||
)
|
||||
|
||||
|
||||
def trees_lists(max_depth: int = 3) -> st.SearchStrategy:
|
||||
return st.lists(trees(max_depth=max_depth), unique_by=lambda t: t.name)
|
||||
|
||||
|
||||
@given(trees=trees_lists(max_depth=0))
|
||||
def test_ls(trees: list[Tree]):
|
||||
with make_runner() as runner:
|
||||
path = Path(runner.env["BSV_REPO"] or "")
|
||||
for tree in trees:
|
||||
tree.build(path)
|
||||
|
||||
result = runner.invoke(cli.cli, ["ls", "-lA"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.stderr == ""
|
||||
|
||||
trees.sort(key=lambda t: t.name)
|
||||
lines = [line for line in result.stdout.splitlines() if line != "\n"]
|
||||
|
||||
for line, tree in zip(lines, trees, strict=True):
|
||||
match = re.fullmatch(
|
||||
r"""
|
||||
([dl-])([r-][w-][x-][r-][w-][x-][r-][w-][x-])
|
||||
\ +(\d+)
|
||||
\ (\d{4}-\d{2}-\d{2}\ \d{2}:\d{2}:\d{2})
|
||||
\ ([^\n]+)
|
||||
""",
|
||||
line,
|
||||
re.VERBOSE,
|
||||
)
|
||||
assert match
|
||||
assert match[1] == tree.type_prefix
|
||||
assert match[2] == str(tree.perms)
|
||||
if tree.type_prefix != "d":
|
||||
assert match[3] == str(len(tree.content))
|
||||
assert match[4] == tree.time.astimezone().replace(tzinfo=None).isoformat(
|
||||
" ", "seconds"
|
||||
)
|
||||
assert match[5] == tree.name
|
||||
|
||||
pass
|
||||
224
tests/test_bsv/test_vfs.py
Normal file
224
tests/test_bsv/test_vfs.py
Normal file
@@ -0,0 +1,224 @@
|
||||
# pybsv - Backup, Synchronization, Versioning.
|
||||
# Copyright (C) 2025 Simon Boyé
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""Tests for the `VirtualFileSystem` class and related stuff."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
from bsv.vfs import FsError, Permissions, VirtualFileSystem
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fs(tmp_path: Path) -> VirtualFileSystem:
|
||||
"""Fixture that returns a `VirtualFileSystem`."""
|
||||
return VirtualFileSystem(tmp_path)
|
||||
|
||||
|
||||
########################################################################################
|
||||
# Permissions
|
||||
|
||||
|
||||
def test_permissions():
|
||||
perm0 = Permissions(0o1234)
|
||||
assert perm0.unix_perms == 0o1234
|
||||
|
||||
perm1 = Permissions("752")
|
||||
assert perm1.unix_perms == 0o752
|
||||
|
||||
assert perm0 == perm0
|
||||
assert perm0 != perm1
|
||||
|
||||
assert repr(perm0) == "Permissions(0o1234)"
|
||||
assert repr(perm1) == "Permissions(0o0752)"
|
||||
|
||||
assert str(perm0) == "-w--wxr-T"
|
||||
assert str(perm1) == "rwxr-x-w-"
|
||||
|
||||
|
||||
########################################################################################
|
||||
# mkdir
|
||||
|
||||
|
||||
def test_mkdir_fails_with_relative_path(fs: VirtualFileSystem):
|
||||
with pytest.raises(FsError):
|
||||
fs.mkdir("test")
|
||||
|
||||
|
||||
def test_mkdir_default(fs: VirtualFileSystem):
|
||||
assert not fs.exists("/test")
|
||||
fs.mkdir("/test")
|
||||
assert fs.is_dir("/test")
|
||||
|
||||
|
||||
def test_mkdir_nested_fails_without_parents(fs: VirtualFileSystem):
|
||||
assert not fs.exists("/foo")
|
||||
with pytest.raises(FsError):
|
||||
fs.mkdir("/foo/bar")
|
||||
|
||||
|
||||
def test_mkdir_nested(fs: VirtualFileSystem):
|
||||
assert not fs.exists("/test")
|
||||
fs.mkdir("/test/foobar", parents=True)
|
||||
assert fs.is_dir("/test/foobar")
|
||||
|
||||
|
||||
def test_mkdir_fails_if_exists(fs: VirtualFileSystem):
|
||||
assert not fs.exists("/foo")
|
||||
fs.mkdir("/foo")
|
||||
assert fs.is_dir("/foo")
|
||||
with pytest.raises(FsError):
|
||||
fs.mkdir("/foo")
|
||||
|
||||
|
||||
def test_mkdir_exists_ok(fs: VirtualFileSystem):
|
||||
assert not fs.exists("/test")
|
||||
fs.mkdir("/test")
|
||||
assert fs.is_dir("/test")
|
||||
fs.mkdir("/test", exist_ok=True)
|
||||
|
||||
|
||||
def test_mkdir_exists_ok_fail_if_file(fs: VirtualFileSystem):
|
||||
fs.write_bytes("/test", b"test")
|
||||
assert fs.is_file("/test")
|
||||
with pytest.raises(FsError):
|
||||
fs.mkdir("/test", exist_ok=True)
|
||||
|
||||
|
||||
def test_mkdir_mode(fs: VirtualFileSystem):
|
||||
assert not fs.exists("/test")
|
||||
permissions = Permissions(0o741)
|
||||
fs.mkdir("/test", mode=permissions)
|
||||
assert fs.is_dir("/test")
|
||||
assert fs.metadata("/test").permissions == permissions
|
||||
|
||||
|
||||
########################################################################################
|
||||
# read_bytes / write_bytes
|
||||
|
||||
|
||||
def test_read_write_bytes(fs: VirtualFileSystem):
|
||||
assert not fs.exists("/test")
|
||||
|
||||
fs.write_bytes("/test", b"This is a test.")
|
||||
assert fs.read_bytes("/test") == b"This is a test."
|
||||
|
||||
stream = BytesIO(b"Another test.")
|
||||
fs.write_bytes("/test", stream)
|
||||
assert fs.read_bytes("/test") == b"Another test."
|
||||
|
||||
with pytest.raises(FsError):
|
||||
fs.read_bytes("/does_not_exist")
|
||||
|
||||
with pytest.raises(FsError):
|
||||
fs.write_bytes("/does_not_exist/foobar", b"")
|
||||
|
||||
|
||||
def test_open_read_write(fs: VirtualFileSystem):
|
||||
assert not fs.exists("/test")
|
||||
|
||||
with fs.open_write("/test") as stream:
|
||||
stream.write(b"foo")
|
||||
stream.write(b"bar")
|
||||
|
||||
assert fs.exists("/test")
|
||||
with fs.open_read("/test") as stream:
|
||||
assert stream.read(3) == b"foo"
|
||||
assert stream.read(3) == b"bar"
|
||||
assert stream.read() == b""
|
||||
|
||||
# Test overwrite
|
||||
with fs.open_write("/test") as stream:
|
||||
stream.write(b"baz")
|
||||
|
||||
with fs.open_read("/test") as stream:
|
||||
assert stream.read() == b"baz"
|
||||
|
||||
with pytest.raises(FsError):
|
||||
fs.open_read("/does_not_exist")
|
||||
|
||||
with pytest.raises(FsError):
|
||||
fs.open_write("/does_not_exist/foobar")
|
||||
|
||||
|
||||
########################################################################################
|
||||
# metadata
|
||||
|
||||
|
||||
def test_metadata(fs: VirtualFileSystem):
|
||||
file_permissions = Permissions(0o754)
|
||||
file_time = datetime(2025, 5, 17, 13, 57, 32, tzinfo=UTC)
|
||||
file_content = b"This is a test\n"
|
||||
|
||||
fs.write_bytes("/test_file", file_content)
|
||||
fs.set_permissions("/test_file", file_permissions)
|
||||
fs.set_modification_time("/test_file", file_time)
|
||||
|
||||
md = fs.metadata("/test_file")
|
||||
assert md.path == PurePosixPath("/test_file")
|
||||
assert md.permissions == file_permissions
|
||||
assert md.type == "file"
|
||||
assert md.modification_time == file_time
|
||||
assert md.byte_size == len(file_content)
|
||||
assert not md.is_hidden_files
|
||||
assert fs.metadata("/test_file") == md
|
||||
|
||||
fs.set_permissions("/test_file", Permissions(0o644))
|
||||
assert fs.metadata("/test_file") != md
|
||||
|
||||
fs.mkdir("/.test_dir")
|
||||
assert fs.metadata("/.test_dir").type == "dir"
|
||||
assert fs.metadata("/.test_dir").is_hidden_files
|
||||
|
||||
fs.make_link("/test_link", "/link_target")
|
||||
assert fs.metadata("/test_link").type == "link"
|
||||
|
||||
|
||||
########################################################################################
|
||||
# iter_dir
|
||||
|
||||
|
||||
def test_iter_dir(fs: VirtualFileSystem):
|
||||
expected = [
|
||||
(PurePosixPath("/dir"), "dir"),
|
||||
(PurePosixPath("/file"), "file"),
|
||||
(PurePosixPath("/link"), "link"),
|
||||
]
|
||||
for path, file_type in expected:
|
||||
if file_type == "dir":
|
||||
fs.mkdir(path)
|
||||
elif file_type == "file":
|
||||
fs.write_bytes(path, b"")
|
||||
elif file_type == "link":
|
||||
fs.make_link(path, "/foobar")
|
||||
|
||||
items_metadata = sorted(fs.iter_dir("/"))
|
||||
for md, [path, file_type] in zip(items_metadata, expected, strict=True):
|
||||
assert md.path == path
|
||||
assert md.type == file_type
|
||||
|
||||
|
||||
def test_iter_dir_failure(fs: VirtualFileSystem):
|
||||
with pytest.raises(FsError):
|
||||
list(fs.iter_dir("/test"))
|
||||
|
||||
fs.write_bytes("/test", b"")
|
||||
with pytest.raises(FsError):
|
||||
list(fs.iter_dir("/test"))
|
||||
Reference in New Issue
Block a user