Skip to content
Open
106 changes: 106 additions & 0 deletions build_support/catalog/test_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,112 @@ def test_no_list_table_in_output(self, tmp_path, monkeypatch):
assert ".. contents::" not in rst


# ---------------------------------------------------------------------------
# Tests for clean_stale_img_files
# ---------------------------------------------------------------------------


@pytest.mark.catalog_update
class TestCleanStaleImgFiles:
"""Tests for ``clean_stale_img_files(catalog_dir)``.

All tests construct a temporary catalog directory with hand-crafted game
files and img/ contents so they are completely isolated from the real
catalog on disk.
"""

def _make_catalog(self, tmp_path):
"""Return a minimal catalog dir with an img/ subdirectory."""
catalog_dir = tmp_path / "catalog"
(catalog_dir / "img").mkdir(parents=True)
(catalog_dir / "img" / ".gitkeep").touch()
return catalog_dir

def test_stale_file_is_removed(self, tmp_path):
"""A file in img/ with no matching game file is deleted."""
catalog_dir = self._make_catalog(tmp_path)
stale = catalog_dir / "img" / "oldgame.png"
stale.touch()
update.clean_stale_img_files(catalog_dir)
assert not stale.exists()

def test_gitkeep_is_preserved(self, tmp_path):
"""The .gitkeep sentinel is never removed, even when nothing else is kept."""
catalog_dir = self._make_catalog(tmp_path)
(catalog_dir / "img" / "oldgame.png").touch()
update.clean_stale_img_files(catalog_dir)
assert (catalog_dir / "img" / ".gitkeep").exists()

def test_expected_efg_files_are_kept(self, tmp_path):
"""Image files that correspond to a current EFG game are not removed."""
catalog_dir = self._make_catalog(tmp_path)
(catalog_dir / "src").mkdir()
(catalog_dir / "src" / "game.efg").touch()
for ext in ["ef", "tex", "png", "pdf", "svg"]:
(catalog_dir / "img" / f"src/game.{ext}").parent.mkdir(parents=True, exist_ok=True)
(catalog_dir / "img" / f"src/game.{ext}").touch()
update.clean_stale_img_files(catalog_dir)
for ext in ["ef", "tex", "png", "pdf", "svg"]:
assert (catalog_dir / "img" / f"src/game.{ext}").exists()

def test_expected_nfg_files_are_kept(self, tmp_path):
"""Image files for an NFG game (no .ef) are not removed."""
catalog_dir = self._make_catalog(tmp_path)
(catalog_dir / "src").mkdir()
(catalog_dir / "src" / "matrix.nfg").touch()
(catalog_dir / "img" / "src").mkdir()
for ext in ["tex", "png", "pdf", "svg"]:
(catalog_dir / "img" / f"src/matrix.{ext}").touch()
update.clean_stale_img_files(catalog_dir)
for ext in ["tex", "png", "pdf", "svg"]:
assert (catalog_dir / "img" / f"src/matrix.{ext}").exists()

def test_empty_directory_is_removed(self, tmp_path):
"""A subdirectory of img/ that becomes empty after cleanup is also removed."""
catalog_dir = self._make_catalog(tmp_path)
stale_dir = catalog_dir / "img" / "oldgroup"
stale_dir.mkdir()
(stale_dir / "oldgame.png").touch()
update.clean_stale_img_files(catalog_dir)
assert not stale_dir.exists()

def test_nonempty_directory_is_kept(self, tmp_path):
"""A subdirectory still containing expected files is not removed."""
catalog_dir = self._make_catalog(tmp_path)
(catalog_dir / "src").mkdir()
(catalog_dir / "src" / "game.efg").touch()
(catalog_dir / "img" / "src").mkdir()
for ext in ["ef", "tex", "png", "pdf", "svg"]:
(catalog_dir / "img" / f"src/game.{ext}").touch()
update.clean_stale_img_files(catalog_dir)
assert (catalog_dir / "img" / "src").is_dir()

def test_multi_variant_images_kept(self, tmp_path):
"""Variant image files (e.g. {slug}__wide.*) are kept when the variant .ef exists."""
catalog_dir = self._make_catalog(tmp_path)
game_dir = catalog_dir / "src"
game_dir.mkdir()
(game_dir / "game.efg").touch()
(game_dir / "game.ef").touch()
(game_dir / "game__wide.ef").touch() # triggers multi-variant
(catalog_dir / "img" / "src").mkdir()
for vkey in ["src/game", "src/game__wide"]:
for ext in ["ef", "tex", "png", "pdf", "svg"]:
p = catalog_dir / "img" / f"{vkey}.{ext}"
p.parent.mkdir(parents=True, exist_ok=True)
p.touch()
update.clean_stale_img_files(catalog_dir)
for vkey in ["src/game", "src/game__wide"]:
for ext in ["ef", "tex", "png", "pdf", "svg"]:
assert (catalog_dir / "img" / f"{vkey}.{ext}").exists()

def test_noop_when_img_absent(self, tmp_path):
"""If catalog/img/ does not exist, the function returns without error."""
catalog_dir = tmp_path / "catalog"
catalog_dir.mkdir()
update.clean_stale_img_files(catalog_dir) # must not raise


# ---------------------------------------------------------------------------
# Tests for update_makefile
# ---------------------------------------------------------------------------
Expand Down
57 changes: 57 additions & 0 deletions build_support/catalog/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,61 @@ def generate_rst_table(
)


def clean_stale_img_files(catalog_dir: Path | None = None) -> None:
"""Remove files and directories from catalog/img/ with no corresponding current game.

Computes the set of expected image files from the game files currently in
*catalog_dir* (all .efg and .nfg files, respecting multi-variant .ef conventions),
then removes anything in catalog/img/ that is not in that set. Preserves
.gitkeep. Empty directories left behind by file removal are also removed.

This is useful after switching branches that reorganise or remove catalog games,
to prevent stale images from persisting alongside the updated catalog.
"""
catalog_dir = catalog_dir or CATALOG_DIR
img_dir = catalog_dir / "img"
if not img_dir.is_dir():
return

# Build the set of expected image file paths from the current catalog contents.
expected_files: set[Path] = set()
game_files = [
p
for p in list(catalog_dir.rglob("*.efg")) + list(catalog_dir.rglob("*.nfg"))
if img_dir not in p.parents # exclude generated copies already inside img/
]
for game_file in game_files:
slug = game_file.relative_to(catalog_dir).with_suffix("").as_posix()
fmt = game_file.suffix.lstrip(".") # "efg" or "nfg"
ef_variants = catalog_ef_file_variants(slug, catalog_dir) if fmt == "efg" else None
if ef_variants:
for variant in ef_variants:
for ext in ["ef", "tex", "png", "pdf", "svg"]:
expected_files.add(img_dir / f"{variant['variant_key']}.{ext}")
else:
exts = (["ef"] if fmt == "efg" else []) + ["tex", "png", "pdf", "svg"]
for ext in exts:
expected_files.add(img_dir / f"{slug}.{ext}")

# Remove unexpected files (preserving .gitkeep).
removed = 0
for path in img_dir.rglob("*"):
if path.is_file() and path.name != ".gitkeep" and path not in expected_files:
path.unlink()
removed += 1

# Remove empty directories, deepest first.
for path in sorted(img_dir.rglob("*"), key=lambda p: len(p.parts), reverse=True):
if path.is_dir() and not any(path.iterdir()):
path.rmdir()
removed += 1

if removed:
print(f"Removed {removed} stale item(s) from {img_dir}")
else:
print(f"No stale files in {img_dir}")


def update_makefile(
catalog_dir: Path | None = None,
am_path: Path | None = None,
Expand Down Expand Up @@ -390,6 +445,8 @@ def update_makefile(
)
args = parser.parse_args()

# Remove img/ files for games that no longer exist in the catalog.
clean_stale_img_files()
# Create RST list-table used by doc/catalog.rst
df = gbt.catalog.games(include_descriptions=True)
generate_rst_table(df, CATALOG_RST_TABLE, regenerate_images=args.regenerate_images)
Expand Down
3 changes: 2 additions & 1 deletion doc/developer.catalog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ Currently supported representations are:

.. note::

You can use the ``--regenerate-images`` flag when building the docs locally for a second time to force any changes to be picked up.
- The ``pygambit.catalog`` module reads games directly from the repo's ``catalog/`` directory when working with an editable install of ``pygambit``.
- You can use the ``--regenerate-images`` flag when building the docs locally for a second time to force any changes to be picked up.

.. warning::

Expand Down
14 changes: 7 additions & 7 deletions src/pygambit/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@

import pygambit as gbt

# Use the full string path to where the catalog data are placed in the package
_CATALOG_RESOURCE = files("pygambit") / "catalog_data"
# This ensures that catalog files are included in editable installs too
if not _CATALOG_RESOURCE.is_dir():
_repo_catalog = Path(__file__).parent.parent.parent / "catalog"
if _repo_catalog.is_dir():
_CATALOG_RESOURCE = _repo_catalog
# Prefer the repo's live catalog/ directory when working in a development checkout.
# This ensures changes to catalog/ are picked up immediately without reinstalling,
# and avoids stale files in catalog_data/ left over from previous installs.
# Fall back to the installed catalog_data/ only for deployed (non-development) packages,
# where catalog/ does not exist relative to the installed source file.
_repo_catalog = Path(__file__).parent.parent.parent / "catalog"
_CATALOG_RESOURCE = _repo_catalog if _repo_catalog.is_dir() else files("pygambit") / "catalog_data"

READERS = {
".nfg": gbt.read_nfg,
Expand Down
Loading