Files
2026-08-26 13:45:15 +02:00

166 lines
5.4 KiB
Python

from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Literal
MACOS_EXECUTABLE_DIRECTORIES = (
Path("/opt/homebrew/bin"),
Path("/usr/local/bin"),
Path("/Library/TeX/texbin"),
)
WINDOWS_RESERVED_NAMES = {
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
}
class WindowsLabelError(ValueError):
pass
def windows_filename_problems(value: str) -> list[str]:
problems = []
forbidden = sorted({character for character in value if character in '<>:"/\\|?*'})
if forbidden:
problems.append("caractères interdits " + " ".join(repr(char) for char in forbidden))
if any(ord(character) < 32 for character in value):
problems.append("caractère de contrôle")
if value.endswith((" ", ".")):
problems.append("espace ou point final")
if not value or value in {".", ".."}:
problems.append("nom vide ou réservé")
elif value.split(".", 1)[0].upper() in WINDOWS_RESERVED_NAMES:
problems.append("nom système réservé")
return problems
def validate_windows_labels(
labels: list[str] | tuple[str, ...], platform_name: str | None = None
) -> None:
"""Reject labels that cannot be used as filenames on native Windows."""
if (platform_name or os.name) != "nt":
return
invalid = []
for label in labels:
problems = windows_filename_problems(label)
if problems:
invalid.append((label, problems))
if not invalid:
return
details = "\n".join(
f" - {label!r}: {', '.join(problems)}" for label, problems in invalid
)
raise WindowsLabelError(
"Labels incompatibles avec Windows :\n"
f"{details}\n"
"Modifiez ces labels avant de poursuivre (par exemple, remplacez ':' par ' - ')."
)
def safe_filename(value: str, fallback: str = "Unknown") -> str:
"""Return a filename component accepted by both Linux and Windows."""
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", value).strip().rstrip(". ")
if not cleaned:
cleaned = fallback
if cleaned.split(".", 1)[0].upper() in WINDOWS_RESERVED_NAMES:
cleaned = f"_{cleaned}"
return cleaned
def add_platform_executable_paths(
environment: dict[str, str], platform_name: str | None = None
) -> dict[str, str]:
"""Expose common desktop-installed executables to child processes."""
result = dict(environment)
if (platform_name or sys.platform) != "darwin":
return result
existing = result.get("PATH", "").split(os.pathsep)
additions = [str(path) for path in MACOS_EXECUTABLE_DIRECTORIES if path.is_dir()]
result["PATH"] = os.pathsep.join(dict.fromkeys([*additions, *existing]))
return result
def find_executable(
*candidates: str, platform_name: str | None = None
) -> str | None:
"""Find a command in PATH or in standard macOS package locations."""
for candidate in candidates:
executable = shutil.which(candidate)
if executable:
return executable
if (platform_name or sys.platform) == "darwin":
for directory in MACOS_EXECUTABLE_DIRECTORIES:
for candidate in candidates:
executable = directory / candidate
if executable.is_file() and os.access(executable, os.X_OK):
return str(executable)
return None
def open_path(path: str | Path) -> None:
"""Open a file with the desktop's default application."""
target = str(Path(path).expanduser().resolve())
if os.name == "nt":
os.startfile(target) # type: ignore[attr-defined]
elif sys.platform.startswith("linux"):
subprocess.Popen(["xdg-open", target])
elif sys.platform == "darwin":
opener = find_executable("open") or "/usr/bin/open"
subprocess.Popen([opener, target])
else:
raise RuntimeError(f"Unsupported platform: {sys.platform}")
def launch_pdf_arranger(path: str | Path) -> None:
target = str(Path(path).expanduser().resolve())
executable = find_executable("pdf-arranger", "pdfarranger")
if executable:
subprocess.Popen([executable, target])
return
if sys.platform == "darwin":
opener = find_executable("open") or "/usr/bin/open"
subprocess.Popen([opener, "-b", "com.apple.Preview", target])
return
raise FileNotFoundError(
"PDF Arranger is not installed or is not available in PATH."
)
def replace_with_link_or_copy(
source: str | Path,
destination: str | Path,
*,
prefer: Literal["hardlink", "symlink"] = "hardlink",
) -> str:
"""Replace destination with a link, falling back to a regular copy."""
source_path = Path(source).expanduser().resolve()
destination_path = Path(destination).expanduser()
destination_path.parent.mkdir(parents=True, exist_ok=True)
if os.path.lexists(destination_path):
destination_path.unlink()
strategies = (prefer, "symlink" if prefer == "hardlink" else "hardlink")
for strategy in strategies:
try:
if strategy == "hardlink":
os.link(source_path, destination_path)
else:
os.symlink(source_path, destination_path)
return strategy
except (NotImplementedError, OSError):
continue
shutil.copy2(source_path, destination_path)
return "copy"