Repository: sphinx-gallery/sphinx-gallery

Issue:
Allow .rst extension for README files
https://github.com/sphinx-gallery/sphinx-gallery/blob/cd6eaed237b00ed505b3faa095694c2d66afe49d/sphinx_gallery/gen_gallery.py#L272

Currently, SG forces the required README files to have the `.txt` extension. I imagine almost every project using SG is formatting these README files in RST so allowing an `.rst` extension makes sense.

Why does this matter? It makes viewing the example directory on GitHub a bit more user-friendly as GitHub will render RST properly when the file has an `.rst` extension. See this page for example: https://github.com/pyvista/pyvista/tree/master/examples

A newcomer to the project might explore the example directory in the repo before heading to the gallery on the docs and I'd much rather have a rendered page there than unwelcoming, raw RST

Discussion:
larsoner: (@banesullivan I hope you were not really keen to work on this, I looked at the code briefly and realized it was more than a ~5 line change so took the time to refactor a bit...)
banesullivan: Thanks @larsoner - I was happy to help but hadn't had a chance to look through it so thanks for handling this!
codecov-io: # [Codecov](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510?src=pr&el=h1) Report
> Merging [#510](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510?src=pr&el=desc) into [master](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/commit/cd6eaed237b00ed505b3faa095694c2d66afe49d?src=pr&el=desc) will **decrease** coverage by `1.19%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510/graphs/tree.svg?width=650&token=K4eytvhwU5&height=150&src=pr)](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##           master     #510     +/-   ##
=========================================
- Coverage   96.26%   95.07%   -1.2%     
=========================================
  Files          29       29             
  Lines        2760     2780     +20     
=========================================
- Hits         2657     2643     -14     
- Misses        103      137     +34
```


| [Impacted Files](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [sphinx\_gallery/gen\_gallery.py](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510/diff?src=pr&el=tree#diff-c3BoaW54X2dhbGxlcnkvZ2VuX2dhbGxlcnkucHk=) | `91.36% <100%> (-1%)` | :arrow_down: |
| [sphinx\_gallery/tests/test\_gen\_rst.py](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510/diff?src=pr&el=tree#diff-c3BoaW54X2dhbGxlcnkvdGVzdHMvdGVzdF9nZW5fcnN0LnB5) | `98.78% <100%> (+0.04%)` | :arrow_up: |
| [sphinx\_gallery/gen\_rst.py](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510/diff?src=pr&el=tree#diff-c3BoaW54X2dhbGxlcnkvZ2VuX3JzdC5weQ==) | `94.31% <100%> (-2.76%)` | :arrow_down: |
| [sphinx\_gallery/tests/test\_docs\_resolv.py](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510/diff?src=pr&el=tree#diff-c3BoaW54X2dhbGxlcnkvdGVzdHMvdGVzdF9kb2NzX3Jlc29sdi5weQ==) | `94.28% <0%> (-5.72%)` | :arrow_down: |
| [sphinx\_gallery/py\_source\_parser.py](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510/diff?src=pr&el=tree#diff-c3BoaW54X2dhbGxlcnkvcHlfc291cmNlX3BhcnNlci5weQ==) | `90.69% <0%> (-4.66%)` | :arrow_down: |
| [sphinx\_gallery/sphinx\_compatibility.py](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510/diff?src=pr&el=tree#diff-c3BoaW54X2dhbGxlcnkvc3BoaW54X2NvbXBhdGliaWxpdHkucHk=) | `95.65% <0%> (-4.35%)` | :arrow_down: |
| [sphinx\_gallery/tests/test\_notebook.py](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510/diff?src=pr&el=tree#diff-c3BoaW54X2dhbGxlcnkvdGVzdHMvdGVzdF9ub3RlYm9vay5weQ==) | `96.22% <0%> (-3.78%)` | :arrow_down: |
| [sphinx\_gallery/backreferences.py](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510/diff?src=pr&el=tree#diff-c3BoaW54X2dhbGxlcnkvYmFja3JlZmVyZW5jZXMucHk=) | `91.54% <0%> (-3.53%)` | :arrow_down: |
| [sphinx\_gallery/docs\_resolv.py](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510/diff?src=pr&el=tree#diff-c3BoaW54X2dhbGxlcnkvZG9jc19yZXNvbHYucHk=) | `82.06% <0%> (-1.35%)` | :arrow_down: |
| ... and [2 more](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510?src=pr&el=footer). Last update [cd6eaed...4773db6](https://codecov.io/gh/sphinx-gallery/sphinx-gallery/pull/510?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

larsoner: @choldgraf CircleCI indicates that it works, feel free to merge if you're happy.
larsoner: The only public change is that `.rst` should work in addition to `.txt`. As a side effect it also adds `'app'` to the `gallery_conf` which might be useful to people writing their own scrapers.
larsoner: (I did not observe any downsides when testing)

Patch:
diff --git a/doc/getting_started.rst b/doc/getting_started.rst
index 4404d6e..bdb0e3c 100644
--- a/doc/getting_started.rst
+++ b/doc/getting_started.rst
@@ -39,7 +39,7 @@ Let's say your Python project looks like this:
     └── examples
       	├── plot_example.py
       	├── example.py
-      	└── README.txt
+      	└── README.txt (or .rst)
 
 Your Python module is in ``my_python_module``, examples for how to use it are
 in ``examples`` and the ``doc`` folder holds the base documentation
@@ -59,7 +59,8 @@ Structure the examples folder
 In order for Sphinx-Gallery to build a gallery from your ``examples`` folder,
 this folder must have the following things:
 
-* **The Gallery Header** (``README.txt``). A file called ``README.txt`` that
+* **The Gallery Header** (``README.txt``). A file called ``README.txt``
+  or ``README.rst`` that
   contains rST that will be used as a header for the gallery generated from
   this folder. It must have at least a title. For example::
 
diff --git a/mayavi_examples/README.txt b/mayavi_examples/README.rst
similarity index 100%
rename from mayavi_examples/README.txt
rename to mayavi_examples/README.rst
diff --git a/sphinx_gallery/gen_gallery.py b/sphinx_gallery/gen_gallery.py
index 36c35fa..26a53b7 100644
--- a/sphinx_gallery/gen_gallery.py
+++ b/sphinx_gallery/gen_gallery.py
@@ -14,29 +14,26 @@ from __future__ import division, print_function, absolute_import
 import codecs
 import copy
 from datetime import timedelta, datetime
+from distutils.version import LooseVersion
 from importlib import import_module
 import re
 import os
 from xml.sax.saxutils import quoteattr, escape
 
+import sphinx
 from sphinx.util.console import red
 from . import sphinx_compatibility, glr_path_static, __version__ as _sg_version
-from .utils import _replace_md5
+from .utils import _replace_md5, Bunch
 from .backreferences import finalize_backreferences
 from .gen_rst import (generate_dir_rst, SPHX_GLR_SIG, _get_memory_base,
-                      extract_intro_and_title, get_docstring_and_rest)
+                      extract_intro_and_title, get_docstring_and_rest,
+                      _get_readme)
 from .scrapers import _scraper_dict, _reset_dict
 from .docs_resolv import embed_code_links
 from .downloads import generate_zipfiles
 from .sorting import NumberOfCodeLinesSortKey
 from .binder import copy_binder_files
 
-try:
-    FileNotFoundError
-except NameError:
-    # Python2
-    FileNotFoundError = IOError
-
 try:
     basestring
 except NameError:
@@ -91,7 +88,7 @@ def parse_config(app):
     lang = app.builder.config.highlight_language
     gallery_conf = _complete_gallery_conf(
         app.config.sphinx_gallery_conf, src_dir, plot_gallery,
-        abort_on_example_error, lang, app.builder.name)
+        abort_on_example_error, lang, app.builder.name, app)
 
     # this assures I can call the config in other places
     app.config.sphinx_gallery_conf = gallery_conf
@@ -101,7 +98,7 @@ def parse_config(app):
 
 def _complete_gallery_conf(sphinx_gallery_conf, src_dir, plot_gallery,
                            abort_on_example_error, lang='python',
-                           builder_name='html'):
+                           builder_name='html', app=None):
     gallery_conf = copy.deepcopy(DEFAULT_GALLERY_CONF)
     gallery_conf.update(sphinx_gallery_conf)
     if sphinx_gallery_conf.get('find_mayavi_figures', False):
@@ -114,6 +111,11 @@ def _complete_gallery_conf(sphinx_gallery_conf, src_dir, plot_gallery,
     gallery_conf.update(plot_gallery=plot_gallery)
     gallery_conf.update(abort_on_example_error=abort_on_example_error)
     gallery_conf['src_dir'] = src_dir
+    # Old Sphinx can't handle pickling app, so let's just expose the one
+    # thing we need internally
+    if LooseVersion(sphinx.__version__) < LooseVersion('1.8'):
+        app = Bunch(config=app.config) if app is not None else app
+    gallery_conf['app'] = app
 
     if gallery_conf.get("mod_example_dir", False):
         backreferences_warning = """\n========
@@ -194,8 +196,8 @@ def _complete_gallery_conf(sphinx_gallery_conf, src_dir, plot_gallery,
     return gallery_conf
 
 
-def get_subsections(srcdir, examples_dir, sortkey):
-    """Return the list of subsections of a gallery
+def get_subsections(srcdir, examples_dir, gallery_conf):
+    """Return the list of subsections of a gallery.
 
     Parameters
     ----------
@@ -203,18 +205,18 @@ def get_subsections(srcdir, examples_dir, sortkey):
         absolute path to directory containing conf.py
     examples_dir : str
         path to the examples directory relative to conf.py
-    sortkey : :func:`python:callable`
-        The sort key to use.
+    gallery_conf : dict
+        The gallery configuration.
 
     Returns
     -------
     out : list
         sorted list of gallery subsection folder names
-
     """
+    sortkey = gallery_conf['subsection_order']
     subfolders = [subfolder for subfolder in os.listdir(examples_dir)
-                  if os.path.exists(os.path.join(
-                      examples_dir, subfolder, 'README.txt'))]
+                  if _get_readme(os.path.join(examples_dir, subfolder),
+                                 gallery_conf, raise_error=False) is not None]
     base_examples_dir_path = os.path.relpath(examples_dir, srcdir)
     subfolders_with_path = [os.path.join(base_examples_dir_path, item)
                             for item in subfolders]
@@ -269,15 +271,8 @@ def generate_gallery_rst(app):
         examples_dir = os.path.join(app.builder.srcdir, examples_dir)
         gallery_dir = os.path.join(app.builder.srcdir, gallery_dir)
 
-        if not os.path.exists(os.path.join(examples_dir, 'README.txt')):
-            raise FileNotFoundError("Main example directory {0} does not "
-                                    "have a README.txt file. Please write "
-                                    "one to introduce your gallery."
-                                    .format(examples_dir))
-
         # Here we don't use an os.walk, but we recurse only twice: flat is
         # better than nested.
-
         this_fhindex, this_computation_times = generate_dir_rst(
             examples_dir, gallery_dir, gallery_conf, seen_backrefs)
 
@@ -292,8 +287,7 @@ def generate_gallery_rst(app):
             fhindex.write(":orphan:\n\n" + this_fhindex)
 
             for subsection in get_subsections(
-                    app.builder.srcdir, examples_dir,
-                    gallery_conf['subsection_order']):
+                    app.builder.srcdir, examples_dir, gallery_conf):
                 src_dir = os.path.join(examples_dir, subsection)
                 target_dir = os.path.join(gallery_dir, subsection)
                 this_fhindex, this_computation_times = \
diff --git a/sphinx_gallery/gen_rst.py b/sphinx_gallery/gen_rst.py
index e303c66..0bf420a 100644
--- a/sphinx_gallery/gen_rst.py
+++ b/sphinx_gallery/gen_rst.py
@@ -40,7 +40,7 @@ try:
     from textwrap import indent
 except ImportError:
     def indent(text, prefix, predicate=None):
-        """Adds 'prefix' to the beginning of selected lines in 'text'.
+        """Add 'prefix' to the beginning of selected lines in 'text'.
 
         If 'predicate' is provided, 'prefix' will only be added to the lines
         where 'predicate(line)' is True. If 'predicate' is not provided,
@@ -56,6 +56,9 @@ except ImportError:
                 yield (prefix + line if predicate(line) else line)
         return ''.join(prefixed_lines())
 
+    FileNotFoundError = IOError
+
+
 import sphinx
 
 from . import glr_path_static
@@ -286,15 +289,28 @@ def save_thumbnail(image_path_template, src_file, file_conf, gallery_conf):
         scale_image(img, thumb_file, *gallery_conf["thumbnail_size"])
 
 
-def generate_dir_rst(src_dir, target_dir, gallery_conf, seen_backrefs):
-    """Generate the gallery reStructuredText for an example directory"""
+def _get_readme(dir_, gallery_conf, raise_error=True):
+    extensions = ['.txt'] + sorted(gallery_conf['app'].config['source_suffix'])
+    for ext in extensions:
+        fname = os.path.join(dir_, 'README' + ext)
+        if os.path.isfile(fname):
+            return fname
+    if raise_error:
+        raise FileNotFoundError(
+            "Example directory {0} does not have a README file with one "
+            "of the expected file extensions {1}. Please write one to "
+            "introduce your gallery.".format(dir_, extensions))
+    return None
 
+
+def generate_dir_rst(src_dir, target_dir, gallery_conf, seen_backrefs):
+    """Generate the gallery reStructuredText for an example directory."""
     head_ref = os.path.relpath(target_dir, gallery_conf['src_dir'])
     fhindex = """\n\n.. _sphx_glr_{0}:\n\n""".format(
         head_ref.replace(os.path.sep, '_'))
 
-    with codecs.open(os.path.join(src_dir, 'README.txt'), 'r',
-                     encoding='utf-8') as fid:
+    fname = _get_readme(src_dir, gallery_conf)
+    with codecs.open(fname, 'r', encoding='utf-8') as fid:
         fhindex += fid.read()
     # Add empty lines to avoid bug in issue #165
     fhindex += "\n\n"
diff --git a/sphinx_gallery/utils.py b/sphinx_gallery/utils.py
index 60f0ccf..afee07f 100644
--- a/sphinx_gallery/utils.py
+++ b/sphinx_gallery/utils.py
@@ -115,3 +115,11 @@ def _replace_md5(fname_new, fname_old=None, method='move'):
         else:
             copyfile(fname_new, fname_old)
     assert os.path.isfile(fname_old)
+
+
+class Bunch(dict):
+    """Dictionary-like object that exposes its keys as attributes."""
+
+    def __init__(self, **kwargs):  # noqa: D102
+        dict.__init__(self, kwargs)
+        self.__dict__ = self


Test patch:
diff --git a/sphinx_gallery/tests/conftest.py b/sphinx_gallery/tests/conftest.py
index 77d7e04..ff49c55 100644
--- a/sphinx_gallery/tests/conftest.py
+++ b/sphinx_gallery/tests/conftest.py
@@ -5,14 +5,18 @@ Pytest fixtures
 from __future__ import division, absolute_import, print_function
 
 import collections
-import logging
 
 import pytest
 
+import sphinx
 import sphinx_gallery.docs_resolv
 import sphinx_gallery.gen_gallery
 import sphinx_gallery.gen_rst
-from sphinx_gallery import sphinx_compatibility
+
+
+def pytest_report_header(config, startdir):
+    """Add information to the pytest run header."""
+    return 'Sphinx:  %s (%s)' % (sphinx.__version__, sphinx.__file__)
 
 
 Params = collections.namedtuple('Params', 'args kwargs')
diff --git a/sphinx_gallery/tests/test_gen_gallery.py b/sphinx_gallery/tests/test_gen_gallery.py
index 20da966..c8a2877 100644
--- a/sphinx_gallery/tests/test_gen_gallery.py
+++ b/sphinx_gallery/tests/test_gen_gallery.py
@@ -273,7 +273,7 @@ def test_example_sorting_title(sphinx_app_wrapper):
     _check_order(sphinx_app, 'title')
 
 
-def test_collect_gallery_files(sphinx_app_wrapper, tmpdir):
+def test_collect_gallery_files(tmpdir):
     """Test that example files are collected properly."""
     rel_filepaths = ['examples/file1.py',
                      'examples/test.rst',
diff --git a/sphinx_gallery/tests/test_gen_rst.py b/sphinx_gallery/tests/test_gen_rst.py
index b7ef18f..61afc12 100644
--- a/sphinx_gallery/tests/test_gen_rst.py
+++ b/sphinx_gallery/tests/test_gen_rst.py
@@ -12,6 +12,7 @@ import io
 import tempfile
 import re
 import os
+import shutil
 import zipfile
 import codeop
 
@@ -20,9 +21,15 @@ import pytest
 import sphinx_gallery.gen_rst as sg
 from sphinx_gallery import downloads
 from sphinx_gallery.gen_gallery import generate_dir_rst, _complete_gallery_conf
-from sphinx_gallery.utils import _TempDir
+from sphinx_gallery.utils import _TempDir, Bunch
 from sphinx_gallery.scrapers import ImagePathIterator
 
+try:
+    FileNotFoundError
+except NameError:
+    # Python2
+    FileNotFoundError = IOError
+
 CONTENT = [
     '"""',
     '================',
@@ -223,8 +230,7 @@ def test_extract_intro_and_title():
 
 
 def test_md5sums():
-    """Test md5sum check functions work on know file content"""
-
+    """Test md5sum check functions work on know file content."""
     with tempfile.NamedTemporaryFile('wb', delete=False) as f:
         f.write(b'Local test\n')
     try:
@@ -246,15 +252,17 @@ def test_md5sums():
 
 @pytest.fixture
 def gallery_conf(tmpdir):
-    """Sets up a test sphinx-gallery configuration"""
-    gallery_conf = _complete_gallery_conf({}, str(tmpdir), True, False)
+    """Set up a test sphinx-gallery configuration."""
+    app = Bunch()
+    app.config = dict(source_suffix={'.rst': None})
+    gallery_conf = _complete_gallery_conf({}, str(tmpdir), True, False,
+                                          app=app)
     gallery_conf.update(examples_dir=_TempDir(), gallery_dir=str(tmpdir))
     return gallery_conf
 
 
 def test_fail_example(gallery_conf, log_collector):
-    """Test that failing examples are only executed until failing block"""
-
+    """Test that failing examples are only executed until failing block."""
     gallery_conf.update(filename_pattern='raise.py')
 
     failing_code = CONTENT + ['#' * 79,
@@ -281,8 +289,7 @@ def test_fail_example(gallery_conf, log_collector):
 
 
 def _generate_rst(gallery_conf, fname, content):
-    """
-    Helper function returning the rST text of a given example content.
+    """Return the rST text of a given example content.
 
     This writes a file gallery_conf['examples_dir']/fname with *content*,
     creates the corresponding rst file by running generate_file_rst() and
@@ -327,21 +334,28 @@ def test_remove_config_comments(gallery_conf):
     assert '# sphinx_gallery_thumbnail_number = 1' not in rst
 
 
-def test_gen_dir_rst(gallery_conf, fakesphinxapp):
+@pytest.mark.parametrize('ext', ('.txt', '.rst', '.bad'))
+def test_gen_dir_rst(gallery_conf, fakesphinxapp, ext):
     """Test gen_dir_rst."""
     print(os.listdir(gallery_conf['examples_dir']))
     fname_readme = os.path.join(gallery_conf['src_dir'], 'README.txt')
     with open(fname_readme, 'wb') as fid:
         fid.write(u"Testing\n=======\n\nÓscar here.".encode('utf-8'))
+    fname_out = os.path.splitext(fname_readme)[0] + ext
+    if fname_readme != fname_out:
+        shutil.move(fname_readme, fname_out)
     args = (gallery_conf['src_dir'], gallery_conf['gallery_dir'],
             gallery_conf, [])
-    out = generate_dir_rst(*args)
-    assert u"Óscar here" in out[0]
+    if ext == '.bad':  # not found with correct ext
+        with pytest.raises(FileNotFoundError, match='does not have a README'):
+            generate_dir_rst(*args)
+    else:
+        out = generate_dir_rst(*args)
+        assert u"Óscar here" in out[0]
 
 
 def test_pattern_matching(gallery_conf, log_collector):
-    """Test if only examples matching pattern are executed"""
-
+    """Test if only examples matching pattern are executed."""
     gallery_conf.update(filename_pattern=re.escape(os.sep) + 'plot_0')
 
     code_output = ('\n Out:\n\n .. code-block:: none\n'
@@ -383,7 +397,7 @@ def test_thumbnail_number(test_str):
 
 
 def test_zip_notebooks(gallery_conf):
-    """Test generated zipfiles are not corrupt"""
+    """Test generated zipfiles are not corrupt."""
     gallery_conf.update(examples_dir='examples')
     examples = downloads.list_downloadable_sources(
         gallery_conf['examples_dir'])
@@ -395,8 +409,7 @@ def test_zip_notebooks(gallery_conf):
 
 
 def test_rst_example(gallery_conf):
-    """Test generated rst file includes the correct paths for binder"""
-
+    """Test generated rst file includes the correct paths for binder."""
     gallery_conf.update(binder={'org': 'sphinx-gallery',
                                 'repo': 'sphinx-gallery.github.io',
                                 'binderhub_url': 'https://mybinder.org',
@@ -448,7 +461,6 @@ def test_output_indentation(gallery_conf):
     assert output_test_string == test_string.replace(r"\n", "\n")
 
 
-
 class TestLoggingTee:
     def setup(self):
         self.output_file = io.StringIO()
@@ -510,6 +522,5 @@ class TestLoggingTee:
 
 
 # TODO: test that broken thumbnail does appear when needed
-# TODO: test that examples are not executed twice
 # TODO: test that examples are executed after a no-plot and produce
 #       the correct image in the thumbnail
diff --git a/sphinx_gallery/tests/test_notebook.py b/sphinx_gallery/tests/test_notebook.py
index 0d5b9f0..e3180c3 100644
--- a/sphinx_gallery/tests/test_notebook.py
+++ b/sphinx_gallery/tests/test_notebook.py
@@ -14,7 +14,7 @@ import pytest
 import sphinx_gallery.gen_rst as sg
 from sphinx_gallery.notebook import (rst2md, jupyter_notebook, save_notebook,
                                      python_to_jupyter_cli)
-from sphinx_gallery.tests.test_gen_rst import gallery_conf
+from sphinx_gallery.tests.test_gen_rst import gallery_conf  # noqa
 
 try:
     FileNotFoundError