A practitioner's guide to shipping PDF tools and automation to non-technical users. Lessons from three years of production tooling at a legal process serving company.
The Setup: Why This Matters
I've been building PDF tools for a legal process serving company for three years. The most valuable lesson didn't come from Python—it came from learning that packaged tools are infrastructure, not applications. The difference is profound.
Most tutorials teach you to write a Python script and hand it to PyInstaller. This post teaches you something different: how internal tools actually ship in production, what trade-offs matter, and why the boring build script is where the real engineering lives.
Last year, we shipped a batch PDF page extractor. Nothing fancy—extract the last page of a PDF, name it consistently, process an entire folder in one click. Our filing staff use it dozens of times daily.
Here's what surprised me: the code quality barely matters. The build automation, UX design, and reproducibility matter infinitely more. A tool with mediocre code that ships, iterates, and doesn't break is better than perfect code that doesn't exist.
This post walks through the real decisions: library choices with tradeoffs, packaging alternatives with honest pros/cons, and build infrastructure that scales from one tool to fifty.
Part 1: Choosing Your PDF Library — The Tradeoff Matrix
Most posts gloss over this. They'll say "use PyMuPDF" and move on. But for production tools, library choice cascades into build size, speed, and compatibility. Let's actually compare.
PyMuPDF (fitz)
import fitz
doc = fitz.open("file.pdf")
new_doc = fitz.open()
new_doc.insert_pdf(doc, from_page=5, to_page=5)
new_doc.save("output.pdf")
✓ Pros:
- Fastest page extraction (C backend via SWIG)
- No re-encoding—copies raw page bytes, preserves quality
- Handles complex PDFs (annotations, forms, transparencies) better than pure Python
- Lowest memory footprint for large files
✗ Cons:
- ~25 MB added to .exe (largest of all options)
- Commercial licensing required for some use cases (read the terms for your scenario)
- Smaller community than PyPDF2
Best for: Batch processing, large files, production tools where speed/reliability > size
PyPDF2
from PyPDF2 import PdfReader, PdfWriter
reader = PdfReader("file.pdf")
writer = PdfWriter()
writer.add_page(reader.pages[0])
writer.write("output.pdf")
✓ Pros:
- Pure Python, no native dependencies
- Adds only ~3 MB to .exe
- Permissive BSD license
- Large, well-documented community
✗ Cons:
- Slower on large files (pages are re-encoded in Python)
- Poor handling of complex PDFs
- Higher memory use
- Recent versions have performance regression issues
Best for: Simple PDFs, license-conscious projects, minimal file size
pdfplumber
import pdfplumber
with pdfplumber.open("file.pdf") as pdf:
page = pdf.pages[0]
# Good for extraction/analysis, not for rewriting
✓ Pros:
- Designed for text/table extraction (excels here)
- Built on pdfminer.six + PyPDF2
- Cleaner API for reading
✗ Cons:
- Not meant for page manipulation
- Slower than PyMuPDF for batch operations
- Adds ~10 MB to .exe
Best for: Data extraction, not page manipulation
Nuitka (Compilation, Not a Library)
Some will suggest compiling the entire thing with Nuitka instead of PyInstaller. Don't. For internal tools:
- Nuitka compilation takes 20–60 seconds per build (vs. 5 seconds for PyInstaller)
- Final binary is only 5–10% smaller (marginal)
- Debugging is harder
- Not worth it unless you're shipping to thousands of users
Verdict: Use PyMuPDF for production tools. The 25 MB is negligible on modern systems, and the speed/reliability gain is real.
Here's a production-ready extractor:
#!/usr/bin/env python3
"""
last_page_extractor.py — Extract last pages from PDFs in bulk.
Modes:
• Double-click in folder with PDFs → processes all .pdf files
• Drag-drop files → processes only those files
• Command line → same as drag-drop
"""
import sys
from pathlib import Path
try:
import fitz
except ImportError:
print("ERROR: pymupdf not installed. Install with: pip install pymupdf")
sys.exit(1)
def extract_last_page(input_pdf: str) -> bool:
"""Extract last page. Return True if successful."""
input_path = Path(input_pdf)
if not input_path.exists():
print(f" ✗ Not found: {input_pdf}")
return False
if not input_path.suffix.lower() == ".pdf":
print(f" ✗ Not PDF: {input_pdf}")
return False
try:
doc = fitz.open(input_pdf)
page_count = len(doc)
if page_count == 0:
print(f" ✗ Empty: {input_pdf}")
doc.close()
return False
# Extract last page
new_doc = fitz.open()
new_doc.insert_pdf(doc, from_page=page_count - 1, to_page=page_count - 1)
# Output: consistent naming
output_path = input_path.parent / f"{input_path.stem}_last_page.pdf"
new_doc.save(output_path)
new_doc.close()
doc.close()
print(f" ✓ {input_path.name} ({page_count} pages)")
return True
except Exception as e:
print(f" ✗ Error: {input_pdf}: {e}")
return False
def process_folder() -> None:
"""Batch mode: scan folder for all PDFs."""
cwd = Path.cwd()
pdf_files = sorted(cwd.glob("*.pdf"))
if not pdf_files:
print(f"No PDFs found in: {cwd}")
input("Press Enter to exit...")
return
print(f"\nFound {len(pdf_files)} PDF(s). Processing...\n")
success = sum(1 for f in pdf_files if extract_last_page(str(f)))
print(f"\n✓ {success}/{len(pdf_files)} successful")
input("Press Enter to exit...")
def process_files(files: list) -> None:
"""Drag-drop mode: process specific files."""
print(f"\nProcessing {len(files)} file(s)...\n")
success = sum(1 for f in files if extract_last_page(f))
print(f"\n✓ {success}/{len(files)} successful")
if __name__ == "__main__":
if len(sys.argv) == 1:
process_folder()
else:
process_files(sys.argv[1:])
Design decisions that matter:
- Return boolean, not exit on error — Allows batch processing to continue when one file fails
- Consistent naming —
_last_page suffix is predictable (staff can glob these later)
- Two modes baked in — Same binary, context-aware behavior
- Output format — Checkmarks + filename + page count. Users see what happened.
- Pause at end —
input("Press Enter...") is intentional. Windows closes console windows instantly; this gives users a moment to see results.
Part 3: Packaging Alternatives (Beyond PyInstaller)
Everyone reaches for PyInstaller. Here's why it's right for this use case—and when it isn't.
PyInstaller (Our Choice)
pyinstaller --onefile --console last_page_extractor.py
# Creates: dist/last_page_extractor.exe (~35 MB)
# Build time: ~5 seconds
Trade-offs:
- Size: 35 MB (PyMuPDF + Python + runtime)
- Speed: Instant startup for small tools, 2–3 sec for complex ones
- Debuggability: Can inspect .exe with
--debug all if needed
- Compatibility: Works on Windows 7+; no C++ redistributables needed
When to use: Internal tools, small teams, iterative development. Build automation is frictionless.
cx_Freeze
cxfreeze --target-dir dist last_page_extractor.py
# Creates: dist/last_page_extractor.exe + libraries/ folder
Trade-offs:
- Size: Smaller .exe (~20 MB) but requires library folder
- Distribution: Users get a folder, not one file (friction)
- Build time: 8–12 seconds
- Debuggability: Less mature than PyInstaller
Verdict: Never, honestly. The folder distribution kills the benefit of smaller size.
Auto-py-to-exe
This is a GUI wrapper around PyInstaller. Don't use for automation.
Why? You can't script it, you can't version it, you can't integrate it into CI/CD. Build infrastructure needs to be reproducible code.
Nuitka (Honest Assessment)
python -m nuitka --onefile last_page_extractor.py
# Creates: last_page_extractor.exe (~28 MB)
# Build time: 45–120 seconds (!!)
Trade-offs:
- Size: 5–10% smaller (~28 MB vs. 35 MB)
- Speed: Compiled Python is faster, but doesn't matter for this workload
- Build time: 10–20x slower than PyInstaller
- Debugging: Harder to debug; less community resources
Verdict: Not worth it. The 7 MB savings costs you 100+ extra seconds per build. On your 50th build, you've wasted 1.5 hours.
Recommendation: Use PyInstaller for anything under 100 MB final size or under 10 seconds build time. If you hit that limit, reconsider your approach—probably your tool is doing too much.
Part 4: Build Automation (The Real Infrastructure)
Here's the honest truth: the Python code is 20% of shipping. The build script is 80%.
Most tutorials ignore this. They'll show you pyinstaller --onefile and say "now you have an exe." They don't mention:
- What Python version the user has installed
- Whether PyInstaller is in their PATH
- Whether they're on a network drive (UNC paths break PyInstaller)
- What happens when their venv is stale
- How to version builds
- How to handle rebuilds without pollution
Here's a production build script:
<#
.SYNOPSIS
Build last_page_extractor.py → last_page_extractor.exe
.DESCRIPTION
Handles venv creation, dependency installation, PyInstaller build.
Detects Python variant (py, python, python3).
Handles UNC paths by staging to %LOCALAPPDATA%.
Creates dist/last_page_extractor.exe.
#>
$ErrorActionPreference = "Stop"
$scriptDir = $PSScriptRoot
# ── UNC path handling (network drives cause PyInstaller to fail) ──────────
$isUNC = $scriptDir -match '^\\\\' -or $scriptDir -match '^//'
if ($isUNC) {
$stageDir = Join-Path $env:LOCALAPPDATA "lpe_build_berman"
Write-Host "[UNC Path] Staging to local: $stageDir"
if (Test-Path $stageDir) { Remove-Item $stageDir -Recurse -Force }
Copy-Item $scriptDir $stageDir -Recurse
Set-Location $stageDir
} else {
Set-Location $scriptDir
}
# ── Find Python (works with py, python, python3) ───────────────────────
$py = $null
foreach ($candidate in @("py -3", "python", "python3")) {
try {
$ver = & ([scriptblock]::Create("$candidate --version")) 2>&1
if ($ver -match "Python 3\.([8-9]|1[0-9])") {
$py = $candidate
break
}
} catch {}
}
if (-not $py) {
Write-Host "ERROR: Python 3.8+ not found" -ForegroundColor Red
exit 1
}
Write-Host "Python: $py ($ver)"
# ── Clean old build artifacts ─────────────────────────────────────────
if (Test-Path "build") { Remove-Item "build" -Recurse -Force }
if (Test-Path "dist") { Remove-Item "dist" -Recurse -Force }
if (Test-Path ".venv") { Remove-Item ".venv" -Recurse -Force }
# ── Create venv ───────────────────────────────────────────────────────
Write-Host "Creating venv..."
& ([scriptblock]::Create("$py -m venv .venv"))
$pip = Join-Path ".venv" "Scripts\pip.exe"
$pyVenv = Join-Path ".venv" "Scripts\python.exe"
# ── Install dependencies ─────────────────────────────────────────────
Write-Host "Installing dependencies..."
& $pip install --upgrade pip --quiet
& $pip install pymupdf pyinstaller --quiet
if ($LASTEXITCODE -ne 0) {
Write-Host "Dependency installation failed" -ForegroundColor Red
exit 1
}
# ── Build with PyInstaller ────────────────────────────────────────────
Write-Host "Building exe..."
& $pyVenv -m PyInstaller `
--onefile `
--console `
--name last_page_extractor `
--clean `
last_page_extractor.py
if ($LASTEXITCODE -ne 0) {
Write-Host "PyInstaller build failed" -ForegroundColor Red
exit 1
}
# ── Verify and copy back ──────────────────────────────────────────────
$exe = Join-Path "dist" "last_page_extractor.exe"
if (-not (Test-Path $exe)) {
Write-Host "ERROR: .exe not found at $exe" -ForegroundColor Red
exit 1
}
Write-Host "SUCCESS: Built $exe" -ForegroundColor Green
if ($isUNC) {
Copy-Item $exe (Join-Path $scriptDir "last_page_extractor.exe") -Force
Write-Host "Copied to: $scriptDir\last_page_extractor.exe"
}
Write-Host "`nBuild complete. Ready to ship."
Wrap in a .cmd for non-technical users:
@echo off
REM BuildExe.cmd — double-click to build
powershell -ExecutionPolicy Bypass -File "%~dp0build_exe.ps1"
pause
Why this script matters:
- Python detection — Works whether user has py, python, or python3
- UNC path handling — Network drives fail with PyInstaller; script detects and stages locally
- Clean builds — Removes old artifacts each time (prevents stale builds)
- Version check — Requires Python 3.8+ (PyMuPDF requirement)
- Error handling — Fails loudly if any step breaks
- One-click for users —
BuildExe.cmd is trivial to run
The script is 50 lines. The gain is automatic rebuilds without friction. That's the real shipping.
Part 5: Learnings from Three Years of Shipping
1. Batch UX > Code Quality
A mediocre tool that processes a whole folder beats a beautiful tool that only works on one file. Users don't care about your code. They care about not doing it manually.
2. The Build Script is Your Insurance Policy
When you find a bug, you fix the Python file. Your build script proves it works reproducibly. In three years, we've shipped bug fixes in <10 minutes because the build was automated. Without it? 30+ minutes of manual setup each time.
3. Console Output is Richer Than You Think
We use --console (not --windowed) specifically so staff see output. Checkmarks (✓) vs crosses (✗) are scannable. File counts are proof the tool worked. input("Press Enter...") at the end is the pause button—Windows closes console windows instantly; you need to force it to stay.
4. Venv Per Build, Not Shared
Some shops keep a single venv and rebuild from it. Don't. Create fresh each time:
- 5 extra seconds
- Guarantees clean isolation
- Catches hidden dependency issues
- Avoids "works on mine" errors
5. PyInstaller + Fresh Venv Beats All Alternatives
We considered Nuitka, cx_Freeze, even .NET (C#). PyInstaller wins because:
- Simplest to automate
- Fastest builds (under 10 seconds)
- Easiest to debug
- Smallest mental overhead
Optimize elsewhere. Not here.
6. Output Naming is Part of UX
<name>_last_page.pdf is predictable. Staff know what happened. If you shipped output_1.pdf, they'd hate you. Boring naming is invisible—that's good.
7. Two-Mode UX (Folder + Files) is Invisible
Same binary, no config, no menus. Double-click → scan folder. Drag files → process those. The code detects context (sys.argv length) and does the right thing. Users see one tool with two behaviors. This is underrated engineering.
We've shipped 12+ tools on this foundation:
- Batch PDF splitters (first page, last page, even pages, ranges)
- Missouri county filing fee lookups (CSV + web scraping)
- Case.net metadata extractors (Selenium + PDF parsing)
- Court PACER integrations
- File renaming utilities
The pattern:
- First tool: invest in build script (one-time cost)
- Second tool: copy script, change filename
- Tenth tool: you have a template; new tools are 1–2 hours of work
We've paid off the build script investment 12x over in dev time saved.
The Complete Package
You need three files:
# (code from Part 2 above)
build_exe.ps1 (60 lines)
# (script from Part 4 above)
BuildExe.cmd (4 lines)
@echo off
powershell -ExecutionPolicy Bypass -File "%~dp0build_exe.ps1"
pause
Workflow
- Double-click
BuildExe.cmd
- Wait 10 seconds
- Get
dist/last_page_extractor.exe
- Give to staff
- They double-click it in a folder of PDFs
- All last pages extract automatically
- They see results; they know it worked
No terminal knowledge required anywhere.
Conclusion: Boring Infrastructure Wins
The software development world obsesses over code elegance, design patterns, and frameworks. But shipping tools to non-technical users teaches you something different: boring infrastructure beats clever code.
A well-built tool with mediocre Python code ships faster, iterates faster, and scales better than a beautifully architected tool with no build automation.
This is what separates teams that ship one thing and declare victory from teams that ship a suite of tools.
The formula: Write the code. Automate the build. Iterate without friction. That's how you build infrastructure instead of toys.
What's your experience shipping tools to non-technical users? Where does your build automation fail?