From e73d2bfa17caa2f92f0bfcf47944e20e32385296 Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Tue, 19 Dec 2017 21:58:11 +0800 Subject: [PATCH] Force Windows local drive names to uppercase Windows generally use upper cased drive names, but allow (without normalizing) lower cased names in cmd.exe, which results in inconsistencies when hashing the full path (to get the name of the project's virtualenv). Python does not provide a good solution[*], so we need to roll our own. [*]: Python does have os.path.normcase(), but it always converts the whole paths to lowercase. This would break virtually *all* existing virtualenvs for Windows users. UNC host names (which Python also treats as drives), on the other hand, can actually be either cased. I am not sure if they are case-sensitive, or should be coerced to what case, so this patch keeps with the existing behaviour, and does not try to coerce them. --- pipenv/project.py | 6 +++--- pipenv/utils.py | 26 ++++++++++++++++++++++---- tests/test_utils.py | 24 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/pipenv/project.py b/pipenv/project.py index 7b5f1592..1b1b7142 100644 --- a/pipenv/project.py +++ b/pipenv/project.py @@ -14,7 +14,7 @@ import toml 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 PIPENV_PIPFILE: 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 @@ class Project(object): 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 index 90a774e9..f42e1fac 100644 --- 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: + """ + 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 diff --git a/tests/test_utils.py b/tests/test_utils.py index ffb875ef..f570a631 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -226,3 +226,27 @@ twine = "*" 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