caphrim007: @peterbe ready for review
caphrim007: Fixes #116
caphrim007: @peterbe I will send updates shortly. thanks for reviewing!
caphrim007: changes made. Ready for review
peterbe: Travis isn't reporting checks here on the PR, but: https://travis-ci.org/github/peterbe/hashin/builds/698623654 is all green!
diff --git a/hashin.py b/hashin.py
index 515e037..df1a229 100755
--- a/hashin.py
+++ b/hashin.py
@@ -388,17 +388,39 @@ def amend_requirements_content(requirements, all_new_lines):
     padding = " " * 4
 
     def is_different_lines(old_lines, new_lines, indent):
+        # This regex is used to only temporarily normalize the names of packages
+        # in the lines being compared. This results in "old" names matching
+        # "new" names so that hashin correctly replaces them when it looks for
+        # them.
+        match_delims = re.compile(r"[-_]")
+
         # This assumes that the package is already mentioned in the old
         # requirements. Now we just need to double-check that its lines are
         # different.
         # The 'new_lines` is what we might intend to replace it with.
-        old = set([l.strip(" \\") for l in old_lines])
+        old = set([match_delims.sub("-", line.strip(" \\")) for line in old_lines])
         new = set([indent + x.strip(" \\") for x in new_lines])
         return old != new
 
     for package, old_name, new_text in all_new_lines:
+        # The call to `escape` will turn hyphens into escaped hyphens
+        #
+        # ex.
+        #   -       becomes     \\-
+        #
+        escaped = re.escape(old_name)
+
+        # This changes those escaped hypens into a pattern to match
+        #
+        # ex.
+        #   \\-     becomes     [-_]
+        #
+        # This is necessary so that hashin will correctly find underscored (old)
+        # and hyphenated (new) package names so that it will correctly replace an
+        # old name with the new name when there is a version update.
+        escape_replaced = escaped.replace("\\-", "[-_]")
         regex = re.compile(
-            r"^(?P<indent>[ \t]*){0}(\[.*\])?==".format(re.escape(old_name)),
+            r"^(?P<indent>[ \t]*){0}(\[.*\])?==".format(escape_replaced),
             re.IGNORECASE | re.MULTILINE,
         )
         # if the package wasn't already there, add it to the bottom
diff --git a/tox.ini b/tox.ini
index 0d54cd5..5c36c84 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py{27,34,35,36}, lint, restlint
+envlist = py{27,34,35,36,37}, lint, restlint
 
 [travis]
 python =
@@ -7,6 +7,7 @@ python =
   3.4: py34
   3.5: py35
   3.6: py36, lint, restlint
+  3.7: py37, lint, restlint
 
 [testenv]
 usedevelop = True

Hashin support for not distinguishing between underscored/hyphenated
pypi appears to follow guidelines outlined in PEP8 around package names preferring to hyphenate them as opposed to underscore them

https://www.python.org/dev/peps/pep-0008/#package-and-module-names

"""
Modules should have short, all-lowercase names. Underscores can be used in the module name if it improves readability. Python packages should also have short, all-lowercase names, although the use of underscores is discouraged.
"""

Note there is a distinction between a module and a package. `hashin` is operating on packages. 

Regardless of the naming choices, pypi appears to support both underscores and hyphens in packages. This is likely being done so-as to prevent major breaking changes across existing software.

Consider, for example, if pypi drew a hard line on this, switched all package names to hyphens, and disallowed underscores; a tidal wave of broken software would exist based on this decision.

So it looks like pypi supports both and (may?) support both forever. However, when you use hashin to get the package of choice, you're always returned the "correct" one; ie, the hyphenated version.

There exists a case where a user may have an existing (underscored) package definition, and hashin should behave to correct this to use the hyphenated version.

For instance, if the user's requirements file has the following (truncated sha's)

```
python_memcached==1.59 \
    --hash=sha256:4dac64916871bd35502 \
    --hash=sha256:a2e28637be13ee0bf1a8
```

Then upon running either of the following

* `hashin python_memcached==1.6.0 -r requirements.txt`
* `hashin python-memcached==1.6.0 -r requirements.txt`

The expected change to the requirements file would be

```
python-memcached==1.60 \
    --hash=sha256:x00c64916871bd35502 \
    --hash=sha256:x02e28637be13ee0bf1a
```

Today, however, the behavior is that `hashin` will do the following.

* Update, in place, if python_memcached is hashed
* Append if python-memcached is hashed

This issue proposes that hashin normalize this behavior to prefer (in all cases) the hyphenated name. Therefore,

* If python_memcached is hashed, replace python_memcached with python-memcached, and update hashes in place for python-memcached
* If python-memcached is hashed, update hashes in place for python-memcached
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 6bf9cc6..7f5d119 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -294,6 +294,26 @@ autocompeter==1.2.3 \\
     assert result == requirements + new_lines[2]
 
 
+def test_amend_requirements_content_new_2():
+    requirements = (
+        """
+# empty so far
+    """.strip()
+        + "\n"
+    )
+    new_lines = (
+        "discogs-client",
+        "Discogs_client",
+        """
+discogs-client==1.1 \\
+    --hash=sha256:4d64ed1b9e0e73095f5cfa87f0e97ddb4c840049e8efeb7e63b46118ba1d623a
+    """.strip()
+        + "\n",
+    )
+    result = hashin.amend_requirements_content(requirements, [new_lines])
+    assert result == requirements + new_lines[2]
+
+
 def test_amend_requirements_different_old_name():
     requirements = (
         """
@@ -2665,3 +2685,222 @@ def test_interactive_upgrade_request_force_yes(capsys):
             "hashin", old_version, new_version, force_yes=True
         )
         assert result == "YES"
+
+
+class TestIssue116:
+    """Tests for validating fix related to GH issue #116
+
+    https://github.com/peterbe/hashin/issues/116
+    """
+
+    def test_amend_requirements_content_new_without_env_markers(self):
+        requirements = (
+            """
+# empty so far
+        """.strip()
+            + "\n"
+        )
+        new_lines = (
+            "discogs-client",
+            "discogs-client",
+            """
+discogs-client==1.1 \\
+    --hash=sha256:4d64ed1b9e0e73095f5cfa87f0e97ddb4c840049e8efeb7e63b46118ba1d623a
+        """.strip()
+            + "\n",
+        )
+        result = hashin.amend_requirements_content(requirements, [new_lines])
+        assert result == requirements + new_lines[2]
+
+    def test_amend_requirements_content_new_with_env_markers(self):
+        requirements = (
+            """
+# empty so far
+        """.strip()
+            + "\n"
+        )
+        new_lines = (
+            "discogs-client; python_version <= '3.4'",
+            "discogs-client; python_version <= '3.4'",
+            """
+discogs-client==1.1; python_version <= '3.4' \\
+    --hash=sha256:4d64ed1b9e0e73095f5cfa87f0e97ddb4c840049e8efeb7e63b46118ba1d623a
+        """.strip()
+            + "\n",
+        )
+        result = hashin.amend_requirements_content(requirements, [new_lines])
+        assert result == requirements + new_lines[2]
+
+    def test_amend_requirements_different_old_name_without_env_markers(self):
+        requirements = (
+            """
+readme_renderer==25.0 \\
+    --hash=sha256:1b6d8dd1673a0b293766b4106af766b6eff3654605f9c4f239e65de6076bc222 \\
+    --hash=sha256:e67d64242f0174a63c3b727801a2fff4c1f38ebe5d71d95ff7ece081945a6cd4
+
+        """.strip()
+            + "\n"
+        )
+
+        new_lines = (
+            "readme-renderer",
+            "readme-renderer",
+            """
+readme-renderer==26.0 \\
+    --hash=sha256:cbe9db71defedd2428a1589cdc545f9bd98e59297449f69d721ef8f1cfced68d \\
+    --hash=sha256:cc4957a803106e820d05d14f71033092537a22daa4f406dfbdd61177e0936376
+
+        """.strip()
+            + "\n",
+        )
+        result = hashin.amend_requirements_content(requirements, [new_lines])
+        assert result == new_lines[2]
+
+    def test_amend_requirements_different_old_name_with_env_markers(self):
+        requirements = (
+            """
+readme_renderer==25.0; python_version <= "3.4" \\
+    --hash=sha256:1b6d8dd1673a0b293766b4106af766b6eff3654605f9c4f239e65de6076bc222 \\
+    --hash=sha256:e67d64242f0174a63c3b727801a2fff4c1f38ebe5d71d95ff7ece081945a6cd4
+
+        """.strip()
+            + "\n"
+        )
+
+        new_lines = (
+            "readme-renderer",
+            "readme-renderer",
+            """
+readme-renderer==26.0; python_version <= "3.4" \\
+    --hash=sha256:cbe9db71defedd2428a1589cdc545f9bd98e59297449f69d721ef8f1cfced68d \\
+    --hash=sha256:cc4957a803106e820d05d14f71033092537a22daa4f406dfbdd61177e0936376
+
+        """.strip()
+            + "\n",
+        )
+        result = hashin.amend_requirements_content(requirements, [new_lines])
+        assert result == new_lines[2]
+
+    def test_amend_requirements_without_env_markers_same_version(self):
+        requirements = (
+            """
+readme_renderer==25.0 \\
+    --hash=sha256:1b6d8dd1673a0b293766b4106af766b6eff3654605f9c4f239e65de6076bc222 \\
+    --hash=sha256:e67d64242f0174a63c3b727801a2fff4c1f38ebe5d71d95ff7ece081945a6cd4
+
+        """.strip()
+            + "\n"
+        )
+
+        new_lines = (
+            "readme-renderer",
+            "readme-renderer",
+            """
+readme_renderer==25.0 \\
+    --hash=sha256:1b6d8dd1673a0b293766b4106af766b6eff3654605f9c4f239e65de6076bc222 \\
+    --hash=sha256:e67d64242f0174a63c3b727801a2fff4c1f38ebe5d71d95ff7ece081945a6cd4
+
+        """.strip()
+            + "\n",
+        )
+        result = hashin.amend_requirements_content(requirements, [new_lines])
+        assert result == requirements
+
+    def test_amend_requirements_with_env_markers_same_version(self):
+        requirements = (
+            """
+readme_renderer==25.0; python_version <= "3.4" \\
+    --hash=sha256:1b6d8dd1673a0b293766b4106af766b6eff3654605f9c4f239e65de6076bc222 \\
+    --hash=sha256:e67d64242f0174a63c3b727801a2fff4c1f38ebe5d71d95ff7ece081945a6cd4
+
+        """.strip()
+            + "\n"
+        )
+
+        new_lines = (
+            "readme-renderer",
+            "readme-renderer",
+            """
+readme_renderer==25.0; python_version <= "3.4" \\
+    --hash=sha256:1b6d8dd1673a0b293766b4106af766b6eff3654605f9c4f239e65de6076bc222 \\
+    --hash=sha256:e67d64242f0174a63c3b727801a2fff4c1f38ebe5d71d95ff7ece081945a6cd4
+
+        """.strip()
+            + "\n",
+        )
+        result = hashin.amend_requirements_content(requirements, [new_lines])
+        assert result == requirements
+
+    def test_run_old_name_no_version_change(self, murlopen, tmpfile, capsys):
+        def mocked_get(url, **options):
+            if url == "https://pypi.org/pypi/readme_renderer/json":
+                return _Response(
+                    {
+                        "info": {"version": "0.25", "name": "readme-renderer"},
+                        "releases": {
+                            "25.0": [
+                                {
+                                    "url": "https://pypi.org/packages/source/p/readme-renderer/readme_renderer-25.0.tar.gz",
+                                    "digests": {"sha256": "bbbbb"},
+                                }
+                            ],
+                            "26.0": [
+                                {
+                                    "url": "https://pypi.org/packages/source/p/readme-renderer/readme_renderer-26.0.tar.gz",
+                                    "digests": {"sha256": "aaaaa"},
+                                }
+                            ],
+                        },
+                    }
+                )
+
+        murlopen.side_effect = mocked_get
+
+        with tmpfile() as filename:
+            with open(filename, "w") as f:
+                f.write("readme_renderer==25.0 \\\n")
+                f.write("    --hash=sha256:bbbbb\n")
+                f.write("\n")
+
+            retcode = hashin.run("readme_renderer==25.0", filename, "sha256")
+            assert retcode == 0
+            with open(filename) as f:
+                output = f.read()
+            assert "readme_renderer==25.0" in output
+
+    def test_run_old_name_new_version_change(self, murlopen, tmpfile, capsys):
+        def mocked_get(url, **options):
+            if url == "https://pypi.org/pypi/readme_renderer/json":
+                return _Response(
+                    {
+                        "info": {"version": "0.25", "name": "readme-renderer"},
+                        "releases": {
+                            "25.0": [
+                                {
+                                    "url": "https://pypi.org/packages/source/p/readme-renderer/readme_renderer-25.0.tar.gz",
+                                    "digests": {"sha256": "bbbbb"},
+                                }
+                            ],
+                            "26.0": [
+                                {
+                                    "url": "https://pypi.org/packages/source/p/readme-renderer/readme_renderer-26.0.tar.gz",
+                                    "digests": {"sha256": "aaaaa"},
+                                }
+                            ],
+                        },
+                    }
+                )
+
+        murlopen.side_effect = mocked_get
+
+        with tmpfile() as filename:
+            with open(filename, "w") as f:
+                f.write("readme_renderer==26.0 \\\n")
+                f.write("    --hash=sha256:bbbbb\n")
+                f.write("\n")
+
+            retcode = hashin.run("readme_renderer==26.0", filename, "sha256")
+            assert retcode == 0
+            with open(filename) as f:
+                output = f.read()
+            assert "readme-renderer==26.0" in output

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest mock flake8 black therapist
apt-get update apt-get install -y gcc
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
backports.tarfile==1.2.0
black==25.1.0
cachetools==5.5.2
certifi==2025.1.31
cffi==1.17.1
chardet==5.2.0
charset-normalizer==3.4.1
click==8.1.8
colorama==0.4.6
cryptography==44.0.2
distlib==0.3.9
docutils==0.21.2
exceptiongroup==1.2.2
filelock==3.18.0
flake8==7.2.0
-e git+https://github.com/peterbe/hashin.git@a9e2239bf46fcba12be9ecac272a806fc48bfe1e#egg=hashin
id==1.5.0
idna==3.10
importlib_metadata==8.6.1
iniconfig==2.1.0
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.1.0
jeepney==0.9.0
keyring==25.6.0
markdown-it-py==3.0.0
mccabe==0.7.0
mdurl==0.1.2
mock==5.2.0
more-itertools==10.6.0
mypy-extensions==1.0.0
nh3==0.2.21
packaging==24.2
pathspec==0.12.1
pip-api==0.0.34
platformdirs==4.3.7
pluggy==1.5.0
pycodestyle==2.13.0
pycparser==2.22
pyflakes==3.3.1
Pygments==2.19.1
pyproject-api==1.9.0
pytest==8.3.5
PyYAML==6.0.2
readme_renderer==44.0
requests==2.32.3
requests-toolbelt==1.0.0
rfc3986==2.0.0
rich==14.0.0
SecretStorage==3.3.3
therapist==2.2.0
tomli==2.2.1
tox==4.25.0
twine==6.1.0
typing_extensions==4.13.0
urllib3==2.3.0
virtualenv==20.29.3
zipp==3.21.0

name: hashin
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - backports-tarfile==1.2.0
      - black==25.1.0
      - cachetools==5.5.2
      - certifi==2025.1.31
      - cffi==1.17.1
      - chardet==5.2.0
      - charset-normalizer==3.4.1
      - click==8.1.8
      - colorama==0.4.6
      - cryptography==44.0.2
      - distlib==0.3.9
      - docutils==0.21.2
      - exceptiongroup==1.2.2
      - filelock==3.18.0
      - flake8==7.2.0
      - id==1.5.0
      - idna==3.10
      - importlib-metadata==8.6.1
      - iniconfig==2.1.0
      - jaraco-classes==3.4.0
      - jaraco-context==6.0.1
      - jaraco-functools==4.1.0
      - jeepney==0.9.0
      - keyring==25.6.0
      - markdown-it-py==3.0.0
      - mccabe==0.7.0
      - mdurl==0.1.2
      - mock==5.2.0
      - more-itertools==10.6.0
      - mypy-extensions==1.0.0
      - nh3==0.2.21
      - packaging==24.2
      - pathspec==0.12.1
      - pip-api==0.0.34
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - pycodestyle==2.13.0
      - pycparser==2.22
      - pyflakes==3.3.1
      - pygments==2.19.1
      - pyproject-api==1.9.0
      - pytest==8.3.5
      - pyyaml==6.0.2
      - readme-renderer==44.0
      - requests==2.32.3
      - requests-toolbelt==1.0.0
      - rfc3986==2.0.0
      - rich==14.0.0
      - secretstorage==3.3.3
      - therapist==2.2.0
      - tomli==2.2.1
      - tox==4.25.0
      - twine==6.1.0
      - typing-extensions==4.13.0
      - urllib3==2.3.0
      - virtualenv==20.29.3
      - zipp==3.21.0
prefix: /opt/conda/envs/hashin

tests/test_cli.py::TestIssue116::test_amend_requirements_different_old_name_without_env_markers tests/test_cli.py::TestIssue116::test_amend_requirements_different_old_name_with_env_markers tests/test_cli.py::TestIssue116::test_amend_requirements_without_env_markers_same_version tests/test_cli.py::TestIssue116::test_amend_requirements_with_env_markers_same_version
tests/test_cli.py::test_get_latest_version_simple tests/test_cli.py::test_get_latest_version_only_pre_release tests/test_cli.py::test_get_latest_version_non_pre_release_leading_zeros tests/test_cli.py::test_get_hashes_error tests/test_cli.py::test_non_200_ok_download tests/test_cli.py::test_main_packageerrors_stderr tests/test_cli.py::test_packages_and_update_all tests/test_cli.py::test_packages_and_update_all_with_requirements_file tests/test_cli.py::test_no_packages_and_not_update_all tests/test_cli.py::test_interactive_not_update_all tests/test_cli.py::test_main_version tests/test_cli.py::test_amend_requirements_content_new tests/test_cli.py::test_amend_requirements_content_new_2 tests/test_cli.py::test_amend_requirements_different_old_name tests/test_cli.py::test_amend_requirements_content_multiple_merge tests/test_cli.py::test_amend_requirements_content_replacement tests/test_cli.py::test_amend_requirements_content_actually_not_replacement tests/test_cli.py::test_amend_requirements_content_replacement_addition tests/test_cli.py::test_amend_requirements_content_replacement_single_to_multi tests/test_cli.py::test_amend_requirements_content_replacement_2 tests/test_cli.py::test_amend_requirements_content_replacement_amongst_others tests/test_cli.py::test_amend_requirements_content_replacement_amongst_others_2 tests/test_cli.py::test_amend_requirements_content_new_similar_name tests/test_cli.py::test_amend_requirements_content_indented tests/test_cli.py::test_run tests/test_cli.py::test_canonical_list_of_hashes tests/test_cli.py::test_run_atomic_not_write_with_error_on_last_package tests/test_cli.py::test_run_interactive tests/test_cli.py::test_run_interactive_quit_and_accept_all tests/test_cli.py::test_run_interactive_case_redirect tests/test_cli.py::test_run_without_specific_version tests/test_cli.py::test_run_with_alternate_index_url tests/test_cli.py::test_run_contained_names tests/test_cli.py::test_run_case_insensitive tests/test_cli.py::test_run_update_all tests/test_cli.py::test_run_comments_with_package_spec_patterns tests/test_cli.py::test_run_dry tests/test_cli.py::test_run_dry_multiple_packages tests/test_cli.py::test_run_pep_0496 tests/test_cli.py::test_filter_releases tests/test_cli.py::test_release_url_metadata_python tests/test_cli.py::test_expand_python_version tests/test_cli.py::test_get_package_hashes tests/test_cli.py::test_get_package_hashes_from_alternate_index_url tests/test_cli.py::test_get_package_hashes_package_not_found tests/test_cli.py::test_get_package_hashes_unknown_algorithm tests/test_cli.py::test_get_package_hashes_without_version tests/test_cli.py::test_with_extras_syntax tests/test_cli.py::test_extras_syntax_edit tests/test_cli.py::test_add_extra_extras_syntax_edit tests/test_cli.py::test_change_extra_extras_syntax_edit tests/test_cli.py::test_remove_extra_extras_syntax_edit tests/test_cli.py::test_interactive_upgrade_request tests/test_cli.py::test_interactive_upgrade_request_repeat_question tests/test_cli.py::test_interactive_upgrade_request_help tests/test_cli.py::test_interactive_upgrade_request_force_yes tests/test_cli.py::TestIssue116::test_amend_requirements_content_new_without_env_markers tests/test_cli.py::TestIssue116::test_amend_requirements_content_new_with_env_markers tests/test_cli.py::TestIssue116::test_run_old_name_no_version_change tests/test_cli.py::TestIssue116::test_run_old_name_new_version_change
hashin.py tox.ini
hashin.py tox.ini