166 lines
5.4 KiB
Python
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"
|