Repository: pypa/pipenv

Issue:
Existing virtualenvs ignored on Windows when using cmd.exe
This is an issue caused by some odd quirk in how cmd.exe handles path names on Windows.

As a result, it is possible to end up in a situation, where running a pipenv command (such as pipenv shell) in a directory, for which a valid virtualenv already exists, causes pipenv to ignore it and create a brand new one, with the same name (except for the hash).

I have recently run into this issue at work. I have a workstation running Windows 10 and Python 2.7, which usually gets called from cmd.exe. What I have just discovered, is that depending on what command I use when changing directories, a slightly different path gets reported as pipfile_location in project.py:

> cd c:\tmp\test

results in "c:\tmp\test" getting set as the current directory in cmd.exe and "c:\tmp\test\Pipfile" getting returned from pipfile_location

vs.

> cd C:\tmp\test

which leads to "C:\tmp\test" getting set as the current directory in cmd.exe and "C:\tmp\test\Pipfile" being the reported location of the Pipfile.

Note the lower- vs. upper-case drive letter, which results in a completely different hash value for the pipfile location, and in consequence, a completely different name being returned from virtualenv_name in project.py, which then triggers the creation of a brand new virtualenv. 
This seems to affect only drive letters -- directory names are fine ("cd C:\TMP" produces "C:\tmp" on my machine). PowerShell does not seem to suffer from this.

##### Describe your environment

1. OS Type : Windows 10 x64 (build 1709)
1. Python version: 2.7.14
1. Pipenv version: 9.0.1

##### Expected result

'pipenv shell' activates the existing environment

##### Actual result

'pipenv shell' creates a new environment alongside the existing one

##### Steps to replicate

Provide the steps to replicate (which usually at least includes the commands and the Pipfile).

With Python 2.7 (and pipenv) installed and added to the PATH env variable:

1. Start the command prompt (Win+R, cmd.exe)
2. From within the command prompt, create a directory somewhere on a disk (e.g. C:\temp\test)
3. Go to that directory, typing the drive letter in upper case (cd C:\temp\test)
4. Call pipenv to create a new virtualenv for that directory (e.g. pipenv install pylint)
5. Change the current working directory to some other directory (e.g. cd C:\)
6. Go back to the test directory, typing the drive letter in lower case (cd c:\temp\test)
7. Call pipenv shell (or pipenv --venv) inside the test directory -- pipenv will
create a new virtualenv, ignoring the one created in step 4



Discussion:

Resolving patch:
diff --git a/pipenv/project.py b/pipenv/project.py
--- a/pipenv/project.py
+++ b/pipenv/project.py
@@ -14,7 +14,7 @@
 from .utils import (
     mkdir_p, convert_deps_from_pip, pep423_name, recase_file,
     find_requirements, is_file, is_vcs, python_version, cleanup_toml,
-    is_installable_file, is_valid_url
+    is_installable_file, is_valid_url, normalize_drive,
 )
 from .environments import PIPENV_MAX_DEPTH, PIPENV_VENV_IN_PROJECT
 from .environments import PIPENV_VIRTUALENV, PIPENV_PIPFILE
@@ -23,7 +23,7 @@
     if not os.path.isfile(PIPENV_PIPFILE):
         raise RuntimeError('Given PIPENV_PIPFILE is not found!')
     else:
-        PIPENV_PIPFILE = os.path.abspath(PIPENV_PIPFILE)
+        PIPENV_PIPFILE = normalize_drive(os.path.abspath(PIPENV_PIPFILE))
 
 
 class Project(object):
@@ -224,7 +224,7 @@ def pipfile_location(self):
                 loc = pipfile.Pipfile.find(max_depth=PIPENV_MAX_DEPTH)
             except RuntimeError:
                 loc = None
-            self._pipfile_location = loc
+            self._pipfile_location = normalize_drive(loc)
 
         return self._pipfile_location
 
diff --git a/pipenv/utils.py b/pipenv/utils.py
--- a/pipenv/utils.py
+++ b/pipenv/utils.py
@@ -277,7 +277,7 @@ def get_requirement(dep):
     remote URIs, and package names, and that we pass only valid requirement strings
     to the requirements parser. Performs necessary modifications to requirements
     object if the user input was a local relative path.
-    
+
     :param str dep: A requirement line
     :returns: :class:`requirements.Requirement` object
     """
@@ -932,11 +932,11 @@ def proper_case(package_name):
 def split_section(input_file, section_suffix, test_function):
     """
     Split a pipfile or a lockfile section out by section name and test function
-    
+
         :param dict input_file: A dictionary containing either a pipfile or lockfile
         :param str section_suffix: A string of the name of the section
         :param func test_function: A test function to test against the value in the key/value pair
-    
+
     >>> split_section(my_lockfile, 'vcs', is_vcs)
     {
         'default': {
@@ -992,7 +992,7 @@ def merge_deps(file_dict, project, dev=False, requirements=False, ignore_hashes=
     Given a file_dict, merges dependencies and converts them to pip dependency lists.
         :param dict file_dict: The result of calling :func:`pipenv.utils.split_file`
         :param :class:`pipenv.project.Project` project: Pipenv project
-        :param bool dev=False: Flag indicating whether dev dependencies are to be installed 
+        :param bool dev=False: Flag indicating whether dev dependencies are to be installed
         :param bool requirements=False: Flag indicating whether to use a requirements file
         :param bool ignore_hashes=False:
         :param bool blocking=False:
@@ -1174,3 +1174,21 @@ def touch_update_stamp():
     except OSError:
         with open(p, 'w') as fh:
             fh.write('')
+
+
+def normalize_drive(path):
+    """Normalize drive in path so they stay consistent.
+
+    This currently only affects local drives on Windows, which can be
+    identified with either upper or lower cased drive names. The case is
+    always converted to uppercase because it seems to be preferred.
+
+    See: <https://github.com/pypa/pipenv/issues/1218>
+    """
+    if os.name != 'nt':
+        return path
+    drive, tail = os.path.splitdrive(path)
+    # Only match (lower cased) local drives (e.g. 'c:'), not UNC mounts.
+    if drive.islower() and len(drive) == 2 and drive[1] == ':':
+        return '{}{}'.format(drive.upper(), tail)
+    return path


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -226,3 +226,27 @@ def test_new_line_end_of_toml_file(this):
         new_toml = pipenv.utils.cleanup_toml(toml)
         # testing if the end of the generated file contains a newline
         assert new_toml[-1] == '\n'
+
+    @pytest.mark.parametrize('input_path, expected', [
+        ('c:\\Program Files\\Python36\\python.exe',
+         'C:\\Program Files\\Python36\\python.exe'),
+        ('C:\\Program Files\\Python36\\python.exe',
+         'C:\\Program Files\\Python36\\python.exe'),
+        ('\\\\host\\share\\file.zip', '\\\\host\\share\\file.zip'),
+        ('artifacts\\file.zip', 'artifacts\\file.zip'),
+        ('.\\artifacts\\file.zip', '.\\artifacts\\file.zip'),
+        ('..\\otherproject\\file.zip', '..\\otherproject\\file.zip'),
+    ])
+    @pytest.mark.skipif(os.name != 'nt', reason='Windows file paths tested')
+    def test_win_normalize_drive(self, input_path, expected):
+        assert pipenv.utils.normalize_drive(input_path) == expected
+
+    @pytest.mark.parametrize('input_path, expected', [
+        ('/usr/local/bin/python', '/usr/local/bin/python'),
+        ('artifacts/file.zip', 'artifacts/file.zip'),
+        ('./artifacts/file.zip', './artifacts/file.zip'),
+        ('../otherproject/file.zip', '../otherproject/file.zip'),
+    ])
+    @pytest.mark.skipif(os.name == 'nt', reason='*nix file paths tested')
+    def test_nix_normalize_drive(self, input_path, expected):
+        assert pipenv.utils.normalize_drive(input_path) == expected