DEV Community

Cover image for From Python Script to .exe: Building Batch PDF Tools That Users Actually Want to Run
Dhardingsea Developer
Dhardingsea Developer

Posted on

From Python Script to .exe: Building Batch PDF Tools That Users Actually Want to Run

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
Enter fullscreen mode Exit fullscreen mode

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...")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Wrap it in a .cmd file for Windows users:

@echo off
powershell -ExecutionPolicy Bypass -File "%~dp0build_exe.ps1"
pause
Enter fullscreen mode Exit fullscreen mode

Users (or you) just double-click BuildExe.cmd and walk away. No terminal knowledge required.

Why this matters:

  1. Removes friction: Bug found? Fix Python file, double-click build script, done. New .exe is ready.
  2. Handles edge cases: The script finds Python (whether installed as py, python, or python3), creates a fresh venv each time, avoids dependency conflicts.
  3. 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.
  4. 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)