From 8f767eda95896d5a5bd24387e525126f379f3cdb Mon Sep 17 00:00:00 2001 From: John Sirois Date: Sun, 5 Jun 2022 15:28:51 -0600 Subject: [PATCH] Add `pex3 lock update --dry-run check` mode. (#1799) This allows for clean automated checking if a lock needs updates. --- pex/cli/commands/lock.py | 136 +++++++++++++------- pex/cli/pex.py | 4 +- tests/integration/cli/commands/test_lock.py | 44 +++++++ 3 files changed, 140 insertions(+), 44 deletions(-) diff --git a/pex/cli/commands/lock.py b/pex/cli/commands/lock.py index 9521db00d..9ec667790 100644 --- a/pex/cli/commands/lock.py +++ b/pex/cli/commands/lock.py @@ -4,7 +4,7 @@ from __future__ import absolute_import, print_function import sys -from argparse import ArgumentParser, _ActionsContainer +from argparse import Action, ArgumentError, ArgumentParser, ArgumentTypeError, _ActionsContainer from collections import defaultdict from pex.argparse import HandleBoolAction @@ -45,6 +45,47 @@ class Value(Enum.Value): PEP_665 = Value("pep-665") +class DryRunStyle(Enum["DryRunStyle.Value"]): + class Value(Enum.Value): + pass + + DISPLAY = Value("display") + CHECK = Value("check") + + +class HandleDryRunAction(Action): + def __init__(self, *args, **kwargs): + kwargs["nargs"] = "?" + super(HandleDryRunAction, self).__init__(*args, **kwargs) + + def __call__(self, parser, namespace, value, option_str=None): + if option_str.startswith("--no-"): + if value: + raise ArgumentError( + None, + "The {option} option does not take a value; given: {value!r}".format( + option=option_str, value=value + ), + ) + dry_run_style = None + elif value: + try: + dry_run_style = DryRunStyle.for_value(value) + except ValueError: + raise ArgumentTypeError( + "Invalid value for {option}: {value!r}. Either pass no value for {default!r} " + "or one of: {choices}".format( + option=option_str, + value=value, + default=DryRunStyle.DISPLAY, + choices=", ".join(map(repr, DryRunStyle.values())), + ) + ) + else: + dry_run_style = DryRunStyle.DISPLAY + setattr(namespace, self.dest, dry_run_style) + + class Lock(OutputMixin, JsonMixin, BuildTimeCommand): """Operate on PEX lock files.""" @@ -191,10 +232,14 @@ def _add_update_arguments(cls, update_parser): "-n", "--dry-run", "--no-dry-run", - action=HandleBoolAction, - default=False, - type=bool, - help="Don't update the lock file; just report what updates would be made.", + action=HandleDryRunAction, + help=( + "Don't update the lock file; just report what updates would be made. By default, " + "the report is to STDOUT and the exit code is zero. If a value of {check!r} is " + "passed, the report is to STDERR and the exit code is non-zero.".format( + check=DryRunStyle.CHECK + ) + ), ) cls._add_lockfile_option(update_parser, verb="create") cls._add_lock_options(update_parser) @@ -373,7 +418,9 @@ def _export(self): def _update(self): # type: () -> Result try: - updates = tuple(Requirement.parse(project) for project in self.options.projects) + update_requirements = tuple( + Requirement.parse(project) for project in self.options.projects + ) except RequirementParseError as e: return Error("Failed to parse project requirement to update: {err}".format(err=e)) @@ -436,7 +483,7 @@ def _update(self): lock_update = try_( lock_updater.update( update_requests=update_requests, - updates=updates, + updates=update_requirements, assume_manylinux=targets.assume_manylinux, ) ) @@ -445,13 +492,13 @@ def _update(self): constraint.project_name: constraint for constraint in lock_file.constraints } dry_run = self.options.dry_run - output = sys.stdout if dry_run else sys.stderr - performed_update = False + output = sys.stdout if dry_run is DryRunStyle.DISPLAY else sys.stderr + version_updates = [] for resolve_update in lock_update.resolves: platform = resolve_update.updated_resolve.platform_tag for project_name, version_update in resolve_update.updates.items(): if version_update: - performed_update = True + version_updates.append(version_update) if version_update.original: print( "{lead_in} {project_name} from {original_version} to {updated_version} " @@ -485,38 +532,41 @@ def _update(self): ), file=output, ) - if performed_update: - original_locked_project_names = { - locked_requirement.pin.project_name - for locked_resolve in lock_file.locked_resolves - for locked_requirement in locked_resolve.locked_requirements - } - new_requirements = OrderedSet( - update - for update in updates - if update.project_name not in original_locked_project_names - ) - constraints_by_project_name.update( - (constraint.project_name, constraint) for constraint in updates + if not version_updates: + return Ok() + + if dry_run: + return Error() if dry_run is DryRunStyle.CHECK else Ok() + + original_locked_project_names = { + locked_requirement.pin.project_name + for locked_resolve in lock_file.locked_resolves + for locked_requirement in locked_resolve.locked_requirements + } + new_requirements = OrderedSet( + update + for update in update_requirements + if update.project_name not in original_locked_project_names + ) + constraints_by_project_name.update( + (constraint.project_name, constraint) for constraint in update_requirements + ) + for requirement in new_requirements: + constraints_by_project_name.pop(requirement.project_name, None) + requirements = OrderedSet(lock_file.requirements) + requirements.update(new_requirements) + + with open(lock_file_path, "w") as fp: + self._dump_lockfile( + lock_file=attr.evolve( + lock_file, + pex_version=__version__, + requirements=SortedTuple(requirements, key=str), + constraints=SortedTuple(constraints_by_project_name.values(), key=str), + locked_resolves=SortedTuple( + resolve_update.updated_resolve for resolve_update in lock_update.resolves + ), + ), + output=fp, ) - for requirement in new_requirements: - constraints_by_project_name.pop(requirement.project_name, None) - requirements = OrderedSet(lock_file.requirements) - requirements.update(new_requirements) - - if not dry_run: - with open(lock_file_path, "w") as fp: - self._dump_lockfile( - lock_file=attr.evolve( - lock_file, - pex_version=__version__, - requirements=SortedTuple(requirements, key=str), - constraints=SortedTuple(constraints_by_project_name.values(), key=str), - locked_resolves=SortedTuple( - resolve_update.updated_resolve - for resolve_update in lock_update.resolves - ), - ), - output=fp, - ) return Ok() diff --git a/pex/cli/pex.py b/pex/cli/pex.py index f68989cde..ee63a37b3 100644 --- a/pex/cli/pex.py +++ b/pex/cli/pex.py @@ -3,6 +3,8 @@ from __future__ import absolute_import +from argparse import ArgumentError, ArgumentTypeError + from pex.cli import commands from pex.cli.command import BuildTimeCommand from pex.commands.command import GlobalConfigurationError, Main @@ -30,5 +32,5 @@ def main(): result = catch(command.run) result.maybe_display() return result.exit_code - except GlobalConfigurationError as e: + except (ArgumentError, ArgumentTypeError, GlobalConfigurationError) as e: return str(e) diff --git a/tests/integration/cli/commands/test_lock.py b/tests/integration/cli/commands/test_lock.py index e798e304a..852ff06f3 100644 --- a/tests/integration/cli/commands/test_lock.py +++ b/tests/integration/cli/commands/test_lock.py @@ -443,6 +443,19 @@ def test_update_noop_dry_run(lock_file_path): assert not result.error +def test_update_noop_dry_run_check(lock_file_path): + # type: (str) -> None + result = run_lock_update_for_py310( + "--dry-run", "check", "-p", "urllib3==1.25.11", lock_file_path + ) + result.assert_success() + assert ( + "There would be no updates for urllib3 in lock generated by " + "cp38-cp38-manylinux_2_33_x86_64.\n" == result.error + ) + assert not result.output + + def test_update_targeted_add(lock_file_path): # type: (str) -> None result = run_lock_update_for_py310("-p", "ansicolors==1.1.8", lock_file_path) @@ -538,6 +551,22 @@ def test_update_targeted_add_dry_run(lock_file_path): ), "A dry run update should not have updated the lock file." +def test_update_targeted_add_dry_run_check(lock_file_path): + # type: (str) -> None + result = run_lock_update_for_py310( + "--dry-run", "check", "-p", "ansicolors==1.1.8", lock_file_path + ) + result.assert_failure() + assert 1 == result.return_code + assert ( + "Would add ansicolors 1.1.8 to lock generated by cp38-cp38-manylinux_2_33_x86_64.\n" + ) == result.error + assert not result.output + assert UPDATE_LOCKFILE == json_codec.load( + lock_file_path + ), "A dry run update should not have updated the lock file." + + def test_update_targeted_upgrade(lock_file_path): # type: (str) -> None assert SortedTuple() == json_codec.load(lock_file_path).constraints @@ -580,6 +609,21 @@ def test_update_targeted_upgrade_dry_run(lock_file_path): ), "A dry run update should not have updated the lock file." +def test_update_targeted_upgrade_dry_run_check(lock_file_path): + # type: (str) -> None + result = run_lock_update_for_py310("--dry-run", "check", "-p", "urllib3<1.26.7", lock_file_path) + result.assert_failure() + assert 1 == result.return_code + assert ( + "Would update urllib3 from 1.25.11 to 1.26.6 in lock generated by " + "cp38-cp38-manylinux_2_33_x86_64.\n" == result.error + ) + assert not result.output + assert UPDATE_LOCKFILE == json_codec.load( + lock_file_path + ), "A dry run update should not have updated the lock file." + + def test_update_targeted_mixed_dry_run(lock_file_path): # type: (str) -> None result = run_lock_update_for_py310(