Repository: sarugaku/pythonfinder

Issue:
Unable to install from git
The commit 5715547c4528a415d8c22f54f36bf3504caeb3dc breaks installation of pythonfinder from git. Running `pyfinder --findall` terminates with:

```
Traceback (most recent call last):
  File "/home/sp/xx/.venv/bin/pyfinder", line 11, in <module>
    load_entry_point('pythonfinder', 'console_scripts', 'pyfinder')()
  File "/home/sp/xx/.venv/local/lib/python2.7/site-packages/click/core.py", line 764, in __call__
    return self.main(*args, **kwargs)
  File "/home/sp/xx/.venv/local/lib/python2.7/site-packages/click/core.py", line 717, in main
    rv = self.invoke(ctx)
  File "/home/sp/xx/.venv/local/lib/python2.7/site-packages/click/core.py", line 956, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/home/sp/xx/.venv/local/lib/python2.7/site-packages/click/core.py", line 555, in invoke
    return callback(*args, **kwargs)
  File "/home/sp/xx/.venv/local/lib/python2.7/site-packages/click/decorators.py", line 17, in new_func
    return f(get_current_context(), *args, **kwargs)
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/cli.py", line 33, in cli
    versions = [v for v in finder.find_all_python_versions()]
  File "/home/sp/xx/.venv/local/lib/python2.7/site-packages/backports/functools_lru_cache.py", line 137, in wrapper
    result = user_function(*args, **kwds)
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/pythonfinder.py", line 136, in find_all_python_versions
    major=major, minor=minor, patch=patch, pre=pre, dev=dev, arch=arch, name=name
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/models/path.py", line 328, in find_all_python_versions
    values = list(self.get_pythons(sub_finder))
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/models/path.py", line 279, in get_pythons
    reverse=True
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/models/path.py", line 277, in <genexpr>
    (p for p in self._filter_paths(finder) if p.is_python),
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/models/path.py", line 264, in <genexpr>
    pth for pth in unnest(finder(p) for p in self.path_entries if p is not None)
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/utils.py", line 150, in unnest
    for el in target:
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/models/path.py", line 264, in <genexpr>
    pth for pth in unnest(finder(p) for p in self.path_entries if p is not None)
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/models/mixins.py", line 76, in find_all_python_versions
    path_filter = filter(None, (sub_finder(p) for p in self.children.values()))
  File "/home/sp/xx/.venv/local/lib/python2.7/site-packages/cached_property.py", line 35, in __get__
    value = obj.__dict__[self.func.__name__] = self.func(obj)
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/models/path.py", line 501, in children
    for child_key, child_val in self._gen_children():
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/models/path.py", line 493, in _gen_children
    entry = PathEntry.create(path=child, **pass_args)
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/models/path.py", line 574, in create
    _new = cls(**creation_args)
  File "<attrs generated init b2fd6775cdaa6b27d0fc0845b196e700ccaa616b>", line 16, in __init__
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/models/path.py", line 517, in get_py_version
    py_version = PythonVersion.from_path(path=self, name=self.name)
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/models/python.py", line 431, in from_path
    py_version = get_python_version(path.path.absolute().as_posix())
  File "/home/sp/xx/.venv/local/lib/python2.7/site-packages/backports/functools_lru_cache.py", line 137, in wrapper
    result = user_function(*args, **kwds)
  File "/home/sp/xx/.venv/src/pythonfinder/src/pythonfinder/utils.py", line 59, in get_python_version
    combine_stderr=False, write_to_stdout=False)
TypeError: run() got an unexpected keyword argument 'write_to_stdout'
```

The reason seems to be that the change https://github.com/sarugaku/vistir/commit/ce38a41f22483f87ef2ecb4ee5ed3f4101b1e614 is not yet in any released version of `visitr`. Releasing  a 0.25 version including that change to PyPI will probably fix that problem.


Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 77e21ed..14264a5 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -9,8 +9,9 @@ matrix:
 install:
   - "pip install --upgrade pip pipenv"
   - "pipenv install --dev"
+  - "pipenv run pip install --upgrade -e .[tests]"
 script:
-    - "pipenv run pytest -v -n 8 tests/"
+    - "pipenv run pytest -v -n auto tests/"
 
 jobs:
   include:
@@ -25,13 +26,14 @@ jobs:
     - stage: packaging
       python: "3.6"
       install:
-        - "pip install --upgrade check-manifest readme-renderer"
+        - "pip install --upgrade twine readme-renderer[md]"
       script:
-        - "python setup.py check -m -r -s"
+        - "python setup.py sdist"
+        - "twine check dist/*"
     - stage: coverage
       python: "3.6"
       install:
-        - "pip install --upgrade pip pipenv"
+        - "pip install --upgrade pip pipenv pytest-cov pytest-xdist pytest-timeout pytest"
         - "pipenv install --dev"
       script:
-        - "pipenv run pytest --timeout 300 --cov=vistir --cov-report=term-missing --cov-report=xml --cov-report=html tests"
+        - "pipenv run pytest --timeout 300 --cov=pythonfinder --cov-report=term-missing --cov-report=xml --cov-report=html tests"
diff --git a/Pipfile.lock b/Pipfile.lock
index d13f917..610f8e1 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -72,6 +72,30 @@
             ],
             "version": "==2.6.0"
         },
+        "backports.functools-lru-cache": {
+            "hashes": [
+                "sha256:9d98697f088eb1b0fa451391f91afb5e3ebde16bbdb272819fd091151fda4f1a",
+                "sha256:f0b0e4eba956de51238e17573b7087e852dfe9854afd2e9c873f73fc0ca0a6dd"
+            ],
+            "markers": "python_version <= '3.4'",
+            "version": "==1.5"
+        },
+        "backports.shutil-get-terminal-size": {
+            "hashes": [
+                "sha256:0975ba55054c15e346944b38956a4c9cbee9009391e41b86c68990effb8c1f64",
+                "sha256:713e7a8228ae80341c70586d1cc0a8caa5207346927e23d09dcbcaf18eadec80"
+            ],
+            "markers": "python_version < '3.3'",
+            "version": "==1.0.0"
+        },
+        "backports.weakref": {
+            "hashes": [
+                "sha256:81bc9b51c0abc58edc76aefbbc68c62a787918ffe943a37947e162c3f8e19e82",
+                "sha256:bc4170a29915f8b22c9e7c4939701859650f2eb84184aee80da329ac0b9825c2"
+            ],
+            "markers": "python_version < '3.3'",
+            "version": "==1.0.post1"
+        },
         "black": {
             "hashes": [
                 "sha256:817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739",
@@ -287,18 +311,18 @@
         },
         "parver": {
             "hashes": [
-                "sha256:ac4afff688d19d5e1876bb68d4bccc1a1b6a5cc8bd6a646939a14d366695ba15",
-                "sha256:f025fba8f88a9c776971df6d62b6cf7f37d1108f84c163bda91e157d7d527075"
+                "sha256:b8b2976fd8a73a0515465b2a265fd9b20cc25a6dc88bc1154fd5f60f10dad4db",
+                "sha256:d9ae08a2629105fdb83e4971ae8a04f1de5a3803d1dd928f6e181aeadb398180"
             ],
             "index": "pypi",
-            "version": "==0.1.1"
+            "version": "==0.2.0"
         },
         "pathlib2": {
             "hashes": [
                 "sha256:8eb170f8d0d61825e09a95b38be068299ddeda82f35e96c3301a8a5e7604cb83",
                 "sha256:d1aa2a11ba7b8f7b21ab852b1fb5afb277e1bb99d5dfc663380b5015c0d80c5a"
             ],
-            "markers": "python_version < '3.6'",
+            "markers": "python_version < '3.5'",
             "version": "==2.3.2"
         },
         "pbr": {
@@ -513,12 +537,12 @@
         },
         "typing": {
             "hashes": [
-                "sha256:3a887b021a77b292e151afb75323dea88a7bc1b3dfa92176cff8e44c8b68bddf",
-                "sha256:b2c689d54e1144bbcfd191b0832980a21c2dbcf7b5ff7a66248a60c90e951eb8",
-                "sha256:d400a9344254803a2368533e4533a4200d21eb7b6b729c173bc38201a74db3f2"
+                "sha256:4027c5f6127a6267a435201981ba156de91ad0d1d98e9ddc2aa173453453492d",
+                "sha256:57dcf675a99b74d64dacf6fba08fb17cf7e3d5fdff53d4a30ea2a5e7e52543d4",
+                "sha256:a4c8473ce11a65999c8f59cb093e70686b6c84c98df58c1dae9b3b196089858a"
             ],
             "markers": "python_version < '3.5'",
-            "version": "==3.6.4"
+            "version": "==3.6.6"
         },
         "urllib3": {
             "hashes": [
@@ -532,15 +556,14 @@
                 "spinner"
             ],
             "hashes": [
-                "sha256:851bd783f2b85a372e563db741dc689cb9263ce2e067e387facdca0c36b6a6ea",
-                "sha256:b38ffc8ef83f85d81b4efa4cd31ea3bcd37bdb2bc9e8da9f20a40859bc44b57e"
+                "sha256:3a1020fb7be000b268af96641ced9ead844b1f75840c41e20e473647688fc630",
+                "sha256:6d2005ad670f77bd9c9b5415c4e2a4a20dce5b0cf0e0d11598eb463b2e0ebe44"
             ],
-            "version": "==0.2.4"
+            "version": "==0.2.5"
         },
         "webencodings": {
             "hashes": [
-                "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
-                "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
+                "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"
             ],
             "version": "==0.5.1"
         },
diff --git a/news/44.bugfix.rst b/news/44.bugfix.rst
new file mode 100644
index 0000000..fe2e60f
--- /dev/null
+++ b/news/44.bugfix.rst
@@ -0,0 +1,1 @@
+Added further resilient version parser functionality to python version parser.
diff --git a/news/47.bugfix.rst b/news/47.bugfix.rst
new file mode 100644
index 0000000..bcba514
--- /dev/null
+++ b/news/47.bugfix.rst
@@ -0,0 +1,1 @@
+Fixed an issue which prevented parsing single digit python versions as valid.
diff --git a/setup.cfg b/setup.cfg
index 1f11ad0..8ceb903 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -42,14 +42,14 @@ package_data =
     README*
 install_requires =
     attrs
-    backports.functools_lru_cache;python_version <= "3.4"
+    backports.functools_lru_cache;python_version<="3.4"
     cached-property
     click
     crayons
     packaging
     pathlib2;python_version<"3.5"
     six
-    vistir[spinner]>0.2.0
+    vistir[spinner]>=0.2.5
 
 [options.packages.find]
 where = src
@@ -74,6 +74,9 @@ strict = true
 addopts = -ra
 testpaths = tests/
 norecursedirs = .* build dist news tasks docs
+filterwarnings =
+    ignore::DeprecationWarning
+    ignore::PendingDeprecationWarning
 
 [isort]
 atomic=true
diff --git a/src/pythonfinder/__init__.py b/src/pythonfinder/__init__.py
index 96eeeb1..bb22841 100644
--- a/src/pythonfinder/__init__.py
+++ b/src/pythonfinder/__init__.py
@@ -1,6 +1,6 @@
 from __future__ import print_function, absolute_import
 
-__version__ = '1.1.9.post1'
+__version__ = '1.1.10.dev0'
 
 # Add NullHandler to "pythonfinder" logger, because Python2's default root
 # logger has no handler and warnings like this would be reported:
diff --git a/src/pythonfinder/environment.py b/src/pythonfinder/environment.py
index 0ea0910..e887840 100644
--- a/src/pythonfinder/environment.py
+++ b/src/pythonfinder/environment.py
@@ -4,6 +4,15 @@ import os
 import platform
 import sys
 
+
+def is_type_checking():
+    try:
+        from typing import TYPE_CHECKING
+    except ImportError:
+        return False
+    return TYPE_CHECKING
+
+
 PYENV_INSTALLED = bool(os.environ.get("PYENV_SHELL")) or bool(
     os.environ.get("PYENV_ROOT")
 )
@@ -24,3 +33,4 @@ else:
 
 
 IGNORE_UNSUPPORTED = bool(os.environ.get("PYTHONFINDER_IGNORE_UNSUPPORTED", False))
+MYPY_RUNNING = os.environ.get("MYPY_RUNNING", is_type_checking())
diff --git a/src/pythonfinder/models/path.py b/src/pythonfinder/models/path.py
index 42608cd..221d892 100644
--- a/src/pythonfinder/models/path.py
+++ b/src/pythonfinder/models/path.py
@@ -513,6 +513,7 @@ class PathEntry(BasePath):
         if self.is_dir:
             return None
         if self.is_python:
+            py_version = None
             try:
                 py_version = PythonVersion.from_path(path=self, name=self.name)
             except (InvalidPythonVersion, ValueError):
diff --git a/src/pythonfinder/models/python.py b/src/pythonfinder/models/python.py
index d7de3e0..4fcbbca 100644
--- a/src/pythonfinder/models/python.py
+++ b/src/pythonfinder/models/python.py
@@ -10,11 +10,12 @@ from collections import defaultdict
 
 import attr
 
-from packaging.version import Version
+from packaging.version import Version, LegacyVersion
 from packaging.version import parse as parse_version
 from vistir.compat import Path
 
 from ..environment import SYSTEM_ARCH, PYENV_ROOT, ASDF_DATA_DIR
+from ..exceptions import InvalidPythonVersion
 from .mixins import BaseFinder, BasePath
 from ..utils import (
     _filter_none,
@@ -109,7 +110,7 @@ class PythonFinder(BaseFinder, BasePath):
             version = None
             try:
                 version = PythonVersion.parse(p.name)
-            except ValueError:
+            except (ValueError, InvalidPythonVersion):
                 entry = next(iter(version_path.find_all_python_versions()), None)
                 if not entry:
                     if self.ignore_unsupported:
@@ -246,7 +247,7 @@ class PythonVersion(object):
     is_postrelease = attr.ib(default=False)
     is_devrelease = attr.ib(default=False)
     is_debug = attr.ib(default=False)
-    version = attr.ib(default=None, validator=optional_instance_of(Version))
+    version = attr.ib(default=None)
     architecture = attr.ib(default=None)
     comes_from = attr.ib(default=None)
     executable = attr.ib(default=None)
@@ -355,49 +356,10 @@ class PythonVersion(object):
         :rtype: dict.
         """
 
-        is_debug = False
-        if version.endswith("-debug"):
-            is_debug = True
-            version, _, _ = version.rpartition("-")
-        try:
-            version = parse_version(str(version))
-        except TypeError:
-            try:
-                version_dict = parse_python_version(str(version))
-            except Exception:
-                raise ValueError("Unable to parse version: %s" % version)
-            else:
-                if not version_dict:
-                    raise ValueError("Not a valid python version: %r" % version)
-                major = int(version_dict.get("major"))
-                minor = int(version_dict.get("minor"))
-                patch = version_dict.get("patch")
-                if patch:
-                    patch = int(patch)
-                version = ".".join([v for v in [major, minor, patch] if v is not None])
-                version = parse_version(version)
-        else:
-            if not version or not version.release:
-                raise ValueError("Not a valid python version: %r" % version)
-            if len(version.release) >= 3:
-                major, minor, patch = version.release[:3]
-            elif len(version.release) == 2:
-                major, minor = version.release
-                patch = None
-            else:
-                major = version.release[0]
-                minor = None
-                patch = None
-        return {
-            "major": major,
-            "minor": minor,
-            "patch": patch,
-            "is_prerelease": version.is_prerelease,
-            "is_postrelease": version.is_postrelease,
-            "is_devrelease": version.is_devrelease,
-            "is_debug": is_debug,
-            "version": version,
-        }
+        version_dict = parse_python_version(str(version))
+        if not version_dict:
+            raise ValueError("Not a valid python version: %r" % version)
+        return version_dict
 
     def get_architecture(self):
         if self.architecture:
@@ -407,7 +369,7 @@ class PythonVersion(object):
         return self.architecture
 
     @classmethod
-    def from_path(cls, path, name=None):
+    def from_path(cls, path, name=None, ignore_unsupported=True):
         """Parses a python version from a system path.
 
         Raises:
@@ -416,23 +378,24 @@ class PythonVersion(object):
         :param path: A string or :class:`~pythonfinder.models.path.PathEntry`
         :type path: str or :class:`~pythonfinder.models.path.PathEntry` instance
         :param str name: Name of the python distribution in question
+        :param bool ignore_unsupported: Whether to ignore or error on unsupported paths.
         :return: An instance of a PythonVersion.
         :rtype: :class:`~pythonfinder.models.python.PythonVersion`
         """
 
         from .path import PathEntry
-        from ..environment import IGNORE_UNSUPPORTED
 
         if not isinstance(path, PathEntry):
             path = PathEntry.create(path, is_root=False, only_python=True, name=name)
-        if not path.is_python and not IGNORE_UNSUPPORTED:
-            raise ValueError("Not a valid python path: %s" % path.path)
-            return
+        from ..environment import IGNORE_UNSUPPORTED
+        ignore_unsupported = ignore_unsupported or IGNORE_UNSUPPORTED
+        if not path.is_python:
+            if not (ignore_unsupported or IGNORE_UNSUPPORTED):
+                raise ValueError("Not a valid python path: %s" % path.path)
         py_version = get_python_version(path.path.absolute().as_posix())
         instance_dict = cls.parse(py_version.strip())
-        if not isinstance(instance_dict.get("version"), Version) and not IGNORE_UNSUPPORTED:
+        if not isinstance(instance_dict.get("version"), Version) and not ignore_unsupported:
             raise ValueError("Not a valid python path: %s" % path.path)
-            return
         if not name:
             name = path.name
         instance_dict.update(
diff --git a/src/pythonfinder/utils.py b/src/pythonfinder/utils.py
index f8ec197..1844191 100644
--- a/src/pythonfinder/utils.py
+++ b/src/pythonfinder/utils.py
@@ -13,7 +13,9 @@ import six
 
 import vistir
 
-from .environment import PYENV_ROOT, ASDF_DATA_DIR
+from packaging.version import LegacyVersion, Version
+
+from .environment import PYENV_ROOT, ASDF_DATA_DIR, MYPY_RUNNING
 from .exceptions import InvalidPythonVersion
 
 six.add_move(six.MovedAttribute("Iterable", "collections", "collections.abc"))
@@ -24,8 +26,14 @@ try:
 except ImportError:
     from backports.functools_lru_cache import lru_cache
 
+if MYPY_RUNNING:
+    from typing import Any, Union, List, Callable, Iterable, Set, Tuple, Dict, Optional
+    from attr.validators import _OptionalValidator
+
 
-version_re = re.compile(r"(?P<major>[0-9]+)\.(?P<minor>[0-9]+)\.?(?P<patch>(?<=\.)[0-9]+)")
+version_re = re.compile(r"(?P<major>\d+)(?:\.(?P<minor>\d+))?(?:\.(?P<patch>(?<=\.)[0-9]+))?\.?"
+                        r"(?:(?P<prerel>[abc]|rc|dev)(?:(?P<prerelversion>\d+(?:\.\d+)*))?)"
+                        r"?(?P<postdev>(\.post(?P<post>\d+))?(\.dev(?P<dev>\d+))?)?")
 
 
 PYTHON_IMPLEMENTATIONS = (
@@ -52,6 +60,7 @@ for rule in RULES:
 
 @lru_cache(maxsize=1024)
 def get_python_version(path):
+    # type: (str) -> str
     """Get python version string using subprocess from a given path."""
     version_cmd = [path, "-c", "import sys; print(sys.version.split()[0])"]
     try:
@@ -66,22 +75,89 @@ def get_python_version(path):
 
 @lru_cache(maxsize=1024)
 def parse_python_version(version_str):
-    m = version_re.match(version_str)
-    if not m:
+    # type: (str) -> Dict[str, Union[str, int, Version]]
+    from packaging.version import parse as parse_version
+    is_debug = False
+    if version_str.endswith("-debug"):
+        is_debug = True
+        version_str, _, _ = version_str.rpartition("-")
+    match = version_re.match(version_str)
+    if not match:
         raise InvalidPythonVersion("%s is not a python version" % version_str)
-    return m.groupdict()
+    version_dict = match.groupdict()  # type: Dict[str, str]
+    major = int(version_dict.get("major", 0)) if version_dict.get("major") else None
+    minor = int(version_dict.get("minor", 0)) if version_dict.get("minor") else None
+    patch = int(version_dict.get("patch", 0)) if version_dict.get("patch") else None
+    is_postrelease = True if version_dict.get("post") else False
+    is_prerelease = True if version_dict.get("prerel") else False
+    is_devrelease = True if version_dict.get("dev") else False
+    if patch:
+        patch = int(patch)
+    version = None  # type: Optional[Union[Version, LegacyVersion]]
+    try:
+        version = parse_version(version_str)
+    except TypeError:
+        version = None
+    if isinstance(version, LegacyVersion) or version is None:
+        v_dict = version_dict.copy()
+        pre = ""
+        if v_dict.get("prerel") and v_dict.get("prerelversion"):
+            pre = v_dict.pop("prerel")
+            pre = "{0}{1}".format(pre, v_dict.pop("prerelversion"))
+        v_dict["pre"] = pre
+        keys = ["major", "minor", "patch", "pre", "postdev", "post", "dev"]
+        values = [v_dict.get(val) for val in keys]
+        version_str = ".".join([str(v) for v in values if v])
+        version = parse_version(version_str)
+    return {
+        "major": major,
+        "minor": minor,
+        "patch": patch,
+        "is_postrelease": is_postrelease,
+        "is_prerelease": is_prerelease,
+        "is_devrelease": is_devrelease,
+        "is_debug": is_debug,
+        "version": version
+    }
 
 
 def optional_instance_of(cls):
+    # type: (Any) -> _OptionalValidator
+    """
+    Return an validator to determine whether an input is an optional instance of a class.
+
+    :return: A validator to determine optional instance membership.
+    :rtype: :class:`~attr.validators._OptionalValidator`
+    """
+
     return attr.validators.optional(attr.validators.instance_of(cls))
 
 
 def path_is_executable(path):
+    # type: (str) -> bool
+    """
+    Determine whether the supplied path is executable.
+
+    :return: Whether the provided path is executable.
+    :rtype: bool
+    """
+
     return os.access(str(path), os.X_OK)
 
 
 @lru_cache(maxsize=1024)
 def path_is_known_executable(path):
+    # type: (vistir.compat.Path) -> bool
+    """
+    Returns whether a given path is a known executable from known executable extensions
+    or has the executable bit toggled.
+
+    :param path: The path to the target executable.
+    :type path: :class:`~vistir.compat.Path`
+    :return: True if the path has chmod +x, or is a readable, known executable extension.
+    :rtype: bool
+    """
+
     return (
         path_is_executable(path)
         or os.access(str(path), os.R_OK)
@@ -91,6 +167,15 @@ def path_is_known_executable(path):
 
 @lru_cache(maxsize=1024)
 def looks_like_python(name):
+    # type: (str) -> bool
+    """
+    Determine whether the supplied filename looks like a possible name of python.
+
+    :param str name: The name of the provided file.
+    :return: Whether the provided name looks like python.
+    :rtype: bool
+    """
+
     if not any(name.lower().startswith(py_name) for py_name in PYTHON_IMPLEMENTATIONS):
         return False
     return any(fnmatch(name, rule) for rule in MATCH_RULES)
@@ -98,11 +183,22 @@ def looks_like_python(name):
 
 @lru_cache(maxsize=1024)
 def path_is_python(path):
+    # type: (vistir.compat.Path) -> bool
+    """
+    Determine whether the supplied path is executable and looks like a possible path to python.
+
+    :param path: The path to an executable.
+    :type path: :class:`~vistir.compat.Path`
+    :return: Whether the provided path is an executable path to python.
+    :rtype: bool
+    """
+
     return path_is_executable(path) and looks_like_python(path.name)
 
 
 @lru_cache(maxsize=1024)
 def ensure_path(path):
+    # type: (Union[vistir.compat.Path, str]) -> bool
     """
     Given a path (either a string or a Path object), expand variables and return a Path object.
 
@@ -119,6 +215,7 @@ def ensure_path(path):
 
 
 def _filter_none(k, v):
+    # type: (Any, Any) -> bool
     if v:
         return True
     return False
@@ -126,6 +223,7 @@ def _filter_none(k, v):
 
 # TODO: Reimplement in vistir
 def normalize_path(path):
+    # type: (str) -> str
     return os.path.normpath(os.path.normcase(
         os.path.abspath(os.path.expandvars(os.path.expanduser(str(path))))
     ))
@@ -133,6 +231,7 @@ def normalize_path(path):
 
 @lru_cache(maxsize=1024)
 def filter_pythons(path):
+    # type: (Union[str, vistir.compat.Path]) -> Iterable
     """Return all valid pythons in a given path"""
     if not isinstance(path, vistir.compat.Path):
         path = vistir.compat.Path(str(path))
@@ -143,6 +242,8 @@ def filter_pythons(path):
 
 # TODO: Port to vistir
 def unnest(item):
+    # type: (Any) -> Iterable[Any]
+    target = None  # type: Optional[Iterable]
     if isinstance(item, Iterable) and not isinstance(item, six.string_types):
         item, target = itertools.tee(item, 2)
     else:
@@ -157,6 +258,7 @@ def unnest(item):
 
 
 def parse_pyenv_version_order(filename="version"):
+    # type: (str) -> List[str]
     version_order_file = normalize_path(os.path.join(PYENV_ROOT, filename))
     if os.path.exists(version_order_file) and os.path.isfile(version_order_file):
         with io.open(version_order_file, encoding="utf-8") as fh:
@@ -167,6 +269,7 @@ def parse_pyenv_version_order(filename="version"):
 
 
 def parse_asdf_version_order(filename=".tool-versions"):
+    # type: (str) -> List[str]
     version_order_file = normalize_path(os.path.join("~", filename))
     if os.path.exists(version_order_file) and os.path.isfile(version_order_file):
         with io.open(version_order_file, encoding="utf-8") as fh:


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index e69de29..5af2f51 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -0,0 +1,43 @@
+# -*- coding=utf-8 -*-
+from __future__ import absolute_import, print_function
+
+import os
+import sys
+
+import pytest
+
+import pythonfinder
+import vistir
+
+
+# def pytest_generate_tests(metafunc):
+#     idlist = []
+#     argvalues = []
+#     for python in PYTHON_VERSIONS:
+#         idlist.append(python.path.as_posix())
+#         argnames = ["python",]
+#         argvalues.append(([python,]))
+#     metafunc.parametrize(argnames, argvalues, ids=idlist, scope="function")
+
+
+# @pytest.fixture(params=PYTHON_VERSIONS)
+# def python(request):
+#     return request.params
+
+
+@pytest.fixture
+def special_character_python(tmpdir):
+    finder = pythonfinder.Finder(global_search=True, system=False, ignore_unsupported=True)
+    python = finder.find_python_version('2')
+    python_name = "{0}+".format(python.name)
+    python_folder = tmpdir.mkdir(python_name)
+    bin_dir = python_folder.mkdir("bin")
+    python_path = bin_dir.join("python")
+    os.link(python.path.as_posix(), python_path.strpath)
+    return python_path
+
+
+@pytest.fixture(autouse=True)
+def setup_env():
+    with vistir.contextmanagers.temp_environ():
+        os.environ["ANSI_COLORS_DISABLED"] = str("1")
diff --git a/tests/test_python.py b/tests/test_python.py
new file mode 100644
index 0000000..d975d47
--- /dev/null
+++ b/tests/test_python.py
@@ -0,0 +1,55 @@
+# -*- coding=utf-8 -*-
+
+import pythonfinder
+import pytest
+import os
+
+from packaging.version import Version
+
+
+def test_python_versions(monkeypatch, special_character_python):
+    def mock_version(*args, **kwargs):
+        version_output = "2.7.15+ (default, Jun 28 2018, 13:15:42)\n[GCC 7.2.0]"
+
+        class FakeObj(object):
+            def __init__(self, out):
+                self.out = out
+
+        c = FakeObj(version_output.split()[0])
+        return c
+    os.environ["PYTHONFINDER_IGNORE_UNSUPPORTED"] = str("1")
+    monkeypatch.setattr("vistir.misc.run", mock_version)
+    parsed = pythonfinder.models.python.PythonVersion.from_path(
+        special_character_python.strpath
+    )
+    assert isinstance(parsed.version, Version)
+
+
+@pytest.mark.parametrize(
+    "path, version_output, version",
+    [
+        ("/fake/path/3.6.2/bin/python", "3.6.2 (default, Jun 28 2018, 13:15:42)", "3.6.2"),
+        ("/fake/path/3.7.0/bin/python", "3.7.0 (default, Jun 28 2018, 13:15:42)", "3.7.0"),
+        ("/fake/path/3.6.20/bin/python", "3.6.20 :: Continuum Analytics, Inc.", "3.6.20"),
+        ("/fake/path/3.5.3/bin/python",
+            "3.5.3 (fdd60ed87e941677e8ea11acf9f1819466521bf2, Apr 27 2018, 15:39:57)\n[PyPy 5.10.1 with GCC 4.2.1 Compatible Apple LLVM 9.0.0 (clang-900.0.39.2)]",
+            "3.5.3",
+        ),
+        ("/fake/path/2.7.15+/bin/python", "2.7.15+ (default, Jun 28 2018, 13:15:42)", "2.7.15+")
+    ],
+)
+def test_python_version_output_variants(
+    monkeypatch, path, version_output, version
+):
+    def mock_version(*args, **kwargs):
+
+        class FakeObj(object):
+            def __init__(self, out):
+                self.out = out
+
+        c = FakeObj(version_output.split()[0])
+        return c
+    os.environ["PYTHONFINDER_IGNORE_UNSUPPORTED"] = str("1")
+    monkeypatch.setattr("vistir.misc.run", mock_version)
+    parsed = pythonfinder.models.python.PythonVersion.from_path(path)
+    assert isinstance(parsed.version, Version)
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..8afbac9
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,65 @@
+# -*- coding=utf-8 -*-
+
+import pytest
+import os
+
+os.environ["ANSI_COLORS_DISABLED"] = "1"
+import vistir
+import pythonfinder.utils
+from pythonfinder import Finder
+
+
+def _get_python_versions():
+    finder = Finder(global_search=True, system=False, ignore_unsupported=True)
+    pythons = finder.find_all_python_versions()
+    for v in pythons:
+        py = v.py_version
+        comes_from = getattr(py, "comes_from", None)
+        if comes_from is not None:
+            comes_from_path = getattr(comes_from, "path", v.path)
+        else:
+            comes_from_path = v.path
+    return list(pythons)
+
+
+PYTHON_VERSIONS = _get_python_versions()
+
+
+versions = [
+    (pythonfinder.utils.get_python_version(python.path.as_posix()), python.as_python.version)
+    for python in PYTHON_VERSIONS
+]
+
+version_dicts = [
+    (pythonfinder.utils.parse_python_version(str(python.as_python.version)), python.as_python.as_dict())
+    for python in PYTHON_VERSIONS
+]
+
+test_paths = [
+    (python.path.as_posix(), True)
+    for python in PYTHON_VERSIONS
+]
+
+@pytest.mark.parse
+@pytest.mark.parametrize("python, expected", versions)
+def test_get_version(python, expected):
+    assert python == str(expected)
+
+
+@pytest.mark.parse
+@pytest.mark.parametrize("python, expected", version_dicts)
+def test_parse_python_version(python, expected):
+    for key in ["comes_from", "architecture", "executable", "name"]:
+        if key in expected:
+            del expected[key]
+        if key in python:
+            del python[key]
+    assert python == expected
+
+
+@pytest.mark.is_python
+@pytest.mark.parametrize("python, expected", test_paths)
+def test_is_python(python, expected):
+    assert pythonfinder.utils.path_is_known_executable(python)
+    assert pythonfinder.utils.looks_like_python(os.path.basename(python))
+    assert pythonfinder.utils.path_is_python(vistir.compat.Path(python))