diff --git a/docs/_sphinxext/aeon_mini_gallery.py b/docs/_sphinxext/aeon_mini_gallery.py new file mode 100644 index 0000000000..3676613486 --- /dev/null +++ b/docs/_sphinxext/aeon_mini_gallery.py @@ -0,0 +1,256 @@ +"""Aeon Mini-Gallery Sphinx Extension. + +Automatically injects example notebook galleries into API reference pages +by scanning notebooks for class usage via AST parsing. +""" + +import ast +import re +from pathlib import Path + +import nbformat +from docutils import nodes +from docutils.parsers.rst import Directive +from sphinx.util import logging, relative_uri + +logger = logging.getLogger(__name__) + + +class AeonUsageVisitor(ast.NodeVisitor): + """AST visitor that detects Aeon object usage in notebook code cells. + + Tracks imports, resolves aliases, and identifies which fully-qualified + Aeon class names are actually instantiated or called in the code. + """ + + def __init__(self): + self.symbol_table = {} + self.module_aliases = {} + self.used_objects = set() + + def visit_Import(self, node): + """Handle 'import aeon.x' statements and track module aliases.""" + for alias in node.names: + if alias.name.startswith("aeon"): + local = alias.asname or alias.name + self.module_aliases[local] = alias.name + self.generic_visit(node) + + def visit_ImportFrom(self, node): + """Handle 'from aeon.x import Y' statements.""" + if node.module and node.module.startswith("aeon"): + for alias in node.names: + local = alias.asname or alias.name + fq = f"{node.module}.{alias.name}" + self.symbol_table[local] = fq + self.generic_visit(node) + + def visit_Call(self, node): + """Detect function/method calls and record used Aeon objects.""" + fq = self._resolve(node.func) + if fq: + self.used_objects.add(fq) + self.generic_visit(node) + + def visit_Attribute(self, node): + """Resolve chained attribute access like aeon.x.Y to fully-qualified names.""" + fq = self._resolve(node) + if fq: + self.used_objects.add(fq) + self.generic_visit(node) + + def visit_Name(self, node): + """Resolve simple name references using the symbol table.""" + if node.id in self.symbol_table: + self.used_objects.add(self.symbol_table[node.id]) + self.generic_visit(node) + + def _resolve(self, node): + """Resolve an AST node to its fully-qualified Aeon object name. + + Handles direct names, module aliases, and chained attribute access. + """ + if isinstance(node, ast.Name): + return self.symbol_table.get(node.id) + + if isinstance(node, ast.Attribute): + chain = [] + while isinstance(node, ast.Attribute): + chain.append(node.attr) + node = node.value + if isinstance(node, ast.Name): + chain.append(node.id) + chain.reverse() + + root = chain[0] + + if root in self.symbol_table: + base = self.symbol_table[root] + suffix = ".".join(chain[1:]) + return f"{base}.{suffix}" if suffix else base + + if root in self.module_aliases: + base = self.module_aliases[root] + suffix = ".".join(chain[1:]) + return f"{base}.{suffix}" + + return None + + +def scan_notebooks(app): + """Scan all notebooks in examples directory and build class→notebook mapping. + + Parses each notebook's code cells using AST, detects Aeon object usage, + and stores the mapping in app.env.aeon_example_map for later use. + """ + logger.info("Scanning notebooks for aeon object usage...") + + example_dir = (Path(app.srcdir) / "../examples").resolve() + mapping = {} + + for nb_path in example_dir.rglob("*.ipynb"): + try: + nb = nbformat.read(nb_path, as_version=4) + except Exception: + continue + + visitor = AeonUsageVisitor() + + for cell in nb.cells: + if cell.cell_type != "code": + continue + try: + visitor.visit(ast.parse(cell.source)) + except Exception: + continue + + rel_path = nb_path.relative_to(example_dir).with_suffix("") + + for fq_name in visitor.used_objects: + mapping.setdefault(fq_name, []).append(str(rel_path)) + + app.env.aeon_example_map = mapping + logger.info(f"Object-example mapping built: {len(mapping)} objects found.") + + +def build_thumbnail_map(app): + """Parse examples.md to build notebook→thumbnail image mapping. + + Extracts :img-top: and :link: fields from MyST grid cards and stores + the mapping in app.env.aeon_thumbnail_map for gallery rendering. + """ + logger.info("Building thumbnail map from examples.md") + + examples_md = Path(app.srcdir) / "examples.md" + + if not examples_md.exists(): + logger.warning("examples.md not found.") + app.env.aeon_thumbnail_map = {} + return + + content = examples_md.read_text() + + pattern = re.compile( + r":img-top:\s*(?P.+?)\s+.*?" r":link:\s*/examples/(?P.+?)\.ipynb", + re.DOTALL, + ) + + thumbnail_map = {} + + for match in pattern.finditer(content): + img = match.group("img").strip() + nb = match.group("nb").strip() + thumbnail_map[nb] = img + + app.env.aeon_thumbnail_map = thumbnail_map + + logger.info(f"Thumbnail mapping built: {len(thumbnail_map)} entries.") + + +class AeonMiniGalleryDirective(Directive): + """Sphinx directive that renders a mini-gallery of example notebooks. + + Usage: .. aeon-mini-gallery:: ClassName + + Looks up relevant examples from the scanned notebook mapping and + renders clickable cards with thumbnails linking to the example pages. + """ + + required_arguments = 1 + + def run(self): + """Execute the directive: look up examples and render gallery HTML.""" + env = self.state.document.settings.env + app = env.app + builder = app.builder + + object_name = self.arguments[0] + + example_map = getattr(env, "aeon_example_map", {}) + thumbnail_map = getattr(env, "aeon_thumbnail_map", {}) + + fq_name = None + for key in example_map: + if key.endswith(f".{object_name}"): + fq_name = key + break + + if not fq_name: + return [] + + examples = sorted(set(example_map.get(fq_name, []))) + if not examples: + return [] + + page_uri = builder.get_target_uri(env.docname) + + section = nodes.section() + section["ids"].append("gallery-examples") + + title = nodes.title(text="Gallery Examples") + section += title + + html_blocks = ['") + + section += nodes.raw("", "\n".join(html_blocks), format="html") + + return [section] + + +def setup(app): # noqa: D103 + app.connect("builder-inited", scan_notebooks) + app.connect("builder-inited", build_thumbnail_map) + + app.add_directive("aeon-mini-gallery", AeonMiniGalleryDirective) + app.add_css_file("css/aeon_gallery.css") + + return { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_static/css/aeon_gallery.css b/docs/_static/css/aeon_gallery.css new file mode 100644 index 0000000000..8cff07e755 --- /dev/null +++ b/docs/_static/css/aeon_gallery.css @@ -0,0 +1,54 @@ +.aeon-gallery-wrapper { + margin-top: 2.5rem; +} + +.aeon-gallery-heading { + font-size: 1.9rem; + font-weight: 600; + margin-bottom: 1.8rem; +} + +.aeon-mini-gallery { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.8rem; +} + +.aeon-mini-card { + display: block; + text-decoration: none; + background: white; + border-radius: 12px; + overflow: hidden; + border: 1px solid #eaeaea; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + transition: all 0.2s ease; +} + +.aeon-mini-card:hover { + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); +} + +.aeon-mini-image { + aspect-ratio: 4 / 3; + background: #f8f9fa; + display: flex; + align-items: center; + justify-content: center; +} + +.aeon-mini-image img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + transition: transform 0.3s ease; +} + +.aeon-mini-title { + padding: 0.5rem 0.8rem; + font-size: 0.9rem; + font-weight: 500; + color: #2c2c2c; + line-height: 1.1; +} diff --git a/docs/_templates/class.rst b/docs/_templates/class.rst index e129e8a752..1859c4f4c3 100644 --- a/docs/_templates/class.rst +++ b/docs/_templates/class.rst @@ -4,3 +4,5 @@ .. currentmodule:: {{ module }} .. autoclass:: {{ objname }} + +.. aeon-mini-gallery:: {{ objname }} diff --git a/docs/_templates/function.rst b/docs/_templates/function.rst index f5676ee83c..dc9c8c6fdc 100644 --- a/docs/_templates/function.rst +++ b/docs/_templates/function.rst @@ -4,3 +4,5 @@ .. currentmodule:: {{ module }} .. autofunction:: {{ objname }} + +.. aeon-mini-gallery:: {{ objname }} diff --git a/docs/conf.py b/docs/conf.py index b2781ffab5..1f3d3590af 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,6 +54,7 @@ "myst_parser", # local extensions (_sphinxext/) "sphinx_remove_toctrees", + "aeon_mini_gallery", ] # Add any paths that contain templates here, relative to this directory. @@ -291,7 +292,10 @@ # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] -html_css_files = ["css/custom.css"] +html_css_files = [ + "css/custom.css", + "css/aeon_gallery.css", +] html_show_sourcelink = False