Initial commit: split dlp-io library out of DataPacker
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import io
|
||||
import builtins
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import dlp_io
|
||||
import dlp_io._api as api_module
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, payload: bytes = b"bridged data") -> None:
|
||||
self.payload = payload
|
||||
self.paths: list[str] = []
|
||||
self.closed = False
|
||||
|
||||
def open_raw(self, path):
|
||||
self.paths.append(str(path))
|
||||
return io.BytesIO(self.payload)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_default_session():
|
||||
dlp_io.shutdown()
|
||||
yield
|
||||
dlp_io.shutdown()
|
||||
|
||||
|
||||
def test_open_signature_matches_io_open() -> None:
|
||||
assert inspect.signature(dlp_io.open) == inspect.signature(io.open)
|
||||
|
||||
|
||||
def test_write_mode_uses_native_open(tmp_path: Path) -> None:
|
||||
output = tmp_path / "output.txt"
|
||||
|
||||
with dlp_io.open(output, "w", encoding="utf-8") as file:
|
||||
file.write("本地写入")
|
||||
|
||||
assert output.read_bytes() == "本地写入".encode("utf-8")
|
||||
|
||||
|
||||
def test_path_update_mode_is_explicitly_unsupported(tmp_path: Path) -> None:
|
||||
path = tmp_path / "data.bin"
|
||||
path.write_bytes(b"data")
|
||||
|
||||
with pytest.raises(io.UnsupportedOperation):
|
||||
dlp_io.open(path, "r+b")
|
||||
|
||||
|
||||
def test_path_rejects_closefd_false(tmp_path: Path) -> None:
|
||||
path = tmp_path / "data.bin"
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
dlp_io.open(path, "rb", closefd=False)
|
||||
|
||||
|
||||
def test_custom_opener_uses_native_open(tmp_path: Path) -> None:
|
||||
path = tmp_path / "native.txt"
|
||||
path.write_text("native", encoding="utf-8")
|
||||
calls = []
|
||||
|
||||
with api_module._ORIGINAL_OPEN(path, "rb") as expected:
|
||||
expected_bytes = expected.read()
|
||||
|
||||
# Keep the returned fd alive independently; os.open is the normal opener contract.
|
||||
import os
|
||||
|
||||
def os_opener(name, flags):
|
||||
calls.append((name, flags))
|
||||
return os.open(name, flags)
|
||||
|
||||
with dlp_io.open(path, "rb", opener=os_opener) as file:
|
||||
assert file.read() == expected_bytes
|
||||
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_custom_opener_update_mode_uses_native_open(tmp_path: Path) -> None:
|
||||
import os
|
||||
|
||||
path = tmp_path / "native-update.bin"
|
||||
path.write_bytes(b"abc")
|
||||
|
||||
with dlp_io.open(path, "r+b", opener=os.open) as file:
|
||||
assert file.read(1) == b"a"
|
||||
file.seek(0)
|
||||
file.write(b"z")
|
||||
|
||||
assert path.read_bytes() == b"zbc"
|
||||
|
||||
|
||||
def test_integer_fd_uses_native_open(tmp_path: Path) -> None:
|
||||
import os
|
||||
|
||||
path = tmp_path / "fd.bin"
|
||||
path.write_bytes(b"fd data")
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
|
||||
with dlp_io.open(descriptor, "rb", closefd=True) as file:
|
||||
assert file.read() == b"fd data"
|
||||
|
||||
|
||||
def test_read_lazily_starts_and_reuses_default_session(monkeypatch) -> None:
|
||||
created: list[tuple[object, float, _FakeSession]] = []
|
||||
|
||||
def fake_start(*, python_cmd=None, timeout=60):
|
||||
session = _FakeSession()
|
||||
created.append((python_cmd, timeout, session))
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(api_module.DlpSession, "start", fake_start)
|
||||
dlp_io.configure(python_executable=r"C:\Approved\python.exe", startup_timeout=12)
|
||||
|
||||
with dlp_io.open(r"D:\data\first.bin", "rb") as first:
|
||||
assert first.read() == b"bridged data"
|
||||
with dlp_io.open(r"D:\data\second.bin", "rb") as second:
|
||||
assert second.read() == b"bridged data"
|
||||
|
||||
assert len(created) == 1
|
||||
assert tuple(Path(item) for item in created[0][0]) == (
|
||||
Path(r"C:\Approved\python.exe"),
|
||||
)
|
||||
assert created[0][1] == 12
|
||||
assert tuple(Path(item) for item in created[0][2].paths) == (
|
||||
Path(r"D:\data\first.bin"),
|
||||
Path(r"D:\data\second.bin"),
|
||||
)
|
||||
|
||||
|
||||
def test_configure_rejects_active_session_and_allows_after_shutdown(monkeypatch) -> None:
|
||||
session = _FakeSession()
|
||||
monkeypatch.setattr(api_module.DlpSession, "start", lambda **kwargs: session)
|
||||
|
||||
with dlp_io.open(r"D:\data\input.bin", "rb") as file:
|
||||
assert file.read() == b"bridged data"
|
||||
|
||||
with pytest.raises(dlp_io.DlpConfigurationError):
|
||||
dlp_io.configure(python_executable=r"C:\Other\python.exe")
|
||||
|
||||
dlp_io.shutdown()
|
||||
assert session.closed
|
||||
|
||||
dlp_io.configure(python_executable=r"C:\Other\python.exe")
|
||||
|
||||
|
||||
def test_binary_buffering_zero_returns_raw_stream(monkeypatch) -> None:
|
||||
session = _FakeSession(b"raw")
|
||||
monkeypatch.setattr(api_module.DlpSession, "start", lambda **kwargs: session)
|
||||
|
||||
with dlp_io.open(r"D:\data\raw.bin", "rb", buffering=0) as file:
|
||||
assert isinstance(file, io.BytesIO)
|
||||
assert file.read() == b"raw"
|
||||
|
||||
|
||||
def test_binary_default_returns_buffered_reader(monkeypatch) -> None:
|
||||
session = _FakeSession(b"buffered")
|
||||
monkeypatch.setattr(api_module.DlpSession, "start", lambda **kwargs: session)
|
||||
|
||||
with dlp_io.open(r"D:\data\buffered.bin", "rb") as file:
|
||||
assert isinstance(file, io.BufferedReader)
|
||||
assert file.read() == b"buffered"
|
||||
|
||||
|
||||
def test_text_mode_preserves_encoding_errors_and_newline(monkeypatch) -> None:
|
||||
session = _FakeSession("第一行\r\n第二行".encode("utf-8"))
|
||||
monkeypatch.setattr(api_module.DlpSession, "start", lambda **kwargs: session)
|
||||
|
||||
with dlp_io.open(
|
||||
r"D:\data\text.txt",
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
errors="strict",
|
||||
newline="",
|
||||
) as file:
|
||||
assert file.read().encode("utf-8") == session.payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"encoding": "utf-8"},
|
||||
{"errors": "strict"},
|
||||
{"newline": ""},
|
||||
],
|
||||
)
|
||||
def test_binary_mode_rejects_text_arguments_before_starting_helper(
|
||||
monkeypatch, kwargs
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
api_module.DlpSession,
|
||||
"start",
|
||||
lambda **unused: pytest.fail("invalid arguments must not start the helper"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
dlp_io.open(r"D:\data\payload.bin", "rb", **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["", "rr", "rbt", "U", "q"])
|
||||
def test_invalid_read_mode_is_rejected_before_starting_helper(
|
||||
monkeypatch, mode: str
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
api_module.DlpSession,
|
||||
"start",
|
||||
lambda **unused: pytest.fail("invalid mode must not start the helper"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
dlp_io.open(r"D:\data\payload.bin", mode)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("buffering", [None, "invalid"])
|
||||
def test_invalid_buffering_is_rejected_before_starting_helper(
|
||||
monkeypatch, buffering
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
api_module.DlpSession,
|
||||
"start",
|
||||
lambda **unused: pytest.fail("invalid buffering must not start the helper"),
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
dlp_io.open(r"D:\data\payload.bin", "rb", buffering=buffering)
|
||||
|
||||
|
||||
def test_negative_buffering_is_rejected_before_starting_helper(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
api_module.DlpSession,
|
||||
"start",
|
||||
lambda **unused: pytest.fail("invalid buffering must not start the helper"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
dlp_io.open(r"D:\data\payload.bin", "rb", buffering=-2)
|
||||
|
||||
|
||||
def test_invalid_pathlike_is_rejected_before_starting_helper(monkeypatch) -> None:
|
||||
class InvalidPath:
|
||||
def __fspath__(self):
|
||||
raise TypeError("invalid path")
|
||||
|
||||
monkeypatch.setattr(
|
||||
api_module.DlpSession,
|
||||
"start",
|
||||
lambda **unused: pytest.fail("invalid path must not start the helper"),
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
dlp_io.open(InvalidPath(), "rb")
|
||||
|
||||
|
||||
def test_open_patch_routes_reads_and_keeps_native_writes(tmp_path, monkeypatch) -> None:
|
||||
session = _FakeSession(b"patched")
|
||||
monkeypatch.setattr(api_module.DlpSession, "start", lambda **kwargs: session)
|
||||
original_builtin = builtins.open
|
||||
original_io = io.open
|
||||
|
||||
dlp_io.install_open_patch()
|
||||
dlp_io.install_open_patch()
|
||||
try:
|
||||
with builtins.open(r"D:\data\patched.bin", "rb") as file:
|
||||
assert file.read() == b"patched"
|
||||
output = tmp_path / "native.txt"
|
||||
with builtins.open(output, "w", encoding="utf-8") as file:
|
||||
file.write("native")
|
||||
with original_builtin(output, "rb") as file:
|
||||
assert file.read() == b"native"
|
||||
finally:
|
||||
dlp_io.uninstall_open_patch()
|
||||
dlp_io.uninstall_open_patch()
|
||||
|
||||
assert builtins.open is original_builtin
|
||||
assert io.open is original_io
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import pytest
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.utils import canonicalize_name, parse_wheel_filename
|
||||
from packaging.version import Version
|
||||
|
||||
import dlp_io
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError: # Python 3.9-3.10
|
||||
import tomli as tomllib
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class DynamicField(Enum):
|
||||
VERSION = "version"
|
||||
|
||||
|
||||
class PackageName(Enum):
|
||||
DLP_IO = canonicalize_name("dlp-io")
|
||||
|
||||
|
||||
class PackageClassifier(Enum):
|
||||
WINDOWS = "Operating System :: Microsoft :: Windows"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuiltDistributions:
|
||||
wheel: Path
|
||||
sdist: Path
|
||||
|
||||
|
||||
def _load_pyproject() -> dict:
|
||||
with (PROJECT_ROOT / "pyproject.toml").open("rb") as file:
|
||||
return tomllib.load(file)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def built_distributions(tmp_path_factory: pytest.TempPathFactory) -> BuiltDistributions:
|
||||
root = tmp_path_factory.mktemp("dlp-io-build")
|
||||
source = root / "source"
|
||||
source.mkdir()
|
||||
shutil.copy2(PROJECT_ROOT / "pyproject.toml", source)
|
||||
shutil.copy2(PROJECT_ROOT / "MANIFEST.in", source)
|
||||
shutil.copytree(PROJECT_ROOT / "dlp_io", source / "dlp_io")
|
||||
shutil.copytree(PROJECT_ROOT / "docs", source / "docs")
|
||||
|
||||
dist = root / "dist"
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"build",
|
||||
"--no-isolation",
|
||||
"--outdir",
|
||||
str(dist),
|
||||
],
|
||||
cwd=source,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
assert completed.returncode == 0
|
||||
|
||||
wheels = tuple(dist.glob("*.whl"))
|
||||
sdists = tuple(dist.glob("*.tar.gz"))
|
||||
assert len(wheels) == 1
|
||||
assert len(sdists) == 1
|
||||
return BuiltDistributions(wheel=wheels[0], sdist=sdists[0])
|
||||
|
||||
|
||||
def test_pyproject_declares_structured_public_package_contract() -> None:
|
||||
config = _load_pyproject()
|
||||
project = config["project"]
|
||||
setuptools = config["tool"]["setuptools"]
|
||||
|
||||
assert PackageName(canonicalize_name(project["name"])) is PackageName.DLP_IO
|
||||
assert SpecifierSet(project["requires-python"]) == SpecifierSet(">=3.9")
|
||||
assert {DynamicField(item) for item in project["dynamic"]} == {
|
||||
DynamicField.VERSION
|
||||
}
|
||||
known_classifier_values = {item.value for item in PackageClassifier}
|
||||
known_classifiers = {
|
||||
PackageClassifier(item)
|
||||
for item in project["classifiers"]
|
||||
if item in known_classifier_values
|
||||
}
|
||||
assert PackageClassifier.WINDOWS in known_classifiers
|
||||
assert {Path(item) for item in setuptools["packages"]} == {Path("dlp_io")}
|
||||
assert {Path(item) for item in setuptools["package-data"]["dlp_io"]} == {
|
||||
Path("py.typed")
|
||||
}
|
||||
|
||||
version_ref = config["tool"]["setuptools"]["dynamic"]["version"]["attr"]
|
||||
module_name, attribute_name = version_ref.rsplit(".", 1)
|
||||
declared_version = getattr(importlib.import_module(module_name), attribute_name)
|
||||
assert Version(declared_version) == Version(dlp_io.__version__)
|
||||
|
||||
|
||||
def test_wheel_contains_only_installable_dlp_io_runtime(
|
||||
built_distributions: BuiltDistributions,
|
||||
) -> None:
|
||||
name, version, build, tags = parse_wheel_filename(built_distributions.wheel.name)
|
||||
with zipfile.ZipFile(built_distributions.wheel) as archive:
|
||||
members = {PurePosixPath(item) for item in archive.namelist()}
|
||||
|
||||
assert PackageName(name) is PackageName.DLP_IO
|
||||
assert version == Version(dlp_io.__version__)
|
||||
assert build == ()
|
||||
assert tags
|
||||
assert PurePosixPath("dlp_io/__init__.py") in members
|
||||
assert PurePosixPath("dlp_io/py.typed") in members
|
||||
member_roots = {Path(item.parts[0]) for item in members}
|
||||
assert member_roots.isdisjoint({Path("tests"), Path("Lib")})
|
||||
assert PurePosixPath(".env") not in members
|
||||
|
||||
|
||||
def test_sdist_contains_package_sources_and_public_readme_only(
|
||||
built_distributions: BuiltDistributions,
|
||||
) -> None:
|
||||
with tarfile.open(built_distributions.sdist, mode="r:gz") as archive:
|
||||
members = {
|
||||
PurePosixPath(*PurePosixPath(item.name).parts[1:])
|
||||
for item in archive.getmembers()
|
||||
if item.isfile()
|
||||
}
|
||||
|
||||
assert PurePosixPath("pyproject.toml") in members
|
||||
assert PurePosixPath("docs/DLP_IO_LIBRARY.md") in members
|
||||
assert PurePosixPath("dlp_io/__init__.py") in members
|
||||
member_roots = {Path(item.parts[0]) for item in members}
|
||||
assert Path("tests") not in member_roots
|
||||
assert PurePosixPath("README.md") not in members
|
||||
assert PurePosixPath(".env") not in members
|
||||
@@ -0,0 +1,408 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from multiprocessing.context import AuthenticationError
|
||||
from multiprocessing.connection import Client
|
||||
|
||||
import pytest
|
||||
|
||||
from dlp_io import (
|
||||
DlpConfigurationError,
|
||||
DlpProtocolError,
|
||||
DlpSession,
|
||||
DlpSessionBusyError,
|
||||
DlpTransportError,
|
||||
)
|
||||
import dlp_io._session as session_module
|
||||
from dlp_io._helper import (
|
||||
CHUNK_SIZE,
|
||||
HelperErrorCode,
|
||||
HelperSourceConfig,
|
||||
render_helper_source,
|
||||
)
|
||||
from dlp_io._raw import _PipeRawIO
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.name != "nt", reason="AF_PIPE requires Windows")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
current = DlpSession.start(python_cmd=[sys.executable], timeout=15)
|
||||
yield current
|
||||
current.close()
|
||||
|
||||
|
||||
def test_helper_round_trip_uses_multiple_chunks(tmp_path, session) -> None:
|
||||
payload = os.urandom(3 * 1024 * 1024)
|
||||
path = tmp_path / "payload.bin"
|
||||
path.write_bytes(payload)
|
||||
|
||||
with session.open(path, "rb") as file:
|
||||
assert file.read() == payload
|
||||
|
||||
|
||||
def test_helper_missing_file_is_explicit(tmp_path, session) -> None:
|
||||
with pytest.raises(OSError):
|
||||
session.open_raw(tmp_path / "missing.bin")
|
||||
|
||||
|
||||
def test_helper_stream_is_seekable(tmp_path, session) -> None:
|
||||
payload = bytes(range(256)) * 2048
|
||||
path = tmp_path / "seek.bin"
|
||||
path.write_bytes(payload)
|
||||
|
||||
with session.open(path, "rb") as file:
|
||||
assert file.read(10) == payload[:10]
|
||||
assert file.seek(100) == 100
|
||||
assert file.read(10) == payload[100:110]
|
||||
assert file.seek(5) == 5
|
||||
assert file.read(10) == payload[5:15]
|
||||
assert file.seek(-7, io.SEEK_END) == len(payload) - 7
|
||||
assert file.read() == payload[-7:]
|
||||
|
||||
|
||||
def test_session_rejects_second_active_stream(tmp_path, session) -> None:
|
||||
path = tmp_path / "busy.bin"
|
||||
path.write_bytes(b"busy")
|
||||
|
||||
first = session.open_raw(path)
|
||||
try:
|
||||
with pytest.raises(DlpSessionBusyError):
|
||||
session.open_raw(path)
|
||||
finally:
|
||||
first.close()
|
||||
|
||||
with session.open(path, "rb") as reopened:
|
||||
assert reopened.read() == b"busy"
|
||||
|
||||
|
||||
def test_open_raw_rechecks_closed_state_after_connection(monkeypatch) -> None:
|
||||
process = _HungProcess()
|
||||
current = DlpSession(process, r"\\.\pipe\race", b"a" * 32)
|
||||
connection = _ClosableConnection()
|
||||
entered = threading.Event()
|
||||
proceed = threading.Event()
|
||||
outcome = []
|
||||
|
||||
def delayed_open(path):
|
||||
entered.set()
|
||||
proceed.wait(timeout=5)
|
||||
return connection, 1
|
||||
|
||||
monkeypatch.setattr(current, "_open_conn", delayed_open)
|
||||
|
||||
def run_open() -> None:
|
||||
try:
|
||||
outcome.append(current.open_raw("race.bin"))
|
||||
except Exception as exc:
|
||||
outcome.append(exc)
|
||||
|
||||
thread = threading.Thread(target=run_open)
|
||||
thread.start()
|
||||
assert entered.wait(timeout=1)
|
||||
with current._state_lock:
|
||||
current._closed = True
|
||||
proceed.set()
|
||||
thread.join(timeout=1)
|
||||
|
||||
assert len(outcome) == 1
|
||||
assert isinstance(outcome[0], RuntimeError)
|
||||
assert connection.closed
|
||||
assert current._stream_lock.acquire(blocking=False)
|
||||
current._stream_lock.release()
|
||||
|
||||
|
||||
def test_wrong_authkey_is_rejected_without_killing_helper(session) -> None:
|
||||
with pytest.raises(AuthenticationError):
|
||||
Client(session.pipe_name, family="AF_PIPE", authkey=b"wrong" * 8)
|
||||
|
||||
assert session.ping()["version"] == 1
|
||||
|
||||
|
||||
def test_protocol_rejects_wrong_version_and_negative_offset(session) -> None:
|
||||
conn, header = session._request({"version": 99, "op": "ping"})
|
||||
conn.close()
|
||||
assert header["ok"] is False
|
||||
assert HelperErrorCode(header["error_code"]) is HelperErrorCode.UNSUPPORTED_VERSION
|
||||
|
||||
conn, header = session._request(
|
||||
{"version": 1, "op": "read", "path": "data.bin", "offset": -1}
|
||||
)
|
||||
conn.close()
|
||||
assert header["ok"] is False
|
||||
assert HelperErrorCode(header["error_code"]) is HelperErrorCode.INVALID_OFFSET
|
||||
|
||||
|
||||
def test_helper_source_requires_32_byte_authkey() -> None:
|
||||
with pytest.raises(DlpConfigurationError):
|
||||
render_helper_source(b"short")
|
||||
|
||||
authkey = bytes(range(32))
|
||||
config = HelperSourceConfig.from_authkey(authkey)
|
||||
|
||||
assert config.authkey == authkey
|
||||
assert config.protocol_version == 1
|
||||
assert config.control_limit == 64 * 1024
|
||||
assert config.chunk_size == 1024 * 1024
|
||||
|
||||
|
||||
class _BrokenConnection:
|
||||
def recv_bytes(self, maxlength=None):
|
||||
raise EOFError("pipe closed")
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class _TruncatedConnection:
|
||||
def __init__(self) -> None:
|
||||
self._chunks = iter((b"abc", b""))
|
||||
|
||||
def recv_bytes(self, maxlength=None):
|
||||
return next(self._chunks)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class _GrowingConnection:
|
||||
def recv_bytes(self, maxlength=None):
|
||||
return b"ab"
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
def test_unexpected_disconnect_is_not_eof() -> None:
|
||||
raw = _PipeRawIO(
|
||||
conn=_BrokenConnection(),
|
||||
size=3,
|
||||
path="broken.bin",
|
||||
offset=0,
|
||||
reopen=lambda path, offset: None,
|
||||
release=lambda raw: None,
|
||||
)
|
||||
|
||||
with pytest.raises(DlpTransportError):
|
||||
raw.readinto(bytearray(3))
|
||||
|
||||
|
||||
def test_zero_length_readinto_does_not_touch_connection() -> None:
|
||||
raw = _PipeRawIO(
|
||||
conn=_BrokenConnection(),
|
||||
size=3,
|
||||
path="zero.bin",
|
||||
offset=0,
|
||||
reopen=lambda path, offset: None,
|
||||
release=lambda raw: None,
|
||||
)
|
||||
|
||||
assert raw.readinto(bytearray()) == 0
|
||||
|
||||
|
||||
def test_explicit_eof_detects_truncated_file() -> None:
|
||||
raw = _PipeRawIO(
|
||||
conn=_TruncatedConnection(),
|
||||
size=4,
|
||||
path="truncated.bin",
|
||||
offset=0,
|
||||
reopen=lambda path, offset: None,
|
||||
release=lambda raw: None,
|
||||
)
|
||||
|
||||
assert raw.readinto(bytearray(3)) == 3
|
||||
with pytest.raises(DlpTransportError):
|
||||
raw.readinto(bytearray(1))
|
||||
|
||||
|
||||
def test_frame_cannot_exceed_helper_reported_file_size() -> None:
|
||||
raw = _PipeRawIO(
|
||||
conn=_GrowingConnection(),
|
||||
size=1,
|
||||
path="growing.bin",
|
||||
offset=0,
|
||||
reopen=lambda path, offset: None,
|
||||
release=lambda raw: None,
|
||||
)
|
||||
|
||||
with pytest.raises(DlpTransportError):
|
||||
raw.readinto(bytearray(1))
|
||||
|
||||
|
||||
class _OversizedConnection:
|
||||
def __init__(self) -> None:
|
||||
self.maxlength = None
|
||||
|
||||
def recv_bytes(self, maxlength=None):
|
||||
self.maxlength = maxlength
|
||||
raise OSError("bad message length")
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
def test_data_frame_is_bounded_and_oversize_is_transport_error() -> None:
|
||||
connection = _OversizedConnection()
|
||||
raw = _PipeRawIO(
|
||||
conn=connection,
|
||||
size=1,
|
||||
path="oversized.bin",
|
||||
offset=0,
|
||||
reopen=lambda path, offset: None,
|
||||
release=lambda raw: None,
|
||||
)
|
||||
|
||||
with pytest.raises(DlpTransportError):
|
||||
raw.readinto(bytearray(1))
|
||||
|
||||
assert connection.maxlength == CHUNK_SIZE
|
||||
|
||||
|
||||
class _ClosableConnection:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_seek_reopen_rejects_file_size_change() -> None:
|
||||
initial = _ClosableConnection()
|
||||
reopened = _ClosableConnection()
|
||||
released = []
|
||||
raw = _PipeRawIO(
|
||||
conn=initial,
|
||||
size=100,
|
||||
path="changing.bin",
|
||||
offset=10,
|
||||
reopen=lambda path, offset: (reopened, 50),
|
||||
release=released.append,
|
||||
)
|
||||
|
||||
with pytest.raises(DlpTransportError):
|
||||
raw.seek(0)
|
||||
|
||||
assert initial.closed
|
||||
assert reopened.closed
|
||||
assert raw.closed
|
||||
assert released == [raw]
|
||||
|
||||
|
||||
class _ControlConnection:
|
||||
def __init__(self, response=None, error=None) -> None:
|
||||
self.response = response
|
||||
self.error = error
|
||||
self.closed = False
|
||||
|
||||
def send_bytes(self, payload) -> None:
|
||||
return None
|
||||
|
||||
def recv_bytes(self, maxlength=None):
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.response
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _unstarted_session() -> DlpSession:
|
||||
return DlpSession(process=object(), pipe_name=r"\\.\pipe\test", authkey=b"a" * 32)
|
||||
|
||||
|
||||
def test_request_maps_authentication_failure_to_transport_error(monkeypatch) -> None:
|
||||
def reject(*args, **kwargs):
|
||||
raise AuthenticationError("digest rejected")
|
||||
|
||||
monkeypatch.setattr(session_module, "Client", reject)
|
||||
|
||||
with pytest.raises(DlpTransportError):
|
||||
_unstarted_session()._request({"version": 1, "op": "ping"})
|
||||
|
||||
|
||||
def test_request_maps_disconnect_and_invalid_json_to_public_errors(monkeypatch) -> None:
|
||||
disconnected = _ControlConnection(error=EOFError("closed"))
|
||||
monkeypatch.setattr(session_module, "Client", lambda *args, **kwargs: disconnected)
|
||||
with pytest.raises(DlpTransportError):
|
||||
_unstarted_session()._request({"version": 1, "op": "ping"})
|
||||
assert disconnected.closed
|
||||
|
||||
invalid = _ControlConnection(response=b"not-json")
|
||||
monkeypatch.setattr(session_module, "Client", lambda *args, **kwargs: invalid)
|
||||
with pytest.raises(DlpProtocolError):
|
||||
_unstarted_session()._request({"version": 1, "op": "ping"})
|
||||
assert invalid.closed
|
||||
|
||||
|
||||
def test_session_close_is_idempotent() -> None:
|
||||
current = DlpSession.start(python_cmd=[sys.executable], timeout=15)
|
||||
process = current._proc
|
||||
|
||||
current.close()
|
||||
current.close()
|
||||
|
||||
assert process.poll() is not None
|
||||
assert process.stdin.closed
|
||||
assert process.stdout.closed
|
||||
assert process.stderr.closed
|
||||
|
||||
|
||||
class _HungProcess:
|
||||
def __init__(self) -> None:
|
||||
self.returncode = None
|
||||
self.terminated = False
|
||||
self.killed = False
|
||||
self.stdin = io.BytesIO()
|
||||
self.stdout = io.BytesIO()
|
||||
self.stderr = io.BytesIO()
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.terminated = True
|
||||
self.returncode = 1
|
||||
|
||||
def kill(self) -> None:
|
||||
self.killed = True
|
||||
self.returncode = 1
|
||||
|
||||
def wait(self, timeout=None):
|
||||
if self.returncode is None:
|
||||
raise subprocess.TimeoutExpired("helper", timeout)
|
||||
return self.returncode
|
||||
|
||||
|
||||
def test_session_close_bounds_hung_quit_and_closes_process_pipes(monkeypatch) -> None:
|
||||
process = _HungProcess()
|
||||
current = DlpSession(process, r"\\.\pipe\hung", b"a" * 32)
|
||||
release = threading.Event()
|
||||
entered = threading.Event()
|
||||
|
||||
def hung_request(request):
|
||||
entered.set()
|
||||
release.wait(timeout=10)
|
||||
raise OSError("released")
|
||||
|
||||
monkeypatch.setattr(current, "_request", hung_request)
|
||||
monkeypatch.setattr(session_module, "_SHUTDOWN_TIMEOUT", 0.01)
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
current.close()
|
||||
finally:
|
||||
release.set()
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
assert entered.is_set()
|
||||
assert elapsed < 1
|
||||
assert process.terminated
|
||||
assert process.stdin.closed
|
||||
assert process.stdout.closed
|
||||
assert process.stderr.closed
|
||||
Reference in New Issue
Block a user