686 lines
26 KiB
Python
686 lines
26 KiB
Python
"""fileops 底层文件 API 测试.
|
|
|
|
覆盖 4 类能力(查找遍历/读写/复制移动删除/元数据校验)+
|
|
DAG 组合调用示例(用 px.TaskSpec + outputs + output_of 验证数据流).
|
|
|
|
Mock 优先级:用 tmp_path 创建真实文件, 不用 mock(标准库文件操作可控).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import pyflowx as px
|
|
from pyflowx.fileops import (
|
|
append_text,
|
|
copy,
|
|
copy_tree,
|
|
delete,
|
|
exists,
|
|
find,
|
|
glob,
|
|
is_dir,
|
|
is_file,
|
|
move,
|
|
mtime,
|
|
read_bytes,
|
|
read_text,
|
|
sha256,
|
|
size,
|
|
stem,
|
|
suffix,
|
|
walk_files,
|
|
write_bytes,
|
|
write_text,
|
|
)
|
|
|
|
|
|
# ====================================================================== #
|
|
# 查找与遍历
|
|
# ====================================================================== #
|
|
class TestFind:
|
|
"""find() 查找匹配."""
|
|
|
|
def test_find_recursive_all(self, tmp_path: Path) -> None:
|
|
"""递归查找所有路径."""
|
|
(tmp_path / "a.txt").write_text("a")
|
|
(tmp_path / "sub").mkdir()
|
|
(tmp_path / "sub" / "b.py").write_text("b")
|
|
result = find(tmp_path, "*")
|
|
names = sorted(p.name for p in result)
|
|
assert "a.txt" in names
|
|
assert "b.py" in names
|
|
assert "sub" in names
|
|
|
|
def test_find_by_pattern(self, tmp_path: Path) -> None:
|
|
"""按扩展名过滤."""
|
|
(tmp_path / "a.py").write_text("")
|
|
(tmp_path / "b.txt").write_text("")
|
|
(tmp_path / "c.py").write_text("")
|
|
result = find(tmp_path, "*.py")
|
|
assert sorted(p.name for p in result) == ["a.py", "c.py"]
|
|
|
|
def test_find_non_recursive(self, tmp_path: Path) -> None:
|
|
"""recursive=False 仅顶层."""
|
|
(tmp_path / "a.py").write_text("")
|
|
(tmp_path / "sub").mkdir()
|
|
(tmp_path / "sub" / "b.py").write_text("")
|
|
result = find(tmp_path, "*.py", recursive=False)
|
|
assert sorted(p.name for p in result) == ["a.py"]
|
|
|
|
def test_find_max_depth(self, tmp_path: Path) -> None:
|
|
"""max_depth 限制递归深度."""
|
|
(tmp_path / "l0.txt").write_text("")
|
|
(tmp_path / "d1").mkdir()
|
|
(tmp_path / "d1" / "l1.txt").write_text("")
|
|
(tmp_path / "d1" / "d2").mkdir()
|
|
(tmp_path / "d1" / "d2" / "l2.txt").write_text("")
|
|
result = find(tmp_path, "*.txt", max_depth=1)
|
|
names = sorted(p.name for p in result)
|
|
assert "l0.txt" in names
|
|
assert "l1.txt" in names
|
|
assert "l2.txt" not in names
|
|
|
|
def test_find_max_depth_zero(self, tmp_path: Path) -> None:
|
|
"""max_depth=0 仅 root 直接 glob."""
|
|
(tmp_path / "l0.txt").write_text("")
|
|
(tmp_path / "d1").mkdir()
|
|
(tmp_path / "d1" / "l1.txt").write_text("")
|
|
result = find(tmp_path, "*.txt", max_depth=0)
|
|
names = [p.name for p in result]
|
|
assert names == ["l0.txt"]
|
|
|
|
def test_find_only_files(self, tmp_path: Path) -> None:
|
|
"""only_files 仅返回文件."""
|
|
(tmp_path / "a.txt").write_text("")
|
|
(tmp_path / "sub").mkdir()
|
|
result = find(tmp_path, "*", only_files=True)
|
|
assert all(p.is_file() for p in result)
|
|
assert not any(p.is_dir() for p in result)
|
|
|
|
def test_find_only_dirs(self, tmp_path: Path) -> None:
|
|
"""only_dirs 仅返回目录."""
|
|
(tmp_path / "a.txt").write_text("")
|
|
(tmp_path / "sub").mkdir()
|
|
result = find(tmp_path, "*", only_dirs=True)
|
|
assert all(p.is_dir() for p in result)
|
|
|
|
def test_find_only_files_and_only_dirs_returns_empty(self, tmp_path: Path) -> None:
|
|
"""only_files + only_dirs 同时 True 返回空列表."""
|
|
(tmp_path / "a.txt").write_text("")
|
|
result = find(tmp_path, "*", only_files=True, only_dirs=True)
|
|
assert result == []
|
|
|
|
def test_find_root_not_exist(self) -> None:
|
|
"""根目录不存在抛 FileNotFoundError."""
|
|
with pytest.raises(FileNotFoundError, match="查找根目录"):
|
|
find("/nonexistent/path/xyz", "*")
|
|
|
|
def test_find_returns_sorted(self, tmp_path: Path) -> None:
|
|
"""结果按路径字典序排序."""
|
|
(tmp_path / "c.txt").write_text("")
|
|
(tmp_path / "a.txt").write_text("")
|
|
(tmp_path / "b.txt").write_text("")
|
|
result = find(tmp_path, "*.txt", recursive=False)
|
|
assert [p.name for p in result] == ["a.txt", "b.txt", "c.txt"]
|
|
|
|
|
|
class TestGlob:
|
|
"""glob() 简化接口."""
|
|
|
|
def test_glob_with_pattern(self, tmp_path: Path) -> None:
|
|
"""glob 模式匹配."""
|
|
(tmp_path / "a.py").write_text("")
|
|
(tmp_path / "b.txt").write_text("")
|
|
result = glob("*.py", root=tmp_path)
|
|
assert [p.name for p in result] == ["a.py"]
|
|
|
|
def test_glob_default_root(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""root=None 用当前目录."""
|
|
monkeypatch.chdir(tmp_path)
|
|
(tmp_path / "x.txt").write_text("")
|
|
result = glob("*.txt")
|
|
assert [p.name for p in result] == ["x.txt"]
|
|
|
|
def test_glob_recursive_double_star(self, tmp_path: Path) -> None:
|
|
"""** 递归匹配."""
|
|
(tmp_path / "a.py").write_text("")
|
|
(tmp_path / "sub").mkdir()
|
|
(tmp_path / "sub" / "b.py").write_text("")
|
|
result = glob("**/*.py", root=tmp_path)
|
|
names = sorted(p.name for p in result)
|
|
assert "a.py" in names
|
|
assert "b.py" in names
|
|
|
|
|
|
class TestWalkFiles:
|
|
"""walk_files() 生成器遍历."""
|
|
|
|
def test_walk_files_yields_files_only(self, tmp_path: Path) -> None:
|
|
"""仅产生文件, 不产生目录."""
|
|
(tmp_path / "a.txt").write_text("")
|
|
(tmp_path / "sub").mkdir()
|
|
(tmp_path / "sub" / "b.txt").write_text("")
|
|
result = list(walk_files(tmp_path))
|
|
assert all(p.is_file() for p in result)
|
|
names = sorted(p.name for p in result)
|
|
assert names == ["a.txt", "b.txt"]
|
|
|
|
def test_walk_files_max_depth(self, tmp_path: Path) -> None:
|
|
"""max_depth 限制."""
|
|
(tmp_path / "l0.txt").write_text("")
|
|
(tmp_path / "d1").mkdir()
|
|
(tmp_path / "d1" / "l1.txt").write_text("")
|
|
(tmp_path / "d1" / "d2").mkdir()
|
|
(tmp_path / "d1" / "d2" / "l2.txt").write_text("")
|
|
result = list(walk_files(tmp_path, max_depth=0))
|
|
assert [p.name for p in result] == ["l0.txt"]
|
|
|
|
def test_walk_files_root_not_exist(self) -> None:
|
|
"""根目录不存在抛 FileNotFoundError."""
|
|
with pytest.raises(FileNotFoundError, match="遍历根目录"):
|
|
list(walk_files("/nonexistent/xyz"))
|
|
|
|
def test_walk_files_lazy(self, tmp_path: Path) -> None:
|
|
"""返回迭代器, 惰性求值."""
|
|
(tmp_path / "a.txt").write_text("")
|
|
result = walk_files(tmp_path)
|
|
assert hasattr(result, "__next__")
|
|
first = next(result)
|
|
assert first.is_file()
|
|
|
|
|
|
# ====================================================================== #
|
|
# 读写
|
|
# ====================================================================== #
|
|
class TestReadText:
|
|
def test_read_text(self, tmp_path: Path) -> None:
|
|
"""读取文本."""
|
|
(tmp_path / "a.txt").write_text("hello", encoding="utf-8")
|
|
assert read_text(tmp_path / "a.txt") == "hello"
|
|
|
|
def test_read_text_encoding(self, tmp_path: Path) -> None:
|
|
"""指定编码."""
|
|
(tmp_path / "a.txt").write_text("你好", encoding="gbk")
|
|
assert read_text(tmp_path / "a.txt", encoding="gbk") == "你好"
|
|
|
|
def test_read_text_not_exist(self, tmp_path: Path) -> None:
|
|
"""文件不存在抛 FileNotFoundError."""
|
|
with pytest.raises(FileNotFoundError):
|
|
read_text(tmp_path / "nonexistent.txt")
|
|
|
|
|
|
class TestReadBytes:
|
|
def test_read_bytes(self, tmp_path: Path) -> None:
|
|
"""读取字节."""
|
|
(tmp_path / "a.bin").write_bytes(b"\x00\x01\x02")
|
|
assert read_bytes(tmp_path / "a.bin") == b"\x00\x01\x02"
|
|
|
|
def test_read_bytes_not_exist(self, tmp_path: Path) -> None:
|
|
with pytest.raises(FileNotFoundError):
|
|
read_bytes(tmp_path / "nonexistent.bin")
|
|
|
|
|
|
class TestWriteText:
|
|
def test_write_text_returns_byte_count(self, tmp_path: Path) -> None:
|
|
"""返回写入字节数."""
|
|
n = write_text(tmp_path / "a.txt", "hello")
|
|
assert n == 5
|
|
|
|
def test_write_text_creates_parent(self, tmp_path: Path) -> None:
|
|
"""父目录不存在自动创建."""
|
|
target = tmp_path / "sub" / "deep" / "a.txt"
|
|
write_text(target, "x")
|
|
assert target.read_text() == "x"
|
|
|
|
def test_write_text_encoding(self, tmp_path: Path) -> None:
|
|
"""指定编码写入."""
|
|
write_text(tmp_path / "a.txt", "你好", encoding="gbk")
|
|
assert (tmp_path / "a.txt").read_bytes() == "你好".encode("gbk")
|
|
|
|
|
|
class TestWriteBytes:
|
|
def test_write_bytes_returns_length(self, tmp_path: Path) -> None:
|
|
n = write_bytes(tmp_path / "a.bin", b"\x00\x01")
|
|
assert n == 2
|
|
|
|
def test_write_bytes_creates_parent(self, tmp_path: Path) -> None:
|
|
target = tmp_path / "sub" / "a.bin"
|
|
write_bytes(target, b"data")
|
|
assert target.read_bytes() == b"data"
|
|
|
|
|
|
class TestAppendText:
|
|
def test_append_to_existing(self, tmp_path: Path) -> None:
|
|
"""追加到已有文件."""
|
|
(tmp_path / "a.txt").write_text("hello")
|
|
append_text(tmp_path / "a.txt", " world")
|
|
assert (tmp_path / "a.txt").read_text() == "hello world"
|
|
|
|
def test_append_creates_new(self, tmp_path: Path) -> None:
|
|
"""文件不存在时创建."""
|
|
append_text(tmp_path / "a.txt", "first")
|
|
assert (tmp_path / "a.txt").read_text() == "first"
|
|
|
|
def test_append_creates_parent(self, tmp_path: Path) -> None:
|
|
"""父目录不存在自动创建."""
|
|
append_text(tmp_path / "sub" / "a.txt", "x")
|
|
assert (tmp_path / "sub" / "a.txt").read_text() == "x"
|
|
|
|
def test_append_returns_byte_count(self, tmp_path: Path) -> None:
|
|
n = append_text(tmp_path / "a.txt", "hello")
|
|
assert n == 5
|
|
|
|
|
|
# ====================================================================== #
|
|
# 复制 / 移动 / 删除
|
|
# ====================================================================== #
|
|
class TestCopy:
|
|
def test_copy_file(self, tmp_path: Path) -> None:
|
|
"""复制文件."""
|
|
(tmp_path / "a.txt").write_text("hello")
|
|
dst = copy(tmp_path / "a.txt", tmp_path / "b.txt")
|
|
assert dst == tmp_path / "b.txt"
|
|
assert (tmp_path / "b.txt").read_text() == "hello"
|
|
assert (tmp_path / "a.txt").exists()
|
|
|
|
def test_copy_to_dir(self, tmp_path: Path) -> None:
|
|
"""目标是目录时复制到目录内."""
|
|
(tmp_path / "a.txt").write_text("x")
|
|
(tmp_path / "dst").mkdir()
|
|
dst = copy(tmp_path / "a.txt", tmp_path / "dst")
|
|
assert dst == tmp_path / "dst" / "a.txt"
|
|
assert dst.read_text() == "x"
|
|
|
|
def test_copy_dir_recursive(self, tmp_path: Path) -> None:
|
|
"""复制目录树."""
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "a.txt").write_text("a")
|
|
(tmp_path / "src" / "sub").mkdir()
|
|
(tmp_path / "src" / "sub" / "b.txt").write_text("b")
|
|
dst = copy(tmp_path / "src", tmp_path / "dst")
|
|
assert (dst / "a.txt").read_text() == "a"
|
|
assert (dst / "sub" / "b.txt").read_text() == "b"
|
|
|
|
def test_copy_no_overwrite(self, tmp_path: Path) -> None:
|
|
"""overwrite=False 时目标存在抛错."""
|
|
(tmp_path / "a.txt").write_text("original")
|
|
(tmp_path / "b.txt").write_text("exists")
|
|
with pytest.raises(FileExistsError, match="overwrite=False"):
|
|
copy(tmp_path / "a.txt", tmp_path / "b.txt")
|
|
|
|
def test_copy_overwrite(self, tmp_path: Path) -> None:
|
|
"""overwrite=True 覆盖."""
|
|
(tmp_path / "a.txt").write_text("new")
|
|
(tmp_path / "b.txt").write_text("old")
|
|
copy(tmp_path / "a.txt", tmp_path / "b.txt", overwrite=True)
|
|
assert (tmp_path / "b.txt").read_text() == "new"
|
|
|
|
def test_copy_dir_overwrite_existing_dir(self, tmp_path: Path) -> None:
|
|
"""overwrite=True 覆盖已存在目录."""
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "new.txt").write_text("new")
|
|
(tmp_path / "dst").mkdir()
|
|
(tmp_path / "dst" / "old.txt").write_text("old")
|
|
copy(tmp_path / "src", tmp_path / "dst", overwrite=True)
|
|
assert (tmp_path / "dst" / "new.txt").read_text() == "new"
|
|
assert not (tmp_path / "dst" / "old.txt").exists()
|
|
|
|
def test_copy_src_not_exist(self, tmp_path: Path) -> None:
|
|
with pytest.raises(FileNotFoundError, match="源路径不存在"):
|
|
copy(tmp_path / "nonexistent", tmp_path / "dst")
|
|
|
|
def test_copy_creates_parent(self, tmp_path: Path) -> None:
|
|
"""目标父目录自动创建."""
|
|
(tmp_path / "a.txt").write_text("x")
|
|
copy(tmp_path / "a.txt", tmp_path / "sub" / "deep" / "b.txt")
|
|
assert (tmp_path / "sub" / "deep" / "b.txt").read_text() == "x"
|
|
|
|
|
|
class TestMove:
|
|
def test_move_file(self, tmp_path: Path) -> None:
|
|
(tmp_path / "a.txt").write_text("hello")
|
|
dst = move(tmp_path / "a.txt", tmp_path / "b.txt")
|
|
assert dst == tmp_path / "b.txt"
|
|
assert (tmp_path / "b.txt").read_text() == "hello"
|
|
assert not (tmp_path / "a.txt").exists()
|
|
|
|
def test_move_to_dir(self, tmp_path: Path) -> None:
|
|
(tmp_path / "a.txt").write_text("x")
|
|
(tmp_path / "dst").mkdir()
|
|
dst = move(tmp_path / "a.txt", tmp_path / "dst")
|
|
assert dst == tmp_path / "dst" / "a.txt"
|
|
assert not (tmp_path / "a.txt").exists()
|
|
|
|
def test_move_no_overwrite(self, tmp_path: Path) -> None:
|
|
(tmp_path / "a.txt").write_text("x")
|
|
(tmp_path / "b.txt").write_text("y")
|
|
with pytest.raises(FileExistsError, match="overwrite=False"):
|
|
move(tmp_path / "a.txt", tmp_path / "b.txt")
|
|
|
|
def test_move_overwrite(self, tmp_path: Path) -> None:
|
|
(tmp_path / "a.txt").write_text("new")
|
|
(tmp_path / "b.txt").write_text("old")
|
|
move(tmp_path / "a.txt", tmp_path / "b.txt", overwrite=True)
|
|
assert (tmp_path / "b.txt").read_text() == "new"
|
|
assert not (tmp_path / "a.txt").exists()
|
|
|
|
def test_move_overwrite_dir(self, tmp_path: Path) -> None:
|
|
"""overwrite=True 覆盖目录."""
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "a.txt").write_text("new")
|
|
(tmp_path / "dst").mkdir()
|
|
(tmp_path / "dst" / "old.txt").write_text("old")
|
|
move(tmp_path / "src", tmp_path / "dst", overwrite=True)
|
|
assert (tmp_path / "dst" / "a.txt").read_text() == "new"
|
|
assert not (tmp_path / "dst" / "old.txt").exists()
|
|
|
|
def test_move_src_not_exist(self, tmp_path: Path) -> None:
|
|
with pytest.raises(FileNotFoundError, match="源路径不存在"):
|
|
move(tmp_path / "nonexistent", tmp_path / "dst")
|
|
|
|
|
|
class TestDelete:
|
|
def test_delete_file(self, tmp_path: Path) -> None:
|
|
(tmp_path / "a.txt").write_text("x")
|
|
assert delete(tmp_path / "a.txt") is True
|
|
assert not (tmp_path / "a.txt").exists()
|
|
|
|
def test_delete_dir(self, tmp_path: Path) -> None:
|
|
(tmp_path / "d").mkdir()
|
|
(tmp_path / "d" / "a.txt").write_text("x")
|
|
assert delete(tmp_path / "d") is True
|
|
assert not (tmp_path / "d").exists()
|
|
|
|
def test_delete_missing_ok_true(self, tmp_path: Path) -> None:
|
|
"""missing_ok=True 路径不存在返回 False."""
|
|
assert delete(tmp_path / "nonexistent", missing_ok=True) is False
|
|
|
|
def test_delete_missing_ok_false(self, tmp_path: Path) -> None:
|
|
"""missing_ok=False 路径不存在抛错."""
|
|
with pytest.raises(FileNotFoundError, match="路径不存在"):
|
|
delete(tmp_path / "nonexistent")
|
|
|
|
|
|
class TestCopyTree:
|
|
def test_copy_tree_returns_file_list(self, tmp_path: Path) -> None:
|
|
"""返回已复制文件列表."""
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "a.txt").write_text("a")
|
|
(tmp_path / "src" / "sub").mkdir()
|
|
(tmp_path / "src" / "sub" / "b.txt").write_text("b")
|
|
result = copy_tree(tmp_path / "src", tmp_path / "dst")
|
|
names = sorted(p.name for p in result)
|
|
assert names == ["a.txt", "b.txt"]
|
|
assert all(p.is_file() for p in result)
|
|
|
|
def test_copy_tree_no_overwrite(self, tmp_path: Path) -> None:
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "a.txt").write_text("a")
|
|
(tmp_path / "dst").mkdir()
|
|
with pytest.raises(FileExistsError, match="overwrite=False"):
|
|
copy_tree(tmp_path / "src", tmp_path / "dst")
|
|
|
|
def test_copy_tree_overwrite(self, tmp_path: Path) -> None:
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "a.txt").write_text("new")
|
|
(tmp_path / "dst").mkdir()
|
|
(tmp_path / "dst" / "old.txt").write_text("old")
|
|
copy_tree(tmp_path / "src", tmp_path / "dst", overwrite=True)
|
|
assert (tmp_path / "dst" / "a.txt").read_text() == "new"
|
|
assert not (tmp_path / "dst" / "old.txt").exists()
|
|
|
|
def test_copy_tree_src_not_dir(self, tmp_path: Path) -> None:
|
|
(tmp_path / "a.txt").write_text("x")
|
|
with pytest.raises(FileNotFoundError, match="源目录不存在或非目录"):
|
|
copy_tree(tmp_path / "a.txt", tmp_path / "dst")
|
|
|
|
|
|
# ====================================================================== #
|
|
# 元数据与校验
|
|
# ====================================================================== #
|
|
class TestSize:
|
|
def test_size(self, tmp_path: Path) -> None:
|
|
(tmp_path / "a.txt").write_bytes(b"hello")
|
|
assert size(tmp_path / "a.txt") == 5
|
|
|
|
def test_size_not_exist(self, tmp_path: Path) -> None:
|
|
with pytest.raises(FileNotFoundError):
|
|
size(tmp_path / "nonexistent")
|
|
|
|
|
|
class TestMtime:
|
|
def test_mtime(self, tmp_path: Path) -> None:
|
|
(tmp_path / "a.txt").write_text("x")
|
|
t = mtime(tmp_path / "a.txt")
|
|
assert isinstance(t, float)
|
|
assert t > 0
|
|
|
|
def test_mtime_not_exist(self, tmp_path: Path) -> None:
|
|
with pytest.raises(FileNotFoundError):
|
|
mtime(tmp_path / "nonexistent")
|
|
|
|
|
|
class TestExistsIsFileIsDir:
|
|
def test_exists_true(self, tmp_path: Path) -> None:
|
|
(tmp_path / "a.txt").write_text("x")
|
|
assert exists(tmp_path / "a.txt") is True
|
|
|
|
def test_exists_false(self, tmp_path: Path) -> None:
|
|
assert exists(tmp_path / "nonexistent") is False
|
|
|
|
def test_is_file_true(self, tmp_path: Path) -> None:
|
|
(tmp_path / "a.txt").write_text("x")
|
|
assert is_file(tmp_path / "a.txt") is True
|
|
|
|
def test_is_file_false_for_dir(self, tmp_path: Path) -> None:
|
|
(tmp_path / "d").mkdir()
|
|
assert is_file(tmp_path / "d") is False
|
|
|
|
def test_is_file_false_for_nonexistent(self, tmp_path: Path) -> None:
|
|
assert is_file(tmp_path / "nonexistent") is False
|
|
|
|
def test_is_dir_true(self, tmp_path: Path) -> None:
|
|
(tmp_path / "d").mkdir()
|
|
assert is_dir(tmp_path / "d") is True
|
|
|
|
def test_is_dir_false_for_file(self, tmp_path: Path) -> None:
|
|
(tmp_path / "a.txt").write_text("x")
|
|
assert is_dir(tmp_path / "a.txt") is False
|
|
|
|
def test_is_dir_false_for_nonexistent(self, tmp_path: Path) -> None:
|
|
assert is_dir(tmp_path / "nonexistent") is False
|
|
|
|
|
|
class TestSha256:
|
|
def test_sha256_known(self, tmp_path: Path) -> None:
|
|
"""已知内容哈希."""
|
|
import hashlib
|
|
|
|
(tmp_path / "a.txt").write_bytes(b"hello")
|
|
expected = hashlib.sha256(b"hello").hexdigest()
|
|
assert sha256(tmp_path / "a.txt") == expected
|
|
|
|
def test_sha256_empty(self, tmp_path: Path) -> None:
|
|
import hashlib
|
|
|
|
(tmp_path / "empty").write_bytes(b"")
|
|
assert sha256(tmp_path / "empty") == hashlib.sha256(b"").hexdigest()
|
|
|
|
def test_sha256_chunk_size(self, tmp_path: Path) -> None:
|
|
"""小 chunk_size 也能正确计算."""
|
|
(tmp_path / "a.txt").write_bytes(b"hello world")
|
|
h1 = sha256(tmp_path / "a.txt", chunk_size=2)
|
|
h2 = sha256(tmp_path / "a.txt", chunk_size=1024)
|
|
assert h1 == h2
|
|
|
|
def test_sha256_not_exist(self, tmp_path: Path) -> None:
|
|
with pytest.raises(FileNotFoundError):
|
|
sha256(tmp_path / "nonexistent")
|
|
|
|
|
|
class TestStemSuffix:
|
|
def test_stem_simple(self) -> None:
|
|
assert stem("a.txt") == "a"
|
|
|
|
def test_stem_multiple_dots(self) -> None:
|
|
assert stem("archive.tar.gz") == "archive.tar"
|
|
|
|
def test_stem_no_extension(self) -> None:
|
|
assert stem("README") == "README"
|
|
|
|
def test_suffix_simple(self) -> None:
|
|
assert suffix("a.txt") == ".txt"
|
|
|
|
def test_suffix_multiple_dots(self) -> None:
|
|
assert suffix("archive.tar.gz") == ".gz"
|
|
|
|
def test_suffix_no_extension(self) -> None:
|
|
assert suffix("README") == ""
|
|
|
|
|
|
# ====================================================================== #
|
|
# DAG 组合调用示例
|
|
# ====================================================================== #
|
|
class TestDagComposition:
|
|
"""fileops 函数在 DAG 中组合调用, 通过 outputs + output_of 传递数据流."""
|
|
|
|
def test_find_read_pipeline(self, tmp_path: Path) -> None:
|
|
"""find → read_text 数据流: 上游查找返回路径列表, 下游读取内容.
|
|
|
|
验证:
|
|
- find 任务 outputs 声明 "paths": "$"
|
|
- 下游任务通过参数名 find_logs 注入上游结果
|
|
- report.output_of 可提取上游输出
|
|
"""
|
|
(tmp_path / "a.log").write_text("content-a")
|
|
(tmp_path / "b.log").write_text("content-b")
|
|
(tmp_path / "c.txt").write_text("ignored")
|
|
|
|
def find_logs() -> list[Path]:
|
|
return find(tmp_path, "*.log")
|
|
|
|
def read_all(find_logs: list[Path]) -> list[str]:
|
|
return [read_text(p) for p in find_logs]
|
|
|
|
graph = px.Graph.from_specs(
|
|
[
|
|
px.TaskSpec("find_logs", fn=find_logs, outputs={"paths": "$"}),
|
|
px.TaskSpec("read_all", fn=read_all, depends_on=("find_logs",)),
|
|
]
|
|
)
|
|
report = px.run(graph, strategy="sequential")
|
|
|
|
assert report.success
|
|
paths = report.output_of("find_logs", "paths")
|
|
assert len(paths) == 2
|
|
contents = report["read_all"]
|
|
assert sorted(contents) == ["content-a", "content-b"]
|
|
|
|
def test_find_copy_batch(self, tmp_path: Path) -> None:
|
|
"""find → copy 批量复制: 上游查找, 下游批量复制到目标目录."""
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "a.txt").write_text("a")
|
|
(tmp_path / "src" / "b.txt").write_text("b")
|
|
(tmp_path / "dst").mkdir()
|
|
|
|
def find_sources() -> list[Path]:
|
|
return find(tmp_path / "src", "*.txt")
|
|
|
|
def copy_all(find_sources: list[Path]) -> list[Path]:
|
|
return [copy(p, tmp_path / "dst") for p in find_sources]
|
|
|
|
graph = px.Graph.from_specs(
|
|
[
|
|
px.TaskSpec("find_sources", fn=find_sources, outputs={"files": "$"}),
|
|
px.TaskSpec("copy_all", fn=copy_all, depends_on=("find_sources",)),
|
|
]
|
|
)
|
|
report = px.run(graph, strategy="sequential")
|
|
|
|
assert report.success
|
|
copied = report["copy_all"]
|
|
assert len(copied) == 2
|
|
dst_files = sorted(p.name for p in (tmp_path / "dst").iterdir())
|
|
assert dst_files == ["a.txt", "b.txt"]
|
|
|
|
def test_find_sha256_checksum(self, tmp_path: Path) -> None:
|
|
"""find → sha256 校验: 上游查找, 下游计算每个文件哈希."""
|
|
(tmp_path / "a.bin").write_bytes(b"hello")
|
|
(tmp_path / "b.bin").write_bytes(b"world")
|
|
|
|
import hashlib
|
|
|
|
def find_bins() -> list[Path]:
|
|
return find(tmp_path, "*.bin")
|
|
|
|
def checksums(find_bins: list[Path]) -> dict[str, str]:
|
|
return {p.name: sha256(p) for p in find_bins}
|
|
|
|
graph = px.Graph.from_specs(
|
|
[
|
|
px.TaskSpec("find_bins", fn=find_bins, outputs={"paths": "$"}),
|
|
px.TaskSpec("checksums", fn=checksums, depends_on=("find_bins",)),
|
|
]
|
|
)
|
|
report = px.run(graph, strategy="sequential")
|
|
|
|
assert report.success
|
|
result = report["checksums"]
|
|
assert result["a.bin"] == hashlib.sha256(b"hello").hexdigest()
|
|
assert result["b.bin"] == hashlib.sha256(b"world").hexdigest()
|
|
|
|
def test_write_read_roundtrip_in_dag(self, tmp_path: Path) -> None:
|
|
"""write_text → read_text 往返: 验证写后读数据流."""
|
|
target = tmp_path / "out.txt"
|
|
|
|
def write_data() -> int:
|
|
return write_text(target, "dag-content")
|
|
|
|
def read_back(write_data: int) -> str:
|
|
assert write_data == len("dag-content")
|
|
return read_text(target)
|
|
|
|
graph = px.Graph.from_specs(
|
|
[
|
|
px.TaskSpec("write_data", fn=write_data, outputs={"bytes": "$"}),
|
|
px.TaskSpec("read_back", fn=read_back, depends_on=("write_data",)),
|
|
]
|
|
)
|
|
report = px.run(graph, strategy="sequential")
|
|
|
|
assert report.success
|
|
assert report["read_back"] == "dag-content"
|
|
assert report.output_of("write_data", "bytes") == len("dag-content")
|
|
|
|
def test_three_stage_pipeline(self, tmp_path: Path) -> None:
|
|
"""三阶段流水线: find → filter → write_summary."""
|
|
(tmp_path / "data").mkdir()
|
|
for i in range(5):
|
|
(tmp_path / "data" / f"f{i}.txt").write_text(f"content-{i}")
|
|
|
|
def find_files() -> list[Path]:
|
|
return find(tmp_path / "data", "*.txt")
|
|
|
|
def summarize(find_files: list[Path]) -> dict[str, int]:
|
|
return {"count": len(find_files), "total_size": sum(size(p) for p in find_files)}
|
|
|
|
def write_summary(summarize: dict[str, int]) -> int:
|
|
content = f"files: {summarize['count']}, size: {summarize['total_size']}"
|
|
return write_text(tmp_path / "summary.txt", content)
|
|
|
|
graph = px.Graph.from_specs(
|
|
[
|
|
px.TaskSpec("find_files", fn=find_files, outputs={"paths": "$"}),
|
|
px.TaskSpec("summarize", fn=summarize, depends_on=("find_files",)),
|
|
px.TaskSpec("write_summary", fn=write_summary, depends_on=("summarize",)),
|
|
]
|
|
)
|
|
report = px.run(graph, strategy="sequential")
|
|
|
|
assert report.success
|
|
summary = read_text(tmp_path / "summary.txt")
|
|
assert "files: 5" in summary
|