Repository: sphinx-gallery/sphinx-gallery

Issue:
name string_replace trips on projects with ".py" in path
https://github.com/sphinx-gallery/sphinx-gallery/blob/fb2da9af8c3c9282150b26c356f97fdddfcefb82/sphinx_gallery/notebook.py#L193

This code will trip on projects that have '.py' as a substring.  I found this because I am working on a project called "aibs.pyproject_template".  This will need a more exclusive replacement string.

Discussion:
nicain: Also all tests passing locally on my dev environment for py27 py35 and py36 via tox
lesteve: Thanks a lot for your PR! I remove the controversial changes and pushed a commit with additional minor tweaks.

Can you add a test in `tests/test_utils.py`, testing both the normal condition and the exception + exception message? Can you also add an entry in CHANGES.rst?

I haven't jumped on the tox bandwagon yet although I have heard it is quite good. Minimal googling seems to indicate that conda and tox do not play nice together, oh well this will have to wait then ...

My 2 cents:
* short and focussed PRs tend to be merged more quickly. Avoid doing two unrelated things in a single PR.
* controversial changes even if minor tend to make the review less pleasant for everyone involved (bikeshedding + arguing).
nicain: @lesteve 

> Can you add a test in tests/test_utils.py, testing both the normal condition and the exception + exception message? Can you also add an entry in CHANGES.rst?
Done.

> I haven't jumped on the tox bandwagon yet although I have heard it is quite good. Minimal googling seems to indicate that conda and tox do not play nice together, oh well this will have to wait then ...

I just learned to use tox myself, and I love it.  I use conda as well, but I can across a handy trick to use tox in a conda development env: create bare conda envs for the python versions you need tox to run against, and add them as symlinks to your path (for example ln -s /foo/envs/toxpy35/bin/python ./python 3.5, executed from ~/local/bin).

Ill stash the tox changes in a branch on my fork, in case you ever want to circle back to them.

choldgraf: Just a couple of quick thoughts from me - nice catch and thanks for the PR @nicain ! :-)
nicain: Updated and ready for re-review.
choldgraf: Once tests are passing I'm +1 on this. Will hold off on merging until @lesteve can confirm he's happy too!

Thanks @nicain !
lesteve: LGTM, thanks a lot @nicain!

Patch:
diff --git a/.gitignore b/.gitignore
index 29fb206..6a1cb61 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,3 +67,6 @@ target/
 # General temporary files to ignore
 *.swp
 *.swo
+
+# VisualStudioCode
+.history
diff --git a/CHANGES.rst b/CHANGES.rst
index 1028a11..0c6698c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -14,6 +14,8 @@ New features
 Bug Fixes
 '''''''''
 
+* Fix name string_replace trips on projects with ".py" in path. See `#322
+  https://github.com/sphinx-gallery/sphinx-gallery/issues/322` for more details.
 * Fix __future__ imports across cells. See `#308
   <https://github.com/sphinx-gallery/sphinx-gallery/pull/308>`_ for more details.
 * Fix encoding related issues when locale is not UTF-8. See `#311
diff --git a/sphinx_gallery/binder.py b/sphinx_gallery/binder.py
index 6e772c8..81a29ce 100644
--- a/sphinx_gallery/binder.py
+++ b/sphinx_gallery/binder.py
@@ -24,6 +24,8 @@ except NameError:
     basestring = str
     unicode = str
 
+from .utils import replace_py_ipynb
+
 
 def gen_binder_url(fname, binder_conf):
     """Generate a Binder URL according to the configuration in conf.py.
@@ -42,7 +44,7 @@ def gen_binder_url(fname, binder_conf):
         environment.
     """
     # Build the URL
-    binder_fpath = '_downloads/{}'.format(fname.replace('.py', '.ipynb'))
+    binder_fpath = '_downloads/{}'.format(replace_py_ipynb(fname))
     binder_url = binder_conf['url']
     binder_url = '/'.join([binder_conf['url'],
                            'v2', 'gh',
diff --git a/sphinx_gallery/gen_rst.py b/sphinx_gallery/gen_rst.py
index 29d32d7..cf4c0bb 100644
--- a/sphinx_gallery/gen_rst.py
+++ b/sphinx_gallery/gen_rst.py
@@ -27,6 +27,8 @@ import traceback
 import codeop
 from distutils.version import LooseVersion
 
+from .utils import replace_py_ipynb
+
 
 # Try Python 2 first, otherwise load from Python 3
 try:
@@ -691,7 +693,7 @@ def generate_file_rst(fname, target_dir, src_dir, gallery_conf):
 
     time_m, time_s = divmod(time_elapsed, 60)
     example_nb = jupyter_notebook(script_blocks)
-    save_notebook(example_nb, example_file.replace('.py', '.ipynb'))
+    save_notebook(example_nb, replace_py_ipynb(example_file))
     with codecs.open(os.path.join(target_dir, base_image_name + '.rst'),
                      mode='w', encoding='utf-8') as f:
         if time_elapsed >= gallery_conf["min_reported_time"]:
@@ -703,7 +705,7 @@ def generate_file_rst(fname, target_dir, src_dir, gallery_conf):
             example_rst += gen_binder_rst(fname, binder_conf)
 
         example_rst += CODE_DOWNLOAD.format(fname,
-                                            fname.replace('.py', '.ipynb'))
+                                            replace_py_ipynb(fname))
         example_rst += SPHX_GLR_SIG
         f.write(example_rst)
 
diff --git a/sphinx_gallery/notebook.py b/sphinx_gallery/notebook.py
index 4ffee59..19d7250 100644
--- a/sphinx_gallery/notebook.py
+++ b/sphinx_gallery/notebook.py
@@ -15,7 +15,9 @@ import argparse
 import json
 import re
 import sys
+
 from .py_source_parser import split_code_and_text_blocks
+from .utils import replace_py_ipynb
 
 
 def jupyter_notebook_skeleton():
@@ -190,4 +192,4 @@ def python_to_jupyter_cli(args=None, namespace=None):
         file_conf, blocks = split_code_and_text_blocks(src_file)
         print('Converting {0}'.format(src_file))
         example_nb = jupyter_notebook(blocks)
-        save_notebook(example_nb, src_file.replace('.py', '.ipynb'))
+        save_notebook(example_nb, replace_py_ipynb(src_file))
diff --git a/sphinx_gallery/utils.py b/sphinx_gallery/utils.py
index 3d7dfe1..4b4e102 100644
--- a/sphinx_gallery/utils.py
+++ b/sphinx_gallery/utils.py
@@ -12,6 +12,7 @@ from __future__ import division, absolute_import, print_function
 
 import tempfile
 from shutil import rmtree
+import os
 
 
 class _TempDir(str):
@@ -36,3 +37,16 @@ class _TempDir(str):
 
     def __del__(self):  # noqa: D105
         rmtree(self._path, ignore_errors=True)
+
+
+def replace_py_ipynb(fname):
+    """Replace .py extension in filename by .ipynb"""
+
+    fname_prefix, extension = os.path.splitext(fname)
+    allowed_extension='.py'
+    if extension != allowed_extension:
+        raise ValueError(
+            "Unrecognized file extension, expected %s, got %s" % (allowed_extension, extension))
+
+    new_extension = '.ipynb'
+    return '{}{}'.format(fname_prefix, new_extension)


Test patch:
diff --git a/sphinx_gallery/tests/test_utils.py b/sphinx_gallery/tests/test_utils.py
new file mode 100644
index 0000000..2d4f6db
--- /dev/null
+++ b/sphinx_gallery/tests/test_utils.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+r"""
+Test utility functions 
+==================
+
+
+"""
+# Author: Nicholas Cain
+# License: 3-clause BSD
+
+from __future__ import division, absolute_import, print_function
+import sphinx_gallery.utils as utils
+import pytest
+
+def test_replace_py_ipynb():
+    
+    # Test behavior of function with expected input:
+    for file_name in ['some/file/name', '/corner.pycase']:
+        assert utils.replace_py_ipynb(file_name+'.py') == file_name+'.ipynb'
+
+    # Test behavior of function with unexpected input:
+    with pytest.raises(ValueError) as expected_exception:
+        utils.replace_py_ipynb(file_name+'.txt')
+
+    assert 'Unrecognized file extension' in str(expected_exception.value)