Skip to content

Commit

Permalink
Add pex3 lock update --dry-run check mode. (#1799)
Browse files Browse the repository at this point in the history
This allows for clean automated checking if a lock needs updates.
  • Loading branch information
jsirois authored Jun 5, 2022
1 parent 81ccce2 commit 8f767ed
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 44 deletions.
136 changes: 93 additions & 43 deletions pex/cli/commands/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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,
)
)
Expand All @@ -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} "
Expand Down Expand Up @@ -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()
4 changes: 3 additions & 1 deletion pex/cli/pex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
44 changes: 44 additions & 0 deletions tests/integration/cli/commands/test_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down

0 comments on commit 8f767ed

Please sign in to comment.