From 7100242291ca0914f549d433814e844d9e4c459f Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Thu, 4 Jun 2026 13:21:35 +0100 Subject: [PATCH] feat: implement hierarchical catalog structure with configurable labels and nested RST dropdowns --- build_support/catalog/catalog_hierarchy.yaml | 23 ++ build_support/catalog/test_update.py | 172 +++++++- build_support/catalog/update.py | 414 +++++++++++-------- doc/developer.catalog.rst | 26 +- 4 files changed, 453 insertions(+), 182 deletions(-) create mode 100644 build_support/catalog/catalog_hierarchy.yaml diff --git a/build_support/catalog/catalog_hierarchy.yaml b/build_support/catalog/catalog_hierarchy.yaml new file mode 100644 index 000000000..621646708 --- /dev/null +++ b/build_support/catalog/catalog_hierarchy.yaml @@ -0,0 +1,23 @@ +# Human-readable labels for non-leaf nodes in the catalog hierarchy. +# Keys are slug path prefixes (e.g. "journals/geb"). +# Leaf games use their title from the game file; only group nodes need entries here. +# When adding a new venue, journal, or top-level category, add a label below. +labels: + books: "Books" + journals: "Journals" + conf: "Conferences" + journals/geb: "Games and Economic Behavior (GEB)" + journals/ijgt: "International Journal of Game Theory (IJGT)" + journals/mor: "Mathematics of Operations Research (MOR)" + journals/other: "Other" + conf/itcs: "Innovations in Theoretical Computer Science (ITCS)" + books/myerson1991: "Myerson (1991) — Game Theory: Analysis of Conflict" + books/vonstengel2022: "von Stengel (2022) — Game Theory Basics" + books/watson2013: "Watson (2013) — Strategy: An Introduction to Game Theory" + journals/geb/gilboa1997: "Gilboa (1997)" + journals/ijgt/nau2004: "Nau et al. (2004)" + journals/ijgt/selten1975: "Selten (1975)" + journals/mor/vonstengelforges2008: "von Stengel & Forges (2008)" + journals/other/reiley2008: "Reiley (2008)" + journals/other/shapley1974: "Shapley (1974)" + conf/itcs/jakobsen2016: "Jakobsen et al. (2016)" diff --git a/build_support/catalog/test_update.py b/build_support/catalog/test_update.py index 70589e664..2e0e402ad 100644 --- a/build_support/catalog/test_update.py +++ b/build_support/catalog/test_update.py @@ -7,7 +7,7 @@ Monkeypatching strategy ----------------------- -``update.py`` depends on three external resources that are replaced in tests: +``update.py`` depends on four external resources that are replaced in tests: 1. ``DRAW_TREE_SETTINGS_CONFIG`` (a ``Path``) — swapped for a tmp YAML file so ``catalog_draw_tree_settings`` reads controlled config without touching the @@ -15,13 +15,18 @@ "DRAW_TREE_SETTINGS_CONFIG", yaml_file)`` replaces the module-level path for the duration of a single test and restores it automatically on teardown. -2. ``generate_tex`` / ``generate_png`` / ``generate_pdf`` / ``generate_svg`` +2. ``CATALOG_HIERARCHY_CONFIG`` (a ``Path``) — swapped for a tmp YAML file so + ``load_hierarchy_labels`` reads controlled labels without touching the real + ``catalog_hierarchy.yaml``. Swap via ``monkeypatch.setattr(update, + "CATALOG_HIERARCHY_CONFIG", yaml_file)``. + +3. ``generate_tex`` / ``generate_png`` / ``generate_pdf`` / ``generate_svg`` (functions imported from ``draw_tree``) — replaced with no-ops or call-tracking lambdas. This lets us test RST-generation logic without actually invoking LaTeX, and lets us assert whether image generation was triggered at all. -3. ``catalog_dir`` (an argument to ``generate_rst_table`` and +4. ``catalog_dir`` (an argument to ``generate_rst_table`` and ``update_makefile``) — both functions accept an optional ``catalog_dir`` kwarg that defaults to the real ``CATALOG_DIR``. Tests pass a ``tmp_path``-based directory instead, keeping all file I/O inside pytest's @@ -581,6 +586,167 @@ def test_per_variant_images_not_regenerated_when_all_exist(self, tmp_path, monke assert calls == [] +# --------------------------------------------------------------------------- +# Tests for hierarchy helpers and hierarchical RST output +# --------------------------------------------------------------------------- + +# A minimal catalog_hierarchy.yaml used by hierarchy tests. +_HIERARCHY_YAML = textwrap.dedent("""\ + labels: + cat: "My Category" + cat/src: "My Source" +""") + + +@pytest.mark.catalog_update +class TestHierarchyHelpers: + """Unit tests for ``load_hierarchy_labels``, ``_node_label``, and ``_build_slug_tree``.""" + + def test_load_hierarchy_labels_returns_dict(self, tmp_path, monkeypatch): + """``load_hierarchy_labels`` returns the labels dict from the YAML.""" + yaml_file = tmp_path / "hier.yaml" + yaml_file.write_text(_HIERARCHY_YAML, encoding="utf-8") + monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", yaml_file) + labels = update.load_hierarchy_labels() + assert labels["cat"] == "My Category" + assert labels["cat/src"] == "My Source" + + def test_node_label_uses_yaml(self, tmp_path, monkeypatch): + """``_node_label`` returns the YAML label when the prefix is present.""" + yaml_file = tmp_path / "hier.yaml" + yaml_file.write_text(_HIERARCHY_YAML, encoding="utf-8") + monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", yaml_file) + labels = update.load_hierarchy_labels() + assert update._node_label("cat", labels) == "My Category" + + def test_node_label_fallback_title_case(self, tmp_path, monkeypatch): + """``_node_label`` falls back to title-casing the last component.""" + yaml_file = tmp_path / "hier.yaml" + yaml_file.write_text(_HIERARCHY_YAML, encoding="utf-8") + monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", yaml_file) + labels = update.load_hierarchy_labels() + assert update._node_label("cat/unknownsrc", labels) == "Unknownsrc" + + def test_build_slug_tree_single_game(self): + """A single-slug DataFrame builds a 2-level tree.""" + df = _make_df(_efg_row("cat/src/game1")) + tree = update._build_slug_tree(df) + assert "cat" in tree + assert "src" in tree["cat"] + assert "game1" in tree["cat"]["src"] + + def test_build_slug_tree_groups_siblings(self): + """Two slugs sharing a prefix are grouped under the same intermediate node.""" + df = _make_df(_efg_row("cat/src/game1"), _efg_row("cat/src/game2")) + tree = update._build_slug_tree(df) + assert set(tree["cat"]["src"].keys()) == {"game1", "game2"} + + def test_build_slug_tree_skips_unknown_format(self): + """Rows with unrecognised Format are excluded from the tree.""" + row = {**_efg_row("cat/src/game1"), "Format": "xyz"} + df = _make_df(row) + assert update._build_slug_tree(df) == {} + + def test_build_slug_tree_skips_empty_description(self): + """Rows with an empty description are excluded from the tree.""" + df = _make_df(_efg_row("cat/src/game1", description="")) + assert update._build_slug_tree(df) == {} + + +@pytest.mark.catalog_update +class TestHierarchicalRstOutput: + """Tests that ``generate_rst_table`` produces correctly nested dropdown RST.""" + + def _mock_generates(self, monkeypatch): + for name in ["generate_tex", "generate_png", "generate_pdf", "generate_svg"]: + monkeypatch.setattr(update, name, lambda *a, **k: None) + + def _write_hierarchy_yaml(self, tmp_path, content=_HIERARCHY_YAML): + yaml_file = tmp_path / "hier.yaml" + yaml_file.write_text(content, encoding="utf-8") + return yaml_file + + def test_top_level_dropdown_is_open(self, tmp_path, monkeypatch): + """Top-level category dropdowns carry ``:open:`` so the first level is visible.""" + self._mock_generates(monkeypatch) + hier_yaml = self._write_hierarchy_yaml(tmp_path) + monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", hier_yaml) + catalog_dir = tmp_path / "catalog" + slug = "cat/src/game1" + _make_image_files(catalog_dir, slug, "efg") + df = _make_df(_efg_row(slug)) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert ".. dropdown:: My Category\n :open:" in rst + + def test_second_level_dropdown_is_not_open(self, tmp_path, monkeypatch): + """Sub-category dropdowns do NOT carry ``:open:`` so they are collapsed by default.""" + self._mock_generates(monkeypatch) + hier_yaml = self._write_hierarchy_yaml(tmp_path) + monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", hier_yaml) + catalog_dir = tmp_path / "catalog" + slug = "cat/src/game1" + _make_image_files(catalog_dir, slug, "efg") + df = _make_df(_efg_row(slug)) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert " .. dropdown:: My Source\n \n" in rst + # Confirm :open: does not immediately follow the second-level dropdown + src_idx = rst.index(" .. dropdown:: My Source") + assert ":open:" not in rst[src_idx:src_idx + 40] + + def test_game_dropdown_is_open(self, tmp_path, monkeypatch): + """Individual game dropdowns carry ``:open:`` so game content is visible on expand.""" + self._mock_generates(monkeypatch) + hier_yaml = self._write_hierarchy_yaml(tmp_path) + monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", hier_yaml) + catalog_dir = tmp_path / "catalog" + slug = "cat/src/game1" + _make_image_files(catalog_dir, slug, "efg") + df = _make_df(_efg_row(slug, title="My Game Title")) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert " .. dropdown:: My Game Title\n :open:" in rst + + def test_sibling_games_both_appear_under_source(self, tmp_path, monkeypatch): + """Two games sharing a source prefix both appear nested under the source dropdown.""" + self._mock_generates(monkeypatch) + hier_yaml = self._write_hierarchy_yaml(tmp_path) + monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", hier_yaml) + catalog_dir = tmp_path / "catalog" + for slug in ["cat/src/game1", "cat/src/game2"]: + _make_image_files(catalog_dir, slug, "efg") + df = _make_df( + _efg_row("cat/src/game1", title="Game One"), + _efg_row("cat/src/game2", title="Game Two"), + ) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert ".. dropdown:: My Category" in rst + assert " .. dropdown:: My Source" in rst + assert "Game One" in rst + assert "Game Two" in rst + + def test_no_list_table_in_output(self, tmp_path, monkeypatch): + """The new output does not use ``.. list-table::`` (replaced by nested dropdowns).""" + self._mock_generates(monkeypatch) + hier_yaml = self._write_hierarchy_yaml(tmp_path) + monkeypatch.setattr(update, "CATALOG_HIERARCHY_CONFIG", hier_yaml) + catalog_dir = tmp_path / "catalog" + slug = "cat/src/game1" + _make_image_files(catalog_dir, slug, "efg") + df = _make_df(_efg_row(slug)) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert ".. list-table::" not in rst + assert ".. contents::" not in rst + + # --------------------------------------------------------------------------- # Tests for update_makefile # --------------------------------------------------------------------------- diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index 96c52f042..f61acc014 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -12,6 +12,7 @@ CATALOG_DIR = Path(__file__).parent.parent.parent / "catalog" MAKEFILE_AM = Path(__file__).parent.parent.parent / "Makefile.am" DRAW_TREE_SETTINGS_CONFIG = Path(__file__).parent / "draw_tree_settings.yaml" +CATALOG_HIERARCHY_CONFIG = Path(__file__).parent / "catalog_hierarchy.yaml" SUPPORTED_GAME_FORMATS = {"efg", "nfg"} @@ -57,200 +58,259 @@ def catalog_ef_file_variants(slug: str, catalog_dir: Path) -> list[dict] | None: variants = [] # Add the primary/default variant - variants.append({ - "label": "Default", - "ef_path": primary_ef, - "variant_key": slug, - }) + variants.append( + { + "label": "Default", + "ef_path": primary_ef, + "variant_key": slug, + } + ) # Add the additional suffix variants for ef_file in additional_efs: - suffix = ef_file.stem[len(stem) + 2:] + suffix = ef_file.stem[len(stem) + 2 :] # noqa: E203 label = suffix.replace("_", " ").title() variant_key = f"{slug}__{suffix}" variants.append({"label": label, "ef_path": ef_file, "variant_key": variant_key}) return variants +def load_hierarchy_labels() -> dict[str, str]: + """Return the human-readable label mapping for catalog hierarchy nodes.""" + with open(CATALOG_HIERARCHY_CONFIG, encoding="utf-8") as f: + config = yaml.safe_load(f) + return config.get("labels", {}) + + +def _node_label(prefix: str, labels: dict[str, str]) -> str: + """Return the display label for a catalog hierarchy node. + + Falls back to title-casing the last path component when the prefix is not + listed in the YAML config. + """ + if prefix in labels: + return labels[prefix] + return Path(prefix).name.replace("_", " ").title() + + +def _build_slug_tree(df: pd.DataFrame) -> dict: + """Build a nested dict tree from the slugs in *df*. + + Intermediate nodes are plain ``dict``s. Leaf nodes (individual games) store + the corresponding ``pd.Series`` row. Slugs are split on ``"/"`` to produce + the nesting. + """ + tree: dict = {} + for _, row in df.iterrows(): + if row.get("Format") not in SUPPORTED_GAME_FORMATS: + continue + if not str(row.get("Description", "")).strip(): + continue + parts = row["Game"].split("/") + node = tree + for part in parts[:-1]: + node = node.setdefault(part, {}) + node[parts[-1]] = row + return tree + + +def _write_game_entry( + f, + row: pd.Series, + slug: str, + catalog_dir: Path, + indent: str, + regenerate_images: bool = False, +) -> None: + """Write RST for a single game entry (dropdown + content) at *indent* depth.""" + i0 = indent # .. dropdown:: title line + i1 = indent + " " # content inside game dropdown + i2 = indent + " " # nested directives (Download, tab-set, jupyter-execute) + i3 = indent + " " # content inside nested directives + i4 = indent + " " # tab-item content + + title = str(row.get("Title", "")).strip() + description = str(row.get("Description", "")).strip() + + ef_variants = catalog_ef_file_variants(slug, catalog_dir) if row["Format"] == "efg" else None + + # ── Image generation ──────────────────────────────────────────────────── + if ef_variants: + _variant_img_exts = ["ef", "tex", "png", "pdf", "svg"] + for variant in ef_variants: + vkey = variant["variant_key"] + variant_paths = [catalog_dir / "img" / f"{vkey}.{ext}" for ext in _variant_img_exts] + if regenerate_images or not all(p.exists() for p in variant_paths): + viz_path = catalog_dir / "img" / vkey + viz_path.parent.mkdir(parents=True, exist_ok=True) + source = ( + str(variant["ef_path"]) + if variant["ef_path"].exists() + else gbt.catalog.load(slug) + ) + for func in [generate_tex, generate_png, generate_pdf, generate_svg]: + func(source, save_to=str(viz_path), **catalog_draw_tree_settings(vkey)) + img_ef = catalog_dir / "img" / f"{vkey}.ef" + if not img_ef.exists() and variant["ef_path"].exists(): + shutil.copy2(variant["ef_path"], img_ef) + else: + all_exts = [] + all_paths = [] + if row["Format"] == "efg": + all_exts.append("ef") + all_paths.append(catalog_dir / "img" / f"{slug}.ef") + all_exts += ["tex", "png", "pdf", "svg"] + for ext in ["tex", "png", "pdf", "svg"]: + all_paths.append(catalog_dir / "img" / f"{slug}.{ext}") + if regenerate_images or not all(p.exists() for p in all_paths): + viz_path = catalog_dir / "img" / slug + viz_path.parent.mkdir(parents=True, exist_ok=True) + curated_ef = catalog_dir / f"{slug}.ef" + source = str(curated_ef) if curated_ef.exists() else gbt.catalog.load(slug) + for func in [generate_tex, generate_png, generate_pdf, generate_svg]: + func(source, save_to=str(viz_path), **catalog_draw_tree_settings(slug)) + img_ef = catalog_dir / "img" / f"{slug}.ef" + if not img_ef.exists() and curated_ef.exists(): + shutil.copy2(curated_ef, img_ef) + + # ── RST output ────────────────────────────────────────────────────────── + f.write(f"{i0}.. dropdown:: {title}\n") + f.write(f"{i0} :open:\n") + f.write(f"{i0}\n") + for line in description.splitlines(): + f.write(f"{i1}{line}\n") + f.write(f"{i1}\n") + f.write(f"{i1}**Load in PyGambit:**\n") + f.write(f"{i1}\n") + f.write(f"{i1}.. code-block:: python\n") + f.write(f"{i1} \n") + f.write(f'{i1} pygambit.catalog.load("{slug}")\n') + f.write(f"{i1}\n") + + # Download links + download_links = [row["Download"]] + if ef_variants: + for variant in ef_variants: + vkey = variant["variant_key"] + for ext in ["ef", "tex", "png", "pdf", "svg"]: + download_links.append(f":download:`{vkey}.{ext} <../catalog/img/{vkey}.{ext}>`") + else: + for ext in all_exts: + download_links.append(f":download:`{slug}.{ext} <../catalog/img/{slug}.{ext}>`") + f.write(f"{i1}.. dropdown:: Download game and image files\n") + f.write(f"{i1} \n") + f.write(f"{i2}{' '.join(download_links)}\n") + f.write(f"{i1}\n") + + # Visualization + if ef_variants: + f.write(f"{i1}.. tab-set::\n") + f.write(f"{i1}\n") + for variant in ef_variants: + label = variant["label"] + vkey = variant["variant_key"] + settings_str = ", ".join( + f"{k}={v!r}" for k, v in catalog_draw_tree_settings(vkey).items() + ) + f.write(f"{i2}.. tab-item:: {label}\n") + f.write(f"{i2}\n") + f.write(f"{i3}.. jupyter-execute::\n") + f.write(f"{i3} :hide-code:\n") + f.write(f"{i3} \n") + f.write(f"{i4}import pygambit\n") + f.write(f"{i4}from draw_tree import draw_tree\n") + if variant["ef_path"].exists(): + f.write(f'{i4}draw_tree("../catalog/{vkey}.ef", {settings_str})\n') + else: + f.write(f'{i4}draw_tree(pygambit.catalog.load("{slug}"), {settings_str})\n') + f.write(f"{i2}\n") + f.write(f"{i1}\n") + else: + f.write(f"{i1}.. jupyter-execute::\n") + f.write(f"{i1} :hide-code:\n") + f.write(f"{i1} \n") + f.write(f"{i2}import pygambit\n") + f.write(f"{i2}from draw_tree import draw_tree\n") + if row["Format"] == "efg": + settings_str = ", ".join( + f"{k}={v!r}" for k, v in catalog_draw_tree_settings(slug).items() + ) + curated_ef = catalog_dir / f"{slug}.ef" + if curated_ef.exists(): + f.write(f'{i2}draw_tree("../catalog/{slug}.ef", {settings_str})\n') + else: + f.write(f'{i2}draw_tree(pygambit.catalog.load("{slug}"), {settings_str})\n') + elif row["Format"] == "nfg": + f.write( + f'{i2}draw_tree(pygambit.catalog.load("{slug}"), ' + f'save_to="../catalog/img/{slug}.png")\n' + ) + f.write(f"{i1}\n") + + +def _write_tree_level( + f, + subtree: dict, + path_prefix: str, + labels: dict[str, str], + catalog_dir: Path, + indent: str, + regenerate_images: bool = False, +) -> None: + """Recursively write RST nested dropdowns for *subtree*. + + Intermediate nodes (``dict`` values) become collapsible ``.. dropdown::`` + sections. Top-level nodes (those whose ``path_prefix`` contains no ``/``) + are rendered open by default so the first level of the hierarchy is + immediately visible. Leaf nodes (``pd.Series`` values) are individual + games rendered via :func:`_write_game_entry`. + """ + for key in sorted(subtree): + value = subtree[key] + child_prefix = f"{path_prefix}/{key}" if path_prefix else key + + if isinstance(value, dict): + label = _node_label(child_prefix, labels) + is_top_level = "/" not in child_prefix + f.write(f"{indent}.. dropdown:: {label}\n") + if is_top_level: + f.write(f"{indent} :open:\n") + f.write(f"{indent}\n") + _write_tree_level( + f, + value, + child_prefix, + labels, + catalog_dir, + indent + " ", + regenerate_images=regenerate_images, + ) + f.write(f"{indent}\n") + else: + _write_game_entry( + f, + value, + child_prefix, + catalog_dir, + indent, + regenerate_images=regenerate_images, + ) + + def generate_rst_table( df: pd.DataFrame, rst_path: Path, regenerate_images: bool = False, catalog_dir: Path | None = None, ): - """Generate RST output with a list-table for games.""" + """Generate RST output with nested dropdowns grouped by catalog hierarchy.""" catalog_dir = catalog_dir or CATALOG_DIR + labels = load_hierarchy_labels() + tree = _build_slug_tree(df) with open(rst_path, "w", encoding="utf-8") as f: - # TOC linking to both sections - f.write(".. contents::\n") - f.write(" :local:\n") - f.write(" :depth: 1\n") - f.write("\n") - f.write(".. list-table::\n") - f.write(" :header-rows: 1\n") - f.write(" :widths: 100\n") - f.write(" :class: tight-table\n") - f.write("\n") - f.write(" * - **Catalog of games**\n") - - for _, row in df.iterrows(): - slug = row["Game"] - title = str(row.get("Title", "")).strip() - description = str(row.get("Description", "")).strip() - # Skip rows with unrecognised formats (defensive guard). - if row["Format"] not in SUPPORTED_GAME_FORMATS: - continue - # Skip any games which lack a description - if not description: - continue - - # Detect whether this EFG has multiple curated .ef layout variants. - ef_variants = ( - catalog_ef_file_variants(slug, catalog_dir) if row["Format"] == "efg" else None - ) - - if ef_variants: - # Multi-variant: generate one image set per variant - _variant_img_exts = ["ef", "tex", "png", "pdf", "svg"] - for variant in ef_variants: - vkey = variant["variant_key"] - variant_paths = [ - catalog_dir / "img" / f"{vkey}.{ext}" for ext in _variant_img_exts - ] - if regenerate_images or not all(p.exists() for p in variant_paths): - viz_path = catalog_dir / "img" / vkey - viz_path.parent.mkdir(parents=True, exist_ok=True) - if variant["ef_path"].exists(): - source = str(variant["ef_path"]) - else: - source = gbt.catalog.load(slug) - for func in [generate_tex, generate_png, generate_pdf, generate_svg]: - func( - source, - save_to=str(viz_path), - **catalog_draw_tree_settings(vkey), - ) - img_ef = catalog_dir / "img" / f"{vkey}.ef" - if not img_ef.exists() and variant["ef_path"].exists(): - shutil.copy2(variant["ef_path"], img_ef) - else: - # Single variant - all_exts = [] - all_paths = [] - if row["Format"] == "efg": - all_exts.append("ef") - all_paths.append(catalog_dir / "img" / f"{slug}.ef") - all_exts += ["tex", "png", "pdf", "svg"] - for ext in ["tex", "png", "pdf", "svg"]: - all_paths.append(catalog_dir / "img" / f"{slug}.{ext}") - missing_any = not all(p.exists() for p in all_paths) - - if regenerate_images or missing_any: - viz_path = catalog_dir / "img" / slug - viz_path.parent.mkdir(parents=True, exist_ok=True) - # Use a committed curated .ef file if present; otherwise derive - # the layout automatically from the game object. - curated_ef = catalog_dir / f"{slug}.ef" - source = str(curated_ef) if curated_ef.exists() else gbt.catalog.load(slug) - for func in [generate_tex, generate_png, generate_pdf, generate_svg]: - func(source, save_to=str(viz_path), **catalog_draw_tree_settings(slug)) - # DrawTree may not write catalog/img/{slug}.ef when its input is - # already an .ef file, so copy it if the img copy is still absent. - img_ef = catalog_dir / "img" / f"{slug}.ef" - if not img_ef.exists() and curated_ef.exists(): - shutil.copy2(curated_ef, img_ef) - - # Main dropdown - f.write(f" * - .. dropdown:: {title}\n") - f.write(" :open:\n") - f.write(" \n") - for line in description.splitlines(): - f.write(f" {line}\n") - - f.write(" \n") - f.write(" **Load in PyGambit:**\n") - f.write(" \n") - f.write(" .. code-block:: python\n") - f.write(" \n") - f.write(f' pygambit.catalog.load("{slug}")\n') - f.write(" \n") - - # Download links - download_links = [row["Download"]] - if ef_variants: - _variant_img_exts = ["ef", "tex", "png", "pdf", "svg"] - for variant in ef_variants: - vkey = variant["variant_key"] - for ext in _variant_img_exts: - download_links.append( - f":download:`{vkey}.{ext} <../catalog/img/{vkey}.{ext}>`" - ) - else: - for ext in all_exts: - download_links.append( - f":download:`{slug}.{ext} <../catalog/img/{slug}.{ext}>`" - ) - f.write(" .. dropdown:: Download game and image files\n") - f.write(" \n") - f.write(f" {' '.join(download_links)}\n") - f.write(" \n") - - # Draw image — tab-set for multiple variants, single block otherwise - if ef_variants: - f.write(" .. tab-set::\n") - f.write(" \n") - for variant in ef_variants: - label = variant["label"] - vkey = variant["variant_key"] - settings = catalog_draw_tree_settings(vkey) - settings_str = ", ".join(f"{k}={val!r}" for k, val in settings.items()) - f.write(f" .. tab-item:: {label}\n") - f.write(" \n") - f.write(" .. jupyter-execute::\n") - f.write(" :hide-code:\n") - f.write(" \n") - f.write(" import pygambit\n") - f.write(" from draw_tree import draw_tree\n") - if variant["ef_path"].exists(): - f.write( - " draw_tree(" - f'"../catalog/{vkey}.ef", {settings_str})\n' - ) - else: - f.write( - f" draw_tree(" - f'pygambit.catalog.load("{slug}"), ' - f"{settings_str})\n" - ) - f.write(" \n") - f.write(" \n") - else: - f.write(" .. jupyter-execute::\n") - f.write(" :hide-code:\n") - f.write(" \n") - f.write(" import pygambit\n") - f.write(" from draw_tree import draw_tree\n") - if row["Format"] == "efg": - settings = catalog_draw_tree_settings(slug) - settings_str = ", ".join(f"{k}={val!r}" for k, val in settings.items()) - curated_ef = catalog_dir / f"{slug}.ef" - if curated_ef.exists(): - f.write( - f' draw_tree("../catalog/{slug}.ef", {settings_str})\n' - ) - else: - f.write( - f" draw_tree(" - f'pygambit.catalog.load("{slug}"), ' - f"{settings_str})\n" - ) - elif row["Format"] == "nfg": - f.write( - f" draw_tree(" - f'pygambit.catalog.load("{slug}"), ' - f'save_to="../catalog/img/{slug}.png")\n' - ) - f.write(" \n") + _write_tree_level( + f, tree, "", labels, catalog_dir, indent="", regenerate_images=regenerate_images + ) def update_makefile( diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index 1db84ff7a..68f0f442e 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -37,11 +37,33 @@ Currently supported representations are: Create a new branch in the ``gambit`` repo. Add your new game file(s) inside the ``catalog`` dir and commit them, or edit an existing game. - If there are multiple games from a particular source, place them in an appropriately named folder. + The catalog uses a hierarchical folder structure that groups games by publication type and venue: + + .. code-block:: text + + catalog/ + books/{author-year}/{game}.efg # games from textbooks + journals/{venue}/{author-year}/{game}.efg # games from journals (venue = geb, ijgt, mor, …) + conf/{venue}/{author-year}/{game}.efg # games from conferences + + The folder path determines the game's slug, used by the load function: + + .. code-block:: python + + pygambit.catalog.load("books/watson2013/exercise29_6") + pygambit.catalog.load("journals/geb/bagwell1995") + + .. note:: + + When adding a game from a **new** journal, conference, or other top-level category, + add a human-readable label for the new hierarchy node(s) to + ``build_support/catalog/catalog_hierarchy.yaml``. + The catalog documentation page groups and labels games based on this file. + Nodes without an entry fall back to a title-cased version of the folder name. .. important:: - The name of the game file will determine it's "slug", used by the load function of the catalog module: + The name of the game file will determine its "slug", used by the load function of the catalog module: .. code-block:: python