Files
revernomad17 91a38b269b Initial release: full Arma 3 mod management toolchain
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>
2026-04-07 16:04:36 +07:00

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}