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>
This commit is contained in:
188
arma_modlist_tools/linker.py
Normal file
188
arma_modlist_tools/linker.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
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}
|
||||
Reference in New Issue
Block a user