From ef3931a2c6e0977af64e500ec702756c000f527c Mon Sep 17 00:00:00 2001 From: Peter Bengtsson Date: Thu, 1 Nov 2018 13:08:53 -0400 Subject: [PATCH 1/5] --update-all and --interactive Fixes #90 --- README.rst | 7 +- hashin.py | 227 ++++++++++++++---- tests/conftest.py | 4 +- tests/test_arg_parse.py | 11 +- tests/test_cli.py | 513 ++++++++++++++++++++++++++++++++++++++-- 5 files changed, 690 insertions(+), 72 deletions(-) diff --git a/README.rst b/README.rst index 45e29eb..deabfc7 100644 --- a/README.rst +++ b/README.rst @@ -210,7 +210,7 @@ To run the tests with test coverage, with ``pytest`` run something like this:: $ pip install pytest-cover - $ pytest --cov hashin --cov-report html tests + $ pytest --cov=hashin --cov-report=html $ open htmlcov/index.html @@ -254,6 +254,11 @@ Version History next + * ``--interactive`` (when you use ``--update-all``) will iterate over all outdated + versions in your requirements file and ask, for each one, if you want to + updated it. + See https://github.com/peterbe/hashin/issues/90 + * Order of hashes should not affect if a package in the requirements file should be replaced or not. See https://github.com/peterbe/hashin/issues/93 diff --git a/hashin.py b/hashin.py index bbec469..838d8d2 100755 --- a/hashin.py +++ b/hashin.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- + """ See README :) """ @@ -16,6 +18,7 @@ import pip_api from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet from packaging.version import parse if sys.version_info >= (3,): @@ -24,6 +27,8 @@ else: from urllib import urlopen + input = raw_input # noqa + if sys.version_info < (2, 7, 9): import warnings @@ -38,55 +43,6 @@ DEFAULT_ALGORITHM = "sha256" -parser = argparse.ArgumentParser() -parser.add_argument( - "packages", - help="One or more package specifiers (e.g. some-package or some-package==1.2.3)", - nargs="*", -) -parser.add_argument( - "-r", - "--requirements-file", - help="requirements file to write to (default requirements.txt)", - default="requirements.txt", -) -parser.add_argument( - "-a", - "--algorithm", - help="The hash algorithm to use: one of sha256, sha384, sha512", - default=DEFAULT_ALGORITHM, -) -parser.add_argument("-v", "--verbose", help="Verbose output", action="store_true") -parser.add_argument( - "--include-prereleases", - help="Include pre-releases (off by default)", - action="store_true", -) -parser.add_argument( - "-p", - "--python-version", - help="Python version to add wheels for. May be used multiple times.", - action="append", - default=[], -) -parser.add_argument( - "--version", help="Version of hashin", action="store_true", default=False -) -parser.add_argument( - "--dry-run", - help="Don't touch requirements.txt and just show the diff", - action="store_true", - default=False, -) -parser.add_argument( - "-u", - "--update-all", - help="Update all mentioned packages in the requirements file.", - action="store_true", - default=False, -) - - major_pip_version = int(pip_api.version().split(".")[0]) if major_pip_version < 8: raise ImportError("hashin only works with pip 8.x or greater") @@ -142,18 +98,22 @@ def run(specs, requirements_file, *args, **kwargs): if not specs: # then, assume all in the requirements file regex = re.compile(r"(^|\n|\n\r).*==") specs = [] + previous_versions = {} with open(requirements_file) as f: for line in f: if regex.search(line): req = Requirement(line.split("\\")[0]) # Deliberately strip the specifier (aka. the version) + version = req.specifier req.specifier = None specs.append(str(req)) + previous_versions[str(req).split(";")[0]] = version + kwargs["previous_versions"] = previous_versions + if isinstance(specs, str): specs = [specs] - run_packages(specs, requirements_file, *args, **kwargs) - return 0 + return run_packages(specs, requirements_file, *args, **kwargs) def run_packages( @@ -164,9 +124,12 @@ def run_packages( verbose=False, include_prereleases=False, dry_run=False, + previous_versions=None, + interactive=False, ): assert isinstance(specs, list), type(specs) all_new_lines = [] + first_interactive = True for spec in specs: restriction = None if ";" in spec: @@ -196,6 +159,30 @@ def run_packages( # We do that by modifying only the `name` part of the `Requirement` instance. req.name = package + new_version_specifier = SpecifierSet("=={}".format(data["version"])) + + if previous_versions and previous_versions.get(str(req)): + # We have some form of previous version and a new version. + # If they' already equal, just skip this one. + if previous_versions[str(req)] == new_version_specifier: + continue + + if interactive: + try: + if not interactive_upgrade_request( + package, + previous_versions[str(req)], + new_version_specifier, + print_header=first_interactive, + ): + first_interactive = False + continue + first_interactive = False + except InteractiveAll: + interactive = False + except (InteractiveQuit, KeyboardInterrupt): + return 1 + maybe_restriction = "" if not restriction else "; {0}".format(restriction) new_lines = "{0}=={1}{2} \\\n".format(req, data["version"], maybe_restriction) padding = " " * 4 @@ -206,6 +193,11 @@ def run_packages( new_lines += "\n" all_new_lines.append((package, new_lines)) + if not all_new_lines: + # This can happen if you use 'interactive' and said no to everything or + # if every single package you listed already has the latest version. + return 0 + with open(file) as f: old_requirements = f.read() requirements = amend_requirements_content(old_requirements, all_new_lines) @@ -228,6 +220,69 @@ def run_packages( if verbose: _verbose("Editing", file) + return 0 + + +class InteractiveAll(Exception): + """When the user wants to say yes to ALL package updates.""" + + +class InteractiveQuit(Exception): + """When the user wants to stop the interactive update questions entirely.""" + + +def interactive_upgrade_request(package, old_version, new_version, print_header=False): + def print_version(v): + return str(v).replace("==", "").ljust(15) + + if print_header: + print( + "PACKAGE".ljust(30), + print_version("YOUR VERSION"), + print_version("NEW VERSION"), + ) + + def print_line(checkbox=None): + if checkbox is None: + checkboxed = "?" + elif checkbox: + checkboxed = "✓" + else: + checkboxed = "✘" + print( + package.ljust(30), + print_version(old_version), + print_version(new_version), + checkboxed, + ) + + print_line() + + def clear_line(): + sys.stdout.write("\033[F") # Cursor up one line + sys.stdout.write("\033[K") # Clear to the end of line + + def ask(): + answer = input("Update? [Y/n/a/q]: ").lower().strip() + if answer == "n": + clear_line() + clear_line() + print_line(False) + return False + if answer == "a": + clear_line() + raise InteractiveAll + if answer == "q": + raise InteractiveQuit + if answer == "y" or answer == "" or answer == "yes": + clear_line() + clear_line() + print_line(True) + return True + return ask() + + return ask() + def amend_requirements_content(requirements, all_new_lines): # I wish we had types! @@ -530,6 +585,67 @@ def get_package_hashes( return {"package": package, "version": version, "hashes": hashes} +def get_parser(): + parser = argparse.ArgumentParser() + parser.add_argument( + "packages", + help="One or more package specifiers (e.g. some-package or some-package==1.2.3)", + nargs="*", + ) + parser.add_argument( + "-r", + "--requirements-file", + help="requirements file to write to (default requirements.txt)", + default="requirements.txt", + ) + parser.add_argument( + "-a", + "--algorithm", + help="The hash algorithm to use: one of sha256, sha384, sha512", + default=DEFAULT_ALGORITHM, + ) + parser.add_argument("-v", "--verbose", help="Verbose output", action="store_true") + parser.add_argument( + "--include-prereleases", + help="Include pre-releases (off by default)", + action="store_true", + ) + parser.add_argument( + "-p", + "--python-version", + help="Python version to add wheels for. May be used multiple times.", + action="append", + default=[], + ) + parser.add_argument( + "--version", help="Version of hashin", action="store_true", default=False + ) + parser.add_argument( + "--dry-run", + help="Don't touch requirements.txt and just show the diff", + action="store_true", + default=False, + ) + parser.add_argument( + "-u", + "--update-all", + help="Update all mentioned packages in the requirements file.", + action="store_true", + default=False, + ) + parser.add_argument( + "-i", + "--interactive", + help=( + "Ask about each possible update. " + "Only applicable together with --update-all/-u." + ), + action="store_true", + default=False, + ) + return parser + + def main(): if "--version" in sys.argv[1:]: # Can't be part of argparse because the 'packages' is mandatory @@ -539,6 +655,7 @@ def main(): print(pkg_resources.get_distribution("hashin").version) return 0 + parser = get_parser() args = parser.parse_args() if args.update_all: @@ -548,6 +665,13 @@ def main(): file=sys.stderr, ) return 2 + elif args.interactive: + print( + "--interactive (or -i) is only applicable together " + "with --update-all (or -u).", + file=sys.stderr, + ) + return 4 elif not args.packages: print("If you don't use --update-all you must list packages.", file=sys.stderr) parser.print_usage() @@ -562,6 +686,7 @@ def main(): verbose=args.verbose, include_prereleases=args.include_prereleases, dry_run=args.dry_run, + interactive=args.interactive, ) except PackageError as exception: print(str(exception), file=sys.stderr) diff --git a/tests/conftest.py b/tests/conftest.py index 40e7372..79469aa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,8 +15,8 @@ def murlopen(): @pytest.fixture -def mock_parser(): - with mock.patch("hashin.parser") as patch: +def mock_get_parser(): + with mock.patch("hashin.get_parser") as patch: yield patch diff --git a/tests/test_arg_parse.py b/tests/test_arg_parse.py index a9718e0..6fb9df4 100644 --- a/tests/test_arg_parse.py +++ b/tests/test_arg_parse.py @@ -1,10 +1,10 @@ import argparse -from hashin import parser +from hashin import get_parser def test_everything(): - args = parser.parse_known_args( + args = get_parser().parse_known_args( [ "example", "another-example", @@ -28,12 +28,13 @@ def test_everything(): include_prereleases=False, dry_run=True, update_all=False, + interactive=False, ) assert args == (expected, []) def test_everything_long(): - args = parser.parse_known_args( + args = get_parser().parse_known_args( [ "example", "another-example", @@ -57,12 +58,13 @@ def test_everything_long(): include_prereleases=False, dry_run=True, update_all=False, + interactive=False, ) assert args == (expected, []) def test_minimal(): - args = parser.parse_known_args(["example"]) + args = get_parser().parse_known_args(["example"]) expected = argparse.Namespace( algorithm="sha256", packages=["example"], @@ -73,5 +75,6 @@ def test_minimal(): include_prereleases=False, dry_run=False, update_all=False, + interactive=False, ) assert args == (expected, []) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3615e37..440bfa8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,9 +1,12 @@ +# -*- coding: utf-8 -*- + import argparse import sys import json import pytest import mock +from packaging.requirements import Requirement import hashin @@ -12,8 +15,7 @@ # As in, Python 3 from urllib.error import HTTPError - STR_TYPE = str -else: # Python 2 +else: FileNotFoundError = IOError # ugly but necessary # Python 2 does not have this exception. HTTPError = None @@ -128,7 +130,7 @@ def mocked_get(url, **options): hashin.run("somepackage==1.2.3", "doesntmatter.txt", "sha256") -def test_main_packageerrors_stderr(mock_run, mock_sys, mock_parser): +def test_main_packageerrors_stderr(mock_run, capsys, mock_get_parser): # Doesn't matter so much what, just make sure it breaks mock_run.side_effect = hashin.PackageError("Some message here") @@ -142,17 +144,18 @@ def mock_parse_args(*a, **k): include_prereleases=False, dry_run=False, update_all=False, + interactive=False, ) - mock_parser.parse_args.side_effect = mock_parse_args + mock_get_parser().parse_args.side_effect = mock_parse_args error = hashin.main() assert error == 1 - mock_sys.stderr.write.assert_any_call("Some message here") - mock_sys.stderr.write.assert_any_call("\n") + captured = capsys.readouterr() + assert captured.err == "Some message here\n" -def test_packages_and_update_all(mock_sys, mock_parser): +def test_packages_and_update_all(capsys, mock_get_parser): def mock_parse_args(*a, **k): return argparse.Namespace( packages=["something"], @@ -163,18 +166,20 @@ def mock_parse_args(*a, **k): include_prereleases=False, dry_run=False, update_all=True, # Note! + interactive=False, ) - mock_parser.parse_args.side_effect = mock_parse_args + mock_get_parser().parse_args.side_effect = mock_parse_args error = hashin.main() assert error == 2 - mock_sys.stderr.write.assert_any_call( - "Can not combine the --update-all option with a list of packages." + captured = capsys.readouterr() + assert captured.err == ( + "Can not combine the --update-all option with a list of packages.\n" ) -def test_no_packages_and_not_update_all(mock_sys, mock_parser): +def test_no_packages_and_not_update_all(capsys, mock_get_parser): def mock_parse_args(*a, **k): return argparse.Namespace( packages=[], # Note! @@ -185,14 +190,39 @@ def mock_parse_args(*a, **k): include_prereleases=False, dry_run=False, update_all=False, + interactive=False, ) - mock_parser.parse_args.side_effect = mock_parse_args + mock_get_parser().parse_args.side_effect = mock_parse_args error = hashin.main() assert error == 3 - mock_sys.stderr.write.assert_any_call( - "If you don't use --update-all you must list packages." + captured = capsys.readouterr() + assert captured.err == ("If you don't use --update-all you must list packages.\n") + + +def test_interactive_not_update_all(mock_get_parser, capsys): + def mock_parse_args(*a, **k): + return argparse.Namespace( + packages=[], + requirements_file="requirements.txt", + algorithm="sha256", + python_version="3.8", + verbose=False, + include_prereleases=False, + dry_run=False, + update_all=False, # Note! + interactive=True, # Note! + ) + + mock_get_parser().parse_args.side_effect = mock_parse_args + + error = hashin.main() + assert error == 4 + captured = capsys.readouterr() + assert not captured.out + assert captured.err == ( + "--interactive (or -i) is only applicable together with --update-all (or -u).\n" ) @@ -529,6 +559,7 @@ def mocked_get(url, **options): assert retcode == 0 with open(filename) as f: output = f.read() + assert output assert output.endswith("\n") lines = output.splitlines() @@ -629,6 +660,388 @@ def mocked_get(url, **options): assert output == "" +def test_run_interactive(murlopen, tmpfile, capsys): + 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/2.7/p/hashin/hashin-0.10-py2-none-any.whl", + "digests": {"sha256": "aaaaa"}, + }, + { + "url": "https://pypi.org/packages/3.3/p/hashin/hashin-0.10-py3-none-any.whl", + "digests": {"sha256": "bbbbb"}, + }, + { + "url": "https://pypi.org/packages/source/p/hashin/hashin-0.10.tar.gz", + "digests": {"sha256": "ccccc"}, + }, + ] + }, + } + ) + elif url == "https://pypi.org/pypi/requests/json": + return _Response( + { + "info": {"version": "1.2.4", "name": "requests"}, + "releases": { + "1.2.4": [ + { + "url": "https://pypi.org/packages/source/p/requests/requests-1.2.4.tar.gz", + "digests": {"sha256": "dededede"}, + } + ] + }, + } + ) + if url == "https://pypi.org/pypi/enum34/json": + return _Response( + { + "info": {"version": "1.1.6", "name": "enum34"}, + "releases": { + "1.1.6": [ + { + "has_sig": False, + "upload_time": "2016-05-16T03:31:13", + "comment_text": "", + "python_version": "py2", + "url": "https://pypi.org/packages/c5/db/enum34-1.1.6-py2-none-any.whl", + "digests": { + "md5": "68f6982cc07dde78f4b500db829860bd", + "sha256": "aaaaa", + }, + "md5_digest": "68f6982cc07dde78f4b500db829860bd", + "downloads": 4297423, + "filename": "enum34-1.1.6-py2-none-any.whl", + "packagetype": "bdist_wheel", + "path": "c5/db/enum34-1.1.6-py2-none-any.whl", + "size": 12427, + }, + { + "has_sig": False, + "upload_time": "2016-05-16T03:31:19", + "comment_text": "", + "python_version": "py3", + "url": "https://pypi.org/packages/af/42/enum34-1.1.6-py3-none-any.whl", + "md5_digest": "a63ecb4f0b1b85fb69be64bdea999b43", + "digests": { + "md5": "a63ecb4f0b1b85fb69be64bdea999b43", + "sha256": "bbbbb", + }, + "downloads": 98598, + "filename": "enum34-1.1.6-py3-none-any.whl", + "packagetype": "bdist_wheel", + "path": "af/42/enum34-1.1.6-py3-none-any.whl", + "size": 12428, + }, + { + "has_sig": False, + "upload_time": "2016-05-16T03:31:30", + "comment_text": "", + "python_version": "source", + "url": "https://pypi.org/packages/bf/3e/enum34-1.1.6.tar.gz", + "md5_digest": "5f13a0841a61f7fc295c514490d120d0", + "digests": { + "md5": "5f13a0841a61f7fc295c514490d120d0", + "sha256": "ccccc", + }, + "downloads": 188090, + "filename": "enum34-1.1.6.tar.gz", + "packagetype": "sdist", + "path": "bf/3e/enum34-1.1.6.tar.gz", + "size": 40048, + }, + { + "has_sig": False, + "upload_time": "2016-05-16T03:31:48", + "comment_text": "", + "python_version": "source", + "url": "https://pypi.org/packages/e8/26/enum34-1.1.6.zip", + "md5_digest": "61ad7871532d4ce2d77fac2579237a9e", + "digests": { + "md5": "61ad7871532d4ce2d77fac2579237a9e", + "sha256": "dddddd", + }, + "downloads": 775920, + "filename": "enum34-1.1.6.zip", + "packagetype": "sdist", + "path": "e8/26/enum34-1.1.6.zip", + "size": 44773, + }, + ] + }, + } + ) + + raise NotImplementedError(url) + + murlopen.side_effect = mocked_get + + with tmpfile() as filename: + before = ( + """ +# This is comment. Ignore this. + +requests[security]==1.2.3 \\ + --hash=sha256:99dcfdaae +hashin==0.9 \\ + --hash=sha256:12ce5c2ef718 +enum34==1.1.5; python_version <= '3.4' \\ + --hash=sha256:12ce5c2ef718 + + """.strip() + + "\n" + ) + with open(filename, "w") as f: + f.write(before) + + # Basically means we're saying "No" to all of them. + with mock.patch("hashin.input", return_value="N"): + retcode = hashin.run(None, filename, "sha256", interactive=True) + assert retcode == 0 + + with open(filename) as f: + output = f.read() + assert output == before + + questions = [] + + def mock_input(question): + questions.append(question) + if len(questions) == 1: + # First one is "requests[security]" + return "" # Default is "yes" + elif len(questions) == 2: + return "N" + elif len(questions) == 3: + return "Y" + + with mock.patch("hashin.input") as mocked_input: + mocked_input.side_effect = mock_input + retcode = hashin.run(None, filename, "sha256", interactive=True) + assert retcode == 0 + + # The expected output is that only "requests[security]" and "enum34" + # get updated. + expected = ( + """ +# This is comment. Ignore this. + +requests[security]==1.2.4 \\ + --hash=sha256:dededede +hashin==0.9 \\ + --hash=sha256:12ce5c2ef718 +enum34==1.1.6; python_version <= "3.4" \\ + --hash=sha256:aaaaa \\ + --hash=sha256:bbbbb \\ + --hash=sha256:ccccc \\ + --hash=sha256:dddddd + """.strip() + + "\n" + ) + with open(filename) as f: + output = f.read() + assert output == expected + + +def test_run_interactive_quit_and_accept_all(murlopen, tmpfile, capsys): + 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/2.7/p/hashin/hashin-0.10-py2-none-any.whl", + "digests": {"sha256": "aaaaa"}, + }, + { + "url": "https://pypi.org/packages/3.3/p/hashin/hashin-0.10-py3-none-any.whl", + "digests": {"sha256": "bbbbb"}, + }, + { + "url": "https://pypi.org/packages/source/p/hashin/hashin-0.10.tar.gz", + "digests": {"sha256": "ccccc"}, + }, + ] + }, + } + ) + elif url == "https://pypi.org/pypi/requests/json": + return _Response( + { + "info": {"version": "1.2.4", "name": "requests"}, + "releases": { + "1.2.4": [ + { + "url": "https://pypi.org/packages/source/p/requests/requests-1.2.4.tar.gz", + "digests": {"sha256": "dededede"}, + } + ] + }, + } + ) + if url == "https://pypi.org/pypi/enum34/json": + return _Response( + { + "info": {"version": "1.1.6", "name": "enum34"}, + "releases": { + "1.1.6": [ + { + "has_sig": False, + "upload_time": "2016-05-16T03:31:13", + "comment_text": "", + "python_version": "py2", + "url": "https://pypi.org/packages/c5/db/enum34-1.1.6-py2-none-any.whl", + "digests": { + "md5": "68f6982cc07dde78f4b500db829860bd", + "sha256": "aaaaa", + }, + "md5_digest": "68f6982cc07dde78f4b500db829860bd", + "downloads": 4297423, + "filename": "enum34-1.1.6-py2-none-any.whl", + "packagetype": "bdist_wheel", + "path": "c5/db/enum34-1.1.6-py2-none-any.whl", + "size": 12427, + }, + { + "has_sig": False, + "upload_time": "2016-05-16T03:31:19", + "comment_text": "", + "python_version": "py3", + "url": "https://pypi.org/packages/af/42/enum34-1.1.6-py3-none-any.whl", + "md5_digest": "a63ecb4f0b1b85fb69be64bdea999b43", + "digests": { + "md5": "a63ecb4f0b1b85fb69be64bdea999b43", + "sha256": "bbbbb", + }, + "downloads": 98598, + "filename": "enum34-1.1.6-py3-none-any.whl", + "packagetype": "bdist_wheel", + "path": "af/42/enum34-1.1.6-py3-none-any.whl", + "size": 12428, + }, + { + "has_sig": False, + "upload_time": "2016-05-16T03:31:30", + "comment_text": "", + "python_version": "source", + "url": "https://pypi.org/packages/bf/3e/enum34-1.1.6.tar.gz", + "md5_digest": "5f13a0841a61f7fc295c514490d120d0", + "digests": { + "md5": "5f13a0841a61f7fc295c514490d120d0", + "sha256": "ccccc", + }, + "downloads": 188090, + "filename": "enum34-1.1.6.tar.gz", + "packagetype": "sdist", + "path": "bf/3e/enum34-1.1.6.tar.gz", + "size": 40048, + }, + { + "has_sig": False, + "upload_time": "2016-05-16T03:31:48", + "comment_text": "", + "python_version": "source", + "url": "https://pypi.org/packages/e8/26/enum34-1.1.6.zip", + "md5_digest": "61ad7871532d4ce2d77fac2579237a9e", + "digests": { + "md5": "61ad7871532d4ce2d77fac2579237a9e", + "sha256": "dddddd", + }, + "downloads": 775920, + "filename": "enum34-1.1.6.zip", + "packagetype": "sdist", + "path": "e8/26/enum34-1.1.6.zip", + "size": 44773, + }, + ] + }, + } + ) + + raise NotImplementedError(url) + + murlopen.side_effect = mocked_get + + with tmpfile() as filename: + before = ( + """ +# This is comment. Ignore this. + +requests[security]==1.2.3 \\ + --hash=sha256:99dcfdaae +hashin==0.9 \\ + --hash=sha256:12ce5c2ef718 +enum34==1.1.5; python_version <= '3.4' \\ + --hash=sha256:12ce5c2ef718 + + """.strip() + + "\n" + ) + with open(filename, "w") as f: + f.write(before) + + questions = [] + + def mock_input(question): + questions.append(question) + if len(questions) == 1: + return "q" + elif len(questions) == 2: + return "A" + raise NotImplementedError(questions) + + with mock.patch("hashin.input") as mocked_input: + mocked_input.side_effect = mock_input + retcode = hashin.run(None, filename, "sha256", interactive=True) + assert retcode != 0 + assert len(questions) == 1 + + with open(filename) as f: + output = f.read() + assert output == before + + with mock.patch("hashin.input") as mocked_input: + mocked_input.side_effect = mock_input + retcode = hashin.run(None, filename, "sha256", interactive=True) + assert retcode == 0 + + # The expected output is that only "requests[security]" and "enum34" + # get updated. + expected = ( + """ +# This is comment. Ignore this. + +requests[security]==1.2.4 \\ + --hash=sha256:dededede +hashin==0.10 \\ + --hash=sha256:aaaaa \\ + --hash=sha256:bbbbb \\ + --hash=sha256:ccccc +enum34==1.1.6; python_version <= "3.4" \\ + --hash=sha256:aaaaa \\ + --hash=sha256:bbbbb \\ + --hash=sha256:ccccc \\ + --hash=sha256:dddddd + """.strip() + + "\n" + ) + with open(filename) as f: + output = f.read() + assert output == expected + + # No more questions were asked of `input()`. + assert len(questions) == 2 + + def test_run_without_specific_version(murlopen, tmpfile): def mocked_get(url, **options): if url == "https://pypi.org/pypi/hashin/json": @@ -1745,3 +2158,75 @@ def mocked_get(url, **options): output = f.read() assert "hashin==0.10" in output assert "hashin[stuff]==0.10" not in output + + +def test_interactive_upgrade_request(capsys): + old = Requirement("hashin==0.9") + old_version = old.specifier + new = Requirement("hashin==0.10") + new_version = new.specifier + + with mock.patch("hashin.input", return_value="Y "): + assert hashin.interactive_upgrade_request( + "hashin", old_version, new_version, print_header=True + ) + + captured = capsys.readouterr() + assert "PACKAGE" in captured.out + assert "\nhashin " in captured.out + assert " 0.9 " in captured.out + assert " 0.10 " in captured.out + assert u"✓" in captured.out + + # This time, say no. + with mock.patch("hashin.input", return_value="N"): + assert not hashin.interactive_upgrade_request( + "hashin", old_version, new_version + ) + + captured = capsys.readouterr() + assert "PACKAGE" not in captured.out + assert "hashin " in captured.out + assert " 0.9 " in captured.out + assert " 0.10 " in captured.out + assert u"✘" in captured.out + + # This time, say yes to everything. + with mock.patch("hashin.input", return_value="A"): + with pytest.raises(hashin.InteractiveAll): + hashin.interactive_upgrade_request("hashin", old_version, new_version) + + captured = capsys.readouterr() + assert "hashin " in captured.out + + # This time, quit it. + # This time, say yes to everything. + with mock.patch("hashin.input", return_value="q "): + with pytest.raises(hashin.InteractiveQuit): + hashin.interactive_upgrade_request("hashin", old_version, new_version) + + captured = capsys.readouterr() + assert "hashin " in captured.out + # When you quit, it doesn't clear the last question. + assert "?\n" in captured.out + + +def test_interactive_upgrade_request_repeat_question(capsys): + old = Requirement("hashin==0.9") + old_version = old.specifier + new = Requirement("hashin==0.10") + new_version = new.specifier + + questions = [] + + def mock_input(question): + questions.append(question) + if len(questions) == 1: + return "X" # anything not recognized + elif len(questions) == 2: + return "Y" + raise NotImplementedError(questions) + + with mock.patch("hashin.input") as mocked_input: + mocked_input.side_effect = mock_input + assert hashin.interactive_upgrade_request("hashin", old_version, new_version) From b6f64d9b362a2cce76e0b4df604b8fa914c2bc8f Mon Sep 17 00:00:00 2001 From: Peter Bengtsson Date: Thu, 1 Nov 2018 13:56:14 -0400 Subject: [PATCH 2/5] test_run_interactive_case_insensitive --- hashin.py | 6 ++-- tests/test_cli.py | 75 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/hashin.py b/hashin.py index 838d8d2..1986542 100755 --- a/hashin.py +++ b/hashin.py @@ -107,7 +107,7 @@ def run(specs, requirements_file, *args, **kwargs): version = req.specifier req.specifier = None specs.append(str(req)) - previous_versions[str(req).split(";")[0]] = version + previous_versions[str(req).split(";")[0].lower()] = version kwargs["previous_versions"] = previous_versions if isinstance(specs, str): @@ -161,10 +161,10 @@ def run_packages( new_version_specifier = SpecifierSet("=={}".format(data["version"])) - if previous_versions and previous_versions.get(str(req)): + if previous_versions and previous_versions.get(str(req).lower()): # We have some form of previous version and a new version. # If they' already equal, just skip this one. - if previous_versions[str(req)] == new_version_specifier: + if previous_versions[str(req).lower()] == new_version_specifier: continue if interactive: diff --git a/tests/test_cli.py b/tests/test_cli.py index 440bfa8..1cf97f7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1042,6 +1042,81 @@ def mock_input(question): assert len(questions) == 2 +def test_run_interactive_case_insensitive(murlopen, tmpfile, capsys): + """This test tests if you had a requirements file with packages spelled + with the name with the wrong case.""" + + def mocked_get(url, **options): + + if url == "https://pypi.org/pypi/Hashin/json": + return _Response( + "", + status_code=301, + headers={"location": "https://pypi.org/pypi/hashin/json"}, + ) + + if url == "https://pypi.org/pypi/hashin/json": + return _Response( + { + "info": {"version": "0.10", "name": "hashin"}, + "releases": { + "0.10": [ + { + "url": "https://pypi.org/packages/2.7/p/hashin/hashin-0.10-py2-none-any.whl", + "digests": {"sha256": "aaaaa"}, + }, + { + "url": "https://pypi.org/packages/3.3/p/hashin/hashin-0.10-py3-none-any.whl", + "digests": {"sha256": "bbbbb"}, + }, + { + "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: + before = ( + """ +Hashin==0.9 \\ + --hash=sha256:12ce5c2ef718 + """.strip() + + "\n" + ) + with open(filename, "w") as f: + f.write(before) + + with open(filename) as f: + output = f.read() + assert output == before + + with mock.patch("hashin.input", return_value="Y"): + retcode = hashin.run(None, filename, "sha256", interactive=True) + assert retcode == 0 + + # The expected output is that only "requests[security]" and "enum34" + # get updated. + expected = ( + """ +hashin==0.10 \\ + --hash=sha256:aaaaa \\ + --hash=sha256:bbbbb \\ + --hash=sha256:ccccc + """.strip() + + "\n" + ) + with open(filename) as f: + output = f.read() + assert output == expected + + def test_run_without_specific_version(murlopen, tmpfile): def mocked_get(url, **options): if url == "https://pypi.org/pypi/hashin/json": From 8c11fdcdc981ebdb521e6d86a79b780701b9ee36 Mon Sep 17 00:00:00 2001 From: Peter Bengtsson Date: Thu, 1 Nov 2018 14:13:48 -0400 Subject: [PATCH 3/5] second fix for case insensitivity --- hashin.py | 4 ++-- tests/test_cli.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/hashin.py b/hashin.py index 1986542..b46fee6 100755 --- a/hashin.py +++ b/hashin.py @@ -75,7 +75,6 @@ def _download(url, binary=False): # Note that urlopen will, by default, follow redirects. status_code = r.getcode() - if 301 <= status_code < 400: location, _ = cgi.parse_header(r.headers.get("location", "")) if not location: @@ -168,10 +167,11 @@ def run_packages( continue if interactive: + print("PREVIOUS_VERSIONS", previous_versions) try: if not interactive_upgrade_request( package, - previous_versions[str(req)], + previous_versions[str(req).lower()], new_version_specifier, print_header=first_interactive, ): diff --git a/tests/test_cli.py b/tests/test_cli.py index 1cf97f7..99b217b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1078,6 +1078,23 @@ def mocked_get(url, **options): } ) + if url == "https://pypi.org/pypi/Django/json": + return _Response( + { + "info": {"version": "2.1.3", "name": "Django"}, + "releases": { + "2.1.3": [ + { + "url": "https://files.pythonhosted.org/packages/d1/e5/2676/Django-2.1.3-py3-none-any.whl", + "digests": { + "sha256": "dd46d87af4c1bf54f4c926c3cfa41dc2b5c15782f15e4329752ce65f5dad1c37" + }, + } + ] + }, + } + ) + raise NotImplementedError(url) murlopen.side_effect = mocked_get @@ -1085,6 +1102,9 @@ def mocked_get(url, **options): with tmpfile() as filename: before = ( """ +Django==2.1.2 \\ + --hash=sha256:efbcad7ebb47daafbcead109b38a5bd519a3c3cd92c6ed0f691ff97fcdd16b45 + Hashin==0.9 \\ --hash=sha256:12ce5c2ef718 """.strip() @@ -1105,6 +1125,9 @@ def mocked_get(url, **options): # get updated. expected = ( """ +Django==2.1.3 \\ + --hash=sha256:dd46d87af4c1bf54f4c926c3cfa41dc2b5c15782f15e4329752ce65f5dad1c37 + hashin==0.10 \\ --hash=sha256:aaaaa \\ --hash=sha256:bbbbb \\ From 4b690a95c2c3c9b2df2e2a4b72d36e2e3588fc1a Mon Sep 17 00:00:00 2001 From: Peter Bengtsson Date: Thu, 1 Nov 2018 14:14:40 -0400 Subject: [PATCH 4/5] remove print --- hashin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/hashin.py b/hashin.py index b46fee6..af9f896 100755 --- a/hashin.py +++ b/hashin.py @@ -167,7 +167,6 @@ def run_packages( continue if interactive: - print("PREVIOUS_VERSIONS", previous_versions) try: if not interactive_upgrade_request( package, From 71db48c1bbb34ff869373ecbad73e856d16df672 Mon Sep 17 00:00:00 2001 From: Peter Bengtsson Date: Thu, 8 Nov 2018 12:25:21 -0500 Subject: [PATCH 5/5] fixes to the interactive --- hashin.py | 104 +++++++++++++++++++++++++++++------------ tests/test_cli.py | 117 +++++++++++++++++++++++++++++++++++++--------- 2 files changed, 171 insertions(+), 50 deletions(-) diff --git a/hashin.py b/hashin.py index af9f896..948fb6d 100755 --- a/hashin.py +++ b/hashin.py @@ -106,7 +106,7 @@ def run(specs, requirements_file, *args, **kwargs): version = req.specifier req.specifier = None specs.append(str(req)) - previous_versions[str(req).split(";")[0].lower()] = version + previous_versions[str(req)] = version kwargs["previous_versions"] = previous_versions if isinstance(specs, str): @@ -129,6 +129,7 @@ def run_packages( assert isinstance(specs, list), type(specs) all_new_lines = [] first_interactive = True + yes_to_all = False for spec in specs: restriction = None if ";" in spec: @@ -140,6 +141,16 @@ def run_packages( package, version = spec, None # There are other ways to what the latest version is. + # It's important to keep a track of what the package was called before + # so that if we have to amend the requirements file, we know what to + # look for before. + previous_name = package + + # The 'previous_versions' dict is based on the old names. So figure + # out what the previous version was *before* the new/"correct" name + # is figured out. + previous_version = previous_versions.get(package) if previous_versions else None + req = Requirement(package) data = get_package_hashes( @@ -158,28 +169,42 @@ def run_packages( # We do that by modifying only the `name` part of the `Requirement` instance. req.name = package + if previous_versions is None: + # Need to be smart here. It's a little counter-intuitive. + # If no previous_versions was supplied that has an implied the fact; + # the user was explicit about what they want to install. + # The name it was called in the old requirements file doesn't matter. + previous_name = package + new_version_specifier = SpecifierSet("=={}".format(data["version"])) - if previous_versions and previous_versions.get(str(req).lower()): + if previous_version: # We have some form of previous version and a new version. # If they' already equal, just skip this one. - if previous_versions[str(req).lower()] == new_version_specifier: + if previous_version == new_version_specifier: continue if interactive: try: - if not interactive_upgrade_request( + response = interactive_upgrade_request( package, - previous_versions[str(req).lower()], + previous_version, new_version_specifier, print_header=first_interactive, - ): - first_interactive = False - continue + force_yes=yes_to_all, + ) first_interactive = False - except InteractiveAll: - interactive = False - except (InteractiveQuit, KeyboardInterrupt): + if response == "NO": + continue + elif response == "ALL": + # If you ever answer "all" to the update question, we don't want + # stop showing the interactive prompt but we don't need to + # ask any questions any more. This way, you get to see the + # upgrades that are going to happen. + yes_to_all = True + elif response == "QUIT": + return 1 + except KeyboardInterrupt: return 1 maybe_restriction = "" if not restriction else "; {0}".format(restriction) @@ -190,7 +215,7 @@ def run_packages( if i != len(data["hashes"]) - 1: new_lines += " \\" new_lines += "\n" - all_new_lines.append((package, new_lines)) + all_new_lines.append((package, previous_name, new_lines)) if not all_new_lines: # This can happen if you use 'interactive' and said no to everything or @@ -222,15 +247,9 @@ def run_packages( return 0 -class InteractiveAll(Exception): - """When the user wants to say yes to ALL package updates.""" - - -class InteractiveQuit(Exception): - """When the user wants to stop the interactive update questions entirely.""" - - -def interactive_upgrade_request(package, old_version, new_version, print_header=False): +def interactive_upgrade_request( + package, old_version, new_version, print_header=False, force_yes=False +): def print_version(v): return str(v).replace("==", "").ljust(15) @@ -255,29 +274,56 @@ def print_line(checkbox=None): checkboxed, ) - print_line() + if force_yes: + print_line(True) + return "YES" + else: + print_line() + + printed_help = [] + + def print_help(): + print( + "y - Include this update (default)\n" + "n - Skip this update\n" + "a - Include this and all following upgrades\n" + "q - Skip this and all following upgrades\n" + "? - Print this help\n" + ) + printed_help.append(1) def clear_line(): sys.stdout.write("\033[F") # Cursor up one line sys.stdout.write("\033[K") # Clear to the end of line def ask(): - answer = input("Update? [Y/n/a/q]: ").lower().strip() + answer = input("Update? [Y/n/a/q/?]: ").lower().strip() + if printed_help: + # Because the print_help() prints 5 lines to stdout. + # Plus 2 because of the original question line and the extra blank line. + for i in range(5 + 2): + clear_line() + # printed_help.clear() + del printed_help[:] + if answer == "n": clear_line() clear_line() print_line(False) - return False + return "NO" if answer == "a": clear_line() - raise InteractiveAll + return "ALL" if answer == "q": - raise InteractiveQuit + return "QUIT" if answer == "y" or answer == "" or answer == "yes": clear_line() clear_line() print_line(True) - return True + return "YES" + if answer == "?": + print_help() + return ask() return ask() @@ -304,9 +350,9 @@ def is_different_lines(package, new_lines): break return lines != set([x.strip(" \\") for x in new_lines.splitlines()]) - for package, new_lines in all_new_lines: + for package, old_name, new_lines in all_new_lines: regex = re.compile( - r"(^|\n|\n\r){0}==|(^|\n|\n\r){0}\[.*\]==".format(re.escape(package)), + r"(^|\n|\n\r){0}==|(^|\n|\n\r){0}\[.*\]==".format(re.escape(old_name)), re.IGNORECASE, ) # if the package wasn't already there, add it to the bottom diff --git a/tests/test_cli.py b/tests/test_cli.py index 99b217b..687779e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -247,6 +247,7 @@ def test_amend_requirements_content_new(): + "\n" ) new_lines = ( + "autocompeter", "autocompeter", """ autocompeter==1.2.3 \\ @@ -255,7 +256,29 @@ def test_amend_requirements_content_new(): + "\n", ) result = hashin.amend_requirements_content(requirements, [new_lines]) - assert result == requirements + new_lines[1] + assert result == requirements + new_lines[2] + + +def test_amend_requirements_different_old_name(): + requirements = ( + """ +Discogs_client==1.0 \\ + --hash=sha256:a326d1ab81164b36e7befe8e940048c4bdd79e0f78afc5f59037e0e9b1de46d4 + + """.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 == new_lines[2] def test_amend_requirements_content_multiple_merge(): @@ -275,6 +298,7 @@ def test_amend_requirements_content_multiple_merge(): all_new_lines = [] all_new_lines.append( ( + "autocompeter", "autocompeter", """ autocompeter==1.3.0 \\ @@ -285,6 +309,7 @@ def test_amend_requirements_content_multiple_merge(): ) all_new_lines.append( ( + "examplepackage", "examplepackage", """ examplepackage==10.0.0 \\ @@ -322,6 +347,7 @@ def test_amend_requirements_content_replacement(): ) new_lines = ( + "autocompeter", "autocompeter", """ autocompeter==1.2.3 @@ -331,7 +357,7 @@ def test_amend_requirements_content_replacement(): ) result = hashin.amend_requirements_content(requirements, [new_lines]) - assert result == new_lines[1] + assert result == new_lines[2] def test_amend_requirements_content_actually_not_replacement(): @@ -345,6 +371,7 @@ def test_amend_requirements_content_actually_not_replacement(): ) new_lines = ( + "autocompeter", "autocompeter", """ autocompeter==1.2.2 @@ -370,6 +397,7 @@ def test_amend_requirements_content_replacement_addition(): ) new_lines = ( + "autocompeter", "autocompeter", """ autocompeter==1.2.2 @@ -380,7 +408,7 @@ def test_amend_requirements_content_replacement_addition(): ) result = hashin.amend_requirements_content(requirements, [new_lines]) - assert result == new_lines[1] + assert result == new_lines[2] def test_amend_requirements_content_replacement_single_to_multi(): @@ -394,6 +422,7 @@ def test_amend_requirements_content_replacement_single_to_multi(): + "\n" ) new_lines = ( + "autocompeter", "autocompeter", """ autocompeter==1.2.3 @@ -402,7 +431,7 @@ def test_amend_requirements_content_replacement_single_to_multi(): + "\n", ) result = hashin.amend_requirements_content(requirements, [new_lines]) - assert result == new_lines[1] + assert result == new_lines[2] def test_amend_requirements_content_replacement_2(): @@ -415,6 +444,7 @@ def test_amend_requirements_content_replacement_2(): + "\n" ) new_lines = ( + "autocompeter", "autocompeter", """ autocompeter==1.2.3 \\ @@ -423,7 +453,7 @@ def test_amend_requirements_content_replacement_2(): + "\n", ) result = hashin.amend_requirements_content(requirements, [new_lines]) - assert result == new_lines[1] + assert result == new_lines[2] def test_amend_requirements_content_replacement_amonst_others(): @@ -443,6 +473,7 @@ def test_amend_requirements_content_replacement_amonst_others(): + "\n" ) new_lines = ( + "autocompeter", "autocompeter", """ autocompeter==1.2.3 \\ @@ -450,7 +481,7 @@ def test_amend_requirements_content_replacement_amonst_others(): """.strip(), ) result = hashin.amend_requirements_content(requirements, [new_lines]) - assert result == previous + new_lines[1] + assert result == previous + new_lines[2] def test_amend_requirements_content_replacement_amonst_others_2(): @@ -470,6 +501,7 @@ def test_amend_requirements_content_replacement_amonst_others_2(): + "\n" ) new_lines = ( + "autocompeter", "autocompeter", """ autocompeter==1.2.3 \\ @@ -477,7 +509,7 @@ def test_amend_requirements_content_replacement_amonst_others_2(): """.strip(), ) result = hashin.amend_requirements_content(requirements, [new_lines]) - assert result == previous + new_lines[1] + assert result == previous + new_lines[2] def test_amend_requirements_content_new_similar_name(): @@ -498,6 +530,7 @@ def test_amend_requirements_content_new_similar_name(): + "\n" ) new_lines = ( + "selenium", "selenium", """ selenium==2.53.1 \ @@ -508,7 +541,7 @@ def test_amend_requirements_content_new_similar_name(): result = hashin.amend_requirements_content(previous_1 + previous_2, [new_lines]) assert previous_1 in result assert previous_2 not in result - assert new_lines[1] in result + assert new_lines[2] in result def test_run(murlopen, tmpfile, capsys): @@ -1042,13 +1075,15 @@ def mock_input(question): assert len(questions) == 2 -def test_run_interactive_case_insensitive(murlopen, tmpfile, capsys): +def test_run_interactive_case_redirect(murlopen, tmpfile, capsys): """This test tests if you had a requirements file with packages spelled - with the name with the wrong case.""" + with a different name.""" def mocked_get(url, **options): - if url == "https://pypi.org/pypi/Hashin/json": + # Note that the name "Hash-in" redirects to a name that is not only + # different case, it's also spelled totally differently. + if url == "https://pypi.org/pypi/Hash-in/json": return _Response( "", status_code=301, @@ -1105,7 +1140,7 @@ def mocked_get(url, **options): Django==2.1.2 \\ --hash=sha256:efbcad7ebb47daafbcead109b38a5bd519a3c3cd92c6ed0f691ff97fcdd16b45 -Hashin==0.9 \\ +Hash-in==0.9 \\ --hash=sha256:12ce5c2ef718 """.strip() + "\n" @@ -2265,9 +2300,10 @@ def test_interactive_upgrade_request(capsys): new_version = new.specifier with mock.patch("hashin.input", return_value="Y "): - assert hashin.interactive_upgrade_request( + result = hashin.interactive_upgrade_request( "hashin", old_version, new_version, print_header=True ) + assert result == "YES" captured = capsys.readouterr() assert "PACKAGE" in captured.out @@ -2278,9 +2314,8 @@ def test_interactive_upgrade_request(capsys): # This time, say no. with mock.patch("hashin.input", return_value="N"): - assert not hashin.interactive_upgrade_request( - "hashin", old_version, new_version - ) + result = hashin.interactive_upgrade_request("hashin", old_version, new_version) + assert result == "NO" captured = capsys.readouterr() assert "PACKAGE" not in captured.out @@ -2291,8 +2326,8 @@ def test_interactive_upgrade_request(capsys): # This time, say yes to everything. with mock.patch("hashin.input", return_value="A"): - with pytest.raises(hashin.InteractiveAll): - hashin.interactive_upgrade_request("hashin", old_version, new_version) + result = hashin.interactive_upgrade_request("hashin", old_version, new_version) + assert result == "ALL" captured = capsys.readouterr() assert "hashin " in captured.out @@ -2300,8 +2335,8 @@ def test_interactive_upgrade_request(capsys): # This time, quit it. # This time, say yes to everything. with mock.patch("hashin.input", return_value="q "): - with pytest.raises(hashin.InteractiveQuit): - hashin.interactive_upgrade_request("hashin", old_version, new_version) + result = hashin.interactive_upgrade_request("hashin", old_version, new_version) + assert result == "QUIT" captured = capsys.readouterr() assert "hashin " in captured.out @@ -2327,4 +2362,44 @@ def mock_input(question): with mock.patch("hashin.input") as mocked_input: mocked_input.side_effect = mock_input - assert hashin.interactive_upgrade_request("hashin", old_version, new_version) + result = hashin.interactive_upgrade_request("hashin", old_version, new_version) + assert result == "YES" + + +def test_interactive_upgrade_request_help(capsys): + old = Requirement("hashin==0.9") + old_version = old.specifier + new = Requirement("hashin==0.10") + new_version = new.specifier + + questions = [] + + def mock_input(question): + questions.append(question) + if len(questions) == 1: + return "?" + elif len(questions) == 2: + return "Y" + raise NotImplementedError(questions) + + with mock.patch("hashin.input") as mocked_input: + mocked_input.side_effect = mock_input + result = hashin.interactive_upgrade_request("hashin", old_version, new_version) + assert result == "YES" + + +def test_interactive_upgrade_request_force_yes(capsys): + old = Requirement("hashin==0.9") + old_version = old.specifier + new = Requirement("hashin==0.10") + new_version = new.specifier + + def mock_input(question): + raise AssertionError("Shouldn't ask any questions") + + with mock.patch("hashin.input") as mocked_input: + mocked_input.side_effect = mock_input + result = hashin.interactive_upgrade_request( + "hashin", old_version, new_version, force_yes=True + ) + assert result == "YES"