port print-web-form app to nested flask structure

Replace stub print/ app with full PDF web print service from
~/print-web-form. Adapt UPLOAD_FOLDER to use Path(__file__).parent,
add pypdf dependency, port all tests (10 passing), remove unused static/.
This commit is contained in:
Connor Rhodes 2026-04-23 20:07:07 -05:00
parent af5d3f148d
commit b0c19d5642
9 changed files with 340 additions and 0 deletions

105
print/app.py Normal file
View file

@ -0,0 +1,105 @@
import os
import subprocess
from pathlib import Path
import pypdf
from flask import Flask, flash, redirect, render_template, request, url_for
from werkzeug.utils import secure_filename
UPLOAD_FOLDER = str(Path(__file__).parent / "uploads")
ALLOWED_EXTENSIONS = {"pdf"}
PRINTER_NAME = "HL-2270DW_series"
MAX_PAGES = 10
app = Flask(__name__)
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["SECRET_KEY"] = os.urandom(24)
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024
def allowed_file(filename):
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
def get_pdf_page_count(filepath):
try:
with open(filepath, "rb") as f:
reader = pypdf.PdfReader(f)
return len(reader.pages)
except Exception as e:
app.logger.error(f"Could not read PDF pages: {e}")
return None
@app.route("/", methods=["GET", "POST"])
def upload_and_print():
if request.method == "POST":
if "file" not in request.files:
flash("No file part in the request.", "error")
return redirect(request.url)
file = request.files["file"]
if file.filename == "":
flash("No file selected. Please choose a PDF to print.", "error")
return redirect(request.url)
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
filepath = os.path.join(app.config["UPLOAD_FOLDER"], filename)
file.save(filepath)
try:
page_count = get_pdf_page_count(filepath)
if page_count is None:
flash(
"Could not process the PDF file. It might be corrupt.", "error"
)
return redirect(request.url)
if page_count > MAX_PAGES:
flash(
f"Error: File has {page_count} pages. The maximum allowed is {MAX_PAGES}.",
"error",
)
return redirect(request.url)
app.logger.info(
f"Sending '{filename}' ({page_count} pages) to printer '{PRINTER_NAME}'"
)
command = ["lp", "-d", PRINTER_NAME, filepath]
result = subprocess.run(
command, capture_output=True, text=True, check=True
)
app.logger.info(f"lp command stdout: {result.stdout}")
flash(f"Success! '{filename}' has been sent to the printer.", "success")
except subprocess.CalledProcessError as e:
app.logger.error(f"Error printing file: {e}")
app.logger.error(f"lp command stderr: {e.stderr}")
flash(
"Error sending file to printer. The printer may be offline or misconfigured.",
"error",
)
except Exception as e:
app.logger.error(f"An unexpected error occurred: {e}")
flash("An unexpected server error occurred.", "error")
finally:
if os.path.exists(filepath):
os.remove(filepath)
app.logger.info(f"Cleaned up temporary file: {filepath}")
return redirect(url_for("upload_and_print"))
else:
flash("Invalid file type. Please upload a PDF file.", "error")
return redirect(request.url)
return render_template("index.html", printer_name=PRINTER_NAME, max_pages=MAX_PAGES)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=57928, debug=False)

View file

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Print Service</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
}
</style>
</head>
<body class="bg-gray-100 text-gray-800 flex items-center justify-center min-h-screen">
<div class="w-full max-w-lg mx-auto p-4 md:p-8">
<div class="bg-white rounded-2xl shadow-lg p-8">
<div class="text-center mb-8">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 mx-auto text-blue-500 mb-4" viewBox="0 0 24 24" fill="currentColor"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/></svg>
<h1 class="text-3xl font-bold text-gray-900">PDF Web Print</h1>
<p class="text-gray-500 mt-2">Upload a PDF to print to <strong>{{ printer_name }}</strong>.</p>
<p class="text-sm text-gray-500 mt-1">(Max {{ max_pages }} pages)</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6 space-y-3">
{% for category, message in messages %}
<div class="p-4 rounded-lg text-sm {% if category == 'error' %} bg-red-100 text-red-800 {% else %} bg-green-100 text-green-800 {% endif %}" role="alert">
<span class="font-medium">{% if category == 'error' %}Oops!{% else %}Success!{% endif %}</span> {{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form action="/" method="post" enctype="multipart/form-data">
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center bg-gray-50 hover:border-blue-500 transition-colors duration-300">
<label for="file-upload" class="cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 mx-auto text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p id="file-name" class="mt-4 text-gray-600">
<span class="font-semibold text-blue-600">Click to upload</span>
</p>
<p class="text-xs text-gray-500 mt-1">PDF only, max 16MB.</p>
</label>
<input id="file-upload" name="file" type="file" class="sr-only" accept=".pdf">
</div>
<div class="mt-8">
<button type="submit" class="w-full bg-blue-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-4 focus:ring-blue-300 transition-all duration-300 transform hover:scale-105">
Print File
</button>
</div>
</form>
</div>
<footer class="text-center mt-6 text-sm text-gray-500">
<p>A simple Flask web app for local printing.</p>
</footer>
</div>
<script>
const fileUpload = document.getElementById('file-upload');
const fileNameDisplay = document.getElementById('file-name');
fileUpload.addEventListener('change', () => {
if (fileUpload.files.length > 0) {
fileNameDisplay.innerHTML = `<span class="font-semibold text-gray-800">${fileUpload.files[0].name}</span>`;
} else {
fileNameDisplay.innerHTML = `<span class="font-semibold text-blue-600">Click to upload</span> or drag and drop.`;
}
});
</script>
</body>
</html>

0
print/tests/__init__.py Normal file
View file

59
print/tests/conftest.py Normal file
View file

@ -0,0 +1,59 @@
import io
import tempfile
import pypdf
import pytest
from app import app as flask_app, get_pdf_page_count
@pytest.fixture
def app():
flask_app.config["TESTING"] = True
flask_app.config["UPLOAD_FOLDER"] = tempfile.mkdtemp()
flask_app.config["SECRET_KEY"] = "test-secret"
yield flask_app
@pytest.fixture
def client(app):
return app.test_client()
def _make_pdf(num_pages=3):
writer = pypdf.PdfWriter()
for _ in range(num_pages):
writer.add_blank_page(width=612, height=792)
buf = io.BytesIO()
writer.write(buf)
buf.seek(0)
return buf
@pytest.fixture
def sample_pdf():
return _make_pdf(3).read()
@pytest.fixture
def large_pdf():
return _make_pdf(15).read()
@pytest.fixture
def corrupt_file():
return b"This is not a PDF at all"
@pytest.fixture
def sample_pdf_path(tmp_path):
path = tmp_path / "test.pdf"
path.write_bytes(_make_pdf(3).read())
return str(path)
@pytest.fixture
def corrupt_file_path(tmp_path):
path = tmp_path / "corrupt.pdf"
path.write_bytes(b"not a pdf")
return str(path)

View file

@ -0,0 +1,13 @@
from app import get_pdf_page_count
def test_page_count_valid_pdf(sample_pdf_path):
assert get_pdf_page_count(sample_pdf_path) == 3
def test_page_count_corrupt_file(corrupt_file_path):
assert get_pdf_page_count(corrupt_file_path) is None
def test_page_count_nonexistent_file():
assert get_pdf_page_count("/nonexistent/file.pdf") is None

View file

@ -0,0 +1,69 @@
import io
import os
from unittest.mock import patch
def test_get_index(client):
resp = client.get("/")
assert resp.status_code == 200
assert b"printer" in resp.data.lower() or b"upload" in resp.data.lower()
def test_post_no_file(client):
resp = client.post("/", data={}, follow_redirects=True)
assert resp.status_code == 200
assert b"No file part" in resp.data
def test_post_empty_filename(client):
resp = client.post("/", data={"file": (io.BytesIO(b""), "")}, follow_redirects=True)
assert resp.status_code == 200
assert b"No file selected" in resp.data
def test_post_invalid_extension(client):
data = {"file": (io.BytesIO(b"hello"), "document.txt")}
resp = client.post("/", data=data, follow_redirects=True)
assert resp.status_code == 200
assert b"Invalid file type" in resp.data
@patch("app.subprocess.run")
def test_post_valid_pdf(mock_run, client, app, sample_pdf):
mock_run.return_value.stdout = "request-id-123"
mock_run.return_value.stderr = ""
mock_run.return_value.returncode = 0
data = {"file": (io.BytesIO(sample_pdf), "test.pdf")}
resp = client.post("/", data=data, follow_redirects=True)
assert resp.status_code == 200
assert b"Success" in resp.data
upload_dir = app.config["UPLOAD_FOLDER"]
remaining = os.listdir(upload_dir)
assert len(remaining) == 0
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert cmd[0] == "lp"
@patch("app.subprocess.run")
def test_post_pdf_exceeds_max_pages(mock_run, client, large_pdf):
data = {"file": (io.BytesIO(large_pdf), "big.pdf")}
resp = client.post("/", data=data, follow_redirects=True)
assert resp.status_code == 200
assert b"maximum allowed" in resp.data.lower()
mock_run.assert_not_called()
def test_post_corrupt_pdf(client, app, corrupt_file):
data = {"file": (io.BytesIO(corrupt_file), "bad.pdf")}
resp = client.post("/", data=data, follow_redirects=True)
assert resp.status_code == 200
assert b"corrupt" in resp.data.lower()
upload_dir = app.config["UPLOAD_FOLDER"]
remaining = os.listdir(upload_dir)
assert len(remaining) == 0

0
print/uploads/.gitkeep Normal file
View file