Repository: peterbe/hashin

Issue:
fails on extras syntax
example:

```
$ hashin 'python-language-server[yapf]'
Traceback (most recent call last):
  File "/home/user/go/src/github.com/user/flaskexp/ve/bin/hashin", line 11, in <module>
    sys.exit(main())
  File "/home/user/go/src/github.com/user/flaskexp/ve/lib/python3.6/site-packages/hashin.py", line 499, in main
    include_prereleases=args.include_prereleases,
  File "/home/user/go/src/github.com/user/flaskexp/ve/lib/python3.6/site-packages/hashin.py", line 129, in run
    run_single_package(spec, *args, **kwargs)
  File "/home/user/go/src/github.com/user/flaskexp/ve/lib/python3.6/site-packages/hashin.py", line 157, in run_single_package
    include_prereleases=include_prereleases,
  File "/home/user/go/src/github.com/user/flaskexp/ve/lib/python3.6/site-packages/hashin.py", line 429, in get_package_hashes
    data = get_package_data(package, verbose)
  File "/home/user/go/src/github.com/user/flaskexp/ve/lib/python3.6/site-packages/hashin.py", line 358, in get_package_data
    content = json.loads(_download(url))
  File "/home/user/go/src/github.com/user/flaskexp/ve/lib/python3.6/site-packages/hashin.py", line 100, in _download
    r = urlopen(url)
  File "/usr/lib/python3.6/urllib/request.py", line 223, in urlopen
    return opener.open(url, data, timeout)
  File "/usr/lib/python3.6/urllib/request.py", line 532, in open
    response = meth(req, response)
  File "/usr/lib/python3.6/urllib/request.py", line 642, in http_response
    'http', request, response, code, msg, hdrs)
  File "/usr/lib/python3.6/urllib/request.py", line 564, in error
    result = self._call_chain(*args)
  File "/usr/lib/python3.6/urllib/request.py", line 504, in _call_chain
    result = func(*args)
  File "/usr/lib/python3.6/urllib/request.py", line 756, in http_error_302
    return self.parent.open(new, timeout=req.timeout)
  File "/usr/lib/python3.6/urllib/request.py", line 532, in open
    response = meth(req, response)
  File "/usr/lib/python3.6/urllib/request.py", line 642, in http_response
    'http', request, response, code, msg, hdrs)
  File "/usr/lib/python3.6/urllib/request.py", line 564, in error
    result = self._call_chain(*args)
  File "/usr/lib/python3.6/urllib/request.py", line 504, in _call_chain
    result = func(*args)
  File "/usr/lib/python3.6/urllib/request.py", line 756, in http_error_302
    return self.parent.open(new, timeout=req.timeout)
  File "/usr/lib/python3.6/urllib/request.py", line 532, in open
    response = meth(req, response)
  File "/usr/lib/python3.6/urllib/request.py", line 642, in http_response
    'http', request, response, code, msg, hdrs)
  File "/usr/lib/python3.6/urllib/request.py", line 570, in error
    return self._call_chain(*args)
  File "/usr/lib/python3.6/urllib/request.py", line 504, in _call_chain
    result = func(*args)
  File "/usr/lib/python3.6/urllib/request.py", line 650, in http_error_default
    raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 404: Not Found
```


Discussion:

Patch:
diff --git a/README.rst b/README.rst
index 6380fff..e22dd09 100644
--- a/README.rst
+++ b/README.rst
@@ -252,7 +252,12 @@ put it directly into ``pip``.
 Version History
 ===============
 
-0.13.x
+next
+
+  * Support for "extras syntax". E.g. ``hashin "requests[security]"``. Doesn't
+    actually get hashes for ``security`` (in this case, that's not even a
+    package) but allows that syntax into your ``requirements.txt`` file.
+    See https://github.com/peterbe/hashin/issues/70
 
   * All code is now formatted with `Black <https://pypi.org/project/black/>`_.
 
diff --git a/hashin.py b/hashin.py
index ec636e4..b74ccf2 100755
--- a/hashin.py
+++ b/hashin.py
@@ -6,6 +6,7 @@ See README :)
 from __future__ import print_function
 import argparse
 import cgi
+import difflib
 import tempfile
 import os
 import re
@@ -14,8 +15,8 @@ import json
 from itertools import chain
 
 import pip_api
+from packaging.requirements import Requirement
 from packaging.version import parse
-import difflib
 
 if sys.version_info >= (3,):
     from urllib.request import urlopen
@@ -146,8 +147,10 @@ def run_single_package(
         package, version = spec, None
         # There are other ways to what the latest version is.
 
+    req = Requirement(package)
+
     data = get_package_hashes(
-        package=package,
+        package=req.name,
         version=version,
         verbose=verbose,
         python_versions=python_versions,
@@ -155,9 +158,15 @@ def run_single_package(
         include_prereleases=include_prereleases,
     )
     package = data["package"]
+    # We need to keep this `req` instance for the sake of turning it into a string
+    # the correct way. But, the name might actually be wrong. Suppose the user
+    # asked for "Django" but on PyPI it's actually called "django", then we want
+    # correct that.
+    # We do that by modifying only the `name` part of the `Requirement` instance.
+    req.name = package
 
     maybe_restriction = "" if not restriction else "; {0}".format(restriction)
-    new_lines = "{0}=={1}{2} \\\n".format(package, data["version"], maybe_restriction)
+    new_lines = "{0}=={1}{2} \\\n".format(req, data["version"], maybe_restriction)
     padding = " " * 4
     for i, release in enumerate(data["hashes"]):
         new_lines += "{0}--hash={1}:{2}".format(padding, algorithm, release["hash"])
@@ -190,8 +199,12 @@ def run_single_package(
 
 def amend_requirements_content(requirements, package, new_lines):
     # if the package wasn't already there, add it to the bottom
-    regex = "(^|\n|\n\r){0}==".format(re.escape(package))
-    if not re.search(regex, requirements, re.IGNORECASE):
+    regex = re.compile(
+        r"(^|\n|\n\r){0}==|(^|\n|\n\r){0}\[.*\]==".format(re.escape(package)),
+        re.IGNORECASE,
+    )
+
+    if not regex.search(requirements):
         # easy peasy
         if requirements:
             requirements = requirements.strip() + "\n"
@@ -201,7 +214,7 @@ def amend_requirements_content(requirements, package, new_lines):
         lines = []
         padding = " " * 4
         for line in requirements.splitlines():
-            if line.lower().startswith("{0}==".format(package.lower())):
+            if regex.search(line):
                 lines.append(line)
             elif lines and line.startswith(padding):
                 lines.append(line)
@@ -271,7 +284,7 @@ def expand_python_version(version):
 # This should match the naming convention laid out in PEP 0427
 # url = 'https://pypi.python.org/packages/3.4/P/Pygments/Pygments-2.1-py3-none-any.whl' # NOQA
 CLASSIFY_WHEEL_RE = re.compile(
-    """
+    r"""
     ^(?P<package>.+)-
     (?P<version>\d[^-]*)-
     (?P<python_version>[^-]+)-
@@ -285,7 +298,7 @@ CLASSIFY_WHEEL_RE = re.compile(
 )
 
 CLASSIFY_EGG_RE = re.compile(
-    """
+    r"""
     ^(?P<package>.+)-
     (?P<version>\d[^-]*)-
     (?P<python_version>[^-]+)
@@ -298,7 +311,7 @@ CLASSIFY_EGG_RE = re.compile(
 )
 
 CLASSIFY_ARCHIVE_RE = re.compile(
-    """
+    r"""
     ^(?P<package>.+)-
     (?P<version>\d[^-]*)
     (-(?P<platform>[^\.]+))?
@@ -310,7 +323,7 @@ CLASSIFY_ARCHIVE_RE = re.compile(
 )
 
 CLASSIFY_EXE_RE = re.compile(
-    """
+    r"""
     ^(?P<package>.+)-
     (?P<version>\d[^-]*)[-\.]
     ((?P<platform>[^-]*)-)?
diff --git a/setup.py b/setup.py
index 9a43ff6..882b14f 100644
--- a/setup.py
+++ b/setup.py
@@ -7,38 +7,44 @@ _here = path.dirname(__file__)
 # http://www.eby-sarna.com/pipermail/peak/2010-May/003357.html:
 try:
     import multiprocessing
+
     multiprocessing = multiprocessing  # take it easy pyflakes
 except ImportError:
     pass
 
 setup(
-    name='hashin',
-    version='0.13.4',
-    description='Edits your requirements.txt by hashing them in',
-    long_description=open(path.join(_here, 'README.rst')).read(),
-    author='Peter Bengtsson',
-    author_email='mail@peterbe.com',
-    license='MIT',
-    py_modules=['hashin'],
-    entry_points={
-        'console_scripts': ['hashin = hashin:main']
-        },
-    url='https://github.com/peterbe/hashin',
+    name="hashin",
+    version="0.13.4",
+    description="Edits your requirements.txt by hashing them in",
+    long_description=open(path.join(_here, "README.rst")).read(),
+    author="Peter Bengtsson",
+    author_email="mail@peterbe.com",
+    license="MIT",
+    py_modules=["hashin"],
+    entry_points={"console_scripts": ["hashin = hashin:main"]},
+    url="https://github.com/peterbe/hashin",
     include_package_data=True,
-    python_requires='>=2.7,!=3.0,!=3.1,!=3.2,!=3.3',
-    install_requires=['packaging', 'pip-api'],
-    tests_require=['pytest', 'mock'],
-    setup_requires=['pytest-runner'],
+    python_requires=">=2.7,!=3.0,!=3.1,!=3.2,!=3.3",
+    install_requires=["packaging", "pip-api"],
+    tests_require=["pytest", "mock"],
+    setup_requires=["pytest-runner"],
     classifiers=[
-        'Intended Audience :: Developers',
-        'Intended Audience :: System Administrators',
-        'License :: OSI Approved :: MIT License',
-        'Programming Language :: Python :: 2.7',
-        'Programming Language :: Python :: 3',
-        'Topic :: Software Development :: Build Tools',
-        'Topic :: System :: Installation/Setup',
-        'Topic :: System :: Systems Administration'
-        ],
-    keywords=['pip', 'repeatable', 'deploy', 'deployment', 'hash',
-              'install', 'installer']
+        "Intended Audience :: Developers",
+        "Intended Audience :: System Administrators",
+        "License :: OSI Approved :: MIT License",
+        "Programming Language :: Python :: 2.7",
+        "Programming Language :: Python :: 3",
+        "Topic :: Software Development :: Build Tools",
+        "Topic :: System :: Installation/Setup",
+        "Topic :: System :: Systems Administration",
+    ],
+    keywords=[
+        "pip",
+        "repeatable",
+        "deploy",
+        "deployment",
+        "hash",
+        "install",
+        "installer",
+    ],
 )
diff --git a/tox.ini b/tox.ini
index 89e3636..7599fc5 100644
--- a/tox.ini
+++ b/tox.ini
@@ -16,7 +16,7 @@ commands = pytest tests {posargs}
 [testenv:lint]
 deps =
     -rlint-requirements.txt
-commands=therapist run --use-tracked-files hashin.py tests
+commands=therapist run --use-tracked-files
 
 [testenv:restlint]
 deps =


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index f24f1d5..a949b7e 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1186,3 +1186,185 @@ selenium==2.53.1 \
         self.assertRaises(
             hashin.PackageError, hashin.get_package_hashes, package="uggamugga"
         )
+
+    @cleanup_tmpdir("hashin*")
+    @mock.patch("hashin.urlopen")
+    def test_with_extras_syntax(self, murlopen):
+        """When you want to add the hashes of a package by using the
+        "extras notation". E.g `requests[security]`.
+        In this case, it should basically ignore the `[security]` part when
+        doing the magic but get that back when putting the final result
+        into the requirements file.
+        """
+
+        def mocked_get(url, **options):
+            if url == "https://pypi.org/pypi/hashin/json":
+                return _Response(
+                    {
+                        "info": {"version": "0.10", "name": "hashin"},
+                        "releases": {
+                            "0.10": [
+                                {
+                                    "url": "https://pypi.org/packages/source/p/hashin/hashin-0.10.tar.gz",
+                                    "digests": {"sha256": "ccccc"},
+                                }
+                            ]
+                        },
+                    }
+                )
+            raise NotImplementedError(url)
+
+        murlopen.side_effect = mocked_get
+
+        with tmpfile() as filename:
+            with open(filename, "w") as f:
+                f.write("")
+
+            retcode = hashin.run("hashin[stuff]", filename, "sha256")
+
+            self.assertEqual(retcode, 0)
+            with open(filename) as f:
+                output = f.read()
+            self.assertTrue("hashin[stuff]==0.10" in output)
+
+    @cleanup_tmpdir("hashin*")
+    @mock.patch("hashin.urlopen")
+    def test_extras_syntax_edit(self, murlopen):
+        def mocked_get(url, **options):
+            if url == "https://pypi.org/pypi/hashin/json":
+                return _Response(
+                    {
+                        "info": {"version": "0.10", "name": "hashin"},
+                        "releases": {
+                            "0.10": [
+                                {
+                                    "url": "https://pypi.org/packages/source/p/hashin/hashin-0.10.tar.gz",
+                                    "digests": {"sha256": "ccccc"},
+                                }
+                            ]
+                        },
+                    }
+                )
+            raise NotImplementedError(url)
+
+        murlopen.side_effect = mocked_get
+
+        with tmpfile() as filename:
+            with open(filename, "w") as f:
+                f.write("hashin==0.10\n")
+                f.write("    --hash=sha256:ccccc\n")
+
+            retcode = hashin.run("hashin[stuff]", filename, "sha256")
+
+            self.assertEqual(retcode, 0)
+            with open(filename) as f:
+                output = f.read()
+            self.assertTrue("hashin[stuff]==0.10" in output)
+            self.assertTrue("hashin==0.10" not in output)
+
+    @cleanup_tmpdir("hashin*")
+    @mock.patch("hashin.urlopen")
+    def test_add_extra_extras_syntax_edit(self, murlopen):
+        def mocked_get(url, **options):
+            if url == "https://pypi.org/pypi/hashin/json":
+                return _Response(
+                    {
+                        "info": {"version": "0.10", "name": "hashin"},
+                        "releases": {
+                            "0.10": [
+                                {
+                                    "url": "https://pypi.org/packages/source/p/hashin/hashin-0.10.tar.gz",
+                                    "digests": {"sha256": "ccccc"},
+                                }
+                            ]
+                        },
+                    }
+                )
+            raise NotImplementedError(url)
+
+        murlopen.side_effect = mocked_get
+
+        with tmpfile() as filename:
+            with open(filename, "w") as f:
+                f.write("hashin[stuff]==0.10\n")
+                f.write("    --hash=sha256:ccccc\n")
+
+            retcode = hashin.run("hashin[extra,stuff]", filename, "sha256")
+
+            self.assertEqual(retcode, 0)
+            with open(filename) as f:
+                output = f.read()
+            self.assertTrue("hashin[extra,stuff]==0.10" in output)
+            self.assertTrue("hashin==0.10" not in output)
+            self.assertTrue("hashin[stuff]==0.10" not in output)
+            self.assertTrue("hashin[extra]==0.10" not in output)
+
+    @cleanup_tmpdir("hashin*")
+    @mock.patch("hashin.urlopen")
+    def test_change_extra_extras_syntax_edit(self, murlopen):
+        def mocked_get(url, **options):
+            if url == "https://pypi.org/pypi/hashin/json":
+                return _Response(
+                    {
+                        "info": {"version": "0.10", "name": "hashin"},
+                        "releases": {
+                            "0.10": [
+                                {
+                                    "url": "https://pypi.org/packages/source/p/hashin/hashin-0.10.tar.gz",
+                                    "digests": {"sha256": "ccccc"},
+                                }
+                            ]
+                        },
+                    }
+                )
+            raise NotImplementedError(url)
+
+        murlopen.side_effect = mocked_get
+
+        with tmpfile() as filename:
+            with open(filename, "w") as f:
+                f.write("hashin[stuff]==0.10\n")
+                f.write("    --hash=sha256:ccccc\n")
+
+            retcode = hashin.run("hashin[different]", filename, "sha256")
+
+            self.assertEqual(retcode, 0)
+            with open(filename) as f:
+                output = f.read()
+            self.assertTrue("hashin[different]==0.10" in output)
+            self.assertTrue("hashin[stuff]==0.10" not in output)
+
+    @cleanup_tmpdir("hashin*")
+    @mock.patch("hashin.urlopen")
+    def test_remove_extra_extras_syntax_edit(self, murlopen):
+        def mocked_get(url, **options):
+            if url == "https://pypi.org/pypi/hashin/json":
+                return _Response(
+                    {
+                        "info": {"version": "0.10", "name": "hashin"},
+                        "releases": {
+                            "0.10": [
+                                {
+                                    "url": "https://pypi.org/packages/source/p/hashin/hashin-0.10.tar.gz",
+                                    "digests": {"sha256": "ccccc"},
+                                }
+                            ]
+                        },
+                    }
+                )
+            raise NotImplementedError(url)
+
+        murlopen.side_effect = mocked_get
+
+        with tmpfile() as filename:
+            with open(filename, "w") as f:
+                f.write("hashin[stuff]==0.10\n")
+                f.write("    --hash=sha256:ccccc\n")
+
+            retcode = hashin.run("hashin", filename, "sha256")
+
+            self.assertEqual(retcode, 0)
+            with open(filename) as f:
+                output = f.read()
+            self.assertTrue("hashin==0.10" in output)
+            self.assertTrue("hashin[stuff]==0.10" not in output)