mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 21:23:16 +02:00
- Add comprehensive tests for all packages (3572 tests, 100% branch coverage) - Split oversized test files to stay under 500-line limit - Add per-file ruff ignores for test-appropriate suppressions - Fix _cache_decks.py to properly convert JSON lists to tuples - Add session-scoped conftest fixture for logging handler cleanup (Python 3.14) - Update ruff pre-commit hook to v0.15.2 - Add codespell ignore words for test data - Add generated output files to .gitignore
455 lines
15 KiB
Python
455 lines
15 KiB
Python
"""CUPS queue inspection, display, and interactive fix functions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.brother_printer.constants import (
|
|
BOLD,
|
|
CYAN,
|
|
DIM,
|
|
GREEN,
|
|
MIN_LPSTAT_JOB_PARTS,
|
|
RED,
|
|
RESET,
|
|
YELLOW,
|
|
_out,
|
|
_prompt,
|
|
)
|
|
from python_pkg.brother_printer.cups_service import find_cups_printer_name
|
|
from python_pkg.brother_printer.data_classes import CUPSJob, CUPSQueueStatus
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
|
|
# ── Queue inspection ─────────────────────────────────────────────────
|
|
|
|
|
|
def _parse_lpstat_printer_line(line: str) -> tuple[bool, str]:
|
|
"""Parse an lpstat -p line. Returns (enabled, reason)."""
|
|
enabled = "disabled" not in line.lower()
|
|
reason = ""
|
|
match = re.search(r"\d{4}\s+-\s*(.+)", line)
|
|
if match:
|
|
reason = match.group(1).strip()
|
|
return enabled, reason
|
|
|
|
|
|
def _parse_lpstat_jobs(output: str, printer_name: str) -> list[CUPSJob]:
|
|
"""Parse lpstat -o output into CUPSJob list."""
|
|
jobs: list[CUPSJob] = []
|
|
for line in output.splitlines():
|
|
if not line.startswith(printer_name):
|
|
continue
|
|
parts = line.split()
|
|
if len(parts) >= MIN_LPSTAT_JOB_PARTS:
|
|
job_id = parts[0]
|
|
user = parts[1]
|
|
size = parts[2]
|
|
date = " ".join(parts[3:])
|
|
jobs.append(CUPSJob(job_id=job_id, user=user, size=size, date=date))
|
|
return jobs
|
|
|
|
|
|
def get_cups_queue_status() -> CUPSQueueStatus:
|
|
"""Check if the CUPS queue is disabled and list pending jobs."""
|
|
printer_name = find_cups_printer_name()
|
|
if not printer_name:
|
|
return CUPSQueueStatus()
|
|
|
|
result = CUPSQueueStatus(printer_name=printer_name)
|
|
lpstat_path = shutil.which("lpstat")
|
|
if not lpstat_path:
|
|
return result
|
|
|
|
try:
|
|
r = subprocess.run(
|
|
[lpstat_path, "-p", printer_name],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
check=False,
|
|
)
|
|
for line in r.stdout.splitlines():
|
|
if "printer" in line.lower() and printer_name in line:
|
|
result.enabled, result.reason = _parse_lpstat_printer_line(line)
|
|
break
|
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
|
pass
|
|
|
|
try:
|
|
r = subprocess.run(
|
|
[lpstat_path, "-o", printer_name],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
check=False,
|
|
)
|
|
result.jobs = _parse_lpstat_jobs(r.stdout, printer_name)
|
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
|
pass
|
|
|
|
has_errors, last_error = _check_cups_backend_errors(printer_name)
|
|
result.has_backend_errors = has_errors
|
|
result.last_backend_error = last_error
|
|
|
|
return result
|
|
|
|
|
|
# ── CUPS fix actions ─────────────────────────────────────────────────
|
|
|
|
|
|
def _cups_enable_printer(printer_name: str) -> bool:
|
|
"""Re-enable a disabled CUPS printer. Returns True on success."""
|
|
cupsenable_path = shutil.which("cupsenable")
|
|
if not cupsenable_path:
|
|
_out(f" {RED}cupsenable not found.{RESET}")
|
|
return False
|
|
try:
|
|
subprocess.run(
|
|
[cupsenable_path, printer_name],
|
|
timeout=5,
|
|
check=True,
|
|
)
|
|
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as e:
|
|
_out(f" {RED}Failed to enable printer: {e}{RESET}")
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
|
|
def _cups_cancel_all_jobs(printer_name: str) -> bool:
|
|
"""Cancel all pending jobs. Returns True on success."""
|
|
cancel_path = shutil.which("cancel")
|
|
if not cancel_path:
|
|
_out(f" {RED}cancel command not found.{RESET}")
|
|
return False
|
|
try:
|
|
subprocess.run(
|
|
[cancel_path, "-a", printer_name],
|
|
timeout=5,
|
|
check=True,
|
|
)
|
|
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as e:
|
|
_out(f" {RED}Failed to cancel jobs: {e}{RESET}")
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
|
|
def _cups_cancel_job(job_id: str) -> bool:
|
|
"""Cancel a specific job. Returns True on success."""
|
|
cancel_path = shutil.which("cancel")
|
|
if not cancel_path:
|
|
return False
|
|
try:
|
|
subprocess.run(
|
|
[cancel_path, job_id],
|
|
timeout=5,
|
|
check=True,
|
|
)
|
|
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
|
|
def _cups_restart_service() -> bool:
|
|
"""Restart the CUPS service. Returns True on success."""
|
|
systemctl_path = shutil.which("systemctl")
|
|
if not systemctl_path:
|
|
_out(f" {RED}systemctl not found.{RESET}")
|
|
return False
|
|
sys.stdout.write(f" {DIM}Restarting CUPS...{RESET}")
|
|
sys.stdout.flush()
|
|
try:
|
|
proc = subprocess.Popen(
|
|
[systemctl_path, "restart", "cups"],
|
|
)
|
|
deadline = time.time() + 30
|
|
while proc.poll() is None:
|
|
if time.time() > deadline:
|
|
proc.kill()
|
|
proc.wait()
|
|
sys.stdout.write("\n")
|
|
_out(f" {RED}CUPS restart timed out (stuck backend process?).{RESET}")
|
|
_out(
|
|
f" {DIM}Try: sudo kill -9 $(pgrep -f 'cups/backend/usb')"
|
|
f" && sudo systemctl restart cups{RESET}"
|
|
)
|
|
return False
|
|
sys.stdout.write(".")
|
|
sys.stdout.flush()
|
|
time.sleep(1)
|
|
sys.stdout.write("\n")
|
|
if proc.returncode != 0:
|
|
_out(f" {RED}CUPS restart failed (exit code {proc.returncode}).{RESET}")
|
|
return False
|
|
except OSError as e:
|
|
sys.stdout.write("\n")
|
|
_out(f" {RED}Failed to restart CUPS: {e}{RESET}")
|
|
return False
|
|
time.sleep(2)
|
|
return True
|
|
|
|
|
|
# ── Backend error detection ──────────────────────────────────────────
|
|
|
|
|
|
def _is_cups_printer_healthy(printer_name: str) -> bool:
|
|
"""Check live CUPS state via lpstat. Returns True if enabled with no issues."""
|
|
lpstat_path = shutil.which("lpstat")
|
|
if not lpstat_path:
|
|
return False
|
|
try:
|
|
r = subprocess.run(
|
|
[lpstat_path, "-p", printer_name],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
check=False,
|
|
)
|
|
for line in r.stdout.splitlines():
|
|
if (
|
|
printer_name in line
|
|
and "idle" in line.lower()
|
|
and "enabled" in line.lower()
|
|
):
|
|
return True
|
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
|
pass
|
|
return False
|
|
|
|
|
|
def _find_backend_error_in_log(
|
|
lines: list[str],
|
|
) -> tuple[str, str, str]:
|
|
"""Scan CUPS log lines (reversed) for backend errors.
|
|
|
|
Returns:
|
|
(backend_error, error_timestamp, last_success_timestamp)
|
|
"""
|
|
backend_error = ""
|
|
error_timestamp = ""
|
|
last_success_timestamp = ""
|
|
|
|
for line in reversed(lines):
|
|
if (
|
|
"backend errors" in line or "stopped with status" in line
|
|
) and not backend_error:
|
|
backend_error = line.strip()
|
|
ts_match = re.search(r"\[([^\]]+)\]", line)
|
|
if ts_match:
|
|
error_timestamp = ts_match.group(1)
|
|
if ("Completed" in line or "total" in line) and error_timestamp:
|
|
ts_match = re.search(r"\[([^\]]+)\]", line)
|
|
if ts_match:
|
|
last_success_timestamp = ts_match.group(1)
|
|
break
|
|
|
|
return backend_error, error_timestamp, last_success_timestamp
|
|
|
|
|
|
def _check_cups_backend_errors(
|
|
printer_name: str,
|
|
) -> tuple[bool, str]:
|
|
"""Check CUPS error log for backend errors. Returns (has_errors, last_error)."""
|
|
if _is_cups_printer_healthy(printer_name):
|
|
return False, ""
|
|
|
|
log_path = Path("/var/log/cups/error_log")
|
|
if not log_path.exists():
|
|
return False, ""
|
|
try:
|
|
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
except OSError:
|
|
return False, ""
|
|
|
|
backend_error, error_timestamp, last_success_timestamp = _find_backend_error_in_log(
|
|
lines
|
|
)
|
|
|
|
if not backend_error:
|
|
return False, ""
|
|
|
|
if last_success_timestamp and last_success_timestamp > error_timestamp:
|
|
return False, ""
|
|
|
|
return True, backend_error
|
|
|
|
|
|
# ── Queue status display ────────────────────────────────────────────
|
|
|
|
|
|
def display_cups_queue_status(queue: CUPSQueueStatus) -> None:
|
|
"""Display CUPS queue status and offer interactive fixes."""
|
|
if not queue.printer_name:
|
|
return
|
|
if queue.enabled and not queue.jobs and not queue.has_backend_errors:
|
|
return
|
|
|
|
_out()
|
|
_out(f"{BOLD}── Print Queue ──{RESET}")
|
|
_out()
|
|
|
|
if queue.has_backend_errors and queue.enabled and not queue.jobs:
|
|
_out(f" {YELLOW}{BOLD}⚡ CUPS backend has stale errors{RESET}")
|
|
_out(
|
|
f" {DIM}New print jobs may silently fail."
|
|
f" A CUPS restart usually fixes this.{RESET}"
|
|
)
|
|
_out()
|
|
|
|
if not queue.enabled:
|
|
_out(f" {RED}{BOLD}⚠ Printer queue is DISABLED{RESET}")
|
|
if queue.reason:
|
|
_out(f" {DIM}Reason: {queue.reason}{RESET}")
|
|
_out()
|
|
|
|
if queue.jobs:
|
|
_out(f" {BOLD}Pending jobs ({len(queue.jobs)}):{RESET}")
|
|
for job in queue.jobs:
|
|
_out(f" {job.job_id} {DIM}{job.user} {job.size}B {job.date}{RESET}")
|
|
_out()
|
|
|
|
_offer_queue_fix(queue)
|
|
|
|
|
|
# ── Interactive queue fix ────────────────────────────────────────────
|
|
|
|
|
|
def _offer_queue_fix(queue: CUPSQueueStatus) -> None:
|
|
"""Prompt the user to fix a disabled queue / pending jobs."""
|
|
_out(f" {BOLD}Available actions:{RESET}")
|
|
|
|
options: list[str] = []
|
|
if not queue.enabled and queue.jobs:
|
|
_out(f" {CYAN}1){RESET} Re-enable printer and retry all jobs")
|
|
_out(f" {CYAN}2){RESET} Re-enable printer and cancel all jobs")
|
|
_out(f" {CYAN}3){RESET} Cancel all jobs (keep printer disabled)")
|
|
_out(f" {CYAN}4){RESET} Restart CUPS service (fixes stale backend)")
|
|
_out(f" {CYAN}5){RESET} Restart CUPS + re-enable + retry all jobs")
|
|
_out(f" {CYAN}6){RESET} Do nothing")
|
|
options = ["1", "2", "3", "4", "5", "6"]
|
|
elif not queue.enabled:
|
|
_out(f" {CYAN}1){RESET} Re-enable printer")
|
|
_out(f" {CYAN}2){RESET} Restart CUPS service (fixes stale backend)")
|
|
_out(f" {CYAN}3){RESET} Do nothing")
|
|
options = ["1", "2", "3"]
|
|
elif queue.jobs:
|
|
_out(f" {CYAN}1){RESET} Cancel all pending jobs")
|
|
_out(f" {CYAN}2){RESET} Restart CUPS service (fixes stale backend)")
|
|
_out(f" {CYAN}3){RESET} Do nothing")
|
|
options = ["1", "2", "3"]
|
|
else:
|
|
_out(f" {CYAN}1){RESET} Restart CUPS service (fixes stale backend)")
|
|
_out(f" {CYAN}2){RESET} Do nothing")
|
|
options = ["1", "2"]
|
|
|
|
_out()
|
|
choice = _prompt(f" Choose [{'/'.join(options)}]: ")
|
|
_out()
|
|
|
|
if not queue.enabled and queue.jobs:
|
|
_handle_disabled_with_jobs(queue, choice)
|
|
elif not queue.enabled:
|
|
_handle_disabled_no_jobs(queue, choice)
|
|
elif queue.jobs:
|
|
_handle_enabled_with_jobs(queue, choice)
|
|
else:
|
|
_handle_backend_errors_only(choice)
|
|
|
|
|
|
def _dwj_enable_only(printer_name: str) -> None:
|
|
"""Choice 1: re-enable printer so queued jobs are retried."""
|
|
if _cups_enable_printer(printer_name):
|
|
_out(f" {GREEN}✓ Printer re-enabled. Jobs will be retried.{RESET}")
|
|
|
|
|
|
def _dwj_cancel_and_enable(printer_name: str) -> None:
|
|
"""Choice 2: cancel all jobs then re-enable."""
|
|
_cups_cancel_all_jobs(printer_name)
|
|
if _cups_enable_printer(printer_name):
|
|
_out(f" {GREEN}✓ All jobs cancelled and printer re-enabled.{RESET}")
|
|
|
|
|
|
def _dwj_cancel_only(printer_name: str) -> None:
|
|
"""Choice 3: cancel all jobs."""
|
|
if _cups_cancel_all_jobs(printer_name):
|
|
_out(f" {GREEN}✓ All jobs cancelled.{RESET}")
|
|
|
|
|
|
def _dwj_restart_only(_printer_name: str) -> None:
|
|
"""Choice 4: restart CUPS."""
|
|
if _cups_restart_service():
|
|
_out(f" {GREEN}✓ CUPS restarted.{RESET}")
|
|
|
|
|
|
def _dwj_restart_and_enable(printer_name: str) -> None:
|
|
"""Choice 5: restart CUPS and re-enable printer."""
|
|
if _cups_restart_service():
|
|
_cups_enable_printer(printer_name)
|
|
_out(
|
|
f" {GREEN}✓ CUPS restarted, printer re-enabled."
|
|
f" Jobs will be retried.{RESET}"
|
|
)
|
|
|
|
|
|
_DWJ_ACTIONS: dict[str, Callable[[str], None]] = {
|
|
"1": _dwj_enable_only,
|
|
"2": _dwj_cancel_and_enable,
|
|
"3": _dwj_cancel_only,
|
|
"4": _dwj_restart_only,
|
|
"5": _dwj_restart_and_enable,
|
|
}
|
|
|
|
|
|
def _handle_disabled_with_jobs(queue: CUPSQueueStatus, choice: str) -> None:
|
|
"""Handle fix for disabled printer with pending jobs."""
|
|
action = _DWJ_ACTIONS.get(choice)
|
|
if action is not None:
|
|
action(queue.printer_name)
|
|
else:
|
|
_out(f" {DIM}No changes made.{RESET}")
|
|
|
|
|
|
def _handle_disabled_no_jobs(queue: CUPSQueueStatus, choice: str) -> None:
|
|
"""Handle fix for disabled printer with no pending jobs."""
|
|
if choice == "1":
|
|
if _cups_enable_printer(queue.printer_name):
|
|
_out(f" {GREEN}✓ Printer re-enabled.{RESET}")
|
|
elif choice == "2":
|
|
if _cups_restart_service():
|
|
_cups_enable_printer(queue.printer_name)
|
|
_out(f" {GREEN}✓ CUPS restarted and printer re-enabled.{RESET}")
|
|
else:
|
|
_out(f" {DIM}No changes made.{RESET}")
|
|
|
|
|
|
def _handle_enabled_with_jobs(queue: CUPSQueueStatus, choice: str) -> None:
|
|
"""Handle fix for enabled printer with stuck jobs."""
|
|
if choice == "1":
|
|
if _cups_cancel_all_jobs(queue.printer_name):
|
|
_out(f" {GREEN}✓ All jobs cancelled.{RESET}")
|
|
elif choice == "2":
|
|
if _cups_restart_service():
|
|
_out(f" {GREEN}✓ CUPS restarted.{RESET}")
|
|
else:
|
|
_out(f" {DIM}No changes made.{RESET}")
|
|
|
|
|
|
def _handle_backend_errors_only(choice: str) -> None:
|
|
"""Handle fix when only stale backend errors are detected."""
|
|
if choice == "1":
|
|
if _cups_restart_service():
|
|
_out(f" {GREEN}✓ CUPS restarted. Stale backend errors cleared.{RESET}")
|
|
else:
|
|
_out(f" {DIM}No changes made.{RESET}")
|