Pipeline: parse HTML presets, compare modlists, download from Caddy file server, create junctions/symlinks to Arma 3 Server directory. Includes update/sync flows, missing-mod reporting, OS compat layer, shared config, dep checker, comprehensive test suite (71 tests). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
189 lines
5.7 KiB
Python
189 lines
5.7 KiB
Python
"""
|
|
arma_modlist_tools.linker
|
|
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
Manage directory links between downloaded mod folders and the Arma 3 Server
|
|
directory. Works on both Windows (junction links) and Linux (symlinks).
|
|
|
|
Platform behaviour:
|
|
- **Windows**: junctions via ``cmd /c mklink /J`` — no admin rights required.
|
|
- **Linux**: symlinks via ``os.symlink()`` — standard directory symlinks.
|
|
|
|
Typical usage::
|
|
|
|
from arma_modlist_tools.linker import get_link_status, link_group, unlink_group
|
|
from pathlib import Path
|
|
|
|
arma = Path("/opt/arma3server")
|
|
group = Path("downloads/shared")
|
|
|
|
status = get_link_status(group, arma)
|
|
result = link_group(group, arma)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from .compat import is_windows
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _is_junction(path: Path) -> bool:
|
|
"""
|
|
Return ``True`` if *path* is an active directory junction / symlink.
|
|
|
|
- **Windows**: checks ``FILE_ATTRIBUTE_REPARSE_POINT`` (0x400) in
|
|
``os.lstat().st_file_attributes``. ``os.path.islink()`` is unreliable
|
|
for junctions on Windows.
|
|
- **Linux**: ``os.path.islink()`` correctly identifies symlinks.
|
|
"""
|
|
try:
|
|
if is_windows():
|
|
s = os.lstat(str(path))
|
|
attrs = getattr(s, "st_file_attributes", 0)
|
|
return bool(attrs & 0x400) # FILE_ATTRIBUTE_REPARSE_POINT
|
|
else:
|
|
return os.path.islink(str(path))
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def get_mod_folders(group_dir: Path) -> list[Path]:
|
|
"""
|
|
Return a sorted list of ``@*`` subdirectories inside *group_dir*.
|
|
|
|
:param group_dir: The mod category folder (e.g. ``downloads/shared``).
|
|
"""
|
|
if not group_dir.is_dir():
|
|
return []
|
|
return sorted(
|
|
p for p in group_dir.iterdir()
|
|
if p.is_dir() and p.name.startswith("@")
|
|
)
|
|
|
|
|
|
def get_link_status(group_dir: Path, arma_dir: Path) -> list[dict]:
|
|
"""
|
|
Return the link status for every ``@Mod`` folder in *group_dir*.
|
|
|
|
:returns: List of dicts with keys:
|
|
|
|
- ``name`` — folder name (e.g. ``@ace``)
|
|
- ``source_path`` — absolute path of the mod folder
|
|
- ``link_path`` — where the link would/does live in *arma_dir*
|
|
- ``is_linked`` — ``True`` if a junction/symlink currently exists
|
|
"""
|
|
result = []
|
|
for mod in get_mod_folders(group_dir):
|
|
link_path = arma_dir / mod.name
|
|
result.append({
|
|
"name": mod.name,
|
|
"source_path": mod.resolve(),
|
|
"link_path": link_path,
|
|
"is_linked": _is_junction(link_path),
|
|
})
|
|
return result
|
|
|
|
|
|
def create_junction(link_path: Path, target: Path) -> bool:
|
|
"""
|
|
Create a directory junction (Windows) or symlink (Linux) at *link_path*
|
|
pointing to *target*.
|
|
|
|
:returns: ``True`` on success, ``False`` on failure.
|
|
"""
|
|
if is_windows():
|
|
proc = subprocess.run(
|
|
["cmd", "/c", "mklink", "/J", str(link_path), str(target)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return proc.returncode == 0
|
|
else:
|
|
try:
|
|
os.symlink(str(target), str(link_path))
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def remove_junction(link_path: Path) -> tuple[bool, str]:
|
|
"""
|
|
Remove the junction / symlink at *link_path*.
|
|
|
|
- **Windows**: ``os.rmdir()`` removes the junction pointer without touching
|
|
the target directory's contents.
|
|
- **Linux**: ``os.unlink()`` removes the symlink without touching the target.
|
|
|
|
:returns: ``(True, "")`` on success, ``(False, error_message)`` on failure.
|
|
"""
|
|
try:
|
|
if is_windows():
|
|
os.rmdir(str(link_path))
|
|
else:
|
|
os.unlink(str(link_path))
|
|
return True, ""
|
|
except OSError as exc:
|
|
return False, str(exc)
|
|
|
|
|
|
def link_group(group_dir: Path, arma_dir: Path) -> dict:
|
|
"""
|
|
Create links for every unlinked ``@Mod`` in *group_dir*.
|
|
|
|
:returns: ``{"linked": n, "already_linked": n, "failed": n, "errors": {name: msg}}``
|
|
"""
|
|
status = get_link_status(group_dir, arma_dir)
|
|
linked = already_linked = failed = 0
|
|
errors: dict[str, str] = {}
|
|
|
|
for s in status:
|
|
if s["is_linked"]:
|
|
already_linked += 1
|
|
continue
|
|
if s["link_path"].exists():
|
|
failed += 1
|
|
errors[s["name"]] = "path exists but is not a junction/symlink"
|
|
continue
|
|
ok = create_junction(s["link_path"], s["source_path"])
|
|
if ok:
|
|
linked += 1
|
|
else:
|
|
failed += 1
|
|
errors[s["name"]] = "link creation failed"
|
|
|
|
return {"linked": linked, "already_linked": already_linked, "failed": failed, "errors": errors}
|
|
|
|
|
|
def unlink_group(group_dir: Path, arma_dir: Path) -> dict:
|
|
"""
|
|
Remove links for every linked ``@Mod`` in *group_dir*.
|
|
|
|
:returns: ``{"unlinked": n, "not_linked": n, "failed": n, "errors": {name: msg}}``
|
|
"""
|
|
status = get_link_status(group_dir, arma_dir)
|
|
unlinked = not_linked = failed = 0
|
|
errors: dict[str, str] = {}
|
|
|
|
for s in status:
|
|
if not s["is_linked"]:
|
|
not_linked += 1
|
|
continue
|
|
ok, err = remove_junction(s["link_path"])
|
|
if ok:
|
|
unlinked += 1
|
|
else:
|
|
failed += 1
|
|
errors[s["name"]] = err
|
|
|
|
return {"unlinked": unlinked, "not_linked": not_linked, "failed": failed, "errors": errors}
|