From fe1a6ed74992d92a0c146b1bf6cbf9ea530cd1da Mon Sep 17 00:00:00 2001
From: antior
Date: Fri, 31 Jul 2026 16:42:59 +0800
Subject: [PATCH] Consolidate to single dlp_io module and publish release
artifacts via Gitea Actions
---
.gitea/workflows/publish-dlp-io.yaml | 450 +++++--------
MANIFEST.in | 1 -
README.md | 18 +-
dlp_io.py | 903 +++++++++++++++++++++++++++
dlp_io/__init__.py | 31 -
dlp_io/_api.py | 144 -----
dlp_io/_config.py | 14 -
dlp_io/_errors.py | 22 -
dlp_io/_helper.py | 172 -----
dlp_io/_patch.py | 94 ---
dlp_io/_raw.py | 134 ----
dlp_io/_session.py | 319 ----------
dlp_io/py.typed | 0
docs/DLP_IO_LIBRARY.md | 21 +-
pyproject.toml | 11 +-
requirements-dev.txt | 2 +
setup.py | 33 +
tests/test_dlp_io_api.py | 2 +-
tests/test_dlp_io_packaging.py | 59 +-
tests/test_dlp_io_session.py | 6 +-
20 files changed, 1191 insertions(+), 1245 deletions(-)
create mode 100644 dlp_io.py
delete mode 100644 dlp_io/__init__.py
delete mode 100644 dlp_io/_api.py
delete mode 100644 dlp_io/_config.py
delete mode 100644 dlp_io/_errors.py
delete mode 100644 dlp_io/_helper.py
delete mode 100644 dlp_io/_patch.py
delete mode 100644 dlp_io/_raw.py
delete mode 100644 dlp_io/_session.py
delete mode 100644 dlp_io/py.typed
create mode 100644 setup.py
diff --git a/.gitea/workflows/publish-dlp-io.yaml b/.gitea/workflows/publish-dlp-io.yaml
index a905776..44329bd 100644
--- a/.gitea/workflows/publish-dlp-io.yaml
+++ b/.gitea/workflows/publish-dlp-io.yaml
@@ -95,11 +95,11 @@ jobs:
"DLP_IO_PACKAGE_VERSION=$packageVersion" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append
Write-Host "package source: $tag @ $headCommit"
- - name: Test Python 3.9-3.13
+ - name: Test Python 3.10-3.14
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
- foreach ($pythonVersion in @("3.9", "3.10", "3.11", "3.12", "3.13")) {
+ foreach ($pythonVersion in @("3.10", "3.11", "3.12", "3.13", "3.14")) {
& py "-$pythonVersion" --version
if ($LASTEXITCODE -ne 0) { throw "Python $pythonVersion is required" }
& py "-$pythonVersion" -m pip install -r requirements-dev.txt --progress-bar off
@@ -118,20 +118,57 @@ jobs:
if ($LASTEXITCODE -ne 0) { throw "build dependency install failed" }
py -3.13 -m build
if ($LASTEXITCODE -ne 0) { throw "dlp-io build failed" }
- py -3.13 -m twine check "dist\dlp_io-$($env:DLP_IO_PACKAGE_VERSION)*"
- if ($LASTEXITCODE -ne 0) { throw "twine check failed" }
$version = $env:DLP_IO_PACKAGE_VERSION
$expected = @(
"dlp_io-$version-py3-none-any.whl",
"dlp_io-$version.tar.gz"
)
+ foreach ($pythonVersion in @("3.10", "3.11", "3.12", "3.13", "3.14")) {
+ $abi = $pythonVersion.Replace(".", "")
+ $wheel = "dlp_io-$version-cp$abi-cp$abi-win_amd64.whl"
+ $env:DLP_IO_PYD = "1"
+ try {
+ & py "-$pythonVersion" -m build --wheel --no-isolation
+ if ($LASTEXITCODE -ne 0) { throw "pyd build failed on $pythonVersion" }
+ } finally {
+ Remove-Item Env:DLP_IO_PYD
+ }
+ if (-not (Test-Path -LiteralPath (Join-Path dist $wheel) -PathType Leaf)) {
+ throw "expected pyd wheel missing: $wheel"
+ }
+ $expected += $wheel
+ }
+
+ py -3.13 -m twine check "dist\dlp_io-$($env:DLP_IO_PACKAGE_VERSION)*"
+ if ($LASTEXITCODE -ne 0) { throw "twine check failed" }
$actual = @(Get-ChildItem -LiteralPath dist -File | ForEach-Object Name)
if (Compare-Object $expected $actual) {
throw "distribution file set mismatch: $($actual -join ', ')"
}
- - name: Smoke test isolated wheel
+ $assetsDir = (New-Item -ItemType Directory -Force assets).FullName
+ Copy-Item -LiteralPath (Resolve-Path dlp_io.py) -Destination $assetsDir
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+ foreach ($wheelFile in @(Get-ChildItem -LiteralPath dist -Filter "*-cp3*-win_amd64.whl" -File)) {
+ $zip = [System.IO.Compression.ZipFile]::OpenRead($wheelFile.FullName)
+ try {
+ $pydEntries = @($zip.Entries | Where-Object { $_.FullName -like "*.pyd" })
+ if ($pydEntries.Count -ne 1) {
+ throw "pyd wheel must contain exactly one .pyd: $($wheelFile.Name)"
+ }
+ $target = Join-Path $assetsDir $pydEntries[0].Name
+ [System.IO.Compression.ZipFileExtensions]::ExtractToFile($pydEntries[0], $target, $true)
+ } finally {
+ $zip.Dispose()
+ }
+ }
+ $bareAssets = @(Get-ChildItem -LiteralPath $assetsDir -File | ForEach-Object Name)
+ if ($bareAssets.Count -ne 6) {
+ throw "bare asset set mismatch: $($bareAssets -join ', ')"
+ }
+
+ - name: Smoke test release artifacts
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
@@ -145,8 +182,8 @@ jobs:
py -3.13 -m venv $rootFull
if ($LASTEXITCODE -ne 0) { throw "wheel smoke venv creation failed" }
$python = Join-Path $rootFull "Scripts\python.exe"
- $dist = (Resolve-Path dist).Path
- & $python -m pip install --no-index --find-links $dist "dlp-io==$env:DLP_IO_PACKAGE_VERSION" --progress-bar off
+ $pureWheel = (Resolve-Path (Join-Path dist "dlp_io-$($env:DLP_IO_PACKAGE_VERSION)-py3-none-any.whl")).Path
+ & $python -m pip install --no-index $pureWheel --progress-bar off
if ($LASTEXITCODE -ne 0) { throw "wheel installation failed" }
Push-Location $rootFull
try {
@@ -161,293 +198,146 @@ jobs:
}
}
- - name: Upload package to Gitea PyPI
- shell: pwsh
- env:
- TWINE_USERNAME: antior
- TWINE_PASSWORD: ${{ secrets.CI_PACKAGE_TOKEN }}
- TWINE_REPOSITORY_URL: https://gitea.docker.antior.cn/api/packages/antior/pypi
- run: |
- $ErrorActionPreference = "Stop"
- if ([string]::IsNullOrWhiteSpace($env:TWINE_PASSWORD)) {
- throw "CI_PACKAGE_TOKEN repository secret is required"
- }
-
- function Get-RegistryEntries([string]$SimpleUrl, [hashtable]$Headers) {
+ foreach ($pythonVersion in @("3.10", "3.11", "3.12", "3.13", "3.14")) {
+ $abi = $pythonVersion.Replace(".", "")
+ $pydWheel = (Resolve-Path (Join-Path dist "dlp_io-$($env:DLP_IO_PACKAGE_VERSION)-cp$abi-cp$abi-win_amd64.whl")).Path
+ $pydRoot = Join-Path $env:TEMP ("dlp-io-pyd-smoke-$abi-" + $PID)
+ $pydRootFull = [IO.Path]::GetFullPath($pydRoot)
+ if (-not $pydRootFull.StartsWith($tempFull, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "unsafe pyd smoke path: $pydRootFull"
+ }
try {
- $response = Invoke-WebRequest -Headers $Headers -Uri $SimpleUrl
- } catch {
- if ([int]$_.Exception.Response.StatusCode -eq 404) { return @() }
- throw
- }
- $pattern = '[^"]+)"[^>]*>(?[^<]+)'
- foreach ($match in [regex]::Matches($response.Content, $pattern, "IgnoreCase")) {
- $href = [Net.WebUtility]::HtmlDecode($match.Groups["href"].Value)
- $uri = [Uri]$href
- $hashMatch = [regex]::Match($uri.Fragment, '^#sha256=(?[0-9a-fA-F]{64})$')
- if (-not $uri.IsAbsoluteUri -or -not $hashMatch.Success) {
- throw "registry simple index contains an invalid artifact link"
+ & py "-$pythonVersion" -m venv $pydRootFull
+ if ($LASTEXITCODE -ne 0) { throw "pyd smoke venv creation failed on $pythonVersion" }
+ $pydPython = Join-Path $pydRootFull "Scripts\python.exe"
+ & $pydPython -m pip install --no-index $pydWheel --progress-bar off
+ if ($LASTEXITCODE -ne 0) { throw "pyd wheel installation failed on $pythonVersion" }
+ Push-Location $pydRootFull
+ try {
+ & $pydPython -c "import dlp_io,sys,tempfile; from pathlib import Path; assert dlp_io.__version__ == '$env:DLP_IO_PACKAGE_VERSION'; td=tempfile.TemporaryDirectory(); p=Path(td.name)/'payload.bin'; w=dlp_io.open(p,'wb'); assert w.write(b'0123456789') == 10; w.close(); dlp_io.configure(python_executable=sys.executable); r=dlp_io.open(p,'rb'); assert r.read(4) == b'0123'; assert r.seek(-3,2) == 7; assert r.read() == b'789'; r.close(); dlp_io.shutdown(); td.cleanup(); print('pyd smoke passed on $pythonVersion')"
+ if ($LASTEXITCODE -ne 0) { throw "pyd wheel smoke failed on $pythonVersion" }
+ } finally {
+ Pop-Location
}
- $builder = [UriBuilder]$uri
- $builder.Fragment = ""
- [pscustomobject]@{
- filename = [Net.WebUtility]::HtmlDecode($match.Groups["name"].Value)
- url = $builder.Uri.AbsoluteUri
- sha256 = $hashMatch.Groups["hash"].Value.ToLowerInvariant()
+ } finally {
+ if (Test-Path -LiteralPath $pydRootFull) {
+ Remove-Item -LiteralPath $pydRootFull -Recurse -Force
}
}
}
- function Get-ArchiveContentDigest([string]$Path) {
- $script = @'
- import hashlib
- import sys
- import tarfile
- import zipfile
- from pathlib import Path, PurePosixPath
-
- path = Path(sys.argv[1])
- entries = []
- if path.suffix == ".whl":
- with zipfile.ZipFile(path) as archive:
- entries = [
- (item.filename, archive.read(item))
- for item in archive.infolist()
- if not item.is_dir()
- ]
- else:
- with tarfile.open(path, "r:*") as archive:
- for item in archive.getmembers():
- if not item.isfile():
- continue
- stream = archive.extractfile(item)
- parts = PurePosixPath(item.name).parts[1:]
- if stream is not None and parts:
- entries.append(("/".join(parts), stream.read()))
-
- digest = hashlib.sha256()
- for name, content in sorted(entries):
- digest.update(name.encode("utf-8"))
- digest.update(b"\0")
- digest.update(len(content).to_bytes(8, "big"))
- digest.update(hashlib.sha256(content).digest())
- print(digest.hexdigest())
- '@
- $digest = (& py -3.13 -c $script $Path).Trim()
- if ($LASTEXITCODE -ne 0 -or $digest -notmatch '^[0-9a-f]{64}$') {
- throw "failed to calculate archive content digest: $Path"
- }
- return $digest
- }
-
- $packageVersion = $env:DLP_IO_PACKAGE_VERSION
- $expected = @(
- "dlp_io-$packageVersion-py3-none-any.whl",
- "dlp_io-$packageVersion.tar.gz"
- )
- $headers = @{ Authorization = "token $env:TWINE_PASSWORD" }
- $simpleUrl = "$env:TWINE_REPOSITORY_URL/simple/dlp-io/"
- $registryOrigin = [Uri]$env:TWINE_REPOSITORY_URL
- $entries = @(Get-RegistryEntries $simpleUrl $headers)
- $missingArtifacts = [System.Collections.Generic.List[string]]::new()
- $root = Join-Path $env:TEMP ("dlp-io-upload-state-" + [Guid]::NewGuid().ToString("N"))
- $rootFull = [IO.Path]::GetFullPath($root)
- $tempFull = [IO.Path]::GetFullPath($env:TEMP).TrimEnd('\') + '\'
- if (-not $rootFull.StartsWith($tempFull, [StringComparison]::OrdinalIgnoreCase)) {
- throw "unsafe upload verification path: $rootFull"
+ $bareRoot = Join-Path $env:TEMP ("dlp-io-bare-smoke-" + $PID)
+ $bareRootFull = [IO.Path]::GetFullPath($bareRoot)
+ if (-not $bareRootFull.StartsWith($tempFull, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "unsafe bare smoke path: $bareRootFull"
}
try {
- New-Item -ItemType Directory -Force $rootFull | Out-Null
- foreach ($filename in $expected) {
- $local = (Resolve-Path (Join-Path dist $filename)).Path
- $entry = @($entries | Where-Object filename -eq $filename)
- if ($entry.Count -eq 0) {
- $missingArtifacts.Add($local)
- continue
+ foreach ($pythonVersion in @("3.10", "3.11", "3.12", "3.13", "3.14")) {
+ $abi = $pythonVersion.Replace(".", "")
+ $sandbox = Join-Path $bareRootFull "cp$abi"
+ New-Item -ItemType Directory -Force $sandbox | Out-Null
+ Copy-Item -LiteralPath (Resolve-Path (Join-Path assets "dlp_io.cp$abi-win_amd64.pyd")) -Destination $sandbox
+ Push-Location $sandbox
+ try {
+ & py "-$pythonVersion" -c "import dlp_io,sys,tempfile; from pathlib import Path; assert dlp_io.__version__ == '$env:DLP_IO_PACKAGE_VERSION'; assert dlp_io.__file__.endswith('.pyd'); td=tempfile.TemporaryDirectory(); p=Path(td.name)/'payload.bin'; w=dlp_io.open(p,'wb'); assert w.write(b'0123456789') == 10; w.close(); dlp_io.configure(python_executable=sys.executable); r=dlp_io.open(p,'rb'); assert r.read(4) == b'0123'; assert r.seek(-3,2) == 7; assert r.read() == b'789'; r.close(); dlp_io.shutdown(); td.cleanup(); print('bare pyd smoke passed on $pythonVersion')"
+ if ($LASTEXITCODE -ne 0) { throw "bare pyd smoke failed on $pythonVersion" }
+ } finally {
+ Pop-Location
}
- if ($entry.Count -ne 1) {
- throw "registry simple index contains duplicate artifact links: $filename"
- }
- $downloadUri = [Uri]$entry[0].url
- if (-not $downloadUri.Scheme.Equals($registryOrigin.Scheme, [StringComparison]::OrdinalIgnoreCase) -or
- -not $downloadUri.Authority.Equals($registryOrigin.Authority, [StringComparison]::OrdinalIgnoreCase)) {
- throw "download URL is not on the package registry origin: $filename"
- }
- $remote = Join-Path $rootFull $filename
- Invoke-WebRequest -Headers $headers -Uri $downloadUri.AbsoluteUri -OutFile $remote
- $remoteHash = (Get-FileHash -LiteralPath $remote -Algorithm SHA256).Hash.ToLowerInvariant()
- if ($remoteHash -ne $entry[0].sha256) {
- throw "registry download hash mismatch: $filename"
- }
- if ((Get-ArchiveContentDigest $local) -ne (Get-ArchiveContentDigest $remote)) {
- throw "existing registry artifact differs from tagged build: $filename"
- }
- Write-Host "registry artifact already matches tagged build: $filename"
}
-
- if ($missingArtifacts.Count -gt 0) {
- $twineArgs = @(
- "-3.13", "-m", "twine", "upload", "--non-interactive",
- "--disable-progress-bar", "--repository-url", $env:TWINE_REPOSITORY_URL
- ) + $missingArtifacts
- & py @twineArgs
- if ($LASTEXITCODE -ne 0) { throw "twine upload failed" }
- } else {
- Write-Host "all registry artifacts already match the tagged build"
- }
- } finally {
- if (Test-Path -LiteralPath $rootFull) {
- Remove-Item -LiteralPath $rootFull -Recurse -Force
- }
- }
-
- - name: Test registry package files
- shell: pwsh
- env:
- REGISTRY_TOKEN: ${{ secrets.CI_PACKAGE_TOKEN }}
- TWINE_REPOSITORY_URL: https://gitea.docker.antior.cn/api/packages/antior/pypi
- run: |
- $ErrorActionPreference = "Stop"
- $packageVersion = $env:DLP_IO_PACKAGE_VERSION
- $headers = @{ Authorization = "token $env:REGISTRY_TOKEN" }
-
- function Get-RegistryEntries([string]$SimpleUrl, [hashtable]$Headers) {
+ $pySandbox = Join-Path $bareRootFull "py"
+ New-Item -ItemType Directory -Force $pySandbox | Out-Null
+ Copy-Item -LiteralPath (Resolve-Path (Join-Path assets "dlp_io.py")) -Destination $pySandbox
+ Push-Location $pySandbox
try {
- $response = Invoke-WebRequest -Headers $Headers -Uri $SimpleUrl
- } catch {
- if ([int]$_.Exception.Response.StatusCode -eq 404) { return @() }
- throw
- }
- $pattern = '[^"]+)"[^>]*>(?[^<]+)'
- foreach ($match in [regex]::Matches($response.Content, $pattern, "IgnoreCase")) {
- $href = [Net.WebUtility]::HtmlDecode($match.Groups["href"].Value)
- $uri = [Uri]$href
- $hashMatch = [regex]::Match($uri.Fragment, '^#sha256=(?[0-9a-fA-F]{64})$')
- if (-not $uri.IsAbsoluteUri -or -not $hashMatch.Success) {
- throw "registry simple index contains an invalid artifact link"
- }
- $builder = [UriBuilder]$uri
- $builder.Fragment = ""
- [pscustomobject]@{
- filename = [Net.WebUtility]::HtmlDecode($match.Groups["name"].Value)
- url = $builder.Uri.AbsoluteUri
- sha256 = $hashMatch.Groups["hash"].Value.ToLowerInvariant()
- }
- }
- }
-
- function Get-ArchiveContentDigest([string]$Path) {
- $script = @'
- import hashlib
- import sys
- import tarfile
- import zipfile
- from pathlib import Path, PurePosixPath
-
- path = Path(sys.argv[1])
- entries = []
- if path.suffix == ".whl":
- with zipfile.ZipFile(path) as archive:
- entries = [
- (item.filename, archive.read(item))
- for item in archive.infolist()
- if not item.is_dir()
- ]
- else:
- with tarfile.open(path, "r:*") as archive:
- for item in archive.getmembers():
- if not item.isfile():
- continue
- stream = archive.extractfile(item)
- parts = PurePosixPath(item.name).parts[1:]
- if stream is not None and parts:
- entries.append(("/".join(parts), stream.read()))
-
- digest = hashlib.sha256()
- for name, content in sorted(entries):
- digest.update(name.encode("utf-8"))
- digest.update(b"\0")
- digest.update(len(content).to_bytes(8, "big"))
- digest.update(hashlib.sha256(content).digest())
- print(digest.hexdigest())
- '@
- $digest = (& py -3.13 -c $script $Path).Trim()
- if ($LASTEXITCODE -ne 0 -or $digest -notmatch '^[0-9a-f]{64}$') {
- throw "failed to calculate archive content digest: $Path"
- }
- return $digest
- }
-
- $expected = @(
- "dlp_io-$packageVersion-py3-none-any.whl",
- "dlp_io-$packageVersion.tar.gz"
- )
- $simpleUrl = "$env:TWINE_REPOSITORY_URL/simple/dlp-io/"
- $entries = @()
- $actual = @()
- for ($attempt = 1; $attempt -le 20; $attempt++) {
- $entries = @(Get-RegistryEntries $simpleUrl $headers)
- $actual = @($entries | ForEach-Object filename)
- if (-not (Compare-Object $expected $actual)) { break }
- if ($attempt -lt 20) { Start-Sleep -Seconds 3 }
- }
- if (Compare-Object $expected $actual) {
- throw "registry package file mismatch: $($actual -join ', ')"
- }
-
- $registryRoot = Join-Path $env:TEMP ("dlp-io-registry-smoke-" + $PID)
- $registryRootFull = [IO.Path]::GetFullPath($registryRoot)
- $tempFull = [IO.Path]::GetFullPath($env:TEMP).TrimEnd('\') + '\'
- if (-not $registryRootFull.StartsWith($tempFull, [StringComparison]::OrdinalIgnoreCase)) {
- throw "unsafe registry smoke path: $registryRootFull"
- }
- try {
- $downloads = Join-Path $registryRootFull "downloads"
- New-Item -ItemType Directory -Force $downloads | Out-Null
- $registryOrigin = [Uri]$env:TWINE_REPOSITORY_URL
- foreach ($filename in $expected) {
- $entry = @($entries | Where-Object filename -eq $filename)
- if ($entry.Count -ne 1 -or [string]::IsNullOrWhiteSpace($entry[0].url)) {
- throw "registry simple index is missing a download URL: $filename"
- }
- if ([string]::IsNullOrWhiteSpace($entry[0].sha256)) {
- throw "registry simple index is missing SHA-256: $filename"
- }
- $downloadUri = [Uri]$entry[0].url
- if (-not $downloadUri.IsAbsoluteUri -or
- -not $downloadUri.Scheme.Equals($registryOrigin.Scheme, [StringComparison]::OrdinalIgnoreCase) -or
- -not $downloadUri.Authority.Equals($registryOrigin.Authority, [StringComparison]::OrdinalIgnoreCase)) {
- throw "download URL is not on the package registry origin: $filename"
- }
- $target = Join-Path $downloads $filename
- Invoke-WebRequest -Headers $headers -Uri $downloadUri.AbsoluteUri -OutFile $target
- $downloadHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant()
- if ($downloadHash -ne $entry[0].sha256) {
- throw "registry download hash mismatch: $filename"
- }
- $local = (Resolve-Path (Join-Path dist $filename)).Path
- if ((Get-ArchiveContentDigest $local) -ne (Get-ArchiveContentDigest $target)) {
- throw "registry artifact differs from tagged build: $filename"
- }
- }
-
- $venv = Join-Path $registryRootFull "venv"
- py -3.13 -m venv $venv
- if ($LASTEXITCODE -ne 0) { throw "registry smoke venv creation failed" }
- $python = Join-Path $venv "Scripts\python.exe"
- $registryWheel = Join-Path $downloads "dlp_io-$packageVersion-py3-none-any.whl"
- & $python -m pip install --no-index --no-deps $registryWheel --progress-bar off
- if ($LASTEXITCODE -ne 0) { throw "registry package installation failed" }
- Push-Location $registryRootFull
- try {
- & $python -c "import dlp_io,sys,tempfile; from pathlib import Path; assert dlp_io.__version__ == '$packageVersion'; td=tempfile.TemporaryDirectory(); p=Path(td.name)/'payload.bin'; w=dlp_io.open(p,'wb'); w.write(b'registry'); w.close(); dlp_io.configure(python_executable=sys.executable); r=dlp_io.open(p,'rb'); assert r.read() == b'registry'; r.close(); dlp_io.shutdown(); td.cleanup(); print('registry install passed')"
- if ($LASTEXITCODE -ne 0) { throw "registry installation smoke failed" }
+ & py -3.13 -c "import dlp_io,sys,tempfile; from pathlib import Path; assert dlp_io.__version__ == '$env:DLP_IO_PACKAGE_VERSION'; assert dlp_io.__file__.endswith('.py'); td=tempfile.TemporaryDirectory(); p=Path(td.name)/'payload.bin'; w=dlp_io.open(p,'wb'); assert w.write(b'0123456789') == 10; w.close(); dlp_io.configure(python_executable=sys.executable); r=dlp_io.open(p,'rb'); assert r.read(4) == b'0123'; assert r.seek(-3,2) == 7; assert r.read() == b'789'; r.close(); dlp_io.shutdown(); td.cleanup(); print('bare py smoke passed')"
+ if ($LASTEXITCODE -ne 0) { throw "bare py smoke failed" }
} finally {
Pop-Location
}
} finally {
- if (Test-Path -LiteralPath $registryRootFull) {
- Remove-Item -LiteralPath $registryRootFull -Recurse -Force
+ if (Test-Path -LiteralPath $bareRootFull) {
+ Remove-Item -LiteralPath $bareRootFull -Recurse -Force
}
}
- Write-Host "registry verified: dlp-io==$packageVersion ($($actual.Count) files)"
+
+ - name: Create Gitea release with wheels
+ shell: pwsh
+ env:
+ GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITEA_SERVER: ${{ gitea.server_url }}
+ GITEA_REPOSITORY: ${{ github.repository }}
+ run: |
+ $ErrorActionPreference = "Stop"
+ if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) {
+ throw "GITHUB_TOKEN is required to publish the release"
+ }
+ $tag = $env:DLP_IO_PACKAGE_TAG
+ $version = $env:DLP_IO_PACKAGE_VERSION
+ $api = "$env:GITEA_SERVER/api/v1/repos/$env:GITEA_REPOSITORY"
+ $headers = @{ Authorization = "token $env:GITEA_TOKEN" }
+
+ $artifacts = @(Get-ChildItem -LiteralPath dist -File) + @(Get-ChildItem -LiteralPath assets -File)
+ if ($artifacts.Count -eq 0) { throw "no distribution files to publish" }
+
+ $release = $null
+ try {
+ $release = Invoke-RestMethod -Headers $headers -Uri "$api/releases/tags/$tag"
+ } catch {
+ if ([int]$_.Exception.Response.StatusCode -ne 404) { throw }
+ }
+ if ($null -eq $release) {
+ $notes = @(
+ "dlp-io $version",
+ "",
+ "## 产物",
+ "",
+ "- ``dlp_io-$version-py3-none-any.whl``:纯 Python wheel,pip 安装,跨平台",
+ "- ``dlp_io-$version-cp3XX-cp3XX-win_amd64.whl``:pyd wheel,pip 安装,Windows 按解释器版本选用(cp310-cp314)",
+ "- ``dlp_io-$version.tar.gz``:sdist 源码包",
+ "- ``dlp_io.py`` / ``dlp_io.cp3XX-win_amd64.pyd``:免安装单文件,直接放进项目目录即可 ``import dlp_io``",
+ "",
+ "## 安装",
+ "",
+ "纯 Python wheel(跨平台):",
+ "",
+ "``````",
+ "py -m pip install $env:GITEA_SERVER/$env:GITEA_REPOSITORY/releases/download/$tag/dlp_io-$version-py3-none-any.whl",
+ "``````",
+ "",
+ "pyd wheel(Windows,示例为 CPython 3.13,其它版本替换文件名中的 cp313):",
+ "",
+ "``````",
+ "py -3.13 -m pip install $env:GITEA_SERVER/$env:GITEA_REPOSITORY/releases/download/$tag/dlp_io-$version-cp313-cp313-win_amd64.whl",
+ "``````"
+ ) -join "`n"
+ $body = @{
+ tag_name = $tag
+ name = "dlp-io $version"
+ body = $notes
+ draft = $false
+ prerelease = $false
+ } | ConvertTo-Json
+ $release = Invoke-RestMethod -Method Post -Headers $headers -Uri "$api/releases" -Body $body -ContentType "application/json"
+ Write-Host "created release: $tag"
+ } else {
+ Write-Host "release already exists: $tag"
+ }
+
+ foreach ($artifact in $artifacts) {
+ $existing = @($release.assets | Where-Object name -eq $artifact.Name)
+ foreach ($asset in $existing) {
+ Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/assets/$($asset.id)" | Out-Null
+ Write-Host "replaced asset: $($artifact.Name)"
+ }
+ Invoke-RestMethod -Method Post -Headers $headers -Uri "$api/releases/$($release.id)/assets?name=$($artifact.Name)" -Form @{ attachment = Get-Item $artifact.FullName } | Out-Null
+ Write-Host "uploaded asset: $($artifact.Name)"
+ }
+
+ $published = Invoke-RestMethod -Headers $headers -Uri "$api/releases/$($release.id)"
+ $publishedNames = @($published.assets | ForEach-Object name)
+ $localNames = @($artifacts | ForEach-Object Name)
+ if (Compare-Object $localNames $publishedNames) {
+ throw "release asset mismatch: $($publishedNames -join ', ')"
+ }
+ Write-Host "release verified: $tag ($($localNames.Count) files)"
diff --git a/MANIFEST.in b/MANIFEST.in
index 3b000f1..b44539d 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,6 +1,5 @@
include pyproject.toml
include docs/DLP_IO_LIBRARY.md
-recursive-include dlp_io *.py py.typed
prune tests
exclude README.md
global-exclude __pycache__ *.py[cod] .env
diff --git a/README.md b/README.md
index 1c0adfc..89c1582 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,24 @@ pytest
## 构建发布
+发布流程由 `.gitea/workflows/publish-dlp-io.yaml` 驱动:推送 `dlp-io-vX.Y.Z` 格式的 annotated tag,Windows runner(Python 3.10–3.14)会自动完成测试、构建,并把产物上传到 Gitea Release:
+
+- `dlp_io-X.Y.Z-py3-none-any.whl`(纯 Python,跨平台)
+- `dlp_io-X.Y.Z-cp310` … `cp314-win_amd64.whl`(5 个 pyd wheel,Cython 编译,Windows 按解释器版本选用)
+- `dlp_io-X.Y.Z.tar.gz`(sdist)
+- `dlp_io.py` 和 5 个裸 `dlp_io.cp3XX-win_amd64.pyd`(免安装单文件,放进项目目录即可 import)
+
+安装方式见 [docs/DLP_IO_LIBRARY.md](docs/DLP_IO_LIBRARY.md)。
+
+本地只构建纯 Python 版:
+
```bash
py -3.13 -m build
```
-发布流程由 `.gitea/workflows/publish-dlp-io.yaml` 驱动:推送 `dlp-io-vX.Y.Z` 格式的 annotated tag 即可触发测试、构建、上传到 Gitea PyPI 仓库。
+pyd wheel 由 CI 编译,不需要本地编译;runner 主机需预装 MSVC 生成工具(Visual Studio Build Tools 的 C++ 工作负载)。如需本地验证 pyd 构建,安装 `cython` 后执行:
+
+```bash
+set DLP_IO_PYD=1
+py -3.13 -m build --wheel --no-isolation
+```
diff --git a/dlp_io.py b/dlp_io.py
new file mode 100644
index 0000000..b61c9aa
--- /dev/null
+++ b/dlp_io.py
@@ -0,0 +1,903 @@
+"""io-compatible file reads through an approved Python helper on Windows DLP hosts."""
+
+from __future__ import annotations
+
+import atexit
+import builtins
+import io
+import json
+import operator
+import os
+import queue
+import shutil
+import subprocess
+import threading
+from dataclasses import dataclass
+from enum import Enum
+from multiprocessing.context import AuthenticationError
+from multiprocessing.connection import Client
+
+__version__ = "0.1.0"
+
+__all__ = [
+ "DlpConfig",
+ "DlpConfigurationError",
+ "DlpHelperStartError",
+ "DlpIoError",
+ "DlpProtocolError",
+ "DlpSession",
+ "DlpSessionBusyError",
+ "DlpTransportError",
+ "configure",
+ "install_open_patch",
+ "install_reader_patch",
+ "open",
+ "shutdown",
+ "uninstall_open_patch",
+]
+
+
+# ---------------------------------------------------------------------------
+# Errors
+
+
+class DlpIoError(Exception):
+ """Base exception for dlp-io failures."""
+
+
+class DlpConfigurationError(DlpIoError):
+ """The bridge configuration is invalid for the current lifecycle state."""
+
+
+class DlpHelperStartError(DlpIoError):
+ """The approved Python helper could not be started."""
+
+
+class DlpProtocolError(DlpIoError):
+ """The helper and client exchanged an invalid protocol message."""
+
+
+class DlpTransportError(DlpIoError):
+ """The authenticated pipe stream failed before a valid EOF."""
+
+
+class DlpSessionBusyError(DlpIoError):
+ """A session already owns an active file stream."""
+
+
+# ---------------------------------------------------------------------------
+# Configuration
+
+
+@dataclass(frozen=True)
+class DlpConfig:
+ python_executable: str | None = None
+ startup_timeout: float = 60
+
+ def python_command(self) -> list[str] | None:
+ if self.python_executable is None:
+ return None
+ return [self.python_executable]
+
+
+# ---------------------------------------------------------------------------
+# Helper protocol
+
+PROTOCOL_VERSION = 1
+CONTROL_LIMIT = 64 * 1024
+CHUNK_SIZE = 1024 * 1024
+HANDSHAKE_PREFIX = "DLP_IO 1 "
+
+
+class HelperErrorCode(str, Enum):
+ INVALID_PATH = "invalid_path"
+ INVALID_OFFSET = "invalid_offset"
+ FILE_ACCESS = "file_access"
+ INVALID_REQUEST = "invalid_request"
+ INVALID_REQUEST_TYPE = "invalid_request_type"
+ UNSUPPORTED_VERSION = "unsupported_version"
+ UNKNOWN_OPERATION = "unknown_operation"
+ INTERNAL = "internal"
+
+
+@dataclass(frozen=True)
+class HelperSourceConfig:
+ authkey: bytes
+ protocol_version: int = PROTOCOL_VERSION
+ control_limit: int = CONTROL_LIMIT
+ chunk_size: int = CHUNK_SIZE
+
+ @classmethod
+ def from_authkey(cls, authkey: bytes) -> HelperSourceConfig:
+ if not isinstance(authkey, bytes) or len(authkey) != 32:
+ raise DlpConfigurationError("authkey must contain exactly 32 bytes")
+ return cls(authkey=authkey)
+
+
+_HELPER_TEMPLATE = r'''
+import json
+import os
+import sys
+from multiprocessing.connection import AuthenticationError, Listener
+
+VERSION = __PROTOCOL_VERSION__
+CONTROL_LIMIT = __CONTROL_LIMIT__
+CHUNK_SIZE = __CHUNK_SIZE__
+PIPE_PREFIX = r"\\.\pipe\DlpIo_"
+AUTHKEY = bytes.fromhex("__AUTHKEY_HEX__")
+
+
+def send_json(conn, value):
+ conn.send_bytes(json.dumps(value).encode("utf-8"))
+
+
+def error(conn, code, message):
+ send_json(conn, {
+ "version": VERSION,
+ "ok": False,
+ "error_code": code,
+ "error": message,
+ })
+
+
+def handle_read(conn, request):
+ path = request.get("path")
+ offset = request.get("offset", 0)
+ if not isinstance(path, str) or not path:
+ error(conn, "invalid_path", "path must be a non-empty string")
+ return
+ if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
+ error(conn, "invalid_offset", "offset must be a non-negative integer")
+ return
+ try:
+ size = os.path.getsize(path)
+ stream = open(path, "rb")
+ except Exception as exc:
+ error(conn, "file_access", "%s: %s" % (type(exc).__name__, exc))
+ return
+ try:
+ stream.seek(offset)
+ send_json(conn, {"version": VERSION, "ok": True, "size": size})
+ while True:
+ chunk = stream.read(CHUNK_SIZE)
+ if not chunk:
+ conn.send_bytes(b"")
+ return
+ conn.send_bytes(chunk)
+ except (BrokenPipeError, EOFError, OSError):
+ return
+ finally:
+ stream.close()
+
+
+def handle(conn):
+ try:
+ request = json.loads(conn.recv_bytes(maxlength=CONTROL_LIMIT).decode("utf-8"))
+ except Exception as exc:
+ error(conn, "invalid_request", "invalid request: %s" % exc)
+ return True
+ if not isinstance(request, dict):
+ error(conn, "invalid_request_type", "request must be a JSON object")
+ return True
+ if request.get("version") != VERSION:
+ error(conn, "unsupported_version", "unsupported protocol version")
+ return True
+ operation = request.get("op")
+ if operation == "ping":
+ send_json(conn, {
+ "version": VERSION,
+ "ok": True,
+ "pid": os.getpid(),
+ "python": sys.version,
+ })
+ return True
+ if operation == "read":
+ handle_read(conn, request)
+ return True
+ if operation == "quit":
+ send_json(conn, {"version": VERSION, "ok": True})
+ return False
+ error(conn, "unknown_operation", "unknown operation: %r" % operation)
+ return True
+
+
+def main():
+ address = PIPE_PREFIX + os.urandom(16).hex()
+ listener = Listener(address=address, family="AF_PIPE", authkey=AUTHKEY)
+ sys.stdout.write("DLP_IO " + str(VERSION) + " " + address + "\n")
+ sys.stdout.flush()
+ running = True
+ while running:
+ try:
+ conn = listener.accept()
+ except AuthenticationError:
+ continue
+ try:
+ running = handle(conn)
+ except Exception as exc:
+ try:
+ error(conn, "internal", "%s: %s" % (type(exc).__name__, exc))
+ except Exception:
+ pass
+ finally:
+ try:
+ conn.close()
+ except Exception:
+ pass
+ listener.close()
+
+
+try:
+ main()
+except Exception as exc:
+ sys.stderr.write("dlp-io helper fatal: %s: %s\n" % (type(exc).__name__, exc))
+ sys.stderr.flush()
+ sys.exit(1)
+'''
+
+
+def render_helper_source(authkey: bytes) -> str:
+ config = HelperSourceConfig.from_authkey(authkey)
+ replacements = {
+ "__AUTHKEY_HEX__": config.authkey.hex(),
+ "__PROTOCOL_VERSION__": str(config.protocol_version),
+ "__CONTROL_LIMIT__": str(config.control_limit),
+ "__CHUNK_SIZE__": str(config.chunk_size),
+ }
+ source = _HELPER_TEMPLATE
+ for marker, value in replacements.items():
+ source = source.replace(marker, value)
+ return source
+
+
+# ---------------------------------------------------------------------------
+# Pipe-backed raw stream
+
+
+class _PipeRawIO(io.RawIOBase):
+ _DISCARD_LIMIT = 64 * 1024 * 1024
+
+ def __init__(self, *, conn, size, path, offset, reopen, release) -> None:
+ super().__init__()
+ self._conn = conn
+ self._size = int(size)
+ self._path = path
+ self._pos = int(offset)
+ self._expected_end = max(self._size, self._pos)
+ self._reopen = reopen
+ self._release = release
+ self._buffer = bytearray()
+ self._eof = False
+ self._released = False
+
+ def readable(self) -> bool:
+ return True
+
+ def seekable(self) -> bool:
+ return True
+
+ def tell(self) -> int:
+ if self.closed:
+ raise ValueError("I/O operation on closed file")
+ return self._pos
+
+ def _receive(self) -> None:
+ try:
+ chunk = self._conn.recv_bytes(maxlength=CHUNK_SIZE)
+ except (EOFError, OSError) as exc:
+ raise DlpTransportError("helper disconnected before EOF") from exc
+ if chunk:
+ received_end = self._pos + len(self._buffer) + len(chunk)
+ if received_end > self._expected_end:
+ raise DlpTransportError(
+ "file size changed during transfer: expected end %d, received at least %d"
+ % (self._expected_end, received_end)
+ )
+ self._buffer.extend(chunk)
+ return
+ if self._pos != self._expected_end:
+ raise DlpTransportError(
+ "file size changed during transfer: expected end %d, received %d"
+ % (self._expected_end, self._pos)
+ )
+ self._eof = True
+
+ def readinto(self, target) -> int:
+ if self.closed:
+ raise ValueError("I/O operation on closed file")
+ if len(target) == 0:
+ return 0
+ if not self._buffer:
+ if self._eof:
+ return 0
+ self._receive()
+ if self._eof:
+ return 0
+ count = min(len(target), len(self._buffer))
+ target[:count] = self._buffer[:count]
+ del self._buffer[:count]
+ self._pos += count
+ return count
+
+ def seek(self, offset, whence=io.SEEK_SET) -> int:
+ if self.closed:
+ raise ValueError("I/O operation on closed file")
+ if whence == io.SEEK_SET:
+ target = offset
+ elif whence == io.SEEK_CUR:
+ target = self._pos + offset
+ elif whence == io.SEEK_END:
+ target = self._size + offset
+ else:
+ raise ValueError("invalid whence: %r" % (whence,))
+ if target < 0:
+ raise ValueError("negative seek position: %d" % target)
+ delta = target - self._pos
+ if delta == 0:
+ return self._pos
+ if 0 < delta <= self._DISCARD_LIMIT:
+ remaining = delta
+ while remaining:
+ scratch = bytearray(min(remaining, CHUNK_SIZE))
+ count = self.readinto(scratch)
+ if count == 0:
+ self._pos = target
+ self._expected_end = max(self._size, target)
+ break
+ remaining -= count
+ return self._pos
+
+ self._conn.close()
+ try:
+ conn, size = self._reopen(self._path, target)
+ except Exception:
+ self.close()
+ raise
+ if int(size) != self._size:
+ conn.close()
+ self.close()
+ raise DlpTransportError(
+ "file size changed during seek: initial %d, reopened %d"
+ % (self._size, size)
+ )
+ self._conn = conn
+ self._pos = target
+ self._expected_end = max(self._size, target)
+ self._buffer.clear()
+ self._eof = False
+ return self._pos
+
+ def _release_once(self) -> None:
+ if self._released:
+ return
+ self._released = True
+ self._release(self)
+
+ def close(self) -> None:
+ if not self.closed:
+ try:
+ self._conn.close()
+ finally:
+ self._release_once()
+ super().close()
+
+
+# ---------------------------------------------------------------------------
+# Helper session
+
+_SHUTDOWN_TIMEOUT = 5
+
+
+def _locate_python() -> list[str] | None:
+ for variable in ("DLP_IO_PYTHON", "DATAPACKER_DLP_PYTHON"):
+ value = os.environ.get(variable)
+ if value:
+ return [value]
+ executable = shutil.which("python")
+ if executable:
+ return [executable]
+ if shutil.which("py"):
+ return ["py", "-3"]
+ return None
+
+
+def _helper_process_options(os_name=None) -> dict:
+ if (os_name or os.name) == "nt":
+ return {"creationflags": subprocess.CREATE_NO_WINDOW}
+ return {}
+
+
+def _stop_process(process) -> None:
+ if process.poll() is not None:
+ return
+ process.terminate()
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ pass
+
+
+def _close_process_pipes(process) -> None:
+ for name in ("stdin", "stdout", "stderr"):
+ stream = getattr(process, name, None)
+ if stream is None:
+ continue
+ try:
+ stream.close()
+ except OSError:
+ pass
+
+
+class DlpSession:
+ """One authenticated, short-lived approved-Python helper session."""
+
+ def __init__(self, process, pipe_name: str, authkey: bytes) -> None:
+ self._proc = process
+ self.pipe_name = pipe_name
+ self._authkey = authkey
+ self._request_lock = threading.Lock()
+ self._stream_lock = threading.Lock()
+ self._state_lock = threading.RLock()
+ self._active_raw = None
+ self._closed = False
+
+ @classmethod
+ def start(cls, *, python_cmd=None, timeout=60):
+ if os.name != "nt":
+ raise DlpConfigurationError("dlp-io bridge reads require Windows AF_PIPE")
+ if timeout <= 0:
+ raise DlpConfigurationError("startup timeout must be positive")
+ command = list(python_cmd) if python_cmd else _locate_python()
+ if not command:
+ raise DlpHelperStartError(
+ "no approved Python found; configure DLP_IO_PYTHON"
+ )
+ authkey = os.urandom(32)
+ source = render_helper_source(authkey)
+ try:
+ process = subprocess.Popen(
+ command + ["-u", "-"],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ **_helper_process_options(),
+ )
+ except OSError as exc:
+ raise DlpHelperStartError(
+ "failed to start approved Python %r: %s" % (command, exc)
+ ) from exc
+ try:
+ process.stdin.write(source.encode("utf-8"))
+ process.stdin.close()
+ except (BrokenPipeError, OSError) as exc:
+ _stop_process(process)
+ raise DlpHelperStartError("failed to inject helper source: %s" % exc) from exc
+
+ result = queue.Queue()
+
+ def read_handshake() -> None:
+ try:
+ result.put(process.stdout.readline())
+ except Exception as exc:
+ result.put(exc)
+
+ thread = threading.Thread(target=read_handshake, daemon=True)
+ thread.start()
+ try:
+ line = result.get(timeout=timeout)
+ except queue.Empty as exc:
+ _stop_process(process)
+ raise DlpHelperStartError(
+ "timed out waiting for helper handshake after %s seconds" % timeout
+ ) from exc
+ if isinstance(line, Exception):
+ _stop_process(process)
+ raise DlpHelperStartError("failed to read helper handshake: %s" % line)
+ decoded = line.decode("utf-8", "replace").strip()
+ if not decoded.startswith(HANDSHAKE_PREFIX):
+ _stop_process(process)
+ stderr = process.stderr.read().decode("utf-8", "replace").strip()
+ raise DlpHelperStartError(
+ "invalid helper handshake %r; stderr: %s" % (decoded, stderr)
+ )
+ return cls(process, decoded[len(HANDSHAKE_PREFIX):], authkey)
+
+ def _ensure_open(self) -> None:
+ if self._closed:
+ raise RuntimeError("dlp-io session is closed")
+
+ def _validate_header(self, header) -> dict:
+ if not isinstance(header, dict):
+ raise DlpProtocolError("helper response must be a JSON object")
+ if header.get("version") != PROTOCOL_VERSION:
+ raise DlpProtocolError("helper response has an unsupported version")
+ if not isinstance(header.get("ok"), bool):
+ raise DlpProtocolError("helper response is missing boolean ok")
+ return header
+
+ def _request(self, request):
+ with self._request_lock:
+ try:
+ encoded = json.dumps(request).encode("utf-8")
+ except (TypeError, ValueError) as exc:
+ raise DlpProtocolError("helper request is not JSON serializable") from exc
+ try:
+ conn = Client(self.pipe_name, family="AF_PIPE", authkey=self._authkey)
+ except AuthenticationError as exc:
+ raise DlpTransportError("failed to authenticate helper pipe") from exc
+ except (EOFError, OSError) as exc:
+ raise DlpTransportError("failed to connect to helper pipe") from exc
+ try:
+ try:
+ conn.send_bytes(encoded)
+ except (EOFError, OSError) as exc:
+ raise DlpTransportError("failed to send helper request") from exc
+ try:
+ payload = conn.recv_bytes(maxlength=CONTROL_LIMIT)
+ except (EOFError, OSError) as exc:
+ raise DlpTransportError("failed to receive helper response") from exc
+ try:
+ header = json.loads(payload.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise DlpProtocolError("helper response is not valid UTF-8 JSON") from exc
+ return conn, self._validate_header(header)
+ except Exception:
+ conn.close()
+ raise
+
+ def ping(self) -> dict:
+ with self._state_lock:
+ self._ensure_open()
+ conn, header = self._request({"version": PROTOCOL_VERSION, "op": "ping"})
+ conn.close()
+ if not header["ok"]:
+ raise DlpProtocolError(header.get("error", "ping failed"))
+ return header
+
+ def _open_conn(self, path: str, offset: int = 0):
+ conn, header = self._request(
+ {
+ "version": PROTOCOL_VERSION,
+ "op": "read",
+ "path": path,
+ "offset": offset,
+ }
+ )
+ if not header["ok"]:
+ conn.close()
+ raise OSError(header.get("error", "helper read failed"))
+ size = header.get("size")
+ if isinstance(size, bool) or not isinstance(size, int) or size < 0:
+ conn.close()
+ raise DlpProtocolError("helper returned an invalid file size")
+ return conn, size
+
+ def _release_stream(self, raw) -> None:
+ with self._state_lock:
+ if self._active_raw is raw:
+ self._active_raw = None
+ self._stream_lock.release()
+
+ def _reopen_stream(self, path: str, offset: int):
+ with self._state_lock:
+ self._ensure_open()
+ return self._open_conn(path, offset)
+
+ def open_raw(self, path):
+ with self._state_lock:
+ self._ensure_open()
+ if not self._stream_lock.acquire(blocking=False):
+ raise DlpSessionBusyError("dlp-io session already has an active stream")
+ normalized = os.fsdecode(os.fspath(path))
+ try:
+ conn, size = self._open_conn(normalized)
+ raw = _PipeRawIO(
+ conn=conn,
+ size=size,
+ path=normalized,
+ offset=0,
+ reopen=self._reopen_stream,
+ release=self._release_stream,
+ )
+ with self._state_lock:
+ if self._closed:
+ raw.close()
+ raise RuntimeError("dlp-io session is closed")
+ self._active_raw = raw
+ return raw
+ except Exception:
+ self._stream_lock.release()
+ raise
+
+ def open(
+ self,
+ file,
+ mode="r",
+ buffering=-1,
+ encoding=None,
+ errors=None,
+ newline=None,
+ ):
+ buffering = _validate_path_mode(mode, buffering, encoding, errors, newline)
+ if "r" not in mode or "+" in mode:
+ raise io.UnsupportedOperation("DlpSession.open supports read-only modes")
+
+ return _wrap_reader(
+ self.open_raw(file), mode, buffering, encoding, errors, newline
+ )
+
+ def open_read(self, path):
+ """Compatibility alias returning a buffered binary reader."""
+ return self.open(path, "rb")
+
+ def close(self) -> None:
+ with self._state_lock:
+ if self._closed:
+ return
+ self._closed = True
+ active = self._active_raw
+ if active is not None:
+ active.close()
+
+ def request_quit() -> None:
+ try:
+ conn, header = self._request(
+ {"version": PROTOCOL_VERSION, "op": "quit"}
+ )
+ conn.close()
+ if not header["ok"]:
+ raise DlpProtocolError(header.get("error", "helper quit failed"))
+ except Exception:
+ pass
+
+ quit_thread = threading.Thread(target=request_quit, daemon=True)
+ quit_thread.start()
+ quit_thread.join(timeout=_SHUTDOWN_TIMEOUT)
+ try:
+ if quit_thread.is_alive():
+ _stop_process(self._proc)
+ quit_thread.join(timeout=_SHUTDOWN_TIMEOUT)
+ else:
+ try:
+ self._proc.wait(timeout=_SHUTDOWN_TIMEOUT)
+ except subprocess.TimeoutExpired:
+ _stop_process(self._proc)
+ finally:
+ _close_process_pipes(self._proc)
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ self.close()
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Public io-compatible API
+
+_ORIGINAL_OPEN = builtins.open
+_DEFAULT_BUFFER_SIZE = 1024 * 1024
+_state_lock = threading.RLock()
+_config = DlpConfig()
+_default_session = None
+
+
+def _validate_path_mode(mode, buffering, encoding, errors, newline) -> int:
+ buffering = operator.index(buffering)
+ if buffering < -1:
+ raise ValueError("invalid buffering size")
+ if not isinstance(mode, str):
+ raise TypeError("open() argument 'mode' must be str, not %s" % type(mode).__name__)
+ allowed = set("axrwb+t")
+ invalid = set(mode) - allowed
+ duplicate = any(mode.count(character) > 1 for character in allowed)
+ if not mode or invalid or duplicate or sum(c in mode for c in "axrw") != 1:
+ raise ValueError("invalid mode: %r" % mode)
+ if "b" in mode and "t" in mode:
+ raise ValueError("can't have text and binary mode at once")
+ if "b" in mode:
+ if encoding is not None:
+ raise ValueError("binary mode doesn't take an encoding argument")
+ if errors is not None:
+ raise ValueError("binary mode doesn't take an errors argument")
+ if newline is not None:
+ raise ValueError("binary mode doesn't take a newline argument")
+ elif buffering == 0:
+ raise ValueError("can't have unbuffered text I/O")
+ return buffering
+
+
+def configure(*, python_executable=None, startup_timeout=60) -> None:
+ """Configure the lazy default session before its first bridged read."""
+ global _config
+ with _state_lock:
+ if _default_session is not None:
+ raise DlpConfigurationError(
+ "cannot configure dlp_io while the default session is active"
+ )
+ if startup_timeout <= 0:
+ raise DlpConfigurationError("startup_timeout must be positive")
+ _config = DlpConfig(
+ python_executable=python_executable,
+ startup_timeout=startup_timeout,
+ )
+
+
+def _get_default_session():
+ global _default_session
+ with _state_lock:
+ if _default_session is None:
+ _default_session = DlpSession.start(
+ python_cmd=_config.python_command(),
+ timeout=_config.startup_timeout,
+ )
+ return _default_session
+
+
+def _wrap_reader(raw, mode, buffering, encoding, errors, newline):
+ binary = "b" in mode
+ if binary and buffering == 0:
+ return raw
+ if not binary and buffering == 0:
+ raw.close()
+ raise ValueError("can't have unbuffered text I/O")
+ buffer_size = _DEFAULT_BUFFER_SIZE if buffering in (-1, 1) else buffering
+ if buffer_size <= 0:
+ raw.close()
+ raise ValueError("invalid buffering size")
+ buffered = io.BufferedReader(raw, buffer_size=buffer_size)
+ if binary:
+ return buffered
+ return io.TextIOWrapper(
+ buffered,
+ encoding=encoding,
+ errors=errors,
+ newline=newline,
+ line_buffering=buffering == 1,
+ )
+
+
+def open(
+ file,
+ mode="r",
+ buffering=-1,
+ encoding=None,
+ errors=None,
+ newline=None,
+ closefd=True,
+ opener=None,
+):
+ """Open a file with io.open-compatible calling conventions."""
+ is_path = isinstance(file, (str, bytes, os.PathLike))
+ if not is_path or opener is not None:
+ return _ORIGINAL_OPEN(
+ file,
+ mode,
+ buffering,
+ encoding,
+ errors,
+ newline,
+ closefd,
+ opener,
+ )
+ buffering = _validate_path_mode(mode, buffering, encoding, errors, newline)
+ if "+" in mode:
+ raise io.UnsupportedOperation(
+ "dlp_io does not support update mode for path-based files"
+ )
+ pure_read = "r" in mode
+ if pure_read:
+ if not closefd:
+ raise ValueError("Cannot use closefd=False with file name")
+ path = os.fsdecode(os.fspath(file))
+ raw = _get_default_session().open_raw(path)
+ return _wrap_reader(raw, mode, buffering, encoding, errors, newline)
+ return _ORIGINAL_OPEN(file, mode, buffering, encoding, errors, newline, closefd)
+
+
+def shutdown() -> None:
+ """Close and forget the lazy default session. This operation is idempotent."""
+ global _default_session
+ with _state_lock:
+ session = _default_session
+ _default_session = None
+ if session is not None:
+ session.close()
+
+
+atexit.register(shutdown)
+
+
+# ---------------------------------------------------------------------------
+# Global open() patch
+
+_ORIGINAL_IO_OPEN = io.open
+_patch_lock = threading.RLock()
+_installed = False
+
+
+def _reader_wrapper(open_read_func):
+ def patched_open(
+ file,
+ mode="r",
+ buffering=-1,
+ encoding=None,
+ errors=None,
+ newline=None,
+ closefd=True,
+ opener=None,
+ ):
+ is_path = isinstance(file, (str, bytes, os.PathLike))
+ if not is_path or opener is not None:
+ return _ORIGINAL_OPEN(
+ file,
+ mode,
+ buffering,
+ encoding,
+ errors,
+ newline,
+ closefd,
+ opener,
+ )
+ buffering = _validate_path_mode(mode, buffering, encoding, errors, newline)
+ if "+" in mode:
+ raise io.UnsupportedOperation(
+ "dlp_io does not support update mode for path-based files"
+ )
+ pure_read = "r" in mode
+ if pure_read:
+ if not closefd:
+ raise ValueError("Cannot use closefd=False with file name")
+ reader = open_read_func(os.fsdecode(os.fspath(file)))
+ if isinstance(reader, io.RawIOBase):
+ return _wrap_reader(reader, mode, buffering, encoding, errors, newline)
+ if "b" in mode:
+ return reader
+ return io.TextIOWrapper(
+ reader,
+ encoding=encoding,
+ errors=errors,
+ newline=newline,
+ line_buffering=buffering == 1,
+ )
+ return _ORIGINAL_OPEN(file, mode, buffering, encoding, errors, newline, closefd)
+
+ return patched_open
+
+
+def install_open_patch() -> None:
+ """Route global path-based reads through the lazy default dlp-io session."""
+ global _installed
+ with _patch_lock:
+ if _installed:
+ return
+ builtins.open = open
+ io.open = open
+ _installed = True
+
+
+def install_reader_patch(open_read_func) -> None:
+ """Compatibility hook for an explicitly managed session.open_raw callback."""
+ global _installed
+ with _patch_lock:
+ if _installed:
+ return
+ wrapper = _reader_wrapper(open_read_func)
+ builtins.open = wrapper
+ io.open = wrapper
+ _installed = True
+
+
+def uninstall_open_patch() -> None:
+ global _installed
+ with _patch_lock:
+ if not _installed:
+ return
+ builtins.open = _ORIGINAL_OPEN
+ io.open = _ORIGINAL_IO_OPEN
+ _installed = False
diff --git a/dlp_io/__init__.py b/dlp_io/__init__.py
deleted file mode 100644
index 3eb4a38..0000000
--- a/dlp_io/__init__.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from ._api import configure, open, shutdown
-from ._config import DlpConfig
-from ._errors import (
- DlpConfigurationError,
- DlpHelperStartError,
- DlpIoError,
- DlpProtocolError,
- DlpSessionBusyError,
- DlpTransportError,
-)
-from ._session import DlpSession
-from ._patch import install_open_patch, install_reader_patch, uninstall_open_patch
-
-__version__ = "0.1.0"
-
-__all__ = [
- "DlpConfig",
- "DlpConfigurationError",
- "DlpHelperStartError",
- "DlpIoError",
- "DlpProtocolError",
- "DlpSession",
- "DlpSessionBusyError",
- "DlpTransportError",
- "configure",
- "install_open_patch",
- "install_reader_patch",
- "open",
- "shutdown",
- "uninstall_open_patch",
-]
diff --git a/dlp_io/_api.py b/dlp_io/_api.py
deleted file mode 100644
index 85b10be..0000000
--- a/dlp_io/_api.py
+++ /dev/null
@@ -1,144 +0,0 @@
-from __future__ import annotations
-
-import atexit
-import builtins
-import io
-import operator
-import os
-import threading
-
-from ._config import DlpConfig
-from ._errors import DlpConfigurationError
-from ._session import DlpSession
-
-_ORIGINAL_OPEN = builtins.open
-_DEFAULT_BUFFER_SIZE = 1024 * 1024
-_state_lock = threading.RLock()
-_config = DlpConfig()
-_default_session = None
-
-
-def _validate_path_mode(mode, buffering, encoding, errors, newline) -> int:
- buffering = operator.index(buffering)
- if buffering < -1:
- raise ValueError("invalid buffering size")
- if not isinstance(mode, str):
- raise TypeError("open() argument 'mode' must be str, not %s" % type(mode).__name__)
- allowed = set("axrwb+t")
- invalid = set(mode) - allowed
- duplicate = any(mode.count(character) > 1 for character in allowed)
- if not mode or invalid or duplicate or sum(c in mode for c in "axrw") != 1:
- raise ValueError("invalid mode: %r" % mode)
- if "b" in mode and "t" in mode:
- raise ValueError("can't have text and binary mode at once")
- if "b" in mode:
- if encoding is not None:
- raise ValueError("binary mode doesn't take an encoding argument")
- if errors is not None:
- raise ValueError("binary mode doesn't take an errors argument")
- if newline is not None:
- raise ValueError("binary mode doesn't take a newline argument")
- elif buffering == 0:
- raise ValueError("can't have unbuffered text I/O")
- return buffering
-
-
-def configure(*, python_executable=None, startup_timeout=60) -> None:
- """Configure the lazy default session before its first bridged read."""
- global _config
- with _state_lock:
- if _default_session is not None:
- raise DlpConfigurationError(
- "cannot configure dlp_io while the default session is active"
- )
- if startup_timeout <= 0:
- raise DlpConfigurationError("startup_timeout must be positive")
- _config = DlpConfig(
- python_executable=python_executable,
- startup_timeout=startup_timeout,
- )
-
-
-def _get_default_session():
- global _default_session
- with _state_lock:
- if _default_session is None:
- _default_session = DlpSession.start(
- python_cmd=_config.python_command(),
- timeout=_config.startup_timeout,
- )
- return _default_session
-
-
-def _wrap_reader(raw, mode, buffering, encoding, errors, newline):
- binary = "b" in mode
- if binary and buffering == 0:
- return raw
- if not binary and buffering == 0:
- raw.close()
- raise ValueError("can't have unbuffered text I/O")
- buffer_size = _DEFAULT_BUFFER_SIZE if buffering in (-1, 1) else buffering
- if buffer_size <= 0:
- raw.close()
- raise ValueError("invalid buffering size")
- buffered = io.BufferedReader(raw, buffer_size=buffer_size)
- if binary:
- return buffered
- return io.TextIOWrapper(
- buffered,
- encoding=encoding,
- errors=errors,
- newline=newline,
- line_buffering=buffering == 1,
- )
-
-
-def open(
- file,
- mode="r",
- buffering=-1,
- encoding=None,
- errors=None,
- newline=None,
- closefd=True,
- opener=None,
-):
- """Open a file with io.open-compatible calling conventions."""
- is_path = isinstance(file, (str, bytes, os.PathLike))
- if not is_path or opener is not None:
- return _ORIGINAL_OPEN(
- file,
- mode,
- buffering,
- encoding,
- errors,
- newline,
- closefd,
- opener,
- )
- buffering = _validate_path_mode(mode, buffering, encoding, errors, newline)
- if "+" in mode:
- raise io.UnsupportedOperation(
- "dlp_io does not support update mode for path-based files"
- )
- pure_read = "r" in mode
- if pure_read:
- if not closefd:
- raise ValueError("Cannot use closefd=False with file name")
- path = os.fsdecode(os.fspath(file))
- raw = _get_default_session().open_raw(path)
- return _wrap_reader(raw, mode, buffering, encoding, errors, newline)
- return _ORIGINAL_OPEN(file, mode, buffering, encoding, errors, newline, closefd)
-
-
-def shutdown() -> None:
- """Close and forget the lazy default session. This operation is idempotent."""
- global _default_session
- with _state_lock:
- session = _default_session
- _default_session = None
- if session is not None:
- session.close()
-
-
-atexit.register(shutdown)
diff --git a/dlp_io/_config.py b/dlp_io/_config.py
deleted file mode 100644
index 775609a..0000000
--- a/dlp_io/_config.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-
-@dataclass(frozen=True)
-class DlpConfig:
- python_executable: str | None = None
- startup_timeout: float = 60
-
- def python_command(self) -> list[str] | None:
- if self.python_executable is None:
- return None
- return [self.python_executable]
diff --git a/dlp_io/_errors.py b/dlp_io/_errors.py
deleted file mode 100644
index 8e9eed7..0000000
--- a/dlp_io/_errors.py
+++ /dev/null
@@ -1,22 +0,0 @@
-class DlpIoError(Exception):
- """Base exception for dlp-io failures."""
-
-
-class DlpConfigurationError(DlpIoError):
- """The bridge configuration is invalid for the current lifecycle state."""
-
-
-class DlpHelperStartError(DlpIoError):
- """The approved Python helper could not be started."""
-
-
-class DlpProtocolError(DlpIoError):
- """The helper and client exchanged an invalid protocol message."""
-
-
-class DlpTransportError(DlpIoError):
- """The authenticated pipe stream failed before a valid EOF."""
-
-
-class DlpSessionBusyError(DlpIoError):
- """A session already owns an active file stream."""
diff --git a/dlp_io/_helper.py b/dlp_io/_helper.py
deleted file mode 100644
index 202c115..0000000
--- a/dlp_io/_helper.py
+++ /dev/null
@@ -1,172 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from enum import Enum
-
-from ._errors import DlpConfigurationError
-
-PROTOCOL_VERSION = 1
-CONTROL_LIMIT = 64 * 1024
-CHUNK_SIZE = 1024 * 1024
-HANDSHAKE_PREFIX = "DLP_IO 1 "
-
-
-class HelperErrorCode(str, Enum):
- INVALID_PATH = "invalid_path"
- INVALID_OFFSET = "invalid_offset"
- FILE_ACCESS = "file_access"
- INVALID_REQUEST = "invalid_request"
- INVALID_REQUEST_TYPE = "invalid_request_type"
- UNSUPPORTED_VERSION = "unsupported_version"
- UNKNOWN_OPERATION = "unknown_operation"
- INTERNAL = "internal"
-
-
-@dataclass(frozen=True)
-class HelperSourceConfig:
- authkey: bytes
- protocol_version: int = PROTOCOL_VERSION
- control_limit: int = CONTROL_LIMIT
- chunk_size: int = CHUNK_SIZE
-
- @classmethod
- def from_authkey(cls, authkey: bytes) -> HelperSourceConfig:
- if not isinstance(authkey, bytes) or len(authkey) != 32:
- raise DlpConfigurationError("authkey must contain exactly 32 bytes")
- return cls(authkey=authkey)
-
-
-_HELPER_TEMPLATE = r'''
-import json
-import os
-import sys
-from multiprocessing.connection import AuthenticationError, Listener
-
-VERSION = __PROTOCOL_VERSION__
-CONTROL_LIMIT = __CONTROL_LIMIT__
-CHUNK_SIZE = __CHUNK_SIZE__
-PIPE_PREFIX = r"\\.\pipe\DlpIo_"
-AUTHKEY = bytes.fromhex("__AUTHKEY_HEX__")
-
-
-def send_json(conn, value):
- conn.send_bytes(json.dumps(value).encode("utf-8"))
-
-
-def error(conn, code, message):
- send_json(conn, {
- "version": VERSION,
- "ok": False,
- "error_code": code,
- "error": message,
- })
-
-
-def handle_read(conn, request):
- path = request.get("path")
- offset = request.get("offset", 0)
- if not isinstance(path, str) or not path:
- error(conn, "invalid_path", "path must be a non-empty string")
- return
- if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
- error(conn, "invalid_offset", "offset must be a non-negative integer")
- return
- try:
- size = os.path.getsize(path)
- stream = open(path, "rb")
- except Exception as exc:
- error(conn, "file_access", "%s: %s" % (type(exc).__name__, exc))
- return
- try:
- stream.seek(offset)
- send_json(conn, {"version": VERSION, "ok": True, "size": size})
- while True:
- chunk = stream.read(CHUNK_SIZE)
- if not chunk:
- conn.send_bytes(b"")
- return
- conn.send_bytes(chunk)
- except (BrokenPipeError, EOFError, OSError):
- return
- finally:
- stream.close()
-
-
-def handle(conn):
- try:
- request = json.loads(conn.recv_bytes(maxlength=CONTROL_LIMIT).decode("utf-8"))
- except Exception as exc:
- error(conn, "invalid_request", "invalid request: %s" % exc)
- return True
- if not isinstance(request, dict):
- error(conn, "invalid_request_type", "request must be a JSON object")
- return True
- if request.get("version") != VERSION:
- error(conn, "unsupported_version", "unsupported protocol version")
- return True
- operation = request.get("op")
- if operation == "ping":
- send_json(conn, {
- "version": VERSION,
- "ok": True,
- "pid": os.getpid(),
- "python": sys.version,
- })
- return True
- if operation == "read":
- handle_read(conn, request)
- return True
- if operation == "quit":
- send_json(conn, {"version": VERSION, "ok": True})
- return False
- error(conn, "unknown_operation", "unknown operation: %r" % operation)
- return True
-
-
-def main():
- address = PIPE_PREFIX + os.urandom(16).hex()
- listener = Listener(address=address, family="AF_PIPE", authkey=AUTHKEY)
- sys.stdout.write("DLP_IO " + str(VERSION) + " " + address + "\n")
- sys.stdout.flush()
- running = True
- while running:
- try:
- conn = listener.accept()
- except AuthenticationError:
- continue
- try:
- running = handle(conn)
- except Exception as exc:
- try:
- error(conn, "internal", "%s: %s" % (type(exc).__name__, exc))
- except Exception:
- pass
- finally:
- try:
- conn.close()
- except Exception:
- pass
- listener.close()
-
-
-try:
- main()
-except Exception as exc:
- sys.stderr.write("dlp-io helper fatal: %s: %s\n" % (type(exc).__name__, exc))
- sys.stderr.flush()
- sys.exit(1)
-'''
-
-
-def render_helper_source(authkey: bytes) -> str:
- config = HelperSourceConfig.from_authkey(authkey)
- replacements = {
- "__AUTHKEY_HEX__": config.authkey.hex(),
- "__PROTOCOL_VERSION__": str(config.protocol_version),
- "__CONTROL_LIMIT__": str(config.control_limit),
- "__CHUNK_SIZE__": str(config.chunk_size),
- }
- source = _HELPER_TEMPLATE
- for marker, value in replacements.items():
- source = source.replace(marker, value)
- return source
diff --git a/dlp_io/_patch.py b/dlp_io/_patch.py
deleted file mode 100644
index 3f0aa2a..0000000
--- a/dlp_io/_patch.py
+++ /dev/null
@@ -1,94 +0,0 @@
-from __future__ import annotations
-
-import builtins
-import io
-import os
-import threading
-
-from ._api import _ORIGINAL_OPEN, _validate_path_mode, _wrap_reader, open as dlp_open
-
-_ORIGINAL_IO_OPEN = io.open
-_patch_lock = threading.RLock()
-_installed = False
-
-
-def _reader_wrapper(open_read_func):
- def patched_open(
- file,
- mode="r",
- buffering=-1,
- encoding=None,
- errors=None,
- newline=None,
- closefd=True,
- opener=None,
- ):
- is_path = isinstance(file, (str, bytes, os.PathLike))
- if not is_path or opener is not None:
- return _ORIGINAL_OPEN(
- file,
- mode,
- buffering,
- encoding,
- errors,
- newline,
- closefd,
- opener,
- )
- buffering = _validate_path_mode(mode, buffering, encoding, errors, newline)
- if "+" in mode:
- raise io.UnsupportedOperation(
- "dlp_io does not support update mode for path-based files"
- )
- pure_read = "r" in mode
- if pure_read:
- if not closefd:
- raise ValueError("Cannot use closefd=False with file name")
- reader = open_read_func(os.fsdecode(os.fspath(file)))
- if isinstance(reader, io.RawIOBase):
- return _wrap_reader(reader, mode, buffering, encoding, errors, newline)
- if "b" in mode:
- return reader
- return io.TextIOWrapper(
- reader,
- encoding=encoding,
- errors=errors,
- newline=newline,
- line_buffering=buffering == 1,
- )
- return _ORIGINAL_OPEN(file, mode, buffering, encoding, errors, newline, closefd)
-
- return patched_open
-
-
-def install_open_patch() -> None:
- """Route global path-based reads through the lazy default dlp-io session."""
- global _installed
- with _patch_lock:
- if _installed:
- return
- builtins.open = dlp_open
- io.open = dlp_open
- _installed = True
-
-
-def install_reader_patch(open_read_func) -> None:
- """Compatibility hook for an explicitly managed session.open_raw callback."""
- global _installed
- with _patch_lock:
- if _installed:
- return
- wrapper = _reader_wrapper(open_read_func)
- builtins.open = wrapper
- io.open = wrapper
- _installed = True
-
-
-def uninstall_open_patch() -> None:
- global _installed
- with _patch_lock:
- if not _installed:
- return
- builtins.open = _ORIGINAL_OPEN
- io.open = _ORIGINAL_IO_OPEN
- _installed = False
diff --git a/dlp_io/_raw.py b/dlp_io/_raw.py
deleted file mode 100644
index 2574507..0000000
--- a/dlp_io/_raw.py
+++ /dev/null
@@ -1,134 +0,0 @@
-from __future__ import annotations
-
-import io
-
-from ._errors import DlpTransportError
-from ._helper import CHUNK_SIZE
-
-
-class _PipeRawIO(io.RawIOBase):
- _DISCARD_LIMIT = 64 * 1024 * 1024
-
- def __init__(self, *, conn, size, path, offset, reopen, release) -> None:
- super().__init__()
- self._conn = conn
- self._size = int(size)
- self._path = path
- self._pos = int(offset)
- self._expected_end = max(self._size, self._pos)
- self._reopen = reopen
- self._release = release
- self._buffer = bytearray()
- self._eof = False
- self._released = False
-
- def readable(self) -> bool:
- return True
-
- def seekable(self) -> bool:
- return True
-
- def tell(self) -> int:
- if self.closed:
- raise ValueError("I/O operation on closed file")
- return self._pos
-
- def _receive(self) -> None:
- try:
- chunk = self._conn.recv_bytes(maxlength=CHUNK_SIZE)
- except (EOFError, OSError) as exc:
- raise DlpTransportError("helper disconnected before EOF") from exc
- if chunk:
- received_end = self._pos + len(self._buffer) + len(chunk)
- if received_end > self._expected_end:
- raise DlpTransportError(
- "file size changed during transfer: expected end %d, received at least %d"
- % (self._expected_end, received_end)
- )
- self._buffer.extend(chunk)
- return
- if self._pos != self._expected_end:
- raise DlpTransportError(
- "file size changed during transfer: expected end %d, received %d"
- % (self._expected_end, self._pos)
- )
- self._eof = True
-
- def readinto(self, target) -> int:
- if self.closed:
- raise ValueError("I/O operation on closed file")
- if len(target) == 0:
- return 0
- if not self._buffer:
- if self._eof:
- return 0
- self._receive()
- if self._eof:
- return 0
- count = min(len(target), len(self._buffer))
- target[:count] = self._buffer[:count]
- del self._buffer[:count]
- self._pos += count
- return count
-
- def seek(self, offset, whence=io.SEEK_SET) -> int:
- if self.closed:
- raise ValueError("I/O operation on closed file")
- if whence == io.SEEK_SET:
- target = offset
- elif whence == io.SEEK_CUR:
- target = self._pos + offset
- elif whence == io.SEEK_END:
- target = self._size + offset
- else:
- raise ValueError("invalid whence: %r" % (whence,))
- if target < 0:
- raise ValueError("negative seek position: %d" % target)
- delta = target - self._pos
- if delta == 0:
- return self._pos
- if 0 < delta <= self._DISCARD_LIMIT:
- remaining = delta
- while remaining:
- scratch = bytearray(min(remaining, CHUNK_SIZE))
- count = self.readinto(scratch)
- if count == 0:
- self._pos = target
- self._expected_end = max(self._size, target)
- break
- remaining -= count
- return self._pos
-
- self._conn.close()
- try:
- conn, size = self._reopen(self._path, target)
- except Exception:
- self.close()
- raise
- if int(size) != self._size:
- conn.close()
- self.close()
- raise DlpTransportError(
- "file size changed during seek: initial %d, reopened %d"
- % (self._size, size)
- )
- self._conn = conn
- self._pos = target
- self._expected_end = max(self._size, target)
- self._buffer.clear()
- self._eof = False
- return self._pos
-
- def _release_once(self) -> None:
- if self._released:
- return
- self._released = True
- self._release(self)
-
- def close(self) -> None:
- if not self.closed:
- try:
- self._conn.close()
- finally:
- self._release_once()
- super().close()
diff --git a/dlp_io/_session.py b/dlp_io/_session.py
deleted file mode 100644
index 0b72818..0000000
--- a/dlp_io/_session.py
+++ /dev/null
@@ -1,319 +0,0 @@
-from __future__ import annotations
-
-import io
-import json
-import os
-import queue
-import shutil
-import subprocess
-import threading
-from multiprocessing.context import AuthenticationError
-from multiprocessing.connection import Client
-
-from ._errors import (
- DlpConfigurationError,
- DlpHelperStartError,
- DlpProtocolError,
- DlpSessionBusyError,
- DlpTransportError,
-)
-from ._helper import (
- CONTROL_LIMIT,
- HANDSHAKE_PREFIX,
- PROTOCOL_VERSION,
- render_helper_source,
-)
-from ._raw import _PipeRawIO
-
-_SHUTDOWN_TIMEOUT = 5
-
-
-def _locate_python() -> list[str] | None:
- for variable in ("DLP_IO_PYTHON", "DATAPACKER_DLP_PYTHON"):
- value = os.environ.get(variable)
- if value:
- return [value]
- executable = shutil.which("python")
- if executable:
- return [executable]
- if shutil.which("py"):
- return ["py", "-3"]
- return None
-
-
-def _helper_process_options(os_name=None) -> dict:
- if (os_name or os.name) == "nt":
- return {"creationflags": subprocess.CREATE_NO_WINDOW}
- return {}
-
-
-def _stop_process(process) -> None:
- if process.poll() is not None:
- return
- process.terminate()
- try:
- process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- process.kill()
- try:
- process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- pass
-
-
-def _close_process_pipes(process) -> None:
- for name in ("stdin", "stdout", "stderr"):
- stream = getattr(process, name, None)
- if stream is None:
- continue
- try:
- stream.close()
- except OSError:
- pass
-
-
-class DlpSession:
- """One authenticated, short-lived approved-Python helper session."""
-
- def __init__(self, process, pipe_name: str, authkey: bytes) -> None:
- self._proc = process
- self.pipe_name = pipe_name
- self._authkey = authkey
- self._request_lock = threading.Lock()
- self._stream_lock = threading.Lock()
- self._state_lock = threading.RLock()
- self._active_raw = None
- self._closed = False
-
- @classmethod
- def start(cls, *, python_cmd=None, timeout=60):
- if os.name != "nt":
- raise DlpConfigurationError("dlp-io bridge reads require Windows AF_PIPE")
- if timeout <= 0:
- raise DlpConfigurationError("startup timeout must be positive")
- command = list(python_cmd) if python_cmd else _locate_python()
- if not command:
- raise DlpHelperStartError(
- "no approved Python found; configure DLP_IO_PYTHON"
- )
- authkey = os.urandom(32)
- source = render_helper_source(authkey)
- try:
- process = subprocess.Popen(
- command + ["-u", "-"],
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- **_helper_process_options(),
- )
- except OSError as exc:
- raise DlpHelperStartError(
- "failed to start approved Python %r: %s" % (command, exc)
- ) from exc
- try:
- process.stdin.write(source.encode("utf-8"))
- process.stdin.close()
- except (BrokenPipeError, OSError) as exc:
- _stop_process(process)
- raise DlpHelperStartError("failed to inject helper source: %s" % exc) from exc
-
- result = queue.Queue()
-
- def read_handshake() -> None:
- try:
- result.put(process.stdout.readline())
- except Exception as exc:
- result.put(exc)
-
- thread = threading.Thread(target=read_handshake, daemon=True)
- thread.start()
- try:
- line = result.get(timeout=timeout)
- except queue.Empty as exc:
- _stop_process(process)
- raise DlpHelperStartError(
- "timed out waiting for helper handshake after %s seconds" % timeout
- ) from exc
- if isinstance(line, Exception):
- _stop_process(process)
- raise DlpHelperStartError("failed to read helper handshake: %s" % line)
- decoded = line.decode("utf-8", "replace").strip()
- if not decoded.startswith(HANDSHAKE_PREFIX):
- _stop_process(process)
- stderr = process.stderr.read().decode("utf-8", "replace").strip()
- raise DlpHelperStartError(
- "invalid helper handshake %r; stderr: %s" % (decoded, stderr)
- )
- return cls(process, decoded[len(HANDSHAKE_PREFIX):], authkey)
-
- def _ensure_open(self) -> None:
- if self._closed:
- raise RuntimeError("dlp-io session is closed")
-
- def _validate_header(self, header) -> dict:
- if not isinstance(header, dict):
- raise DlpProtocolError("helper response must be a JSON object")
- if header.get("version") != PROTOCOL_VERSION:
- raise DlpProtocolError("helper response has an unsupported version")
- if not isinstance(header.get("ok"), bool):
- raise DlpProtocolError("helper response is missing boolean ok")
- return header
-
- def _request(self, request):
- with self._request_lock:
- try:
- encoded = json.dumps(request).encode("utf-8")
- except (TypeError, ValueError) as exc:
- raise DlpProtocolError("helper request is not JSON serializable") from exc
- try:
- conn = Client(self.pipe_name, family="AF_PIPE", authkey=self._authkey)
- except AuthenticationError as exc:
- raise DlpTransportError("failed to authenticate helper pipe") from exc
- except (EOFError, OSError) as exc:
- raise DlpTransportError("failed to connect to helper pipe") from exc
- try:
- try:
- conn.send_bytes(encoded)
- except (EOFError, OSError) as exc:
- raise DlpTransportError("failed to send helper request") from exc
- try:
- payload = conn.recv_bytes(maxlength=CONTROL_LIMIT)
- except (EOFError, OSError) as exc:
- raise DlpTransportError("failed to receive helper response") from exc
- try:
- header = json.loads(payload.decode("utf-8"))
- except (UnicodeDecodeError, json.JSONDecodeError) as exc:
- raise DlpProtocolError("helper response is not valid UTF-8 JSON") from exc
- return conn, self._validate_header(header)
- except Exception:
- conn.close()
- raise
-
- def ping(self) -> dict:
- with self._state_lock:
- self._ensure_open()
- conn, header = self._request({"version": PROTOCOL_VERSION, "op": "ping"})
- conn.close()
- if not header["ok"]:
- raise DlpProtocolError(header.get("error", "ping failed"))
- return header
-
- def _open_conn(self, path: str, offset: int = 0):
- conn, header = self._request(
- {
- "version": PROTOCOL_VERSION,
- "op": "read",
- "path": path,
- "offset": offset,
- }
- )
- if not header["ok"]:
- conn.close()
- raise OSError(header.get("error", "helper read failed"))
- size = header.get("size")
- if isinstance(size, bool) or not isinstance(size, int) or size < 0:
- conn.close()
- raise DlpProtocolError("helper returned an invalid file size")
- return conn, size
-
- def _release_stream(self, raw) -> None:
- with self._state_lock:
- if self._active_raw is raw:
- self._active_raw = None
- self._stream_lock.release()
-
- def _reopen_stream(self, path: str, offset: int):
- with self._state_lock:
- self._ensure_open()
- return self._open_conn(path, offset)
-
- def open_raw(self, path):
- with self._state_lock:
- self._ensure_open()
- if not self._stream_lock.acquire(blocking=False):
- raise DlpSessionBusyError("dlp-io session already has an active stream")
- normalized = os.fsdecode(os.fspath(path))
- try:
- conn, size = self._open_conn(normalized)
- raw = _PipeRawIO(
- conn=conn,
- size=size,
- path=normalized,
- offset=0,
- reopen=self._reopen_stream,
- release=self._release_stream,
- )
- with self._state_lock:
- if self._closed:
- raw.close()
- raise RuntimeError("dlp-io session is closed")
- self._active_raw = raw
- return raw
- except Exception:
- self._stream_lock.release()
- raise
-
- def open(
- self,
- file,
- mode="r",
- buffering=-1,
- encoding=None,
- errors=None,
- newline=None,
- ):
- from ._api import _validate_path_mode, _wrap_reader
-
- buffering = _validate_path_mode(mode, buffering, encoding, errors, newline)
- if "r" not in mode or "+" in mode:
- raise io.UnsupportedOperation("DlpSession.open supports read-only modes")
-
- return _wrap_reader(
- self.open_raw(file), mode, buffering, encoding, errors, newline
- )
-
- def open_read(self, path):
- """Compatibility alias returning a buffered binary reader."""
- return self.open(path, "rb")
-
- def close(self) -> None:
- with self._state_lock:
- if self._closed:
- return
- self._closed = True
- active = self._active_raw
- if active is not None:
- active.close()
-
- def request_quit() -> None:
- try:
- conn, header = self._request(
- {"version": PROTOCOL_VERSION, "op": "quit"}
- )
- conn.close()
- if not header["ok"]:
- raise DlpProtocolError(header.get("error", "helper quit failed"))
- except Exception:
- pass
-
- quit_thread = threading.Thread(target=request_quit, daemon=True)
- quit_thread.start()
- quit_thread.join(timeout=_SHUTDOWN_TIMEOUT)
- try:
- if quit_thread.is_alive():
- _stop_process(self._proc)
- quit_thread.join(timeout=_SHUTDOWN_TIMEOUT)
- else:
- try:
- self._proc.wait(timeout=_SHUTDOWN_TIMEOUT)
- except subprocess.TimeoutExpired:
- _stop_process(self._proc)
- finally:
- _close_process_pipes(self._proc)
-
- def __enter__(self):
- return self
-
- def __exit__(self, exc_type, exc_value, traceback):
- self.close()
- return False
diff --git a/dlp_io/py.typed b/dlp_io/py.typed
deleted file mode 100644
index e69de29..0000000
diff --git a/docs/DLP_IO_LIBRARY.md b/docs/DLP_IO_LIBRARY.md
index 9521197..7118094 100644
--- a/docs/DLP_IO_LIBRARY.md
+++ b/docs/DLP_IO_LIBRARY.md
@@ -6,17 +6,28 @@
## 安装
-当前 Gitea Python Package Registry 已开放匿名下载。Package 页面由 Gitea 根据上传后的 metadata 自动生成;本项目的 `.gitea/workflows/publish-dlp-io.yaml` 负责从 immutable tag 构建 wheel/sdist 并通过 `twine upload` 发布,不生成静态网页。
+发布物托管在 Gitea Release 页面,由 `.gitea/workflows/publish-dlp-io.yaml` 从 immutable tag `dlp-io-vX.Y.Z` 自动构建并上传,全部产物在 CI runner 上编译,不依赖本地环境。
-Package 页面:
+Release 页面:
-Windows PowerShell:
+每个版本包含以下文件:
+
+- `dlp_io-X.Y.Z-py3-none-any.whl`:纯 Python wheel,适用于任何 CPython >= 3.10 环境。
+- `dlp_io-X.Y.Z-cp310-cp310-win_amd64.whl` 至 `cp314`:pyd wheel,Windows 专用,按解释器版本区分;整个库由 Cython 编译为单个二进制 `.pyd`,功能与纯 Python 版完全一致,可按对源码保护的要求选用。
+- `dlp_io-X.Y.Z.tar.gz`:sdist 源码包。
+- `dlp_io.py` 与 `dlp_io.cp310-win_amd64.pyd` 至 `cp314`:免安装单文件,与 wheel 内容一致;直接放进项目目录(或加入 `PYTHONPATH`)即可 `import dlp_io`,适合不便使用 pip 的环境。
+
+按解释器版本选择对应资产,可直接用 pip 安装 URL。Windows PowerShell:
```powershell
-py -m pip install --index-url https://gitea.docker.antior.cn/api/packages/antior/pypi/simple dlp-io==0.1.0
+# 纯 Python 版(任意平台)
+py -m pip install https://gitea.docker.antior.cn/antior/dlp-io/releases/download/dlp-io-v0.1.0/dlp_io-0.1.0-py3-none-any.whl
+
+# pyd 版(示例为 CPython 3.13,其它版本替换文件名中的 cp313)
+py -3.13 -m pip install https://gitea.docker.antior.cn/antior/dlp-io/releases/download/dlp-io-v0.1.0/dlp_io-0.1.0-cp313-cp313-win_amd64.whl
```
-安装和下载不需要 token。上传新版本、覆盖管理和删除 Package 仍需 Gitea 凭据;发布凭据只能放在 workflow secret 中,不得写入命令行、URL 或 tracked 文件。
+也可以先从 Release 页面下载 wheel,再 `py -m pip install <文件路径>`。下载 Release 资产不需要 token;发布只能在 CI 中通过注入的 `GITHUB_TOKEN` 完成,发布凭据不得写入命令行、URL 或 tracked 文件。
调用方需要安装 `dlp-io`。白名单 Python 无需安装该包:helper 源码由调用方以 UTF-8 通过 stdin 注入,且只依赖 Python 标准库,不会在磁盘创建临时 `.py` 文件。
diff --git a/pyproject.toml b/pyproject.toml
index 3106dbf..99a4632 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,27 +7,20 @@ name = "dlp-io"
dynamic = ["version"]
description = "io-compatible file reads through an approved Python helper on Windows DLP hosts"
readme = "docs/DLP_IO_LIBRARY.md"
-requires-python = ">=3.9"
+requires-python = ">=3.10"
classifiers = [
"Development Status :: 3 - Alpha",
"Operating System :: Microsoft :: Windows",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
]
[project.urls]
Repository = "https://gitea.docker.antior.cn/antior/dlp-io"
-[tool.setuptools]
-packages = ["dlp_io"]
-include-package-data = true
-
[tool.setuptools.dynamic]
version = {attr = "dlp_io.__version__"}
-
-[tool.setuptools.package-data]
-dlp_io = ["py.typed"]
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 1978f20..bfc1f53 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,7 +1,9 @@
build
+cython
packaging
pytest
pytest-cov
+pyyaml
setuptools>=68,<82
tomli; python_version < "3.11"
wheel
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..2117e84
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,33 @@
+"""Build hook for dlp-io.
+
+Default builds (``python -m build``) stay pure Python and produce the
+``py3-none-any`` wheel declared in ``pyproject.toml``.
+
+Setting ``DLP_IO_PYD=1`` compiles the single ``dlp_io`` module into a C
+extension with Cython, producing a platform wheel (for example
+``cp313-cp313-win_amd64``) that ships one ``.pyd`` binary instead of
+the ``.py`` source.
+"""
+
+from __future__ import annotations
+
+import os
+
+from setuptools import setup
+
+_BUILD_PYD = os.environ.get("DLP_IO_PYD") == "1"
+_MODULE = "dlp_io"
+
+if _BUILD_PYD:
+ from Cython.Build import cythonize
+
+ setup(
+ ext_modules=cythonize(
+ [f"{_MODULE}.py"],
+ build_dir=os.path.join("build", "cython"),
+ compiler_directives={"language_level": "3"},
+ ),
+ py_modules=[],
+ )
+else:
+ setup(py_modules=[_MODULE])
diff --git a/tests/test_dlp_io_api.py b/tests/test_dlp_io_api.py
index 1d5b387..d5353ae 100644
--- a/tests/test_dlp_io_api.py
+++ b/tests/test_dlp_io_api.py
@@ -8,7 +8,7 @@ from pathlib import Path
import pytest
import dlp_io
-import dlp_io._api as api_module
+import dlp_io as api_module
class _FakeSession:
diff --git a/tests/test_dlp_io_packaging.py b/tests/test_dlp_io_packaging.py
index 0a56244..e92df39 100644
--- a/tests/test_dlp_io_packaging.py
+++ b/tests/test_dlp_io_packaging.py
@@ -11,6 +11,7 @@ from enum import Enum
from pathlib import Path, PurePosixPath
import pytest
+import yaml
from packaging.specifiers import SpecifierSet
from packaging.utils import canonicalize_name, parse_wheel_filename
from packaging.version import Version
@@ -19,11 +20,12 @@ import dlp_io
try:
import tomllib
-except ModuleNotFoundError: # Python 3.9-3.10
+except ModuleNotFoundError: # Python 3.10
import tomli as tomllib
PROJECT_ROOT = Path(__file__).resolve().parents[1]
+WORKFLOW_PATH = PROJECT_ROOT / ".gitea" / "workflows" / "publish-dlp-io.yaml"
class DynamicField(Enum):
@@ -38,6 +40,14 @@ class PackageClassifier(Enum):
WINDOWS = "Operating System :: Microsoft :: Windows"
+class WorkflowStep(Enum):
+ CLONE_TAG = "Clone immutable package tag"
+ TEST_MATRIX = "Test Python 3.10-3.14"
+ BUILD = "Build and check distribution"
+ SMOKE = "Smoke test release artifacts"
+ RELEASE = "Create Gitea release with wheels"
+
+
@dataclass(frozen=True)
class BuiltDistributions:
wheel: Path
@@ -49,15 +59,25 @@ def _load_pyproject() -> dict:
return tomllib.load(file)
+def _load_workflow() -> dict:
+ with WORKFLOW_PATH.open(encoding="utf-8") as file:
+ return yaml.safe_load(file)
+
+
+def _copy_source_tree(source: Path) -> None:
+ source.mkdir()
+ shutil.copy2(PROJECT_ROOT / "pyproject.toml", source)
+ shutil.copy2(PROJECT_ROOT / "MANIFEST.in", source)
+ shutil.copy2(PROJECT_ROOT / "setup.py", source)
+ shutil.copy2(PROJECT_ROOT / "dlp_io.py", source)
+ shutil.copytree(PROJECT_ROOT / "docs", source / "docs")
+
+
@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")
+ _copy_source_tree(source)
dist = root / "dist"
completed = subprocess.run(
@@ -85,10 +105,9 @@ def built_distributions(tmp_path_factory: pytest.TempPathFactory) -> BuiltDistri
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 SpecifierSet(project["requires-python"]) == SpecifierSet(">=3.10")
assert {DynamicField(item) for item in project["dynamic"]} == {
DynamicField.VERSION
}
@@ -99,10 +118,6 @@ def test_pyproject_declares_structured_public_package_contract() -> None:
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)
@@ -121,8 +136,8 @@ def test_wheel_contains_only_installable_dlp_io_runtime(
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
+ assert PurePosixPath("dlp_io.py") in members
+ assert not [item for item in members if item.suffix == ".pyd"]
member_roots = {Path(item.parts[0]) for item in members}
assert member_roots.isdisjoint({Path("tests"), Path("Lib")})
assert PurePosixPath(".env") not in members
@@ -139,9 +154,23 @@ def test_sdist_contains_package_sources_and_public_readme_only(
}
assert PurePosixPath("pyproject.toml") in members
+ assert PurePosixPath("setup.py") in members
assert PurePosixPath("docs/DLP_IO_LIBRARY.md") in members
- assert PurePosixPath("dlp_io/__init__.py") in members
+ assert PurePosixPath("dlp_io.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
+
+
+def test_publish_workflow_tests_builds_and_releases_tagged_distributions() -> None:
+ document = _load_workflow()
+ trigger = document.get("on", document.get(True))
+ assert set(trigger["push"]["tags"]) == {"dlp-io-v*.*.*"}
+ assert "workflow_dispatch" in trigger
+
+ job = document["jobs"]["publish"]
+ assert job["runs-on"] == "windows-latest"
+ assert Path(job["defaults"]["run"]["working-directory"]) == Path("repo")
+ step_names = [step["name"] for step in job["steps"]]
+ assert step_names == [step.value for step in WorkflowStep]
diff --git a/tests/test_dlp_io_session.py b/tests/test_dlp_io_session.py
index a942664..e10e523 100644
--- a/tests/test_dlp_io_session.py
+++ b/tests/test_dlp_io_session.py
@@ -18,14 +18,14 @@ from dlp_io import (
DlpSessionBusyError,
DlpTransportError,
)
-import dlp_io._session as session_module
-from dlp_io._helper import (
+import dlp_io as session_module
+from dlp_io import (
CHUNK_SIZE,
HelperErrorCode,
HelperSourceConfig,
render_helper_source,
)
-from dlp_io._raw import _PipeRawIO
+from dlp_io import _PipeRawIO
pytestmark = pytest.mark.skipif(os.name != "nt", reason="AF_PIPE requires Windows")