I've spent the last few years building internal tools for a legal process serving company. Some of them are Python scripts that only I use. Others are tools that filing staff need to run daily. Those tools taught me something: the easiest Python script is worthless if it requires the user to open a terminal.
This post is about the complete pipeline: writing Python tools for PDF manipulation, packaging them as standalone .exe files, and automating the build process so you can iterate without friction.
Let's build a real tool: a batch PDF page extractor that processes an entire folder of documents when you double-click it.
The Problem: From Manual to Bulk
Early on, we built a tool to extract the first page of PDFs—useful for forms processing. It worked fine as a Python script if you dragged a file onto it. But then the ask came: "Can it process a whole folder at once?"
That simple question exposed the real challenge: batch processing and user experience are inseparable. A tool that works on one file is a toy. A tool that scans a folder, processes everything, and shows results? That's infrastructure.
Part 1: Writing the Tool (PyMuPDF)
Let's start with a simple but practical example: extracting the last page of PDFs. You could use PyPDF2 or pdfplumber, but PyMuPDF (fitz) is faster, lighter, and handles edge cases better.
Here's the core:
import fitz # PyMuPDF
from pathlib import Path
def extract_last_page(input_pdf: str) -> bool:
input_path = Path(input_pdf)
if not input_path.exists():
print(f" ✗ Not found: {input_pdf}")
return False
try:
doc = fitz.open(input_pdf)
page_count = len(doc)
if page_count == 0:
print(f" ✗ No pages in: {input_pdf}")
doc.close()
return False
# Create new PDF with only the last page
new_doc = fitz.open()
last_page_num = page_count - 1
new_doc.insert_pdf(doc, from_page=last_page_num, to_page=last_page_num)
# Save as <name>_last_page.pdf
stem = input_path.stem
output_name = f"{stem}_last_page.pdf"
output_path = input_path.parent / output_name
new_doc.save(output_path)
new_doc.close()
doc.close()
print(f" ✓ {input_path.name} (page {page_count})")
return True
except Exception as e:
print(f" ✗ {input_pdf}: {e}")
return False
Why this approach:
-
fitz.open()is fast and memory-efficient -
insert_pdf()copies pages without re-encoding—critical for large PDFs - Return boolean so caller knows if it succeeded
- Descriptive output (checkmarks, file names) for user feedback
Part 2: The UX Layer—Folder Scanning
Here's where we separate "works for me" from "works for staff":
from pathlib import Path
def process_folder() -> None:
"""Scan current folder for PDFs and process all."""
cwd = Path.cwd()
pdf_files = sorted(cwd.glob("*.pdf"))
if not pdf_files:
print(f"\nNo PDF files found in: {cwd}")
input("Press Enter to exit...")
return
print(f"\nFound {len(pdf_files)} PDF file(s)")
print(f"Processing...\n")
success = 0
for pdf_file in pdf_files:
if extract_last_page(str(pdf_file)):
success += 1
print(f"\n✓ Processed: {success}/{len(pdf_files)} files")
input("Press Enter to exit...")
Key design decisions:
-
Path.glob("*.pdf")finds all PDFs in the current directory—no config needed -
sorted()ensures predictable order (matters when staff review output) - Two usage modes:
- No args: scan folder (double-click scenario)
- Args: process dropped files (drag-drop scenario)
-
input("Press Enter...")pauses so users see the results—critical on Windows where console windows close instantly
The main script branches based on arguments:
if __name__ == "__main__":
if len(sys.argv) == 1:
process_folder() # Double-click: scan & process all
else:
process_arguments(sys.argv[1:]) # Drag-drop: process these
This is invisible complexity. Users see one tool with two behaviors. The code knows which one from context.
Part 3: Building the .exe—PyInstaller
Now the hard part: getting a Python script into the hands of non-technical users.
PyInstaller converts Python + dependencies into a standalone executable. No Python installation needed. One file, double-click, done.
pip install pyinstaller pymupdf
pyinstaller --onefile --console last_page_extractor.py
This creates dist/last_page_extractor.exe—a fully self-contained binary.
Why --onefile:
- Users get one file, not a folder of DLLs
- No "where do I put this" confusion
- Simpler to version and distribute
Why --console:
- Shows output (checkmarks, error messages, file count)
- Windows habit: console window closes when done, giving users a natural "exit" moment
- Staff can screenshot the output for auditing
Part 4: Automating the Build (The Real Lesson)
Here's where most tutorials stop. Here's where the real work begins.
The problem: Every time you fix a bug or add a feature, rebuilding is friction. Create venv, install deps, run PyInstaller, manage output. One extra step and you stop iterating.
The solution: A PowerShell script that does all of it.
$ErrorActionPreference = "Stop"
$scriptDir = $PSScriptRoot
# 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") { $py = $candidate; break }
} catch {}
}
if (-not $py) {
Write-Host "ERROR: Python 3 not found" -ForegroundColor Red
exit 1
}
# Create venv & install
Write-Host "Creating venv..."
& ([scriptblock]::Create("$py -m venv .venv"))
$pip = Join-Path ".venv" "Scripts\pip.exe"
$pyVenv = Join-Path ".venv" "Scripts\python.exe"
Write-Host "Installing dependencies..."
& $pip install pymupdf pyinstaller --quiet
# Build
Write-Host "Building .exe..."
& $pyVenv -m PyInstaller --onefile --console --name last_page_extractor last_page_extractor.py
$exe = Join-Path "dist" "last_page_extractor.exe"
if (Test-Path $exe) {
Write-Host "SUCCESS: $exe" -ForegroundColor Green
} else {
Write-Host "ERROR: .exe not found" -ForegroundColor Red
exit 1
}
Wrap it in a .cmd file for Windows users:
@echo off
powershell -ExecutionPolicy Bypass -File "%~dp0build_exe.ps1"
pause
Users (or you) just double-click BuildExe.cmd and walk away. No terminal knowledge required.
Why this matters:
- Removes friction: Bug found? Fix Python file, double-click build script, done. New .exe is ready.
-
Handles edge cases: The script finds Python (whether installed as
py,python, orpython3), creates a fresh venv each time, avoids dependency conflicts. -
UNC path handling: If you're building from a network share, the script stages to
%LOCALAPPDATA%(Windows won't run PyInstaller from UNC paths). Users never see this. - Reproducible: Every build starts fresh. No "works on my machine" surprises.
Part 5: Learnings & Best Practices
1. Two-Mode UX is Invisible Complexity
Your tool shouldn't make users think about how to use it. Detect the context (folder vs. files) and do the right thing. This is the difference between "a tool that works" and "a tool people actually use."
2. Output Matters More Than You Think
Console output is user feedback. Checkmarks (✓) vs. crosses (✗) are scannable. File names in output let users verify what happened. input("Press Enter...") is the pause button—users won't miss the result window closing.
3. PyInstaller is Fast Enough
You'll hear "PyInstaller is slow, use Nuitka." For CLI tools that run occasionally, it doesn't matter. A 2-3 second startup is fine. The complexity of Nuitka isn't worth it unless you're shipping a game or real-time app.
4. Venv Per Build, Not Shared
Some scripts reuse a venv. Don't. Create fresh each time. It adds 5 seconds, guarantees clean builds, and avoids "works on mine" errors from stale packages.
5. Build Script Ownership is Underrated
Teams ship code. Great teams ship with build infrastructure. A 50-line PowerShell script that removes friction for future you is worth more than a perfect Python module. Invest in it.
6. Console vs. GUI is a UX Choice, Not a Technical One
We use --console because staff can see results and know when to close the window. For a GUI tool, use --windowed. But don't add GUI complexity unless users need it. A good console tool beats a mediocre GUI every time.
7. Name Your Outputs Consistently
<basename>_last_page.pdf is predictable. Staff can tell at a glance what happened. Avoid generic names like output.pdf or temp_1.pdf. Your naming scheme is part of the UX.
Part 6: Scaling This Approach
This pattern scales to dozens of tools:
- First tool: Do it right. Spend time on the build script.
- Second tool: Copy the build script, change the filename. You've already paid the setup cost.
- Tenth tool: You have a repeatable template. New tools are hours, not days.
We've built:
- Batch PDF splitters (first page, last page, even pages, ranges)
- Court filing fee lookups (CSV processing)
- Case metadata extraction (PDF + web scraping)
- File renaming utilities
All built on the same foundation: Python + PyMuPDF or similar, PyInstaller, and PowerShell build automation.
The Complete Example
Here's what you need:
last_page_extractor.py — The tool (100 lines of clear Python)
build_exe.ps1 — The build script (50 lines, copy-paste for new projects)
BuildExe.cmd — The launcher (2 lines, so non-technical users can build)
Put all three in a folder. Double-click BuildExe.cmd. Get last_page_extractor.exe in dist/.
Staff double-clicks the .exe in a folder of PDFs. All last pages extract automatically. They see results. Done.
Conclusion
The gap between "works locally" and "users can run it" is bigger than most tutorials suggest. It's not about language features or libraries. It's about:
- Context-aware UX (fold vs. files—do the right thing)
- Clear output (so users know what happened)
- Friction-free building (so you iterate without pain)
- Reproducible packaging (so it works everywhere)
A Python script plus PyInstaller plus a good build script is a complete tool. Not a prototype—a shipped product.
The legal tech space taught me that. But it applies everywhere: finance, HR, ops, QA—any role with repeatable file tasks where someone would otherwise do it manually.
Write the tool. Automate the build. Watch staff use it daily.
That's the real shipping.
What build automation patterns do you use? Comment below—I'd love to hear how others handle this.
Top comments (0)