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