From 9702d1e8961562ee50a8ff38a4f1d050c802bbbd Mon Sep 17 00:00:00 2001 From: Kevin C Date: Mon, 23 Jan 2023 21:42:47 -0800 Subject: [PATCH] feat: Support "automated install" usage via `make` A common annoyance when managing deps is having long sessions of repeated iteration loops of `add`, `remove`, tweaking `pyproject.toml` + `lock --no-update` + `install --sync`, re-running tests, trying to find a configuration that works. GNU `make` can implement "automated install" behavior, simplifying the loop to: "save any change, `make`, repeat if needed". Consider: ```make VENV := .venv PYTHON_VERSION := $(shell cat .python-version) PYTHON_VERSIONS_PATH ?= ~/.pyenv/versions PYTHON_INSTALL_CMD ?= pyenv install .PHONY: test test: $(VENV)/install.sentinel # tests depend on venv state poetry run pytest $(VENV)/install.sentinel: poetry.lock $(VENV)/bin/python # venv state depends on poetry.lock and venv poetry install --sync @ touch $@ .poetry.lock: pyproject.toml # poetry.lock depends on pyproject.toml poetry lock --check && touch poetry.lock || poetry lock --no-update $(VENV)/bin/python: $(PYTHON_VERSIONS_PATH)/$(PYTHON_VERSION)/bin/python @ [[ "$(shell python --version)" = "Python $(PYTHON_VERSION)" ]] || { \ echo 'Expected Python $(PYTHON_VERSION) in $$PATH' >& 2; \ exit 1; \ } poetry env use python $(PYTHON_VERSIONS_PATH)/$(PYTHON_VERSION)/bin/python: $(PYTHON_INSTALL_CMD) $(PYTHON_VERSION) ``` This greatly simplifies handling many workflow states. In all cases: * The lockfile changed and venv needs to sync * I just freshly downloaded the project * The required Python version isn't installed * I want to run tests * I changed pyproject.toml Just run `make`. These changes to `add` and `remove` speed up the "dep search iteration loop" by avoiding unnecessary `lock --check` and `lock --no-update` calls after changing `pyproject.toml` Resolves: #7392 --- src/poetry/console/commands/add.py | 6 +-- src/poetry/console/commands/remove.py | 6 +-- src/poetry/installation/installer.py | 3 ++ src/poetry/packages/locker.py | 15 +++++++ tests/console/commands/test_add.py | 59 ++++++++++++++++++++------- tests/console/commands/test_remove.py | 22 ++++++---- tests/helpers.py | 4 ++ 7 files changed, 86 insertions(+), 29 deletions(-) diff --git a/src/poetry/console/commands/add.py b/src/poetry/console/commands/add.py index 05bdc21bf77..83507549120 100644 --- a/src/poetry/console/commands/add.py +++ b/src/poetry/console/commands/add.py @@ -241,10 +241,7 @@ def handle(self) -> int: ) ) - # Refresh the locker - self.poetry.set_locker( - self.poetry.locker.__class__(self.poetry.locker.lock, poetry_content) - ) + self.poetry.locker.refresh(poetry_content) self.installer.set_locker(self.poetry.locker) # Cosmetic new line @@ -264,6 +261,7 @@ def handle(self) -> int: if status == 0 and not self.option("dry-run"): assert isinstance(content, TOMLDocument) self.poetry.file.write(content) + self.installer.touch_lockfile() return status diff --git a/src/poetry/console/commands/remove.py b/src/poetry/console/commands/remove.py index 88ea83880af..75c2dc99ff6 100644 --- a/src/poetry/console/commands/remove.py +++ b/src/poetry/console/commands/remove.py @@ -105,10 +105,7 @@ def handle(self) -> int: "The following packages were not found: " + ", ".join(sorted(not_found)) ) - # Refresh the locker - self.poetry.set_locker( - self.poetry.locker.__class__(self.poetry.locker.lock, poetry_content) - ) + self.poetry.locker.refresh(poetry_content) self.installer.set_locker(self.poetry.locker) self.installer.set_package(self.poetry.package) @@ -122,6 +119,7 @@ def handle(self) -> int: if not self.option("dry-run") and status == 0: assert isinstance(content, TOMLDocument) self.poetry.file.write(content) + self.installer.touch_lockfile() return status diff --git a/src/poetry/installation/installer.py b/src/poetry/installation/installer.py index 9b906f7678f..5a333845072 100644 --- a/src/poetry/installation/installer.py +++ b/src/poetry/installation/installer.py @@ -158,6 +158,9 @@ def lock(self, update: bool = True) -> Installer: return self + def touch_lockfile(self) -> None: + self._locker.lock.touch() + def is_updating(self) -> bool: return self._update diff --git a/src/poetry/packages/locker.py b/src/poetry/packages/locker.py index 2f4a2dcfda4..87df7b26409 100644 --- a/src/poetry/packages/locker.py +++ b/src/poetry/packages/locker.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING from typing import Any +from typing import TypeVar from typing import cast from packaging.utils import canonicalize_name @@ -34,10 +35,13 @@ from poetry.core.packages.file_dependency import FileDependency from poetry.core.packages.url_dependency import URLDependency from poetry.core.packages.vcs_dependency import VCSDependency + from tomlkit.container import Container from tomlkit.toml_document import TOMLDocument from poetry.repositories.lockfile_repository import LockfileRepository + Self = TypeVar("Self", bound="Locker") + logger = logging.getLogger(__name__) _GENERATED_IDENTIFIER = "@" + "generated" GENERATED_COMMENT = ( @@ -90,6 +94,17 @@ def is_fresh(self) -> bool: return False + def refresh( + self: Self, new_config: dict[str, Any] | Container | None = None + ) -> Self: + """ + Refreshes the locker using new_config (or existing config, if not provided). + """ + self._local_config = self._local_config if new_config is None else new_config + self._lock_data = None + self._content_hash = self._get_content_hash() + return self + def locked_repository(self) -> LockfileRepository: """ Searches and returns a repository of locked packages. diff --git a/tests/console/commands/test_add.py b/tests/console/commands/test_add.py index 9bc238aeb7a..06ebac67e26 100644 --- a/tests/console/commands/test_add.py +++ b/tests/console/commands/test_add.py @@ -13,6 +13,7 @@ from poetry.repositories.legacy_repository import LegacyRepository from tests.helpers import get_dependency from tests.helpers import get_package +from tests.helpers import modtime if TYPE_CHECKING: @@ -151,6 +152,7 @@ def test_add_no_constraint_editable_error( assert tester.io.fetch_error() == expected assert tester.command.installer.executor.installations_count == 0 assert content == app.poetry.file.read()["tool"]["poetry"] + assert not app.poetry.locker.is_locked() def test_add_equal_constraint( @@ -1193,6 +1195,18 @@ def test_add_with_lock( assert content_hash != app.poetry.locker.lock_data["metadata"]["content-hash"] +def test_add_refreshes_lockfile_modtime( + app: PoetryTestApplication, repo: TestRepository, tester: CommandTester +): + repo.add_package(get_package("cachy", "0.1.0")) + repo.add_package(get_package("cachy", "0.2.0")) + + tester.execute("cachy") + + assert app.poetry.locker.is_locked() + assert modtime(app.poetry.locker.lock) >= modtime(app.poetry.file) + + def test_add_no_constraint_old_installer( app: PoetryTestApplication, repo: TestRepository, @@ -2145,29 +2159,45 @@ def test_add_with_lock_old_installer( assert old_tester.io.fetch_output() == expected +def test_add_refreshes_lockfile_modtime_old_installer( + app: PoetryTestApplication, + repo: TestRepository, + installer: NoopInstaller, + old_tester: CommandTester, +): + repo.add_package(get_package("cachy", "0.1.0")) + repo.add_package(get_package("cachy", "0.2.0")) + + old_tester.execute("cachy") + + assert app.poetry.locker.is_locked() + assert modtime(app.poetry.locker.lock) >= modtime(app.poetry.file) + + def test_add_keyboard_interrupt_restore_content( poetry_with_up_to_date_lockfile: Poetry, repo: TestRepository, command_tester_factory: CommandTesterFactory, mocker: MockerFixture, ): - tester = command_tester_factory("add", poetry=poetry_with_up_to_date_lockfile) + poetry = poetry_with_up_to_date_lockfile + tester = command_tester_factory("add", poetry=poetry) mocker.patch( "poetry.installation.installer.Installer.run", side_effect=KeyboardInterrupt() ) - original_pyproject_content = poetry_with_up_to_date_lockfile.file.read() - original_lockfile_content = poetry_with_up_to_date_lockfile._locker.lock_data + original_pyproject_content = poetry.file.read() + original_lockfile_content = poetry._locker.lock_data + original_lockfile_modtime = modtime(poetry.locker.lock) repo.add_package(get_package("cachy", "0.2.0")) repo.add_package(get_package("docker", "4.3.1")) tester.execute("cachy") - assert poetry_with_up_to_date_lockfile.file.read() == original_pyproject_content - assert ( - poetry_with_up_to_date_lockfile._locker.lock_data == original_lockfile_content - ) + assert poetry.file.read() == original_pyproject_content + assert poetry._locker.lock_data == original_lockfile_content + assert modtime(poetry.locker.lock) == original_lockfile_modtime @pytest.mark.parametrize( @@ -2183,17 +2213,18 @@ def test_add_with_dry_run_keep_files_intact( repo: TestRepository, command_tester_factory: CommandTesterFactory, ): - tester = command_tester_factory("add", poetry=poetry_with_up_to_date_lockfile) + poetry = poetry_with_up_to_date_lockfile + tester = command_tester_factory("add", poetry=poetry) - original_pyproject_content = poetry_with_up_to_date_lockfile.file.read() - original_lockfile_content = poetry_with_up_to_date_lockfile._locker.lock_data + original_pyproject_content = poetry.file.read() + original_lockfile_content = poetry._locker.lock_data + original_lockfile_modtime = modtime(poetry.locker.lock) repo.add_package(get_package("cachy", "0.2.0")) repo.add_package(get_package("docker", "4.3.1")) tester.execute(command) - assert poetry_with_up_to_date_lockfile.file.read() == original_pyproject_content - assert ( - poetry_with_up_to_date_lockfile._locker.lock_data == original_lockfile_content - ) + assert poetry.file.read() == original_pyproject_content + assert poetry._locker.lock_data == original_lockfile_content + assert modtime(poetry.locker.lock) == original_lockfile_modtime diff --git a/tests/console/commands/test_remove.py b/tests/console/commands/test_remove.py index 0a2fa1b6c00..4aeb9522c97 100644 --- a/tests/console/commands/test_remove.py +++ b/tests/console/commands/test_remove.py @@ -9,6 +9,7 @@ from poetry.factory import Factory from tests.helpers import get_package +from tests.helpers import modtime if TYPE_CHECKING: @@ -98,6 +99,8 @@ def test_remove_without_specific_group_removes_from_all_groups( assert expected in string_content + assert modtime(app.poetry.locker.lock) >= modtime(app.poetry.file) + def test_remove_without_specific_group_removes_from_specific_groups( tester: CommandTester, @@ -155,6 +158,8 @@ def test_remove_without_specific_group_removes_from_specific_groups( assert expected in string_content + assert modtime(app.poetry.locker.lock) >= modtime(app.poetry.file) + def test_remove_does_not_live_empty_groups( tester: CommandTester, @@ -275,10 +280,12 @@ def test_remove_command_should_not_write_changes_upon_installer_errors( mocker.patch("poetry.installation.installer.Installer.run", return_value=1) original_content = app.poetry.file.read().as_string() + original_lockfile_modtime = modtime(app.poetry.locker.lock) tester.execute("foo") assert app.poetry.file.read().as_string() == original_content + assert modtime(app.poetry.locker.lock) == original_lockfile_modtime def test_remove_with_dry_run_keep_files_intact( @@ -286,16 +293,17 @@ def test_remove_with_dry_run_keep_files_intact( repo: TestRepository, command_tester_factory: CommandTesterFactory, ): - tester = command_tester_factory("remove", poetry=poetry_with_up_to_date_lockfile) + poetry = poetry_with_up_to_date_lockfile + tester = command_tester_factory("remove", poetry=poetry) - original_pyproject_content = poetry_with_up_to_date_lockfile.file.read() - original_lockfile_content = poetry_with_up_to_date_lockfile._locker.lock_data + original_pyproject_content = poetry.file.read() + original_lockfile_content = poetry._locker.lock_data + original_lockfile_modtime = modtime(poetry.locker.lock) repo.add_package(get_package("docker", "4.3.1")) tester.execute("docker --dry-run") - assert poetry_with_up_to_date_lockfile.file.read() == original_pyproject_content - assert ( - poetry_with_up_to_date_lockfile._locker.lock_data == original_lockfile_content - ) + assert poetry.file.read() == original_pyproject_content + assert poetry._locker.lock_data == original_lockfile_content + assert modtime(poetry.locker.lock) == original_lockfile_modtime diff --git a/tests/helpers.py b/tests/helpers.py index feef37728c0..5677197bb84 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -96,6 +96,10 @@ def copy_or_symlink(source: Path, dest: Path) -> None: os.symlink(str(source), str(dest)) +def modtime(path: str | Path | TOMLFile) -> float: + return os.path.getmtime(str(path)) + + class MockDulwichRepo: def __init__(self, root: Path | str, **__: Any) -> None: self.path = str(root)