diff --git a/.circleci/config.yml b/.circleci/config.yml index e4e2a89f3..399418db4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -41,8 +41,7 @@ jobs: numpy matplotlib seaborn statsmodels pillow joblib \ "sphinx!=7.3.2,!=7.3.3,!=7.3.4,!=7.3.5,!=7.3.6" pytest \ traits memory_profiler "ipython!=8.7.0" plotly graphviz \ - "git+https://github.com/pyvista/pyvista" \ - "docutils>=0.18" imageio pydata-sphinx-theme \ + "pyvista>=0.44.0" "docutils>=0.18" imageio pydata-sphinx-theme \ "jupyterlite-sphinx>=0.8.0,<0.9.0" "jupyterlite-pyodide-kernel<0.1.0" \ libarchive-c "sphinxcontrib-video>=0.2.1rc0" intersphinx_registry pip uninstall -yq vtk # pyvista installs vtk above diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 772406b4a..f97cc15dc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.2 + rev: v0.5.6 hooks: - id: ruff-format exclude: plot_syntaxerror @@ -35,7 +35,7 @@ repos: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: 2.1.4 + rev: 2.2.1 hooks: - id: pyproject-fmt additional_dependencies: [tox] diff --git a/CHANGES.rst b/CHANGES.rst index bf8e23ec1..632114566 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,35 @@ Changelog ========= +v0.17.1 +------- + +**Fixed bugs:** + +- FIX: Fix stability of stored compiled regex `#1369 `__ (`larsoner `__) +- ENH: Improve \_sanitize_rst `#1366 `__ (`timhoffm `__) +- Obey prefer_full_module setting when finding backreferences `#1364 `__ (`QuLogic `__) +- Fix linking to class attributes with prefer_full_module `#1363 `__ (`QuLogic `__) +- Improve minigallery directive path input resolution `#1360 `__ (`lucyleeow `__) +- FIX Allow str path minigallery entries when backreferences off `#1355 `__ (`lucyleeow `__) +- FIX generate zipfiles when index passed by user `#1353 `__ (`lucyleeow `__) + +**Documentation** + +- DOC Improve doc about joblib warnings `#1367 `__ (`lucyleeow `__) +- DOC add note on filtering joblib warnings `#1362 `__ (`lucyleeow `__) +- DOC Minor update to minigallery directive doc `#1358 `__ (`lucyleeow `__) + +**Project maintenance** + +- [pre-commit.ci] pre-commit autoupdate `#1368 `__ (`pre-commit-ci[bot] `__) +- MNT Change mark and fixture names for adding files `#1365 `__ (`lucyleeow `__) +- MNT Add warning when ‘examples_dirs’ and ‘gallery_dirs’ unequal lengths `#1361 `__ (`lucyleeow `__) +- [pre-commit.ci] pre-commit autoupdate `#1357 `__ (`pre-commit-ci[bot] `__) +- Update pyvista in doc CI `#1352 `__ (`lucyleeow `__) +- [pre-commit.ci] pre-commit autoupdate `#1351 `__ (`pre-commit-ci[bot] `__) +- MNT Bump version `#1350 `__ (`lucyleeow `__) + v0.17.0 ------- diff --git a/doc/configuration.rst b/doc/configuration.rst index e36d6e2ec..8d3762d36 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -44,13 +44,14 @@ file, inside a ``sphinx_gallery_conf`` dictionary. - ``abort_on_example_error`` (:ref:`abort_on_first`) - ``expected_failing_examples`` (:ref:`dont_fail_exit`) - ``only_warn_on_example_error`` (:ref:`warning_on_error`) +- ``reset_modules`` and ``reset_modules_order`` (:ref:`reset_modules`) - ``parallel`` (:ref:`parallel`) **Cross-referencing** - ``reference_url``, ``prefer_full_module`` (:ref:`link_to_documentation`) - ``backreferences_dir``, ``doc_module``, ``exclude_implicit_doc``, - and ``inspect_global_variables`` (:ref:`references_to_examples`) + and ``inspect_global_variables`` (:ref:`minigalleries_to_examples`) - ``minigallery_sort_order`` (:ref:`minigallery_order`) **Images and thumbnails** @@ -86,7 +87,6 @@ file, inside a ``sphinx_gallery_conf`` dictionary. **Miscellaneous** -- ``reset_modules`` and ``reset_modules_order`` (:ref:`reset_modules`) - ``recommender`` (:ref:`recommend_examples`) - ``log_level`` (:ref:`log_level`) - ``show_api_usage`` and ``api_usage_ignore`` (:ref:`show_api_usage`) @@ -656,36 +656,51 @@ Add mini-galleries Sphinx-Gallery provides the :class:`sphinx_gallery.directives.MiniGallery` directive so that you can easily add a reduced version of the Gallery to -your Sphinx documentation ``.rst`` files. The mini-gallery directive therefore +your Sphinx documentation ``.rst`` files. The minigallery directive therefore supports passing a list (space separated) of any of the following: -* full qualified name of object (see :ref:`references_to_examples`) +* fully qualified name of object (see :ref:`references_to_examples`) - this + adds all examples where the object was used in the code or referenced in + the example text * pathlike strings to example Python files, including glob-style (see :ref:`file_based_minigalleries`) +To use object names, you must enable backreference generation, see +:ref:`references_to_examples` for details. +If backreference generation is not enabled, object entries to the +:class:`~sphinx_gallery.directives.MiniGallery` directive will be ignored +and all entries will be treated as pathlike strings or glob-style pathlike strings. +See :ref:`file_based_minigalleries` for details. + .. _references_to_examples: Add mini-galleries for API documentation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -When documenting a given function/method/attribute/object/class, Sphinx-Gallery -enables you to link to any examples that either: +Sphinx-Gallery can generate minigalleries for objects from specified modules, +consisting of all examples that either: 1. Use the function/method/attribute/object or instantiate the class in the - code (generates *implicit backreferences*). + code (called *implicit backreferences*) or 2. Refer to that function/method/attribute/object/class using sphinx markup ``:func:`` / ``:meth:`` / ``:attr:`` / ``:obj:`` / ``:class:`` in a text block. You can omit this role markup if you have set the `default_role `_ - in your ``conf.py`` to any of these roles (generates *explicit + in your ``conf.py`` to any of these roles (called *explicit backreferences*). -The former is useful for auto-documenting functions/methods/attributes/objects -that are used and classes that are explicitly instantiated. The generated links -are called implicit backreferences. The latter is useful for classes that are -typically implicitly returned rather than explicitly instantiated (e.g., +This allows you to pass a fully qualified name of an object (e.g., function, method, +attribute, class) to the minigallery directive to add a minigallery of all examples +relevant to that object. This can be useful in API documentation. + +**Implicit backreferences** are useful for auto-documenting objects +that are used and classes that are explicitly instantiated, in the code. Any examples +where an object is used in the code are added *implicitly* as backreferences. +**Explicit backreferences** are for objects that are *explicitly* referred to +in an example's text. They are useful for classes that are +typically implicitly returned in the code rather than explicitly instantiated (e.g., :class:`matplotlib.axes.Axes` which is most often instantiated only indirectly -within function calls). Such links are called explicit backreferences. +within function calls).. For example, we can embed a small gallery of all examples that use or refer to :obj:`numpy.exp`, which looks like this: @@ -713,9 +728,14 @@ your Sphinx-Gallery configuration ``conf.py`` file with:: The path you specify in ``backreferences_dir`` (here we choose ``gen_modules/backreferences``) will be populated with -ReStructuredText files. Each .rst file will contain a reduced version of the +ReStructuredText files, with names ending with '.examples'. +Each .rst file will contain a reduced version of the gallery specific to every function/class that is used across all the examples and belonging to the modules listed in ``doc_module``. +Note that backreference files will be generated for all objects. Objects that +are not used in any example will have an empty file to prevent inclusion +errors during autodoc parsing. + ``backreferences_dir`` should be a string or ``pathlib.Path`` object that is **relative** to the ``conf.py`` file, or ``None``. It is ``None`` by default. @@ -2135,6 +2155,20 @@ If an ``int``, then that number of jobs will be passed to :class:`joblib.Paralle If ``True``, then the same number of jobs will be used as the ``-j`` flag for Sphinx. +Warnings emitted by :mod:`joblib` during documentation building (e.g., the +``UserWarning`` about a +`worker restarting `_) are emitted +during gallery generation at the same time as warnings from example +code execution. These can be filtered out with +``warnings.filterwarnings`` (see :ref:`removing_warnings`). This is particularly +important to do if you have tweaked warning handling in your documentation build +to treat warnings as errors, e.g., with a line like +``warnings.filterwarnings("error)`` which converts all warnings into errors. In +this case, if joblib emits a warning during build of an example, this example will fail +unexpectedly unless they are filtered out. Note that this differs from the warnings +affected by the ``- W`` / ``--fail-on-warning`` ``sphinx-build`` flag, which converts +Sphinx warnings during documentation building into errors. + .. warning:: Some packages might not play nicely with parallel processing, so this feature is considered **experimental**! diff --git a/pyproject.toml b/pyproject.toml index a2110d719..60e5a80f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -166,5 +166,6 @@ filterwarnings = [ ] junit_family = "xunit2" markers = [ - "conf_file: Configuration file.", + "add_conf: Configuration file.", + "add_rst: Add rst file to src.", ] diff --git a/sphinx_gallery/__init__.py b/sphinx_gallery/__init__.py index f774e775e..06e8a7650 100644 --- a/sphinx_gallery/__init__.py +++ b/sphinx_gallery/__init__.py @@ -8,7 +8,7 @@ # dev versions should have "dev" in them, stable should not. # doc/conf.py makes use of this to set the version drop-down. -__version__ = "0.17.0" +__version__ = "0.17.1" def glr_path_static(): diff --git a/sphinx_gallery/_dummy/__init__.py b/sphinx_gallery/_dummy/__init__.py new file mode 100644 index 000000000..422465a2c --- /dev/null +++ b/sphinx_gallery/_dummy/__init__.py @@ -0,0 +1,14 @@ +from .nested import NestedDummyClass # noqa: F401 + + +class DummyClass: + """Dummy class for testing method resolution.""" + + def run(self): + """Do nothing.""" + pass + + @property + def prop(self): + """Property.""" + return "Property" diff --git a/sphinx_gallery/_dummy/nested.py b/sphinx_gallery/_dummy/nested.py new file mode 100644 index 000000000..7f1d3f007 --- /dev/null +++ b/sphinx_gallery/_dummy/nested.py @@ -0,0 +1,11 @@ +class NestedDummyClass: + """Nested dummy class for testing method resolution.""" + + def run(self): + """Do nothing.""" + pass + + @property + def prop(self): + """Property.""" + return "Property" diff --git a/sphinx_gallery/backreferences.py b/sphinx_gallery/backreferences.py index e955ec616..f99202dbf 100644 --- a/sphinx_gallery/backreferences.py +++ b/sphinx_gallery/backreferences.py @@ -16,6 +16,7 @@ import sphinx.util from sphinx.errors import ExtensionError +from ._dummy import DummyClass # noqa: F401 from .scrapers import _find_image_ext from .utils import _W_KW, _replace_md5 @@ -37,19 +38,6 @@ """ -class DummyClass: - """Dummy class for testing method resolution.""" - - def run(self): - """Do nothing.""" - pass - - @property - def prop(self): - """Property.""" - return "Property" - - class NameFinder(ast.NodeVisitor): """Finds the longest form of variable names and their imports in code. diff --git a/sphinx_gallery/directives.py b/sphinx_gallery/directives.py index 97c98bc25..ff5a91aa8 100644 --- a/sphinx_gallery/directives.py +++ b/sphinx_gallery/directives.py @@ -8,6 +8,7 @@ from docutils.parsers.rst import Directive, directives from docutils.parsers.rst.directives import images from sphinx.errors import ExtensionError +from sphinx.util.logging import getLogger from .backreferences import ( THUMBNAIL_PARENT_DIV, @@ -17,6 +18,8 @@ from .gen_rst import extract_intro_and_title from .py_source_parser import split_code_and_text_blocks +logger = getLogger("sphinx-gallery") + class MiniGallery(Directive): """Custom directive to insert a mini-gallery. @@ -50,6 +53,52 @@ class MiniGallery(Directive): "heading-level": directives.single_char_or_unicode, } + def _get_target_dir(self, config, src_dir, path, obj): + """Get thumbnail target directory, errors when ambiguous/not in example dir.""" + examples_dirs = config.sphinx_gallery_conf["examples_dirs"] + if not isinstance(examples_dirs, list): + examples_dirs = [examples_dirs] + gallery_dirs = config.sphinx_gallery_conf["gallery_dirs"] + if not isinstance(gallery_dirs, list): + gallery_dirs = [gallery_dirs] + + gal_matches = [] + for e, g in zip(examples_dirs, gallery_dirs): + e = Path(src_dir, e).resolve(strict=True) + g = Path(src_dir, g).resolve(strict=True) + try: + gal_matches.append((path.relative_to(e), g)) + except ValueError: + continue + + # `path` inside one `examples_dirs` + if (n_match := len(gal_matches)) == 1: + ex_parents, target_dir = ( + gal_matches[0][0].parents, + gal_matches[0][1], + ) + # `path` inside several `examples_dirs`, take the example dir with + # longest match (shortest parents after relative) + elif n_match > 1: + ex_parents, target_dir = min( + [(match[0].parents, match[1]) for match in gal_matches], + key=lambda x: len(x[0]), + ) + # `path` is not inside a `examples_dirs` + else: + raise ExtensionError( + f"minigallery directive error: path input '{obj}' found file:" + f" '{path}' but this does not live inside any examples_dirs: " + f"{examples_dirs}" + ) + + # Add subgallery path, if present + subdir = "" + if (ex_p := ex_parents[0]) != Path("."): + subdir = ex_p + target_dir = target_dir / subdir + return target_dir + def run(self): """Generate mini-gallery from backreference and example files.""" from .gen_rst import _get_callables @@ -67,12 +116,16 @@ def run(self): # Retrieve the backreferences directory config = self.state.document.settings.env.config backreferences_dir = config.sphinx_gallery_conf["backreferences_dir"] + if backreferences_dir is None: + logger.warning( + "'backreferences_dir' config is None, minigallery " + "directive will resolve all inputs as file paths or globs." + ) # Retrieve source directory src_dir = config.sphinx_gallery_conf["src_dir"] # Parse the argument into the individual objects - obj_list = [] if self.arguments: @@ -100,10 +153,10 @@ def has_backrefs(obj): file_paths = [] for obj in obj_list: - if path := has_backrefs(obj): - file_paths.append((obj, path)) + if backreferences_dir and (path := has_backrefs(obj)): + file_paths.append((obj, path.resolve())) elif paths := Path(src_dir).glob(obj): - file_paths.extend([(obj, p) for p in paths]) + file_paths.extend([(obj, p.resolve()) for p in paths]) if len(file_paths) == 0: return [] @@ -119,7 +172,7 @@ def has_backrefs(obj): ) for obj, path in sorted( set(file_paths), - key=((lambda x: sortkey(os.path.abspath(x[-1]))) if sortkey else None), + key=((lambda x: sortkey(str(x[-1]))) if sortkey else None), ): if path.suffix == ".examples": # Insert the backreferences file(s) using the `include` directive. @@ -131,24 +184,8 @@ def has_backrefs(obj): :end-before: thumbnail-parent-div-close""" ) else: - dirs = [ - (e, g) - for e, g in zip( - config.sphinx_gallery_conf["examples_dirs"], - config.sphinx_gallery_conf["gallery_dirs"], - ) - if (obj.find(e) != -1) - ] - if len(dirs) != 1: - raise ExtensionError( - f"Error in gallery lookup: input={obj}, matches={dirs}, " - f"examples={config.sphinx_gallery_conf['examples_dirs']}" - ) - - example_dir, target_dir = [Path(src_dir, d) for d in dirs[0]] - - # finds thumbnails in subdirs - target_dir = target_dir / path.relative_to(example_dir).parent + target_dir = self._get_target_dir(config, src_dir, path, obj) + # Get thumbnail _, script_blocks = split_code_and_text_blocks( str(path), return_node=False ) diff --git a/sphinx_gallery/docs_resolv.py b/sphinx_gallery/docs_resolv.py index 6f0c5c0fa..ae1ce6e70 100644 --- a/sphinx_gallery/docs_resolv.py +++ b/sphinx_gallery/docs_resolv.py @@ -199,14 +199,12 @@ def _get_index_match(self, first, second): def _get_link_type(self, cobj, use_full_module=False): """Get a valid link and type_, False if not found.""" - module_type = "module_short" - if use_full_module: - module_type = "module" + module_type = "module" if use_full_module else "module_short" first, second = cobj[module_type], cobj["name"] match = self._get_index_match(first, second) if match is None and "." in second: # possible class attribute first, second = second.split(".", 1) - first = ".".join([cobj["module_short"], first]) + first = ".".join([cobj[module_type], first]) match = self._get_index_match(first, second) if match is None: link = type_ = None diff --git a/sphinx_gallery/gen_gallery.py b/sphinx_gallery/gen_gallery.py index f8b3c7b1d..c3b29f4a9 100644 --- a/sphinx_gallery/gen_gallery.py +++ b/sphinx_gallery/gen_gallery.py @@ -159,9 +159,11 @@ def _update_gallery_conf_exclude_implicit_doc(gallery_conf): This is separate function for better testability. """ - # prepare regex for exclusions from implicit documentation + # prepare regex for exclusions from implicit documentation, ensuring that what + # gets complied has a stable __repr__ (i.e., by sorting the exclude_implicit_doc + # set before joining) exclude_regex = ( - re.compile("|".join(gallery_conf["exclude_implicit_doc"])) + re.compile("|".join(sorted(gallery_conf["exclude_implicit_doc"]))) if gallery_conf["exclude_implicit_doc"] else False ) @@ -567,6 +569,12 @@ def _prepare_sphx_glr_dirs(gallery_conf, srcdir): if not isinstance(gallery_dirs, list): gallery_dirs = [gallery_dirs] + if len(examples_dirs) != len(gallery_dirs): + logger.warning( + "'examples_dirs' and 'gallery_dirs' are of different lengths. " + "Surplus entries will be ignored." + ) + if bool(gallery_conf["backreferences_dir"]): backreferences_dir = os.path.join(srcdir, gallery_conf["backreferences_dir"]) os.makedirs(backreferences_dir, exist_ok=True) @@ -608,13 +616,15 @@ def _finish_index_rst( ) indexst += subsections_toctree - if sg_root_index: - # Download examples - if gallery_conf["download_all_examples"]: - download_fhindex = generate_zipfiles( - gallery_dir_abs_path, app.builder.srcdir, gallery_conf - ) + # Always generate download zipfiles, only add to index.rst if required + if gallery_conf["download_all_examples"]: + download_fhindex = generate_zipfiles( + gallery_dir_abs_path, app.builder.srcdir, gallery_conf + ) + if sg_root_index: indexst += download_fhindex + + if sg_root_index: # Signature if app.config.sphinx_gallery_conf["show_signature"]: indexst += SPHX_GLR_SIG diff --git a/sphinx_gallery/gen_rst.py b/sphinx_gallery/gen_rst.py index 02fc269b6..74f691d71 100644 --- a/sphinx_gallery/gen_rst.py +++ b/sphinx_gallery/gen_rst.py @@ -284,8 +284,12 @@ def _sanitize_rst(string): # ``whatever thing`` --> whatever thing p = r"(\s|^)`" string = re.sub(p + r"`([^`]+)`" + e, r"\1\2\3", string) + # `~mymodule.MyClass` --> MyClass + string = re.sub(p + r"~([^`]+)" + e, _regroup, string) + # `whatever thing` --> whatever thing - string = re.sub(p + r"([^`]+)" + e, r"\1\2\3", string) + # `.MyClass` --> MyClass + string = re.sub(p + r"\.?([^`]+)" + e, r"\1\2\3", string) # **string** --> string string = re.sub(r"\*\*([^\*]*)\*\*", r"\1", string) @@ -1234,8 +1238,16 @@ def _get_backreferences(gallery_conf, script_vars, script_blocks, node, target_f if example_code_obj: _write_code_obj(target_file, example_code_obj) exclude_regex = gallery_conf["exclude_implicit_doc_regex"] + + def _normalize_name(cobj): + full_name = "{module}.{name}".format(**cobj) + for pattern in gallery_conf["prefer_full_module"]: + if re.search(pattern, full_name): + return full_name + return "{module_short}.{name}".format(**cobj) + backrefs = { - "{module_short}.{name}".format(**cobj) + _normalize_name(cobj) for cobjs in example_code_obj.values() for cobj in cobjs if cobj["module"].startswith(gallery_conf["doc_module"]) @@ -1335,7 +1347,7 @@ def generate_file_rst(fname, target_dir, src_dir, gallery_conf): script_blocks, script_vars, gallery_conf, file_conf ) - logger.debug("%s ran in : %.2g seconds\n", src_file, time_elapsed) + logger.debug("%s ran in : %.2g seconds", src_file, time_elapsed) # Create dummy images _make_dummy_images(executable, file_conf, script_vars) diff --git a/sphinx_gallery/tests/conftest.py b/sphinx_gallery/tests/conftest.py index 8bd44abce..4087a4177 100644 --- a/sphinx_gallery/tests/conftest.py +++ b/sphinx_gallery/tests/conftest.py @@ -1,9 +1,9 @@ """Pytest fixtures.""" -import os import shutil from contextlib import contextmanager from io import StringIO +from pathlib import Path from unittest.mock import Mock import pytest @@ -16,6 +16,23 @@ from sphinx_gallery.scrapers import _import_matplotlib from sphinx_gallery.utils import _get_image +NESTED_PY = """\"\"\" +Header +====== + +Text. +\"\"\" + +a = 1 +""" + +GALLERY_HEADER = """ +Gallery header +============== + +Some text. +""" + def pytest_report_header(config, startdir=None): """Add information to the pytest run header.""" @@ -71,9 +88,12 @@ def unicode_sample(tmpdir): from sphinx_gallery.back_references import identify_names identify_names -from sphinx_gallery.back_references import DummyClass +from sphinx_gallery._dummy import DummyClass DummyClass().prop +from sphinx_gallery._dummy.nested import NestedDummyClass +NestedDummyClass().prop + import matplotlib.pyplot as plt _ = plt.figure() @@ -120,9 +140,9 @@ def req_pil(): @pytest.fixture def conf_file(request): try: - env = request.node.get_closest_marker("conf_file") + env = request.node.get_closest_marker("add_conf") except AttributeError: # old pytest - env = request.node.get_marker("conf_file") + env = request.node.get_marker("add_conf") kwargs = env.kwargs if env else {} result = { "content": "", @@ -133,6 +153,16 @@ def conf_file(request): return result +@pytest.fixture +def rst_file(request): + try: + env = request.node.get_closest_marker("add_rst") + except AttributeError: # old pytest + env = request.node.get_marker("add_rst") + file = env.kwargs["file"] if env else "" + return file + + class SphinxAppWrapper: """Wrapper for sphinx.application.Application. @@ -181,13 +211,24 @@ def build_sphinx_app(self, *args, **kwargs): @pytest.fixture -def sphinx_app_wrapper(tmpdir, conf_file, req_mpl, req_pil): - _fixturedir = os.path.join(os.path.dirname(__file__), "testconfs") - srcdir = os.path.join(str(tmpdir), "config_test") +def sphinx_app_wrapper(tmpdir, conf_file, rst_file, req_mpl, req_pil): + _fixturedir = Path(__file__).parent / "testconfs" + srcdir = Path(tmpdir) / "config_test" shutil.copytree(_fixturedir, srcdir) - shutil.copytree( - os.path.join(_fixturedir, "src"), os.path.join(str(tmpdir), "examples") - ) + # Copy files to 'examples/' as well because default `examples_dirs` is + # '../examples' - for tests where we don't update config + shutil.copytree((_fixturedir / "src"), (Path(tmpdir) / "examples")) + if rst_file: + with open((srcdir / "minigallery_test.rst"), "w") as rstfile: + rstfile.write(rst_file) + # Add nested gallery + if "sub_folder/sub_sub_folder" in rst_file: + dir_path = srcdir / "src" / "sub_folder" / "sub_sub_folder" + dir_path.mkdir(parents=True) + with open((dir_path / "plot_nested.py"), "w") as pyfile: + pyfile.write(NESTED_PY) + with open((dir_path / "GALLERY_HEADER.rst"), "w") as rstfile: + rstfile.write(GALLERY_HEADER) base_config = f""" import os @@ -199,14 +240,14 @@ def sphinx_app_wrapper(tmpdir, conf_file, req_mpl, req_pil): # General information about the project. project = 'Sphinx-Gallery '\n\n """ - with open(os.path.join(srcdir, "conf.py"), "w") as conffile: + with open((srcdir / "conf.py"), "w") as conffile: conffile.write(base_config + conf_file["content"]) return SphinxAppWrapper( srcdir, srcdir, - os.path.join(srcdir, "_build"), - os.path.join(srcdir, "_build", "toctree"), + (srcdir / "_build"), + (srcdir / "_build" / "toctree"), "html", warning=StringIO(), status=StringIO(), diff --git a/sphinx_gallery/tests/test_backreferences.py b/sphinx_gallery/tests/test_backreferences.py index 634c5d22a..ae2190bfc 100644 --- a/sphinx_gallery/tests/test_backreferences.py +++ b/sphinx_gallery/tests/test_backreferences.py @@ -51,6 +51,11 @@ "this and that; and these things and those things", False, ), + ( + "See `.MyClass` and `~.MyClass.close`", + "See MyClass and close", + False, + ), ], ) def test_thumbnail_div(content, tooltip, is_backref): @@ -128,8 +133,17 @@ def test_identify_names(unicode_sample, gallery_conf): "DummyClass": [ { "name": "DummyClass", - "module": "sphinx_gallery.back_references", - "module_short": "sphinx_gallery.back_references", + "module": "sphinx_gallery._dummy", + "module_short": "sphinx_gallery._dummy", + "is_class": False, + "is_explicit": False, + } + ], + "NestedDummyClass": [ + { + "name": "NestedDummyClass", + "module": "sphinx_gallery._dummy.nested", + "module_short": "sphinx_gallery._dummy", "is_class": False, "is_explicit": False, } diff --git a/sphinx_gallery/tests/test_full.py b/sphinx_gallery/tests/test_full.py index e4cf0df24..8aa519638 100644 --- a/sphinx_gallery/tests/test_full.py +++ b/sphinx_gallery/tests/test_full.py @@ -54,7 +54,7 @@ # (examples + examples_rst_index + examples_with_rst + examples_README_header + root-level) N_EXECUTE = 2 + 3 + 1 + 1 + 1 # gen_modules + sg_api_usage + doc/index.rst + minigallery.rst -N_OTHER = 9 + 1 + 1 + 1 + 1 +N_OTHER = 11 + 1 + 1 + 1 + 1 N_RST = N_EXAMPLES + N_PASS + N_INDEX + N_EXECUTE + N_OTHER N_RST = f"({N_RST}|{N_RST - 1}|{N_RST - 2})" # AppVeyor weirdness @@ -83,7 +83,14 @@ def _sphinx_app(tmpdir_factory, buildername): src_dir = Path(__file__).parent / "tinybuild" def ignore(src, names): - return ("_build", "gen_modules", "auto_examples") + return ( + "_build", + "gen_modules", + "auto_examples", + "auto_examples_README_header", + "auto_examples_rst_index", + "auto_examples_with_rst", + ) shutil.copytree(src_dir, temp_dir, ignore=ignore) # For testing iteration, you can get similar behavior just doing `make` @@ -308,6 +315,13 @@ def test_run_sphinx(sphinx_app): assert re.match(want, warning, re.DOTALL) is not None, warning +def test_user_index_download(sphinx_app): + """Test download zipfiles still generated when user supplies index.rst""" + src_dir = Path(sphinx_app.srcdir) / "auto_examples_rst_index" + assert (src_dir / "auto_examples_rst_index_jupyter.zip").is_file() + assert (src_dir / "auto_examples_rst_index_python.zip").is_file() + + def test_thumbnail_path(sphinx_app, tmpdir): """Test sphinx_gallery_thumbnail_path.""" import numpy as np @@ -534,25 +548,49 @@ def test_embed_links_and_styles(sphinx_app): # issue 617 (regex '-'s) # instance dummy_class_inst = re.search( - r'sphinx_gallery.backreferences.html#sphinx[_,-]gallery[.,-]backreferences[.,-][D,d]ummy[C,c]lass" title="sphinx_gallery.backreferences.DummyClass" class="sphx-glr-backref-module-sphinx_gallery-backreferences sphx-glr-backref-type-py-class sphx-glr-backref-instance">dc', # noqa: E501 + r'sphinx_gallery._dummy.html#sphinx[_-]gallery[.-]_dummy[.-][Dd]ummy[Cc]lass" title="sphinx_gallery._dummy.DummyClass" class="sphx-glr-backref-module-sphinx_gallery-_dummy sphx-glr-backref-type-py-class sphx-glr-backref-instance">dc', # noqa: E501 + lines, + ) + assert dummy_class_inst is not None + # class + dummy_class_class = re.search( + r'sphinx_gallery._dummy.html#sphinx[_-]gallery[.-]_dummy[.-][Dd]ummy[Cc]lass" title="sphinx_gallery._dummy.DummyClass" class="sphx-glr-backref-module-sphinx_gallery-_dummy sphx-glr-backref-type-py-class">sphinx_gallery._dummy.DummyClass', # noqa: E501 + lines, + ) + assert dummy_class_class is not None + # method + dummy_class_meth = re.search( + r'sphinx_gallery._dummy.html#sphinx[_-]gallery[.-]_dummy[.-][Dd]ummy[Cc]lass[.-]run" title="sphinx_gallery._dummy.DummyClass.run" class="sphx-glr-backref-module-sphinx_gallery-_dummy sphx-glr-backref-type-py-method">dc.run', # noqa: E501 + lines, + ) + assert dummy_class_meth is not None + # property (Sphinx 2+ calls it a method rather than attribute, so regex) + dummy_class_prop = re.compile( + r'sphinx_gallery._dummy.html#sphinx[_-]gallery[.-]_dummy[.-][Dd]ummy[Cc]lass[.-]prop" title="sphinx_gallery._dummy.DummyClass.prop" class="sphx-glr-backref-module-sphinx_gallery-_dummy sphx-glr-backref-type-py-(attribute|method|property)">dc.prop' + ) # noqa: E501 + assert dummy_class_prop.search(lines) is not None + # gh-1364: methods of nested classes in the module currently being documented + # instance + dummy_class_inst = re.search( + r'sphinx_gallery._dummy.nested.html#sphinx[_-]gallery[.-]_dummy[.-]nested[.-][Nn]ested[Dd]ummy[Cc]lass" title="sphinx_gallery._dummy.NestedDummyClass" class="sphx-glr-backref-module-sphinx_gallery-_dummy sphx-glr-backref-type-py-class sphx-glr-backref-instance">ndc', # noqa: E501 lines, ) assert dummy_class_inst is not None # class dummy_class_class = re.search( - r'sphinx_gallery.backreferences.html#sphinx[_,-]gallery[.,-]backreferences[.,-][D,d]ummy[C,c]lass" title="sphinx_gallery.backreferences.DummyClass" class="sphx-glr-backref-module-sphinx_gallery-backreferences sphx-glr-backref-type-py-class">sphinx_gallery.backreferences.DummyClass', # noqa: E501 + r'sphinx_gallery._dummy.nested.html#sphinx[_-]gallery[.-]_dummy[.-]nested[.-][Nn]ested[Dd]ummy[Cc]lass" title="sphinx_gallery._dummy.NestedDummyClass" class="sphx-glr-backref-module-sphinx_gallery-_dummy sphx-glr-backref-type-py-class">sphinx_gallery._dummy.nested.NestedDummyClass', # noqa: E501 lines, ) assert dummy_class_class is not None # method dummy_class_meth = re.search( - r'sphinx_gallery.backreferences.html#sphinx[_,-]gallery[.,-]backreferences[.,-][D,d]ummy[C,c]lass[.,-]run" title="sphinx_gallery.backreferences.DummyClass.run" class="sphx-glr-backref-module-sphinx_gallery-backreferences sphx-glr-backref-type-py-method">dc.run', # noqa: E501 + r'sphinx_gallery._dummy.nested.html#sphinx[_-]gallery[.-]_dummy[.-]nested[.-][Nn]ested[Dd]ummy[Cc]lass[.-]run" title="sphinx_gallery._dummy.NestedDummyClass.run" class="sphx-glr-backref-module-sphinx_gallery-_dummy sphx-glr-backref-type-py-method">ndc.run', # noqa: E501 lines, ) assert dummy_class_meth is not None # property (Sphinx 2+ calls it a method rather than attribute, so regex) dummy_class_prop = re.compile( - r'sphinx_gallery.backreferences.html#sphinx[_,-]gallery[.,-]backreferences[.,-][D,d]ummy[C,c]lass[.,-]prop" title="sphinx_gallery.backreferences.DummyClass.prop" class="sphx-glr-backref-module-sphinx_gallery-backreferences sphx-glr-backref-type-py-(attribute|method|property)">dc.prop' + r'sphinx_gallery._dummy.nested.html#sphinx[_-]gallery[.-]_dummy[.-]nested[.-][Nn]ested[Dd]ummy[Cc]lass[.-]prop" title="sphinx_gallery._dummy.NestedDummyClass.prop" class="sphx-glr-backref-module-sphinx_gallery-_dummy sphx-glr-backref-type-py-(attribute|method|property)">ndc.prop' ) # noqa: E501 assert dummy_class_prop.search(lines) is not None @@ -652,19 +690,70 @@ def test_backreferences_examples_html(sphinx_app): regex = re.compile(r' SyntaxError bad_code = f"""\ ''' @@ -451,7 +461,7 @@ def test_failing_examples_raise_exception(sphinx_app_wrapper): {bad_line} """ bad_line_no = bad_code.split("\n").index(bad_line) + 1 - with open(os.path.join(example_dir, "plot_3.py"), "w", encoding="utf-8") as fid: + with open(Path(example_dir, "plot_3.py"), "w", encoding="utf-8") as fid: fid.write(bad_code) with pytest.raises(ExtensionError) as excinfo: sphinx_app_wrapper.build_sphinx_app() @@ -462,7 +472,7 @@ def test_failing_examples_raise_exception(sphinx_app_wrapper): assert bad_line in tb -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" sphinx_gallery_conf = { 'examples_dirs': 'src', @@ -478,7 +488,7 @@ def test_expected_failing_examples_were_executed(sphinx_app_wrapper): sphinx_app_wrapper.build_sphinx_app() -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" sphinx_gallery_conf = { 'examples_dirs': 'src', @@ -498,7 +508,7 @@ def test_only_warn_on_example_error(sphinx_app_wrapper): assert "WARNING: Here is a summary of the problems" in build_warn -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" sphinx_gallery_conf = { 'examples_dirs': 'src', @@ -518,7 +528,7 @@ def test_only_warn_on_example_error_sphinx_warning(sphinx_app_wrapper): assert "plot_3.py unexpectedly failed to execute" in exc -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" sphinx_gallery_conf = { 'examples_dirs': 'src', @@ -533,12 +543,13 @@ def test_examples_not_expected_to_pass(sphinx_app_wrapper): assert "expected to fail, but not failing" in exc -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" from sphinx_gallery.gen_rst import _sg_call_memory_noop sphinx_gallery_conf = { 'show_memory': _sg_call_memory_noop, + 'examples_dirs': 'src', 'gallery_dirs': 'ex', }""" ) @@ -553,13 +564,13 @@ def test_show_memory_callable(sphinx_app_wrapper): [ pytest.param( id="first notebook cell", - marks=pytest.mark.conf_file( + marks=pytest.mark.add_conf( content="""sphinx_gallery_conf = {'first_notebook_cell': 2,}""" ), ), pytest.param( id="last notebook cell", - marks=pytest.mark.conf_file( + marks=pytest.mark.add_conf( content="""sphinx_gallery_conf = {'last_notebook_cell': 2,}""" ), ), @@ -572,7 +583,7 @@ def test_notebook_cell_config(sphinx_app_wrapper): fill_gallery_conf_defaults(app, app.config, check_keys=False) -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" sphinx_gallery_conf = { 'backreferences_dir': False, @@ -585,7 +596,21 @@ def test_backreferences_dir_config(sphinx_app_wrapper): fill_gallery_conf_defaults(app, app.config, check_keys=False) -@pytest.mark.conf_file( +@pytest.mark.add_conf( + content=""" +sphinx_gallery_conf = { + 'examples_dirs': 'src', + 'gallery_dirs': 'ex', +}""" +) +def test_minigallery_no_backreferences_dir(sphinx_app_wrapper): + """Check warning when no backreferences_dir set but minigallery directive used.""" + sphinx_app = sphinx_app_wrapper.build_sphinx_app() + build_warn = sphinx_app._warning.getvalue() + assert "'backreferences_dir' config is None, minigallery" in build_warn + + +@pytest.mark.add_conf( content=""" import pathlib @@ -599,6 +624,59 @@ def test_backreferences_dir_pathlib_config(sphinx_app_wrapper): fill_gallery_conf_defaults(app, app.config, check_keys=False) +@pytest.mark.add_conf( + content=""" +sphinx_gallery_conf = { + 'examples_dirs': 'src', + 'gallery_dirs': 'ex', +}""" +) +@pytest.mark.add_rst( + file=""" +Header +====== + +.. minigallery:: index.rst +""" +) +def test_minigallery_not_in_examples_dirs(sphinx_app_wrapper): + """Check error when minigallery directive's path input not in `examples_dirs`.""" + msg = "minigallery directive error: path input 'index.rst'" + with pytest.raises(ExtensionError, match=msg): + sphinx_app_wrapper.build_sphinx_app() + + +@pytest.mark.add_conf( + content=""" +sphinx_gallery_conf = { + 'examples_dirs': ['src', 'src/sub_folder/sub_sub_folder'], + 'gallery_dirs': ['ex', 'ex/sub_folder/sub_sub_folder'], +}""" +) +@pytest.mark.add_rst( + file=""" +Header +====== + +.. minigallery:: src/sub_folder/sub_sub_folder/plot_nested.py +""" +) +def test_minigallery_multi_match(sphinx_app_wrapper): + """Check minigallery directive's path input resolution in nested `examples_dirs`. + + When a examples gallery is nested inside another examples gallery, path inputs + from the nested gallery should resolve to the nested gallery. + """ + sphinx_app = sphinx_app_wrapper.build_sphinx_app() + minigallery_html = Path(sphinx_app.outdir) / "minigallery_test.html" + with open(minigallery_html, "r") as fid: + mg_html = fid.read() + # Check thumbnail correct + assert "_images/sphx_glr_plot_nested_thumb.png" in mg_html + # Check href correct + assert "sphx-glr-ex-sub-folder-sub-sub-folder-plot-nested-py" in mg_html + + def test_write_computation_times_noop(sphinx_app_wrapper): app = sphinx_app_wrapper.create_sphinx_app() write_computation_times(app.config.sphinx_gallery_conf, None, []) @@ -608,7 +686,7 @@ def test_write_api_usage_noop(sphinx_app_wrapper): write_api_entry_usage(sphinx_app_wrapper.create_sphinx_app(), list(), None) -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" sphinx_gallery_conf = { 'pypandoc': ['list',], @@ -624,7 +702,7 @@ def test_pypandoc_config_list(sphinx_app_wrapper): fill_gallery_conf_defaults(app, app.config, check_keys=False) -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" sphinx_gallery_conf = { 'pypandoc': {'bad key': 1}, @@ -639,7 +717,7 @@ def test_pypandoc_config_keys(sphinx_app_wrapper): fill_gallery_conf_defaults(app, app.config, check_keys=False) -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" extensions += ['jupyterlite_sphinx'] @@ -658,17 +736,15 @@ def test_create_jupyterlite_contents(sphinx_app_wrapper): create_jupyterlite_contents(sphinx_app, exception=None) for i_file in ["plot_1", "plot_2", "plot_3"]: - assert os.path.exists( - os.path.join( - sphinx_app.srcdir, - "jupyterlite_contents", - gallery_conf["gallery_dirs"][0], - i_file + ".ipynb", - ) - ) + assert Path( + sphinx_app.srcdir, + "jupyterlite_contents", + gallery_conf["gallery_dirs"][0], + i_file + ".ipynb", + ).exists() -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" extensions += ['jupyterlite_sphinx'] @@ -688,17 +764,15 @@ def test_create_jupyterlite_contents_non_default_contents(sphinx_app_wrapper): create_jupyterlite_contents(sphinx_app, exception=None) for i_file in ["plot_1", "plot_2", "plot_3"]: - assert os.path.exists( - os.path.join( - sphinx_app.srcdir, - "this_is_the_contents_dir", - gallery_conf["gallery_dirs"][0], - i_file + ".ipynb", - ) - ) + assert Path( + sphinx_app.srcdir, + "this_is_the_contents_dir", + gallery_conf["gallery_dirs"][0], + i_file + ".ipynb", + ).exists() -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" sphinx_gallery_conf = { 'backreferences_dir' : os.path.join('modules', 'gen'), @@ -714,10 +788,10 @@ def test_create_jupyterlite_contents_without_jupyterlite_sphinx_loaded( sphinx_app = sphinx_app_wrapper.create_sphinx_app() create_jupyterlite_contents(sphinx_app, exception=None) - assert not os.path.exists(os.path.join(sphinx_app.srcdir, "jupyterlite_contents")) + assert not Path(sphinx_app.srcdir, "jupyterlite_contents").exists() -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" extensions += ['jupyterlite_sphinx'] @@ -739,10 +813,10 @@ def test_create_jupyterlite_contents_with_jupyterlite_disabled_via_config( sphinx_app = sphinx_app_wrapper.create_sphinx_app() create_jupyterlite_contents(sphinx_app, exception=None) - assert not os.path.exists(os.path.join(sphinx_app.outdir, "jupyterlite_contents")) + assert not Path(sphinx_app.outdir, "jupyterlite_contents").exists() -@pytest.mark.conf_file( +@pytest.mark.add_conf( content=""" extensions += ['jupyterlite_sphinx'] @@ -773,13 +847,13 @@ def test_create_jupyterlite_contents_with_modification(sphinx_app_wrapper): create_jupyterlite_contents(sphinx_app, exception=None) for i_file in ["plot_1", "plot_2", "plot_3"]: - notebook_filename = os.path.join( + notebook_filename = Path( sphinx_app.srcdir, "jupyterlite_contents", gallery_conf["gallery_dirs"][0], i_file + ".ipynb", ) - assert os.path.exists(notebook_filename) + assert notebook_filename.exists() with open(notebook_filename) as f: notebook_content = json.load(f) diff --git a/sphinx_gallery/tests/test_load_style.py b/sphinx_gallery/tests/test_load_style.py index bd25186d2..b4f816b57 100644 --- a/sphinx_gallery/tests/test_load_style.py +++ b/sphinx_gallery/tests/test_load_style.py @@ -5,7 +5,7 @@ import pytest -@pytest.mark.conf_file(extensions=["sphinx_gallery.load_style"]) +@pytest.mark.add_conf(extensions=["sphinx_gallery.load_style"]) def test_load_style(sphinx_app_wrapper): """Testing that style loads properly.""" sphinx_app = sphinx_app_wrapper.build_sphinx_app() diff --git a/sphinx_gallery/tests/testconfs/src/plot_1.py b/sphinx_gallery/tests/testconfs/src/plot_1.py index e8c020735..f87aecd6e 100644 --- a/sphinx_gallery/tests/testconfs/src/plot_1.py +++ b/sphinx_gallery/tests/testconfs/src/plot_1.py @@ -9,3 +9,7 @@ print("foo") print("bar") print("again") + +# %% +# +# .. minigallery:: src/plot_2.py diff --git a/sphinx_gallery/tests/tinybuild/doc/conf.py b/sphinx_gallery/tests/tinybuild/doc/conf.py index d2b32206f..9aae10661 100644 --- a/sphinx_gallery/tests/tinybuild/doc/conf.py +++ b/sphinx_gallery/tests/tinybuild/doc/conf.py @@ -38,6 +38,7 @@ sphinx_gallery_conf = { "doc_module": ("sphinx_gallery",), + "prefer_full_module": {r"sphinx_gallery\._dummy"}, "reference_url": { "sphinx_gallery": None, "scipy": "http://docs.scipy.org/doc/scipy/wrong_url", # bad one diff --git a/sphinx_gallery/tests/tinybuild/doc/index.rst b/sphinx_gallery/tests/tinybuild/doc/index.rst index 532559237..7c35c4bab 100644 --- a/sphinx_gallery/tests/tinybuild/doc/index.rst +++ b/sphinx_gallery/tests/tinybuild/doc/index.rst @@ -17,6 +17,8 @@ every module. Examples `here `_. :template: module.rst backreferences + _dummy + _dummy.nested docs_resolv downloads gen_gallery diff --git a/sphinx_gallery/tests/tinybuild/examples/plot_numpy_matplotlib.py b/sphinx_gallery/tests/tinybuild/examples/plot_numpy_matplotlib.py index d68134bc4..b5bf7c675 100644 --- a/sphinx_gallery/tests/tinybuild/examples/plot_numpy_matplotlib.py +++ b/sphinx_gallery/tests/tinybuild/examples/plot_numpy_matplotlib.py @@ -20,6 +20,7 @@ from matplotlib.figure import Figure import sphinx_gallery.backreferences +import sphinx_gallery._dummy.nested from sphinx_gallery.py_source_parser import Block t = np.arange(N) / float(N) @@ -47,6 +48,10 @@ sphinx_gallery.backreferences._make_ref_regex(), ) # 583: methods don't link properly -dc = sphinx_gallery.backreferences.DummyClass() +dc = sphinx_gallery._dummy.DummyClass() dc.run() print(dc.prop) +# 1364: nested methods don't link properly +ndc = sphinx_gallery._dummy.nested.NestedDummyClass() +ndc.run() +print(ndc.prop)