From a444d294b1f30590ef246a1b1389f4c4319db698 Mon Sep 17 00:00:00 2001 From: Cameron Crow Date: Thu, 18 Jun 2026 10:28:45 -0500 Subject: [PATCH] StorageAnalyzer: polish exe packaging (icon + version resource) and automate releases Turn the standalone exe from a bare, metadata-less binary built by hand into a proper, identifiable Windows artifact with a one-command release path. - storageanalyzer.spec now embeds an application icon and a Windows version resource (Properties -> Details: ProductName / version / company / copyright), both derived from storageanalyzer.__version__ so they can never drift from the package version. The version resource is generated at build time into build/. - packaging/: committed app icon (storageanalyzer.ico -- a treemap tile that matches the HTML report) plus make_icon.py to regenerate it (Pillow, build-time only; the .ico is checked in so the exe build gains no new dependency). - installer/storageanalyzer.iss + build-installer.ps1: optional per-user Inno Setup installer -- no admin, opt-in "add to PATH", registered uninstaller. Degrades gracefully to "exe only" when Inno Setup isn't installed. - .github/workflows/release.yml: build + smoke-test the exe and installer on Windows for every push/PR, and publish a GitHub Release with both attached on a v* tag. Releasing becomes: bump __version__, git tag vX.Y.Z, git push --tags. - LICENSE: add the MIT license text (pyproject already declared MIT; the installer bundles it). - README: document the icon/version metadata, the installer, and tagged releases. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 78 +++++++++++++++++++++ LICENSE | 21 ++++++ README.md | 47 ++++++++++++- build-installer.ps1 | 66 ++++++++++++++++++ installer/storageanalyzer.iss | 123 ++++++++++++++++++++++++++++++++++ packaging/make_icon.py | 68 +++++++++++++++++++ packaging/storageanalyzer.ico | Bin 0 -> 23037 bytes storageanalyzer.spec | 90 +++++++++++++++++++++++-- 8 files changed, 484 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 LICENSE create mode 100644 build-installer.ps1 create mode 100644 installer/storageanalyzer.iss create mode 100644 packaging/make_icon.py create mode 100644 packaging/storageanalyzer.ico diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..35daf01 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: build & release + +# Builds the standalone exe (and the Inno Setup installer) on Windows. On a +# version tag (v*) it publishes a GitHub Release with both attached, so cutting +# a release is just: git tag v1.0.1 && git push --tags +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tooling (PyInstaller + pybind11) + run: pip install -e ".[build]" + + - name: Build native C++ extension in place + # Reuses the env's pybind11; compile failures degrade to the pure-Python + # walker (see setup.py), so this never blocks the build. + run: pip install -e . --no-build-isolation + + - name: Build standalone exe + run: pyinstaller storageanalyzer.spec --noconfirm + + - name: Smoke-test the exe + shell: pwsh + run: | + $exe = "dist\storageanalyzer.exe" + & $exe --version + $vi = (Get-Item $exe).VersionInfo + if ($vi.ProductName -ne "StorageAnalyzer") { throw "missing version resource" } + $t = Join-Path $env:RUNNER_TEMP "satest" + New-Item -ItemType Directory -Force -Path $t | Out-Null + Set-Content -Path (Join-Path $t "f.txt") -Value ("x" * 4096) + & $exe $t --no-open -o (Join-Path $env:RUNNER_TEMP "report.html") + if (-not (Test-Path (Join-Path $env:RUNNER_TEMP "report.html"))) { throw "no report produced" } + + - name: Build installer (Inno Setup) + shell: pwsh + run: | + choco install innosetup --no-progress -y + $version = (Select-String -Path src\storageanalyzer\__init__.py ` + -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value + & "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" ` + "/DMyAppVersion=$version" installer\storageanalyzer.iss + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: storageanalyzer-windows + path: | + dist/storageanalyzer.exe + dist/StorageAnalyzer-Setup-*.exe + if-no-files-found: error + + - name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$env:GITHUB_REF_NAME" ` + dist\storageanalyzer.exe ` + (Get-ChildItem dist\StorageAnalyzer-Setup-*.exe).FullName ` + --title "StorageAnalyzer $env:GITHUB_REF_NAME" ` + --generate-notes diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bd36a9a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cameron Crow + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2e73522..5ede415 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,54 @@ pip install -e ".[build]" `build-exe.ps1` compiles the native C++ walker, then runs PyInstaller against `storageanalyzer.spec` to produce a one-file exe (~8.5 MB) with the native -walker and the HTML template bundled in. PyInstaller caches its analysis in -`build\`, so re-runs are quick — pure-Python source edits need no recompile, -only `walker.cpp` changes do. Pass `-Clean` to force a full rebuild. +walker and the HTML template bundled in. The exe is a proper Windows artifact: +it carries the application **icon** and a **version resource** (right-click → +Properties → Details shows ProductName, version, company, and copyright), both +derived from `storageanalyzer.__version__` so they never drift. PyInstaller +caches its analysis in `build\`, so re-runs are quick — pure-Python source edits +need no recompile, only `walker.cpp` changes do. Pass `-Clean` to force a full +rebuild. The exe is fully portable: copy `dist\storageanalyzer.exe` anywhere and run it. It takes the same arguments as the `storageanalyzer` command below. +### Build a Windows installer + +For a friendlier distribution — a per-user installer that adds `storageanalyzer` +to your PATH and registers an uninstaller — build the [Inno Setup](https://jrsoftware.org/isinfo.php) +package: + +```powershell +# one-time: install Inno Setup +winget install JRSoftware.InnoSetup + +# build -- produces dist\StorageAnalyzer-Setup-.exe +.\build-installer.ps1 +``` + +`build-installer.ps1` builds the exe first, then compiles +`installer\storageanalyzer.iss`. The installer needs no admin rights (installs +under `%LOCALAPPDATA%\Programs`), offers an opt-in "add to PATH" task, and shows +up in *Apps & features*. If Inno Setup isn't installed the script still builds +the exe and exits with a note — the one-file exe is shippable on its own. + +### Automated releases + +`.github/workflows/release.yml` builds the exe and the installer on Windows for +every push and PR, and **publishes a GitHub Release with both attached whenever +a version tag is pushed**: + +```powershell +git tag v1.0.1 +git push --tags +``` + +The icon and version metadata are regenerated from `__version__` on every build, +so the only thing to bump for a release is `__version__` in +`src/storageanalyzer/__init__.py` (and the matching `version` in +`pyproject.toml`). The application icon lives at `packaging/storageanalyzer.ico`; +regenerate or tweak it with `python packaging/make_icon.py` (requires Pillow). + ## Usage ```powershell diff --git a/build-installer.ps1 b/build-installer.ps1 new file mode 100644 index 0000000..c3a4092 --- /dev/null +++ b/build-installer.ps1 @@ -0,0 +1,66 @@ +<# + build-installer.ps1 -- build dist\StorageAnalyzer-Setup-.exe + + Steps: + 1. Build the standalone exe (delegates to build-exe.ps1). + 2. Compile installer\storageanalyzer.iss with Inno Setup's ISCC, stamping + the version read from storageanalyzer.__version__. + + Inno Setup is required for step 2. If ISCC.exe is not found the script builds + the exe and then exits with guidance instead of failing hard -- the one-file + exe is already a complete, shippable artifact on its own. + + Install Inno Setup: winget install JRSoftware.InnoSetup + or: choco install innosetup + + Usage: + .\build-installer.ps1 # build exe + installer + .\build-installer.ps1 -Clean # force a full exe rebuild first +#> +param([switch]$Clean) + +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot + +# --- 1. build the exe ------------------------------------------------------ +& "$PSScriptRoot\build-exe.ps1" @(if ($Clean) { "-Clean" }) +if ($LASTEXITCODE -ne 0) { Write-Host "exe build failed" -ForegroundColor Red; exit 1 } + +# --- version (single source of truth: __init__.py __version__) ------------- +$initPy = Join-Path $PSScriptRoot "src\storageanalyzer\__init__.py" +$m = Select-String -Path $initPy -Pattern '__version__\s*=\s*["'']([^"'']+)["'']' +$version = $m.Matches[0].Groups[1].Value +if (-not $version) { Write-Host "could not read __version__" -ForegroundColor Red; exit 1 } + +# --- 2. locate ISCC -------------------------------------------------------- +$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source +if (-not $iscc) { + foreach ($p in @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "$env:ProgramFiles\Inno Setup 6\ISCC.exe")) { + if (Test-Path $p) { $iscc = $p; break } + } +} +if (-not $iscc) { + Write-Host "" + Write-Host "Inno Setup (ISCC.exe) not found -- skipping the installer." -ForegroundColor Yellow + Write-Host "The standalone exe is ready at dist\storageanalyzer.exe." -ForegroundColor Yellow + Write-Host "To build the installer, install Inno Setup and re-run:" -ForegroundColor Yellow + Write-Host " winget install JRSoftware.InnoSetup" -ForegroundColor Yellow + exit 0 +} + +# --- 3. compile the installer ---------------------------------------------- +Write-Host "Compiling installer (version $version) with $iscc ..." -ForegroundColor Cyan +& $iscc "/DMyAppVersion=$version" "$PSScriptRoot\installer\storageanalyzer.iss" +if ($LASTEXITCODE -ne 0) { Write-Host "ISCC failed" -ForegroundColor Red; exit 1 } + +$setup = Join-Path $PSScriptRoot "dist\StorageAnalyzer-Setup-$version.exe" +if (Test-Path $setup) { + $size = "{0:N1} MB" -f ((Get-Item $setup).Length / 1MB) + Write-Host "" + Write-Host "Built: $setup ($size)" -ForegroundColor Green +} else { + Write-Host "ISCC reported success but $setup was not found." -ForegroundColor Red + exit 1 +} diff --git a/installer/storageanalyzer.iss b/installer/storageanalyzer.iss new file mode 100644 index 0000000..e7f4bf3 --- /dev/null +++ b/installer/storageanalyzer.iss @@ -0,0 +1,123 @@ +; Inno Setup script for StorageAnalyzer. +; +; Wraps the standalone dist\storageanalyzer.exe in a per-user installer that +; * installs to %LOCALAPPDATA%\Programs\StorageAnalyzer (no admin needed), +; * optionally adds that folder to the user PATH so `storageanalyzer` works +; from any terminal, +; * registers a proper uninstaller (Apps & features), +; * stamps publisher / version metadata. +; +; The version is injected by build-installer.ps1 from storageanalyzer.__version__; +; the default below is only used if you run ISCC by hand. +; +; Build: ISCC.exe /DMyAppVersion=1.0.0 installer\storageanalyzer.iss +; Or just: .\build-installer.ps1 (builds the exe first, then this) + +#ifndef MyAppVersion + #define MyAppVersion "1.0.0" +#endif + +#define MyAppName "StorageAnalyzer" +#define MyAppPublisher "Cameron Crow" +#define MyAppURL "https://github.com/CameronCrow/StorageAnalyzer" +#define MyAppExeName "storageanalyzer.exe" + +[Setup] +; A stable AppId keeps upgrades/uninstall coherent across versions -- do not change. +AppId={{75FD88CC-FE15-46C9-894F-5A5CABD9E1A5} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL}/releases +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +DisableDirPage=auto +; Per-user install -- no UAC prompt, installs under %LOCALAPPDATA%\Programs. +PrivilegesRequired=lowest +ChangesEnvironment=yes +LicenseFile=..\LICENSE +OutputDir=..\dist +OutputBaseFilename=StorageAnalyzer-Setup-{#MyAppVersion} +SetupIconFile=..\packaging\storageanalyzer.ico +UninstallDisplayIcon={app}\{#MyAppExeName} +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "addtopath"; Description: "Add StorageAnalyzer to my PATH (run ""storageanalyzer"" from any terminal)"; Flags: checkedonce + +[Files] +Source: "..\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\{#MyAppName} on GitHub"; Filename: "{#MyAppURL}" +Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}" + +[Code] +const + EnvironmentKey = 'Environment'; + +procedure EnvAddPath(Path: string); +var + Paths: string; +begin + { Read the existing user PATH (empty if unset). } + if not RegQueryStringValue(HKEY_CURRENT_USER, EnvironmentKey, 'Path', Paths) then + Paths := ''; + + { Skip if already present (delimited, case-insensitive). } + if Pos(';' + Uppercase(Path) + ';', ';' + Uppercase(Paths) + ';') > 0 then + exit; + + { Append, normalising the trailing delimiter. } + if Paths = '' then + Paths := Path + else if Copy(Paths, Length(Paths), 1) = ';' then + Paths := Paths + Path + else + Paths := Paths + ';' + Path; + + if RegWriteStringValue(HKEY_CURRENT_USER, EnvironmentKey, 'Path', Paths) then + Log('PATH: added ' + Path) + else + Log('PATH: FAILED to add ' + Path); +end; + +procedure EnvRemovePath(Path: string); +var + Paths: string; + P: Integer; +begin + if not RegQueryStringValue(HKEY_CURRENT_USER, EnvironmentKey, 'Path', Paths) then + exit; + + P := Pos(';' + Uppercase(Path) + ';', ';' + Uppercase(Paths) + ';'); + if P = 0 then + exit; + + Delete(Paths, P - 1, Length(Path) + 1); + RegWriteStringValue(HKEY_CURRENT_USER, EnvironmentKey, 'Path', Paths); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if (CurStep = ssPostInstall) and WizardIsTaskSelected('addtopath') then + EnvAddPath(ExpandConstant('{app}')); +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usPostUninstall then + EnvRemovePath(ExpandConstant('{app}')); +end; diff --git a/packaging/make_icon.py b/packaging/make_icon.py new file mode 100644 index 0000000..7494e1b --- /dev/null +++ b/packaging/make_icon.py @@ -0,0 +1,68 @@ +"""Generate ``packaging/storageanalyzer.ico`` -- the application icon. + +The icon is a stylised squarified treemap (the same visual the HTML report +draws): a rounded tile partitioned into a few nested rectangles. It is checked +in as a binary so the exe build never depends on Pillow; this script exists only +to regenerate / tweak it. + + python packaging/make_icon.py # rewrites packaging/storageanalyzer.ico + +Requires Pillow (``pip install pillow``). +""" + +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageDraw + +# Palette -- a cool blue/teal treemap on a dark rounded tile. +_BG = (24, 28, 38, 255) +_BORDER = (15, 18, 26, 255) +_TILES = [ + # (x0, y0, x1, y1, fill) -- in a 0..1 unit square, drawn largest-first + (0.06, 0.06, 0.60, 0.62, (56, 132, 222, 255)), # big blue block + (0.62, 0.06, 0.94, 0.40, (88, 196, 214, 255)), # teal + (0.62, 0.42, 0.94, 0.62, (122, 162, 247, 255)), # periwinkle + (0.06, 0.64, 0.36, 0.94, (94, 214, 168, 255)), # green + (0.38, 0.64, 0.60, 0.94, (240, 190, 92, 255)), # amber + (0.62, 0.64, 0.94, 0.94, (233, 116, 122, 255)), # red +] + + +def _render(size: int) -> Image.Image: + """Render the icon at ``size`` x ``size`` px with a rounded background.""" + # Supersample for crisp edges, then downscale. + scale = 4 + s = size * scale + img = Image.new("RGBA", (s, s), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + + radius = int(s * 0.18) + d.rounded_rectangle([0, 0, s - 1, s - 1], radius=radius, fill=_BG, + outline=_BORDER, width=max(1, int(s * 0.012))) + + gap = s * 0.012 + tile_r = max(1, int(s * 0.04)) + for x0, y0, x1, y1, fill in _TILES: + box = [ + x0 * s + gap, y0 * s + gap, + x1 * s - gap, y1 * s - gap, + ] + d.rounded_rectangle(box, radius=tile_r, fill=fill) + + return img.resize((size, size), Image.LANCZOS) + + +def main() -> None: + out = Path(__file__).with_name("storageanalyzer.ico") + sizes = [16, 24, 32, 48, 64, 128, 256] + frames = [_render(n) for n in sizes] + # Pillow writes a proper multi-resolution .ico from the largest frame + + # the explicit sizes list. + frames[-1].save(out, format="ICO", sizes=[(n, n) for n in sizes]) + print(f"wrote {out} ({out.stat().st_size:,} bytes, sizes={sizes})") + + +if __name__ == "__main__": + main() diff --git a/packaging/storageanalyzer.ico b/packaging/storageanalyzer.ico new file mode 100644 index 0000000000000000000000000000000000000000..92957f4f1efe8d6ddddd3ffd8cdf6c8087c1ab51 GIT binary patch literal 23037 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 RDqT%kuI^2<)c?c={|j%=RD}Ql literal 0 HcmV?d00001 diff --git a/storageanalyzer.spec b/storageanalyzer.spec index adb2443..ccd6484 100644 --- a/storageanalyzer.spec +++ b/storageanalyzer.spec @@ -1,15 +1,91 @@ -# PyInstaller build spec -- produces a one-file Windows console exe. +# PyInstaller build spec -- produces a polished one-file Windows console exe. # # Build: pyinstaller storageanalyzer.spec --noconfirm # Or just run build-exe.ps1, which (re)builds the native extension first. +# +# Beyond bundling the code, this spec gives the exe a real Windows identity: +# * an application icon (packaging/storageanalyzer.ico), and +# * a version resource (Properties -> Details: ProductName, version, company, +# copyright, ...) generated from storageanalyzer.__version__ so it can never +# drift from the package version. import glob +import re +from pathlib import Path + +# --- single source of truth: the package __version__ ----------------------- +# Parsed (not imported) so building the version resource never has to import the +# package or its native extension. +_init = Path("src/storageanalyzer/__init__.py").read_text(encoding="utf-8") +_m = re.search(r"""__version__\s*=\s*["']([^"']+)["']""", _init) +_version = _m.group(1) if _m else "0.0.0" +# Windows file/product version wants a 4-int tuple; pad/truncate from the parts. +_nums = (re.findall(r"\d+", _version) + ["0", "0", "0", "0"])[:4] +_vtuple = tuple(int(n) for n in _nums) + +# --- Windows version resource ---------------------------------------------- +# Written to build/ (git-ignored) each build and handed to EXE(version=...). +from PyInstaller.utils.win32.versioninfo import ( # noqa: E402 + FixedFileInfo, + StringFileInfo, + StringStruct, + StringTable, + VarFileInfo, + VarStruct, + VSVersionInfo, +) + +_version_info = VSVersionInfo( + ffi=FixedFileInfo( + filevers=_vtuple, + prodvers=_vtuple, + mask=0x3F, + flags=0x0, + OS=0x40004, # VOS_NT_WINDOWS32 + fileType=0x1, # VFT_APP + subtype=0x0, + date=(0, 0), + ), + kids=[ + StringFileInfo([ + StringTable( + "040904B0", # U.S. English, Unicode + [ + StringStruct("CompanyName", "Cameron Crow"), + StringStruct( + "FileDescription", + "Fast parallel Windows storage analyzer " + "with an interactive HTML report", + ), + StringStruct("FileVersion", _version), + StringStruct("InternalName", "storageanalyzer"), + StringStruct( + "LegalCopyright", "Cameron Crow. MIT License." + ), + StringStruct("OriginalFilename", "storageanalyzer.exe"), + StringStruct("ProductName", "StorageAnalyzer"), + StringStruct("ProductVersion", _version), + ], + ) + ]), + VarFileInfo([VarStruct("Translation", [0x0409, 1200])]), + ], +) + +Path("build").mkdir(exist_ok=True) +_version_file = Path("build/version_info.txt") +_version_file.write_text(str(_version_info), encoding="utf-8") + +# --- application icon (optional) ------------------------------------------- +_icon = "packaging/storageanalyzer.ico" +_icon = _icon if Path(_icon).is_file() else None -# The native C++ walker is optional. If it was built in place (by -# `pip install -e .` or build-exe.ps1) bundle the .pyd and register it as a -# hidden import -- scan.py imports it lazily inside a try/except, so -# PyInstaller's static analysis would otherwise miss it. If it was never -# built, the exe still works on the pure-Python walker. +# --- native walker (optional) ---------------------------------------------- +# If the native C++ walker was built in place (by `pip install -e .` or +# build-exe.ps1) bundle the .pyd and register it as a hidden import -- scan.py +# imports it lazily inside a try/except, so PyInstaller's static analysis would +# otherwise miss it. If it was never built, the exe still works on the +# pure-Python walker. _pyd = glob.glob("src/storageanalyzer/_native_walker*.pyd") binaries = [(p, "storageanalyzer") for p in _pyd] hiddenimports = ["storageanalyzer._native_walker"] if _pyd else [] @@ -33,6 +109,8 @@ exe = EXE( a.datas, [], name="storageanalyzer", + icon=_icon, + version=str(_version_file), debug=False, bootloader_ignore_signals=False, strip=False,