Shipping Internal Tools: Why Build Automation Matters More Than Code Quality

Shipping Internal Tools: Why Build Automation Matters More Than Code Quality

Leader 1 3 13
calendar_today agoschedule8 min read

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.


Part 2: The Actual Tool (With Lessons Baked In)

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 endinput("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 usersBuildExe.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.


Part 6: Scaling to a Tool Suite

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:

last_page_extractor.py (100 lines)

# (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

  1. Double-click BuildExe.cmd
  2. Wait 10 seconds
  3. Get dist/last_page_extractor.exe
  4. Give to staff
  5. They double-click it in a folder of PDFs
  6. All last pages extract automatically
  7. 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?

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Your Backup Data Knows More Than You Think. HYCU aiR Is Finally Asking It the Right Questions.

Tom Smithverified - May 14

Beyond the Crisis: Why Engineering Your Personal Health Baseline Matters

Huifer - Jan 24

Dashboard Operasional Armada Rental Mobil dengan Python + FastAPI

Masbadar - Mar 12

I Wrote a Script to Fix Audible's Unreadable PDF Filenames

snapsynapseverified - Apr 20

How to Fix 'jupyter' is Not Recognized as an Internal or External Command

mouyuan123 - May 26, 2024
chevron_left
999 Points17 Badges
7Posts
3Comments
4Connections
DHSeaDev is the independent studio behind a run of Manifest V3 Chrome extensions and idle games — bu... Show more

Related Jobs

View all jobs →

Commenters (This Week)

5 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!