Files
arma-modlist-tools/update_mods.py
revernomad17 da9e7782d6 fix Python 3.9 compatibility (str | None union syntax)
parser.py and update_mods.py were missing
'from __future__ import annotations', causing a TypeError on
Python < 3.10 when the X | Y union syntax is evaluated at runtime.
All other modules already had the import.

Also lowers MIN_PYTHON from 3.11 to 3.9 -- the toolchain does not
use any Python 3.10/3.11-specific stdlib features beyond the union
annotation syntax which is now handled by the future import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 16:16:38 +07:00

171 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""
CLI entry point: re-download mod files that have changed on the server.
Use this after you have updated mod files on the Caddy server without
changing the modlist structure (same mods, same Steam IDs, new file versions).
Detection method: compare local file sizes against server file sizes.
A file is considered stale when it is missing locally OR its local size
differs from the server-reported size. Use --force to ignore size checks
and re-download every file unconditionally.
Usage:
python update_mods.py # check all groups/mods
python update_mods.py --group shared # limit to one group
python update_mods.py --mod @ace # limit to one mod folder
python update_mods.py --force # re-download everything
python update_mods.py --force --group shared # force-update one group
"""
from __future__ import annotations
import argparse
import sys
from tqdm import tqdm
from arma_modlist_tools.compat import fix_console_encoding
from arma_modlist_tools.config import load_config
from arma_modlist_tools.fetcher import (
build_server_index, find_mod_folder,
list_mod_files, list_mod_updates,
download_file, make_session,
)
fix_console_encoding()
def _fmt_bytes(n: int) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024:
return f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} TB"
def _collect_targets(cfg, group_filter: str | None, mod_filter: str | None) -> list[tuple[str, str, object]]:
"""
Walk downloads/ and return (group, folder_name, folder_path) for each
@Mod folder that passes the group/mod filters.
"""
targets = []
if not cfg.downloads.is_dir():
return targets
for group_dir in sorted(cfg.downloads.iterdir()):
if not group_dir.is_dir():
continue
if group_filter and group_dir.name != group_filter:
continue
for mod_dir in sorted(group_dir.iterdir()):
if not mod_dir.is_dir() or not mod_dir.name.startswith("@"):
continue
if mod_filter and mod_dir.name != mod_filter:
continue
targets.append((group_dir.name, mod_dir.name, mod_dir))
return targets
def main() -> None:
cfg = load_config()
parser = argparse.ArgumentParser(
description="Re-download mod files that have changed on the server."
)
parser.add_argument("--group", "-g", metavar="GROUP",
help="Only check mods in this group folder (e.g. shared)")
parser.add_argument("--mod", "-m", metavar="MOD",
help="Only check this mod folder name (e.g. @ace)")
parser.add_argument("--force", action="store_true",
help="Re-download all files regardless of size match")
args = parser.parse_args()
# ---- Collect local mod folders to check ----
targets = _collect_targets(cfg, args.group, args.mod)
if not targets:
print("\nNo mod folders found matching the given filters.\n")
sys.exit(0)
# ---- Build server index ----
print(f"\nBuilding server index...")
index = build_server_index(cfg.server_url, cfg.server_auth)
print(f" {len(index['by_steam_id'])} mods indexed\n")
session = make_session(cfg.server_auth)
mode = "force" if args.force else "size-check"
print(f" Mode: {mode}")
print(f" Checking {len(targets)} mod folder(s)...\n")
# Column widths for the summary table
COL_MOD = 44
COL_GROUP = 24
total_checked = total_updated = total_bytes = 0
not_on_server = []
for group, folder_name, mod_dir in targets:
# Find this mod on the server by name (no steam_id available from local dir)
mod_stub = {"name": folder_name, "steam_id": None}
folder_url = find_mod_folder(mod_stub, index)
if not folder_url:
tqdm.write(f" [?] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} not found on server")
not_on_server.append(f"{group}/{folder_name}")
continue
# Determine which files need downloading
if args.force:
stale = list_mod_files(folder_url, session)
else:
stale = list_mod_updates(folder_url, mod_dir, session)
all_files = list_mod_files(folder_url, session) if not args.force else stale
checked = len(all_files) if not args.force else len(stale)
if not stale:
print(f" [=] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} {checked} files up-to-date")
total_checked += checked
continue
# Download stale files
mod_bytes = 0
with tqdm(
total=len(stale), unit="file",
desc=f" {folder_name[-COL_MOD:]:<{COL_MOD}}",
position=0, leave=True, dynamic_ncols=True,
) as file_bar:
for rel, url, size in stale:
dest_file = mod_dir / rel
with tqdm(
total=size if size else None,
unit="B", unit_scale=True, unit_divisor=1024,
desc=f" {rel[-40:]:40s}",
position=1, leave=False, dynamic_ncols=True,
) as chunk_bar:
n = download_file(url, dest_file, session,
on_chunk=lambda b: chunk_bar.update(b))
mod_bytes += n
file_bar.update(1)
total_checked += checked
total_updated += len(stale)
total_bytes += mod_bytes
print(f" [+] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} "
f"{checked} files {len(stale)} updated ({_fmt_bytes(mod_bytes)})")
print(f"\n{'='*56}")
print(f" Total: {total_checked} files checked, "
f"{total_updated} updated, "
f"{_fmt_bytes(total_bytes)} downloaded")
if not_on_server:
print(f" Not found on server ({len(not_on_server)}): {', '.join(not_on_server)}")
print(f"{'='*56}\n")
if total_updated == 0 and not not_on_server:
print(" All mods are up-to-date.\n")
if __name__ == "__main__":
main()