Initial commit: split dlp-io library out of DataPacker

This commit is contained in:
2026-07-31 14:58:37 +08:00
commit 3b0c81fd9d
22 changed files with 2509 additions and 0 deletions
+280
View File
@@ -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
+147
View File
@@ -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
+408
View File
@@ -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