Repository: eight04/vpip

Issue:
Version is not always numbers
https://github.com/eight04/vpip/blob/ad6bc73ab91c2de0f180fc06eadda9b4bb21427d/vpip/pypi.py#L77
https://pypi.org/pypi/setuptools/json

Discussion:

Patch:
diff --git a/.pylintrc b/.pylintrc
index 0ab6440..7908cd6 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -7,6 +7,7 @@ disable=
   bad-whitespace,
   
   anomalous-backslash-in-string,
+  consider-using-f-string,
   duplicate-code,
   global-statement,
   inconsistent-return-statements,
diff --git a/.travis.yml b/.travis.yml
index 876e47e..d7b757f 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,8 +1,9 @@
 language: python
 python:
-  - "3.6"
   - "3.7"
   - "3.8"
+  - "3.9"
+  - "nightly"
 install:
   - pip install -r requirements-lock.txt
   - pip install .
diff --git a/README.rst b/README.rst
index 38a47bf..65757c1 100644
--- a/README.rst
+++ b/README.rst
@@ -12,10 +12,6 @@ vpip
   :alt: PyPI
   :target: https://pypi.org/project/vpip
 
-..
-    
-  ``vpip`` = `venv <https://docs.python.org/3/library/venv.html>`_ + `pipm <https://github.com/jnoortheen/pipm>`_
-
 A CLI which aims to provide an ``npm``-like experience when installing Python packages.
 
 Features
@@ -82,6 +78,11 @@ Documentation
 
 https://vpip.readthedocs.io/en/latest/index.html
 
+Similar projects
+----------------
+
+* `pipm <https://github.com/jnoortheen/pipm>`_ - which doesn't use virtualenv.
+
 Changelog
 ---------
 
@@ -132,4 +133,4 @@ Changelog
 * 0.1.0 (Nov 13, 2018)
 
   - First release
-    
\ No newline at end of file
+    
diff --git a/docs/commands.rst b/docs/commands.rst
index 7f89543..87c28a3 100644
--- a/docs/commands.rst
+++ b/docs/commands.rst
@@ -104,7 +104,7 @@ link
 
   vpip link
   
-Link console scripts installed in the local venv to the global Scripts folder so they can be invoked without activating the venv.
+Link console scripts installed in the local venv to the global Scripts folder so they can be invoked without activating the venv. See :func:`vpip.venv.get_global_script_folders`
 
 This command checks the ``console_scripts`` in ``setup.cfg`` to decide which commands should be linked.
 
diff --git a/requirements-lock.txt b/requirements-lock.txt
index 8559aa4..5d235e0 100644
--- a/requirements-lock.txt
+++ b/requirements-lock.txt
@@ -1,5 +1,5 @@
 alabaster==0.7.12
-astroid==2.3.2
+astroid==2.9.2
 atomicwrites==1.3.0
 attrs==19.3.0
 Babel==2.7.0
@@ -7,11 +7,14 @@ bleach==3.1.0
 case-conversion==2.1.0
 certifi==2019.9.11
 chardet==3.0.4
-colorama==0.4.1
-ConfigUpdater==1.0.1
+charset-normalizer==2.0.10
+colorama==0.4.4
+ConfigUpdater==3.0.1
 docutils==0.15.2
 idna==2.8
 imagesize==1.1.0
+importlib-metadata==4.10.0
+iniconfig==1.1.1
 isort==4.3.21
 Jinja2==2.10.3
 keyring==21.1.0
@@ -22,38 +25,43 @@ mccabe==0.6.1
 more-itertools==7.2.0
 natsort==6.0.0
 ordered-set==3.1.1
-packaging==20.1
-pkginfo==1.5.0.1
+packaging==21.3
+pkginfo==1.8.2
+platformdirs==2.4.1
 pluggy==0.13.0
-py==1.8.0
+py==1.11.0
 Pygments==2.4.2
-pylint==2.4.4
+pylint==2.12.2
 pyparsing==2.4.2
-pytest==5.3.5
+pytest==6.2.5
 pytz==2019.3
 pywin32-ctypes==0.2.0
 pyxcute==0.6.0
 readme-renderer==24.0
 regex==2020.1.8
-requests==2.22.0
+requests==2.27.0
 requests-toolbelt==0.9.1
+rfc3986==1.5.0
 semver==2.9.0
 Send2Trash==1.5.0
 setuptools==45.1.0
 six==1.12.0
 snowballstemmer==2.0.0
-Sphinx==2.3.1
+Sphinx==4.3.2
 sphinxcontrib-applehelp==1.0.1
 sphinxcontrib-devhelp==1.0.1
-sphinxcontrib-htmlhelp==1.0.2
+sphinxcontrib-htmlhelp==2.0.0
 sphinxcontrib-jsmath==1.0.1
 sphinxcontrib-qthelp==1.0.2
-sphinxcontrib-serializinghtml==1.1.3
+sphinxcontrib-serializinghtml==1.1.5
+toml==0.10.2
 tornado==6.0.3
 tqdm==4.36.1
-twine==3.1.1
+twine==3.7.1
+typing_extensions==4.0.1
 urllib3==1.25.6
 wcwidth==0.1.7
 webencodings==0.5.1
-wheel==0.34.2
-wrapt==1.11.2
\ No newline at end of file
+wheel==0.37.1
+wrapt==1.11.2
+zipp==3.7.0
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 5d486a3..ef6f88c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,6 @@
-pylint==2.4.4
-pytest==5.3.5
+pylint==2.12.2
+pytest==6.2.5
 pyxcute==0.6.0
-sphinx==2.3.1
-twine==3.1.1
-wheel==0.34.2
+sphinx==4.3.2
+twine==3.7.1
+wheel==0.37.1
diff --git a/setup.cfg b/setup.cfg
index 8ffc75c..2186692 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -15,6 +15,7 @@ classifiers =
     Natural Language :: Chinese (Traditional)
     Operating System :: Microsoft :: Windows :: Windows 7
     Programming Language :: Python :: 3.7
+    Programming Language :: Python :: 3.8
     Topic :: Software Development :: Build Tools
     
 keywords = pip, pipm, venv, vex, virtualenv, pipenv
@@ -27,10 +28,9 @@ zip_safe = True
 packages = find:
 install_requires =
     case-conversion~=2.1
-    configupdater~=1.0
-    packaging~=20.1
-    requests~=2.22
-    setuptools~=45.1
+    configupdater~=3.0
+    packaging~=21.3
+    requests~=2.27
 
 [options.entry_points]
 console_scripts = 
diff --git a/vpip/commands/__init__.py b/vpip/commands/__init__.py
index 24a49fc..0759c15 100644
--- a/vpip/commands/__init__.py
+++ b/vpip/commands/__init__.py
@@ -1,11 +1,11 @@
 import re
 import importlib
-from pkg_resources import resource_listdir
+import importlib.resources
 
 #: List of builtin command names.
 names = [
     filename.partition(".")[0]
-    for filename in resource_listdir(__name__, "")
+    for filename in importlib.resources.contents(__name__)
     if re.match("[a-z]\w+\.py", filename)
 ]
 
@@ -19,4 +19,4 @@ def get_modules():
     for name in names:
         modules[name] = importlib.import_module(".{}".format(name), __name__)
     return modules
-    
\ No newline at end of file
+    
diff --git a/vpip/commands/update_venv.py b/vpip/commands/update_venv.py
index aae4a0b..5784dcc 100644
--- a/vpip/commands/update_venv.py
+++ b/vpip/commands/update_venv.py
@@ -34,7 +34,7 @@ def update_venv(vv, global_pkg_name=None):
     import configparser
     env_dir = pathlib.Path(vv.env_dir)
     config = configparser.ConfigParser()
-    config.read_string("[DEFAULT]\n" + (env_dir / "pyvenv.cfg").read_text())
+    config.read_string("[DEFAULT]\n" + (env_dir / "pyvenv.cfg").read_text(encoding="utf8"))
     config_home = pathlib.Path(config.get("DEFAULT", "home"))
     # https://github.com/python/cpython/blob/0118d109d54bf75c99a8b0fa9aeae1a478ac4b7e/Lib/venv/__init__.py#L109
     current_home = pathlib.Path(getattr(sys, '_base_executable', sys.executable)).parent
@@ -52,4 +52,4 @@ def update_venv(vv, global_pkg_name=None):
     from .. import pip_api
     with vv.activate():
         pip_api.install("pip", upgrade=True, latest=True)
-    
\ No newline at end of file
+    
diff --git a/vpip/dependency.py b/vpip/dependency.py
index f8364f9..06a7226 100644
--- a/vpip/dependency.py
+++ b/vpip/dependency.py
@@ -1,12 +1,42 @@
 import configparser
 import re
 from pathlib import Path
+from typing import Iterator
 
 from configupdater import ConfigUpdater
-from pkg_resources import parse_requirements
+from packaging.requirements import Requirement
 
 LOCK_FILE = "requirements-lock.txt"
 
+def parse_requirements(text) -> Iterator[Requirement]:
+    """Parse requirements text.
+
+    FIXME: switch to an external function from pip if possible.
+    https://pip.pypa.io/en/stable/reference/requirements-file-format/#requirements-file-format
+    """
+    for line in get_continued_lines(text):
+        # FIXME: handle options?
+        line = re.sub(r"(^|\s+)(#|--\w+|-e).+", "", line)
+        if not line:
+            continue
+        if re.match("https?:", line):
+            continue
+        if line.startswith("."):
+            continue
+        yield Requirement(line)
+
+def get_continued_lines(text) -> Iterator[str]:
+    last_line = ""
+    for line in text.split("\n"):
+        last_line += line
+        if line.endswith("\\"):
+            continue
+        if last_line.strip():
+            yield last_line.strip()
+            last_line = ""
+    if last_line.strip():
+        yield last_line.strip()
+
 def get_dev_requires():
     return parse_requirements(DevUpdater().get_requirements())
     
@@ -98,12 +128,12 @@ class ProdUpdater(Updater):
             self.config.add_section("options")
         self.config.set("options", "install_requires", "".join(
             "\n" + self.indent + l for l in lines))
-        self.file.write_text(str(self.config).replace("\r", ""), "utf8")
+        self.file.write_text(str(self.config).replace("\r", ""), encoding="utf8")
         if not self.file_py.exists():
             self.file_py.write_text("\n".join([
                 "from setuptools import setup",
                 "setup()"
-            ]))
+            ]), encoding="utf8")
         
 class UpdateDependencyResult:
     def __init__(self):
@@ -164,7 +194,7 @@ def update_lock():
         if pkg.name == "pip":
             continue
         lines.append("{}=={}".format(pkg.name, pkg.version))
-    Path(LOCK_FILE).write_text("\n".join(lines))
+    Path(LOCK_FILE).write_text("\n".join(lines), encoding="utf8")
 
 def add_dev(packages):
     return update_dependency(DevUpdater(), added=packages)
@@ -184,4 +214,4 @@ def detect_indent(text):
         if match:
             return match.group(1)
     return None
-    
\ No newline at end of file
+    
diff --git a/vpip/pip_api.py b/vpip/pip_api.py
index 668097d..c9df920 100644
--- a/vpip/pip_api.py
+++ b/vpip/pip_api.py
@@ -6,8 +6,8 @@ import re
 from argparse import Namespace
 from subprocess import CalledProcessError
 
+from packaging.requirements import Requirement
 import case_conversion
-from pkg_resources import Requirement
 
 from .execute import execute
 
@@ -25,12 +25,12 @@ def install(package, install_scripts=None, upgrade=False, latest=False):
     :rtype: Namespace
     """
     cmd = "install"
-    require = Requirement.parse(package)
+    require = Requirement(package)
     if install_scripts:
         cmd += " --install-option \"--install-scripts={}\"".format(install_scripts)
     if upgrade:
         cmd += " -U"
-        if not latest and not require.specs:
+        if not latest and not require.specifier:
             try:
                 version = show([require.name])[0].version
             except CalledProcessError:
@@ -159,4 +159,4 @@ def get_compatible_version(version):
     if version.startswith("0."):
         return version
     return ".".join(version.split(".")[:2])
-    
\ No newline at end of file
+    
diff --git a/vpip/pypi.py b/vpip/pypi.py
index 22c8098..289616e 100644
--- a/vpip/pypi.py
+++ b/vpip/pypi.py
@@ -1,6 +1,6 @@
 from collections import namedtuple
+from packaging.version import parse as parse_version, LegacyVersion, Version
 import requests
-import packaging.version
 
 UpdateResult = namedtuple("UpdateResult", ["compatible", "latest"])
 
@@ -36,11 +36,11 @@ def check_update(pkg, curr_version):
     r.raise_for_status()
     
     # curr_version = packaging.version.parse(curr_version)
-    all_versions = [packaging.version.parse(v) for v in r.json()["releases"].keys()]
+    all_versions = [parse_version(v) for v in r.json()["releases"].keys()]
     all_versions = [v for v in all_versions if not v.is_prerelease]
     all_versions.sort()
     
-    curr_version = packaging.version.parse(curr_version)
+    curr_version = parse_version(curr_version)
     latest = None
     compatible = None
     
@@ -55,24 +55,16 @@ def check_update(pkg, curr_version):
     if compatible or latest:
         return UpdateResult(compatible, latest)
 
-def is_compatible(version, new_version):
+def is_compatible(version: Version, new_version: Version) -> bool:
     """Check if two versions are compatible. ``new_version`` may be smaller
     than ``version``.
-    
-    :type version: str
-    :type new_version: str
-    :rtype: bool
     """
-    version = to_version_tuple(version)
-    new_version = to_version_tuple(new_version)
-    if version[0] == new_version[0]:
-        if version[0] != 0:
+    if any(isinstance(v, LegacyVersion) for v in [version, new_version]):
+        return False
+    if version.major == new_version.major:
+        if version.major != 0:
             return True
-        if version[1] == new_version[1]:
+        if version.minor == new_version.minor:
             return True
     return False
     
-def to_version_tuple(version):
-    """Split version into number tuple"""
-    return tuple(int(n) for n in str(version).split("."))
-    
\ No newline at end of file
diff --git a/vpip/venv.py b/vpip/venv.py
index 459b1a2..e0c4145 100644
--- a/vpip/venv.py
+++ b/vpip/venv.py
@@ -4,6 +4,7 @@ import shutil
 import venv
 from contextlib import contextmanager
 from pathlib import Path
+from typing import List
 
 from .execute import execute
 
@@ -16,24 +17,23 @@ def get_script_folder(base):
 GLOBAL_FOLDER = os.path.normpath(os.path.expanduser("~/.vpip/pkg_venvs"))
 
 class GlobalScriptFolderGetter:
-    """Return two folders. One is the system scripts folder and
-    one is the user-site scripts folder.
-    
-    :rtype: list[str]
+    """Return a list of folders. Which are used to write global scripts.
+
+    Following paths are used::
+
+        '~/.local/bin'
+        '~/bin'
+        sysconfig.get_path('scripts')
     """
     def __init__(self):
-        # this includes the system folder and the user-site folder
-        self.folders = None
-        
-    def __call__(self):
-        if self.folders:
-            return self.folders
-        from pip._internal.locations import distutils_scheme
+        import sysconfig
         self.folders = [
-            # FIXME: this won't work if vpip is installed inside a venv
-            distutils_scheme("foo")["scripts"],
-            distutils_scheme("foo", user=True)["scripts"]
+            Path("~/.local/bin").expanduser(),
+            Path("~/bin").expanduser(),
+            Path(sysconfig.get_path("scripts"))
         ]
+        
+    def __call__(self) -> List[Path]:
         return self.folders
         
 get_global_script_folders = GlobalScriptFolderGetter()
@@ -58,7 +58,7 @@ def get_path_without_venv(path, venv_dir):
     if not venv_dir:
         return path
     return os.pathsep.join(
-        p for p in re.split("\s*{}\s*".format(os.pathsep), path)
+        p for p in re.split(rf"\s*{os.pathsep}\s*", path)
         if not p.startswith(venv_dir))
                     
 def get_current_venv():


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index 4f2d6d7..2535168 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,8 +1,9 @@
 def test_pypi_compatible():
     from vpip.pypi import is_compatible
-    assert is_compatible("0.1.0", "0.2.0") is False
-    assert is_compatible("1.1.0", "1.2.0") is True
-    assert is_compatible("1.1.0", "2.2.0") is False
+    from packaging.version import parse as p
+    assert is_compatible(p("0.1.0"), p("0.2.0")) is False
+    assert is_compatible(p("1.1.0"), p("1.2.0")) is True
+    assert is_compatible(p("1.1.0"), p("2.2.0")) is False
     
 def test_global_script_folder():
     # make sure the script folder is already in the path
@@ -10,5 +11,5 @@ def test_global_script_folder():
     import os
     import pathlib
     paths = set(pathlib.Path(p) for p in os.environ["PATH"].split(os.pathsep))
-    assert any(pathlib.Path(f) in paths for f in get_global_script_folders())
-    
\ No newline at end of file
+    assert any(f in paths for f in get_global_script_folders())
+