diff --git a/docs/html/development/release-process.rst b/docs/html/development/release-process.rst index 90309ad621f..28dba04f4ce 100644 --- a/docs/html/development/release-process.rst +++ b/docs/html/development/release-process.rst @@ -50,6 +50,10 @@ document existing behavior with the intention of covering that behavior with the above deprecation process are always acceptable, and will be considered on their merits. +Any user-visible behavior enabled using a feature flag (i.e. the ``-X`` or +the ``--unstable-feature`` option) is not subject to these guarantees and can +be changed or be removed without a deprecation period. + .. note:: pip has a helper function for making deprecation easier for pip maintainers. diff --git a/news/5727.feature b/news/5727.feature new file mode 100644 index 00000000000..98cda8354eb --- /dev/null +++ b/news/5727.feature @@ -0,0 +1,3 @@ +Add a ``-X``/``--unstable-feature`` flag as a way to incrementally introducing +new functionality. Any functionality provided behind this flag is exempted from +the regular deprecation policy. diff --git a/src/pip/_internal/cli/base_command.py b/src/pip/_internal/cli/base_command.py index 549864936d3..701736afee5 100644 --- a/src/pip/_internal/cli/base_command.py +++ b/src/pip/_internal/cli/base_command.py @@ -30,6 +30,7 @@ from pip._internal.utils.misc import get_prog, normalize_path from pip._internal.utils.outdated import pip_version_check from pip._internal.utils.typing import MYPY_CHECK_RUNNING +from pip._internal.utils.unstable import UnstableFeaturesHelper if MYPY_CHECK_RUNNING: from typing import Optional # noqa: F401 @@ -46,6 +47,9 @@ class Command(object): ignore_require_venv = False # type: bool def __init__(self, isolated=False): + + self.unstable = UnstableFeaturesHelper() + parser_kw = { 'usage': self.usage, 'prog': '%s %s' % (get_prog(), self.name), @@ -139,6 +143,9 @@ def main(self, args): ) sys.exit(VIRTUALENV_NOT_FOUND) + # Raises an error if an unknown unstable feature is given. + self.unstable.validate(options.unstable_features) + try: status = self.run(options, args) # FIXME: all commands should return an exit status diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 478b3b24dc5..f155e5d2102 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -672,6 +672,18 @@ def _merge_hash(option, opt_str, value, parser): ) # type: Any +def unstable_features(): + return Option( + '-X', '--unstable-feature', + dest='unstable_features', + metavar='feature', + action='append', + default=[], + help="Enable unstable functionality that is exempted from backwards " + "compatibility guarantees.", + ) + + ########## # groups # ########## @@ -699,6 +711,7 @@ def _merge_hash(option, opt_str, value, parser): no_cache, disable_pip_version_check, no_color, + unstable_features, ] } diff --git a/src/pip/_internal/exceptions.py b/src/pip/_internal/exceptions.py index ad6f4125356..1ac7096e3d1 100644 --- a/src/pip/_internal/exceptions.py +++ b/src/pip/_internal/exceptions.py @@ -247,3 +247,17 @@ def hash_then_or(hash_name): class UnsupportedPythonVersion(InstallationError): """Unsupported python version according to Requires-Python package metadata.""" + + +class UnknownUnstableFeatures(PipError): + """Unstable feature names passed are not known. + """ + + def __init__(self, unknown_names): + super(UnknownUnstableFeatures, self).__init__(unknown_names) + self.unknown_names = unknown_names + + def __str__(self): + return "UnknownUnstableFeatures: {}".format( + ", ".join(map(repr, sorted(self.unknown_names))) + ) diff --git a/src/pip/_internal/utils/unstable.py b/src/pip/_internal/utils/unstable.py new file mode 100644 index 00000000000..9ef90deb96f --- /dev/null +++ b/src/pip/_internal/utils/unstable.py @@ -0,0 +1,26 @@ + +from pip._internal.exceptions import UnknownUnstableFeatures + + +class UnstableFeaturesHelper(object): + """Handles logic for registering/validating/checking + """ + + def __init__(self): + super(UnstableFeaturesHelper, self).__init__() + self._names = set() + self._enabled_names = set() + + def register(self, *names): + self._names.update(set(names)) + + def validate(self, given_names): + # Remember the given names as they are "enabled" features + self._enabled_names = set(given_names) + + unknown_names = self._enabled_names - self._names + if unknown_names: + raise UnknownUnstableFeatures(unknown_names) + + def is_enabled(self, name): + return name in self._enabled_names diff --git a/tests/unit/test_base_command.py b/tests/unit/test_base_command.py index 1cd7277c4ce..5ffa4902b2f 100644 --- a/tests/unit/test_base_command.py +++ b/tests/unit/test_base_command.py @@ -1,5 +1,7 @@ import logging +from mock import Mock + from pip._internal.cli.base_command import Command @@ -32,6 +34,14 @@ def run(self, options, args): ) +def test_base_command_unstable_validation_done_in_main(): + cmd = FakeCommand() + cmd.unstable = Mock() + cmd.main(['fake']) + + cmd.unstable.validate.assert_called_once_with([]) + + class Test_base_command_logging(object): """ Test `pip.base_command.Command` setting up logging consumers based on diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 947d258925e..4c0d8a14ba0 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -17,7 +17,8 @@ from pip._vendor.six import BytesIO from pip._internal.exceptions import ( - HashMismatch, HashMissing, InstallationError, UnsupportedPythonVersion, + HashMismatch, HashMissing, InstallationError, UnknownUnstableFeatures, + UnsupportedPythonVersion, ) from pip._internal.utils.encoding import auto_decode from pip._internal.utils.glibc import check_glibc_version @@ -29,6 +30,7 @@ ) from pip._internal.utils.packaging import check_dist_requires_python from pip._internal.utils.temp_dir import TempDirectory +from pip._internal.utils.unstable import UnstableFeaturesHelper class Tests_EgglinkPath: @@ -665,3 +667,30 @@ def test_split_auth_from_netloc(netloc, expected): def test_remove_auth_from_url(auth_url, expected_url): url = remove_auth_from_url(auth_url) assert url == expected_url + + +class TestUnstableFeaturesHelper(object): + + def test_does_not_enable_on_registration(self): + unstable = UnstableFeaturesHelper() + unstable.register("name1", "name2") + + assert not unstable.is_enabled("name1") + assert not unstable.is_enabled("name2") + + def test_errors_when_validating_unregistered_name(self): + unstable = UnstableFeaturesHelper() + unstable.register("name1", "name2", "name3") + + with pytest.raises(UnknownUnstableFeatures) as e: + unstable.validate(["name3", "name4", "name5"]) + + assert str(e.value) == "UnknownUnstableFeatures: 'name4', 'name5'" + + def test_enables_names_on_validation(self): + unstable = UnstableFeaturesHelper() + unstable.register("name1", "name2") + unstable.validate(["name1"]) + + assert unstable.is_enabled("name1") + assert not unstable.is_enabled("name2")