diff --git a/build_support/catalog/test_update.py b/build_support/catalog/test_update.py index 2e0e402ad..dc540ccba 100644 --- a/build_support/catalog/test_update.py +++ b/build_support/catalog/test_update.py @@ -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 # --------------------------------------------------------------------------- diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index f61acc014..7dddd1394 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -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, @@ -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) diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index 68f0f442e..6c9cda489 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -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:: diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 2b6aadaeb..ab759de93 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -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,