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")