From 0236249a74c0e426ae0105dc0151fb341d571123 Mon Sep 17 00:00:00 2001 From: Alex Kaszynski Date: Fri, 8 Aug 2025 17:41:25 -0600 Subject: [PATCH] in-progress --- .gitignore | 8 ++ doc/Makefile | 14 ++- doc/source/conf.py | 33 ++++++- doc/source/make_tables.py | 137 +++++++++++++++++++---------- pyproject.toml | 1 + pyvista/ext/plot_directive.py | 32 +++++-- tests/plotting/test_tinypages.py | 4 +- tests/plotting/tinypages/README.md | 6 ++ tests/plotting/tinypages/index.rst | 6 ++ 9 files changed, 174 insertions(+), 67 deletions(-) diff --git a/.gitignore b/.gitignore index 7b54e9cfc34..c63adf29f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,14 @@ doc/source/api/utilities/color_table doc/source/api/utilities/colormap_table doc/source/api/examples/dataset-gallery **/grid.vtu +doc/api/ +doc/images +doc/my_partitions.vtpd +doc/my_partitions/ +doc/source/api/core/cell_quality +examples/02-plot/airplane.png +examples/02-plot/sphere-shrinking.mp4 +tests/plotting/tinypages/_build # sphinx-tags autogenerated pages doc/source/tags/ diff --git a/doc/Makefile b/doc/Makefile index 450b0e13196..59f4d0240e4 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -18,15 +18,6 @@ help: .PHONY: help Makefile -clean: - rm -rf $(BUILDDIR)/* - rm -rf $(SOURCEDIR)/examples/ - rm -f errors.txt - rm -f sphinx_warnings.txt - rm -rf $(SOURCEDIR)/images/auto-generated - rm -f $(SOURCEDIR)/getting-started/external_examples.rst - find $(SOURCEDIR) -type d -name "_autosummary" -exec rm -rf {} + - rm -rf $(SOURCEDIR)/tags clean-except-examples: rm -rf $(BUILDDIR)/* @@ -34,8 +25,13 @@ clean-except-examples: rm -f sphinx_warnings.txt rm -rf $(SOURCEDIR)/images/auto-generated rm -f $(SOURCEDIR)/getting-started/external_examples.rst + rm -rf $(SOURCEDIR)/tags + rm -rf $(SOURCEDIR)/api/examples/dataset-gallery/ find $(SOURCEDIR) -type d -name "_autosummary" -exec rm -rf {} + +clean: clean-except-examples + rm -rf $(SOURCEDIR)/examples/ + # remove autosummary files clean-autosummary: find $(SOURCEDIR) -type d -name "_autosummary" -exec rm -rf {} + diff --git a/doc/source/conf.py b/doc/source/conf.py index e3bff466c77..59e15854754 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -1,7 +1,7 @@ """Documentation configuration.""" from __future__ import annotations - +import warnings import datetime import faulthandler import importlib.util @@ -17,6 +17,7 @@ faulthandler.enable() + # This flag is set *before* any pyvista import. It allows `pyvista.core._typing_core._aliases` to # import things like `scipy` or `matplotlib` that would be unnecessarily bulky to import by default # during normal operation. See https://github.com/pyvista/pyvista/pull/7023. @@ -27,9 +28,10 @@ import make_external_gallery import make_tables -make_external_gallery.make_example_gallery() +# make_external_gallery.make_example_gallery() make_tables.make_all_tables() + # -- pyvista configuration --------------------------------------------------- import pyvista from pyvista.core.errors import PyVistaDeprecationWarning @@ -345,7 +347,6 @@ # The full version, including alpha/beta/rc tags. release = pyvista.__version__ - # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # @@ -384,6 +385,11 @@ def __call__(self, gallery_conf, fname): # noqa: ARG002 If default documentation settings are modified in any example, reset here. """ + # set backend each time to avoid mpl from showing mpl figures + import matplotlib as mpl + + mpl.use('Agg', force=True) # must import before pyvista + import pyvista pyvista._wrappers['vtkPolyData'] = pyvista.PolyData @@ -426,6 +432,7 @@ def __repr__(self): 'reset_modules': (reset_pyvista,), 'reset_modules_order': 'both', 'junit': str(Path('sphinx-gallery') / 'junit-results.xml'), + 'parallel': True, # use as many same jobs as the -j flag for Sphinx } suppress_warnings = ['config.cache'] @@ -671,7 +678,27 @@ def get_version_match(semver): locale_dirs = ['../../pyvista-doc-translations/locale'] +def _report_parallel_safety(app, *_): + """Raise an error if an extension is blocking a parallel build.""" + if app.parallel: + for name, ext in sorted(app.extensions.items()): + read_safe = getattr(ext, 'parallel_read_safe', None) + write_safe = getattr(ext, 'parallel_write_safe', None) + if read_safe is not True or write_safe is not True: + msg = ( + f'Parallel build enabled but extension "{name}" is not fully parallel ' + 'safe (read_safe={read_safe}, write_safe={write_safe})' + ) + raise RuntimeError(msg) + + +def _update_placeholders(_, env): + return make_tables.update_placeholders(env) + + def setup(app): # noqa: D103 + app.connect('config-inited', _report_parallel_safety) + app.connect('env-updated', _update_placeholders) app.connect('html-page-context', pv_html_page_context) app.add_css_file('copybutton.css') app.add_css_file('no_search_highlight.css') diff --git a/doc/source/make_tables.py b/doc/source/make_tables.py index 72247dcaf7c..1fb3fff34af 100644 --- a/doc/source/make_tables.py +++ b/doc/source/make_tables.py @@ -18,6 +18,7 @@ import re import sys import textwrap +from typing import Union from typing import TYPE_CHECKING from typing import Any from typing import ClassVar @@ -33,6 +34,8 @@ import matplotlib.pyplot as plt import numpy as np from scipy.stats import linregress +from sphinx.environment import BuildEnvironment +from docutils import nodes import pyvista as pv from pyvista import _validation @@ -98,6 +101,8 @@ def __str__(self) -> str: 'dual_sphere_animation': '.gif', } +PLACEHOLDER = 'GALLERY-URI-PLACEHOLDER' + def _aligned_dedent(txt): """Variant of `textwrap.dedent`. @@ -1939,7 +1944,8 @@ def generate(self): img_path = self._create_default_image() else: # Use the first image generated by the .. pyvista_plot:: directive - filename = f'{module_name}-{func_name}-1_00_00{ext}' + # filename = f'{module_name}-{func_name}-1_00_00{ext}' + filename = f'{module_name}-{func_name}-{PLACEHOLDER}_00_00{ext}' img_path = Path(DATASET_GALLERY_IMAGE_DIR, filename).as_posix() # Get rst file and instance metadata @@ -3319,6 +3325,8 @@ def make_all_carousels(carousels: list[DatasetGalleryCarousel]): # noqa: D103 # Clear loaded datasets from memory DatasetCardFetcher.clear_datasets() + return [carousel.path for carousel in carousels] + CAROUSEL_LIST = [ AllDatasetsCarousel, @@ -3349,54 +3357,91 @@ def make_all_carousels(carousels: list[DatasetGalleryCarousel]): # noqa: D103 def make_all_tables(): # noqa: D103 # Make cell quality tables - os.makedirs(CELL_QUALITY_DIR, exist_ok=True) - CellQualityMeasuresTable.generate() - CellQualityInfoTableTRIANGLE.generate() - CellQualityInfoTableQUAD.generate() - CellQualityInfoTableHEXAHEDRON.generate() - CellQualityInfoTableTETRA.generate() - CellQualityInfoTableWEDGE.generate() - CellQualityInfoTablePYRAMID.generate() - - # Make colormap tables - os.makedirs(COLORMAP_IMAGE_DIR, exist_ok=True) - os.makedirs(COLORMAP_TABLE_DIR, exist_ok=True) - ColormapTableLINEAR.generate() - ColormapTableDIVERGING.generate() - ColormapTableMULTISEQUENTIAL.generate() - ColormapTableCYCLIC.generate() - ColormapTableCATEGORICAL.generate() - ColormapTableMISC.generate() - CETColormapTableLINEAR.generate() - CETColormapTableDIVERGING.generate() - CETColormapTableCYCLIC.generate() - CETColormapTableRAINBOW.generate() - CETColormapTableISOLUMINANT.generate() - - # Make color and chart tables - os.makedirs(CHARTS_IMAGE_DIR, exist_ok=True) - os.makedirs(COLORS_TABLE_DIR, exist_ok=True) - LineStyleTable.generate() - MarkerStyleTable.generate() - ColorSchemeTable.generate() - ColorTable.generate() - ColorTableGRAY.generate() - ColorTableWHITE.generate() - ColorTableBLACK.generate() - ColorTableRED.generate() - ColorTableORANGE.generate() - ColorTableBROWN.generate() - ColorTableYELLOW.generate() - ColorTableGREEN.generate() - ColorTableCYAN.generate() - ColorTableBLUE.generate() - ColorTableVIOLET.generate() - ColorTableMAGENTA.generate() + # os.makedirs(CELL_QUALITY_DIR, exist_ok=True) + # CellQualityMeasuresTable.generate() + # CellQualityInfoTableTRIANGLE.generate() + # CellQualityInfoTableQUAD.generate() + # CellQualityInfoTableHEXAHEDRON.generate() + # CellQualityInfoTableTETRA.generate() + # CellQualityInfoTableWEDGE.generate() + # CellQualityInfoTablePYRAMID.generate() + + # # Make colormap tables + # os.makedirs(COLORMAP_IMAGE_DIR, exist_ok=True) + # os.makedirs(COLORMAP_TABLE_DIR, exist_ok=True) + # ColormapTableLINEAR.generate() + # ColormapTableDIVERGING.generate() + # ColormapTableMULTISEQUENTIAL.generate() + # ColormapTableCYCLIC.generate() + # ColormapTableCATEGORICAL.generate() + # ColormapTableMISC.generate() + # CETColormapTableLINEAR.generate() + # CETColormapTableDIVERGING.generate() + # CETColormapTableCYCLIC.generate() + # CETColormapTableRAINBOW.generate() + # CETColormapTableISOLUMINANT.generate() + + # # Make color and chart tables + # os.makedirs(CHARTS_IMAGE_DIR, exist_ok=True) + # os.makedirs(COLORS_TABLE_DIR, exist_ok=True) + # LineStyleTable.generate() + # MarkerStyleTable.generate() + # ColorSchemeTable.generate() + # ColorTable.generate() + # ColorTableGRAY.generate() + # ColorTableWHITE.generate() + # ColorTableBLACK.generate() + # ColorTableRED.generate() + # ColorTableORANGE.generate() + # ColorTableBROWN.generate() + # ColorTableYELLOW.generate() + # ColorTableGREEN.generate() + # ColorTableCYAN.generate() + # ColorTableBLUE.generate() + # ColorTableVIOLET.generate() + # ColorTableMAGENTA.generate() # Make dataset gallery carousels os.makedirs(DATASET_GALLERY_DIR, exist_ok=True) - make_all_carousels(CAROUSEL_LIST) + return make_all_carousels(CAROUSEL_LIST) + + +def _update_image_placeholders(doctree: nodes.document) -> bool: + def find_matching_image(filename_with_placeholder: str) -> Union[bool, str]: + basename = Path(filename_with_placeholder).name.replace(PLACEHOLDER, '*') + + # move up one since sphinx runs at the Makefile directory + gallery_path = Path(DATASET_GALLERY_IMAGE_DIR).relative_to('..') + actual_file = next(Path(gallery_path).glob(basename), None) + if actual_file: + return True, str(Path(node['uri']).parent / actual_file.name) + return False, filename_with_placeholder + + updated = False + for node in doctree.traverse(nodes.image): + uri = node.get('uri', '') + if PLACEHOLDER in uri: + updated, new_uri = find_matching_image(uri) + node['uri'] = new_uri + + return updated + + +def update_placeholders(env: BuildEnvironment) -> list[nodes.document]: + """Update the image placeholders in a document.""" + updated_docs = [] + for docname in list(env.found_docs): + if docname.startswith('api/examples/dataset-gallery'): + doctree = env.get_doctree(docname) + updated = _update_image_placeholders(doctree) + if updated: + env.write_doctree(docname, doctree) # persist changes + + breakpoint() + return updated_docs if __name__ == '__main__': - make_all_tables() + new_rsts = make_all_tables() + print('Generated rsts:') + print('\n '.join(new_rsts)) diff --git a/pyproject.toml b/pyproject.toml index c86b5eded57..b4cf42f03be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,6 +116,7 @@ docs = [ 'enum-tools==0.13.0', 'imageio-ffmpeg==0.6.0', 'imageio==2.37.0', + 'joblib==1.5.1', 'jupyter_sphinx==0.5.3', 'jupyterlab==4.4.5', 'lxml==6.0.0', diff --git a/pyvista/ext/plot_directive.py b/pyvista/ext/plot_directive.py index a3260d20d84..377255c4734 100644 --- a/pyvista/ext/plot_directive.py +++ b/pyvista/ext/plot_directive.py @@ -80,6 +80,14 @@ directive is executed is controlled by the ``plot_skip_optional`` boolean variable in :file:`conf.py`. + output-name : str + If specified, this string is used as the base name for the generated + output files instead of the automatically computed hash-based name. + The resulting image files will follow the standard numbering scheme + (e.g., ``--__00.png``). This + allows referencing the image by a predictable, fixed path in other RST + files, avoiding the need for wildcards or post-build path replacement. + Additionally, this directive supports all the options of the `image` directive, except for *target* (since plot will add its own target). These include *alt*, *height*, *width*, *scale*, *align*. @@ -126,7 +134,7 @@ """ from __future__ import annotations - +import hashlib import doctest import os from os.path import relpath @@ -203,6 +211,7 @@ class PlotDirective(Directive): 'force_static': directives.flag, 'skip': _option_boolean, 'optional': directives.flag, + 'output-name': directives.unchanged, } def run(self): @@ -540,6 +549,16 @@ def render_figures( return results +def hash_plot_code(code: str, options: dict) -> str: + parts = [ + 'pvplot:v1', + 'ctx=' + str('context' in options), + code, + ] + h = hashlib.sha256('\n'.join(parts).encode('utf-8')).hexdigest() + return h[:16] # 16 is sufficient + + def run(arguments, content, options, state_machine, state, lineno): # noqa: PLR0917 """Run the plot directive.""" document = state_machine.document @@ -547,7 +566,6 @@ def run(arguments, content, options, state_machine, state, lineno): # noqa: PLR nofigs = 'nofigs' in options optional = 'optional' in options force_static = 'force_static' in options - default_fmt = 'png' options.setdefault('include-source', config.pyvista_plot_include_source) @@ -560,6 +578,7 @@ def run(arguments, content, options, state_machine, state, lineno): # noqa: PLR rst_file = document.attributes['source'] rst_dir = str(Path(rst_file).parent) + function_name = None if len(arguments): if not config.pyvista_plot_basedir: @@ -589,15 +608,14 @@ def run(arguments, content, options, state_machine, state, lineno): # noqa: PLR source_file_name = rst_file code = textwrap.dedent('\n'.join(map(str, content))) - # note: this reuses the existing matplotlib plot counter if available - counter = document.attributes.get('_plot_counter', 0) + 1 - document.attributes['_plot_counter'] = counter base = Path(source_file_name).stem ext = Path(source_file_name).suffix - output_base = f'{base}-{counter}{ext}' - function_name = None caption = options.get('caption', '') + # always provide a unique hash + code_hash = hash_plot_code(code, options) + output_base = f'{base}-{code_hash}{ext}' + base = Path(output_base).stem source_ext = Path(output_base).suffix if source_ext in ('.py', '.rst', '.txt'): diff --git a/tests/plotting/test_tinypages.py b/tests/plotting/test_tinypages.py index 30d0afe0bfb..d5dacfcc5c5 100644 --- a/tests/plotting/test_tinypages.py +++ b/tests/plotting/test_tinypages.py @@ -16,8 +16,8 @@ pytest.importorskip('sphinx') # skip all tests if unable to render -if not system_supports_plotting(): - pytestmark = pytest.mark.skip(reason='Requires system to support plotting') +# if not system_supports_plotting(): +# pytestmark = pytest.mark.skip(reason='Requires system to support plotting') ENVIRONMENT_HOOKS = ['PYVISTA_PLOT_SKIP', 'PYVISTA_PLOT_SKIP_OPTIONAL'] diff --git a/tests/plotting/tinypages/README.md b/tests/plotting/tinypages/README.md index bf7a3eb2c34..87984536fc4 100644 --- a/tests/plotting/tinypages/README.md +++ b/tests/plotting/tinypages/README.md @@ -1,3 +1,9 @@ # Test Project for PyVista Sphinx Extensions A tiny sphinx project from `sphinx-quickstart` with all default answers. + +Build with: + +``` +sphinx-build -b html . _build/html -v +``` diff --git a/tests/plotting/tinypages/index.rst b/tests/plotting/tinypages/index.rst index 4cff2c12306..2709c49efef 100644 --- a/tests/plotting/tinypages/index.rst +++ b/tests/plotting/tinypages/index.rst @@ -8,6 +8,12 @@ Contents: some_plots some_autodocs + single_autodoc + +.. |my_figure| image:: sc000.png + +|my_figure| + Indices and Tables ==================