diff --git a/docs/cli.md b/docs/cli.md index b815a51c7ac..5d5717962a4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -630,6 +630,7 @@ Without `--` this command will fail if `${GITLAB_JOB_TOKEN}` starts with a hyphe * `--unset`: Remove the configuration element named by `setting-key`. * `--list`: Show the list of current config variables. * `--local`: Set/Get settings that are specific to a project (in the local configuration file `poetry.toml`). +* `--migrate`: Migrate outdated configuration settings. ## run diff --git a/docs/configuration.md b/docs/configuration.md index 8004061dc39..fcf08e7c866 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -114,6 +114,21 @@ This also works for secret settings, like credentials: export POETRY_HTTP_BASIC_MY_REPOSITORY_PASSWORD=secret ``` +## Migrate outdated configs + +If poetry renames or remove config options it might be necessary to migrate explicit set options. This is possible +by running: + +```bash +poetry config --migrate +``` + +If you need to migrate a local config run: + +```bash +poetry config --migrate --local +``` + ## Default Directories Poetry uses the following default directories: diff --git a/src/poetry/config/config_source.py b/src/poetry/config/config_source.py index ae9da9da050..e06c934e70a 100644 --- a/src/poetry/config/config_source.py +++ b/src/poetry/config/config_source.py @@ -1,13 +1,99 @@ from __future__ import annotations +import dataclasses +import json + from abc import ABC from abc import abstractmethod +from typing import TYPE_CHECKING from typing import Any +from cleo.io.null_io import NullIO + + +if TYPE_CHECKING: + from cleo.io.io import IO + + +UNSET = object() + + +class PropertyNotFoundError(ValueError): + pass + class ConfigSource(ABC): + @abstractmethod + def get_property(self, key: str) -> Any: ... + @abstractmethod def add_property(self, key: str, value: Any) -> None: ... @abstractmethod def remove_property(self, key: str) -> None: ... + + +@dataclasses.dataclass +class ConfigSourceMigration: + old_key: str + new_key: str | None + value_migration: dict[Any, Any] = dataclasses.field(default_factory=dict) + + def dry_run(self, config_source: ConfigSource, io: IO | None = None) -> bool: + io = io or NullIO() + + try: + old_value = config_source.get_property(self.old_key) + except PropertyNotFoundError: + return False + + new_value = ( + self.value_migration[old_value] if self.value_migration else old_value + ) + + msg = f"{self.old_key} = {json.dumps(old_value)}" + + if self.new_key is not None and new_value is not UNSET: + msg += f" -> {self.new_key} = {json.dumps(new_value)}" + elif self.new_key is None: + msg += " -> Removed from config" + elif self.new_key and new_value is UNSET: + msg += f" -> {self.new_key} = Not explicit set" + + io.write_line(msg) + + return True + + def apply(self, config_source: ConfigSource) -> None: + try: + old_value = config_source.get_property(self.old_key) + except PropertyNotFoundError: + return + + new_value = ( + self.value_migration[old_value] if self.value_migration else old_value + ) + + config_source.remove_property(self.old_key) + + if self.new_key is not None and new_value is not UNSET: + config_source.add_property(self.new_key, new_value) + + +def drop_empty_config_category( + keys: list[str], config: dict[Any, Any] +) -> dict[Any, Any]: + config_ = {} + + for key, value in config.items(): + if not keys or key != keys[0]: + config_[key] = value + continue + if keys and key == keys[0]: + if isinstance(value, dict): + value = drop_empty_config_category(keys[1:], value) + + if value != {}: + config_[key] = value + + return config_ diff --git a/src/poetry/config/dict_config_source.py b/src/poetry/config/dict_config_source.py index 942d76ea1b4..4b5a87a1699 100644 --- a/src/poetry/config/dict_config_source.py +++ b/src/poetry/config/dict_config_source.py @@ -3,6 +3,7 @@ from typing import Any from poetry.config.config_source import ConfigSource +from poetry.config.config_source import PropertyNotFoundError class DictConfigSource(ConfigSource): @@ -13,6 +14,19 @@ def __init__(self) -> None: def config(self) -> dict[str, Any]: return self._config + def get_property(self, key: str) -> Any: + keys = key.split(".") + config = self._config + + for i, key in enumerate(keys): + if key not in config: + raise PropertyNotFoundError(f"Key {'.'.join(keys)} not in config") + + if i == len(keys) - 1: + return config[key] + + config = config[key] + def add_property(self, key: str, value: Any) -> None: keys = key.split(".") config = self._config diff --git a/src/poetry/config/file_config_source.py b/src/poetry/config/file_config_source.py index eed4cd053ca..9170e0ef254 100644 --- a/src/poetry/config/file_config_source.py +++ b/src/poetry/config/file_config_source.py @@ -8,6 +8,8 @@ from tomlkit import table from poetry.config.config_source import ConfigSource +from poetry.config.config_source import PropertyNotFoundError +from poetry.config.config_source import drop_empty_config_category if TYPE_CHECKING: @@ -30,6 +32,20 @@ def name(self) -> str: def file(self) -> TOMLFile: return self._file + def get_property(self, key: str) -> Any: + keys = key.split(".") + + config = self.file.read() if self.file.exists() else {} + + for i, key in enumerate(keys): + if key not in config: + raise PropertyNotFoundError(f"Key {'.'.join(keys)} not in config") + + if i == len(keys) - 1: + return config[key] + + config = config[key] + def add_property(self, key: str, value: Any) -> None: with self.secure() as toml: config: dict[str, Any] = toml @@ -62,6 +78,10 @@ def remove_property(self, key: str) -> None: current_config = current_config[key] + current_config = drop_empty_config_category(keys=keys[:-1], config=config) + config.clear() + config.update(current_config) + @contextmanager def secure(self) -> Iterator[TOMLDocument]: if self.file.exists(): diff --git a/src/poetry/console/commands/config.py b/src/poetry/console/commands/config.py index 706e21158dc..8002e537811 100644 --- a/src/poetry/console/commands/config.py +++ b/src/poetry/console/commands/config.py @@ -16,6 +16,8 @@ from poetry.config.config import boolean_normalizer from poetry.config.config import boolean_validator from poetry.config.config import int_normalizer +from poetry.config.config_source import UNSET +from poetry.config.config_source import ConfigSourceMigration from poetry.console.commands.command import Command @@ -25,6 +27,17 @@ from poetry.config.config_source import ConfigSource +CONFIG_MIGRATIONS = [ + ConfigSourceMigration( + old_key="experimental.system-git-client", new_key="system-git-client" + ), + ConfigSourceMigration( + old_key="virtualenvs.prefer-active-python", + new_key="virtualenvs.use-poetry-python", + value_migration={True: UNSET, False: True}, + ), +] + class ConfigCommand(Command): name = "config" @@ -39,6 +52,7 @@ class ConfigCommand(Command): option("list", None, "List configuration settings."), option("unset", None, "Unset configuration setting."), option("local", None, "Set/Get from the project's local configuration."), + option("migrate", None, "Migrate outdated configuration settings."), ] help = """\ @@ -98,6 +112,9 @@ def handle(self) -> int: from poetry.locations import CONFIG_DIR from poetry.toml.file import TOMLFile + if self.option("migrate"): + self._migrate() + config = Config.create() config_file = TOMLFile(CONFIG_DIR / "config.toml") @@ -325,3 +342,37 @@ def _list_configuration( message = f"{k + key} = {json.dumps(value)}" self.line(message) + + def _migrate(self) -> None: + from poetry.config.file_config_source import FileConfigSource + from poetry.locations import CONFIG_DIR + from poetry.toml.file import TOMLFile + + config_file = TOMLFile(CONFIG_DIR / "config.toml") + + if self.option("local"): + config_file = TOMLFile(self.poetry.file.path.parent / "poetry.toml") + if not config_file.exists(): + raise RuntimeError("No local config file found") + + config_source = FileConfigSource(config_file) + + self.io.write_line("Checking for required migrations ...") + + required_migrations = [ + migration + for migration in CONFIG_MIGRATIONS + if migration.dry_run(config_source, io=self.io) + ] + + if not required_migrations: + self.io.write_line("Already up to date.") + return + + if not self.io.is_interactive() or self.confirm( + "Proceed with migration?: ", False + ): + for migration in required_migrations: + migration.apply(config_source) + + self.io.write_line("Config migration successfully done.") diff --git a/tests/config/test_config_source.py b/tests/config/test_config_source.py new file mode 100644 index 00000000000..abd81399adf --- /dev/null +++ b/tests/config/test_config_source.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from poetry.config.config_source import UNSET +from poetry.config.config_source import ConfigSourceMigration +from poetry.config.config_source import drop_empty_config_category +from poetry.config.dict_config_source import DictConfigSource + + +@pytest.mark.parametrize( + ["config_data", "expected"], + [ + ( + { + "category_a": { + "category_b": { + "category_c": {}, + }, + }, + "system-git-client": True, + }, + {"system-git-client": True}, + ), + ( + { + "category_a": { + "category_b": { + "category_c": {}, + "category_d": {"some_config": True}, + }, + }, + "system-git-client": True, + }, + { + "category_a": { + "category_b": { + "category_d": {"some_config": True}, + } + }, + "system-git-client": True, + }, + ), + ], +) +def test_drop_empty_config_category( + config_data: dict[Any, Any], expected: dict[Any, Any] +) -> None: + assert ( + drop_empty_config_category( + keys=["category_a", "category_b", "category_c"], config=config_data + ) + == expected + ) + + +def test_config_source_migration_rename_key() -> None: + config_data = { + "virtualenvs": { + "prefer-active-python": True, + }, + "system-git-client": True, + } + + config_source = DictConfigSource() + config_source._config = config_data + + migration = ConfigSourceMigration( + old_key="virtualenvs.prefer-active-python", + new_key="virtualenvs.use-poetry-python", + ) + + migration.apply(config_source) + + config_source._config = { + "virtualenvs": { + "use-poetry-python": True, + }, + "system-git-client": True, + } + + +def test_config_source_migration_remove_key() -> None: + config_data = { + "virtualenvs": { + "prefer-active-python": True, + }, + "system-git-client": True, + } + + config_source = DictConfigSource() + config_source._config = config_data + + migration = ConfigSourceMigration( + old_key="virtualenvs.prefer-active-python", + new_key=None, + ) + + migration.apply(config_source) + + config_source._config = { + "virtualenvs": {}, + "system-git-client": True, + } + + +def test_config_source_migration_unset_value() -> None: + config_data = { + "virtualenvs": { + "prefer-active-python": True, + }, + "system-git-client": True, + } + + config_source = DictConfigSource() + config_source._config = config_data + + migration = ConfigSourceMigration( + old_key="virtualenvs.prefer-active-python", + new_key="virtualenvs.use-poetry-python", + value_migration={True: UNSET, False: True}, + ) + + migration.apply(config_source) + + config_source._config = { + "virtualenvs": {}, + "system-git-client": True, + } + + +def test_config_source_migration_complex_migration() -> None: + config_data = { + "virtualenvs": { + "prefer-active-python": True, + }, + "system-git-client": True, + } + + config_source = DictConfigSource() + config_source._config = config_data + + migration = ConfigSourceMigration( + old_key="virtualenvs.prefer-active-python", + new_key="virtualenvs.use-poetry-python", + value_migration={True: None, False: True}, + ) + + migration.apply(config_source) + + config_source._config = { + "virtualenvs": { + "use-poetry-python": None, + }, + "system-git-client": True, + } diff --git a/tests/config/test_dict_config_source.py b/tests/config/test_dict_config_source.py new file mode 100644 index 00000000000..925833d5a85 --- /dev/null +++ b/tests/config/test_dict_config_source.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import pytest + +from poetry.config.config_source import PropertyNotFoundError +from poetry.config.dict_config_source import DictConfigSource + + +def test_dict_config_source_add_property() -> None: + config_source = DictConfigSource() + assert config_source._config == {} + + config_source.add_property("system-git-client", True) + assert config_source._config == {"system-git-client": True} + + config_source.add_property("virtualenvs.use-poetry-python", False) + assert config_source._config == { + "virtualenvs": { + "use-poetry-python": False, + }, + "system-git-client": True, + } + + +def test_dict_config_source_remove_property() -> None: + config_data = { + "virtualenvs": { + "use-poetry-python": False, + }, + "system-git-client": True, + } + + config_source = DictConfigSource() + config_source._config = config_data + + config_source.remove_property("system-git-client") + assert config_source._config == { + "virtualenvs": { + "use-poetry-python": False, + } + } + + config_source.remove_property("virtualenvs.use-poetry-python") + assert config_source._config == {"virtualenvs": {}} + + +def test_dict_config_source_get_property() -> None: + config_data = { + "virtualenvs": { + "use-poetry-python": False, + }, + "system-git-client": True, + } + + config_source = DictConfigSource() + config_source._config = config_data + + assert config_source.get_property("virtualenvs.use-poetry-python") is False + assert config_source.get_property("system-git-client") is True + + +def test_dict_config_source_get_property_should_raise_if_not_found() -> None: + config_source = DictConfigSource() + + with pytest.raises( + PropertyNotFoundError, match="Key virtualenvs.use-poetry-python not in config" + ): + _ = config_source.get_property("virtualenvs.use-poetry-python") diff --git a/tests/config/test_file_config_source.py b/tests/config/test_file_config_source.py new file mode 100644 index 00000000000..2edef353fe3 --- /dev/null +++ b/tests/config/test_file_config_source.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tomlkit + +from poetry.config.config_source import PropertyNotFoundError +from poetry.config.file_config_source import FileConfigSource +from poetry.toml import TOMLFile + + +if TYPE_CHECKING: + from pathlib import Path + + +def test_file_config_source_add_property(tmp_path: Path) -> None: + config = tmp_path.joinpath("config.toml") + config.touch() + + config_source = FileConfigSource(TOMLFile(config)) + + assert config_source._file.read() == {} + + config_source.add_property("system-git-client", True) + assert config_source._file.read() == {"system-git-client": True} + + config_source.add_property("virtualenvs.use-poetry-python", False) + assert config_source._file.read() == { + "virtualenvs": { + "use-poetry-python": False, + }, + "system-git-client": True, + } + + +def test_file_config_source_remove_property(tmp_path: Path) -> None: + config_data = { + "virtualenvs": { + "use-poetry-python": False, + }, + "system-git-client": True, + } + + config = tmp_path.joinpath("config.toml") + with config.open(mode="w") as f: + f.write(tomlkit.dumps(config_data)) + + config_source = FileConfigSource(TOMLFile(config)) + + config_source.remove_property("system-git-client") + assert config_source._file.read() == { + "virtualenvs": { + "use-poetry-python": False, + } + } + + config_source.remove_property("virtualenvs.use-poetry-python") + assert config_source._file.read() == {} + + +def test_file_config_source_get_property(tmp_path: Path) -> None: + config_data = { + "virtualenvs": { + "use-poetry-python": False, + }, + "system-git-client": True, + } + + config = tmp_path.joinpath("config.toml") + with config.open(mode="w") as f: + f.write(tomlkit.dumps(config_data)) + + config_source = FileConfigSource(TOMLFile(config)) + + assert config_source.get_property("virtualenvs.use-poetry-python") is False + assert config_source.get_property("system-git-client") is True + + +def test_file_config_source_get_property_should_raise_if_not_found( + tmp_path: Path, +) -> None: + config = tmp_path.joinpath("config.toml") + config.touch() + + config_source = FileConfigSource(TOMLFile(config)) + + with pytest.raises( + PropertyNotFoundError, match="Key virtualenvs.use-poetry-python not in config" + ): + _ = config_source.get_property("virtualenvs.use-poetry-python") diff --git a/tests/console/commands/test_config.py b/tests/console/commands/test_config.py index 11ddfcbdbc3..d4925b85129 100644 --- a/tests/console/commands/test_config.py +++ b/tests/console/commands/test_config.py @@ -2,7 +2,9 @@ import json import os +import textwrap +from pathlib import Path from typing import TYPE_CHECKING import pytest @@ -18,12 +20,11 @@ if TYPE_CHECKING: - from pathlib import Path - from cleo.testers.command_tester import CommandTester from pytest_mock import MockerFixture from poetry.config.dict_config_source import DictConfigSource + from poetry.poetry import Poetry from tests.types import CommandTesterFactory from tests.types import FixtureDirGetter from tests.types import ProjectFactory @@ -566,3 +567,88 @@ def test_config_solver_lazy_wheel( repo = LegacyRepository("foo", "https://foo.com") assert not repo._lazy_wheel + + +current_config = """\ +[experimental] +system-git-client = true + +[virtualenvs] +prefer-active-python = false +""" + +config_migrated = """\ +system-git-client = true + +[virtualenvs] +use-poetry-python = true +""" + + +@pytest.mark.parametrize( + ["proceed", "expected_config"], + [ + ("yes", config_migrated), + ("no", current_config), + ], +) +def test_config_migrate( + proceed: str, + expected_config: str, + tester: CommandTester, + mocker: MockerFixture, + tmp_path: Path, +) -> None: + config_dir = tmp_path / "config" + mocker.patch("poetry.locations.CONFIG_DIR", config_dir) + + config_file = Path(config_dir / "config.toml") + with config_file.open("w") as fh: + fh.write(current_config) + + tester.execute("--migrate", inputs=proceed) + + expected_output = textwrap.dedent("""\ + Checking for required migrations ... + experimental.system-git-client = true -> system-git-client = true + virtualenvs.prefer-active-python = false -> virtualenvs.use-poetry-python = true + """) + + output = tester.io.fetch_output() + assert output.startswith(expected_output) + + with config_file.open("r") as fh: + assert fh.read() == expected_config + + +def test_config_migrate_local_config(tester: CommandTester, poetry: Poetry) -> None: + local_config = poetry.file.path.parent / "poetry.toml" + config_data = textwrap.dedent("""\ + [experimental] + system-git-client = true + + [virtualenvs] + prefer-active-python = false + """) + + with local_config.open("w") as fh: + fh.write(config_data) + + tester.execute("--migrate --local", inputs="yes") + + expected_config = textwrap.dedent("""\ + system-git-client = true + + [virtualenvs] + use-poetry-python = true + """) + + with local_config.open("r") as fh: + assert fh.read() == expected_config + + +def test_config_migrate_local_config_should_raise_if_not_found( + tester: CommandTester, +) -> None: + with pytest.raises(RuntimeError, match="No local config file found"): + tester.execute("--migrate --local", inputs="yes")