Repository: pypa/pip

Issue:
Cannot `pip install` SVN dependency with authentication using SVN 1.8+
**Environment**

* pip version: 19.0.3
* Python version: 3.6.7
* OS: Ubuntu 18.04 x64 LTS

<!-- Feel free to add more information about your environment here -->

**Description**

> [`pip` Subversion docs](https://pip.pypa.io/en/stable/reference/pip_install/#subversion)

Starting with SVN 1.8, [SVN is non-interactive by default](http://svn.apache.org/viewvc?view=revision&revision=1424037). Before that, it will prompt for a password when the user performs a `svn checkout`.

The problem is that when `pip` calls out to `svn checkout` it is not interactive, and will not allow the user to enter their password. One solution is to store SVN passwords, but that may not be allowed by company rules or that may simply not be desirable for security reasons.

For some more context see

- https://github.com/gedex/wp-tools/issues/33
- https://groups.google.com/forum/#!topic/subversion_users/fQ4K5GShGPU
- https://stackoverflow.com/questions/26066979

The solution seems to be:

1. If SVN version is `<1.8`, work as it does now (no extra arguments needed).
2. If SVN version is `>=1.8`, add the `--force-interactive` command line flag.

Some context from `svn checkout --help`

```
  --non-interactive        : do no interactive prompting (default is to prompt
                             only if standard input is a terminal device)
  --force-interactive      : do interactive prompting even if standard input
                             is not a terminal device
```

Perhaps another solution would be to make `svn` think it's being called from a terminal device when called from `pip`?

[Using environment variables is another potential option](https://pip.pypa.io/en/stable/reference/pip_install/#id10), but it runs into the same fundamental issue: users have to store their password (and in this case in plaintext). This doesn't seem to be appropriate for user workstations that could be shared.

Subversion versions on popular supported Linux distros:

- Ubuntu 18.04 LTS: 1.9.7
- Ubuntu 16.04 LTS: 1.9.3
- RHEL 7 / CentOS 7: 1.7.14
- RHEL 6 / CentOS 6: 1.6.11

**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->

On SVN 1.7, 1.8, and 1.9, when `pip` installs an SVN dependency, it prompts the user for their password if they have not saved it locally.

**How to Reproduce**
<!-- Describe the steps to reproduce this bug. -->

1. Install Subversion >=1.8. 

  - If using Ubuntu 18.04, `sudo apt install subversion`. `svn --version` will return `1.9.7`.

2. Make a `venv`:

```bash
$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install pip==19.0.3
```

3. Don't save your SVN password and try to `pip install` an SVN URL from a URL that requires authentication. `pip`'s invocation of `svn` will not prompt for a password, so it will always fail to install.

```bash
(venv) $ pip install svn+https://my-svn-server.com/project
Collecting svn+https://my-svn-server.com/project
   Checking out https://my-svn-server.com/project /tmp/pip-req-build-1g_qavb
svn: E170013: Unable to connect to repository at URL 'https://https://my-svn-server.com/project'
svn: E215004: No more credentials or we tried too many times.
Authentication failed.
Command "svn checkout -q https://my-svn-server.com/project /tmp/pip-req-build-1g_qavb" failed with error code 1 in None
```

**References**

- https://github.com/pypa/pip/blob/e5353f27cdc48494018c9b4eee94a44573eceeed/src/pip/_internal/vcs/subversion.py#L58

- https://github.com/pypa/pip/blob/e5353f27cdc48494018c9b4eee94a44573eceeed/src/pip/_internal/vcs/__init__.py#L546
- https://github.com/pypa/pip/blob/e5353f27cdc48494018c9b4eee94a44573eceeed/src/pip/_internal/utils/misc.py#L722

Discussion:

Patch:
diff --git a/news/6386.bugfix b/news/6386.bugfix
new file mode 100644
index 000000000..366ec2862
--- /dev/null
+++ b/news/6386.bugfix
@@ -0,0 +1,2 @@
+Extend to Subversion 1.8+ the behavior of calling Subversion in
+interactive mode when pip is run interactively.
diff --git a/src/pip/_internal/vcs/subversion.py b/src/pip/_internal/vcs/subversion.py
index 7ab03a277..0d787009f 100644
--- a/src/pip/_internal/vcs/subversion.py
+++ b/src/pip/_internal/vcs/subversion.py
@@ -20,6 +20,7 @@ _svn_info_xml_url_re = re.compile(r'<url>(.*)</url>')
 
 if MYPY_CHECK_RUNNING:
     from typing import List, Optional, Tuple
+    from pip._internal.vcs import RevOptions
 
 logger = logging.getLogger(__name__)
 
@@ -48,10 +49,12 @@ class Subversion(VersionControl):
                 # Subversion doesn't like to check out over an existing
                 # directory --force fixes this, but was only added in svn 1.5
                 rmtree(location)
-            cmd_args = ['export'] + rev_options.to_args() + [url, location]
+            cmd_args = (['export'] + self.get_remote_call_options() +
+                        rev_options.to_args() + [url, location])
             self.run_command(cmd_args, show_stdout=False)
 
     def fetch_new(self, dest, url, rev_options):
+        # type: (str, str, RevOptions) -> None
         rev_display = rev_options.to_display()
         logger.info(
             'Checking out %s%s to %s',
@@ -59,15 +62,21 @@ class Subversion(VersionControl):
             rev_display,
             display_path(dest),
         )
-        cmd_args = ['checkout', '-q'] + rev_options.to_args() + [url, dest]
+        cmd_args = (['checkout', '-q'] +
+                    self.get_remote_call_options() +
+                    rev_options.to_args() + [url, dest])
         self.run_command(cmd_args)
 
     def switch(self, dest, url, rev_options):
-        cmd_args = ['switch'] + rev_options.to_args() + [url, dest]
+        # type: (str, str, RevOptions) -> None
+        cmd_args = (['switch'] + self.get_remote_call_options() +
+                    rev_options.to_args() + [url, dest])
         self.run_command(cmd_args)
 
     def update(self, dest, url, rev_options):
-        cmd_args = ['update'] + rev_options.to_args() + [dest]
+        # type: (str, str, RevOptions) -> None
+        cmd_args = (['update'] + self.get_remote_call_options() +
+                    rev_options.to_args() + [dest])
         self.run_command(cmd_args)
 
     @classmethod
@@ -177,6 +186,11 @@ class Subversion(VersionControl):
         else:
             try:
                 # subversion >= 1.7
+                # Note that using get_remote_call_options is not necessary here
+                # because `svn info` is being run against a local directory.
+                # We don't need to worry about making sure interactive mode
+                # is being used to prompt for passwords, because passwords
+                # are only potentially needed for remote server requests.
                 xml = cls.run_command(
                     ['info', '--xml', location],
                     show_stdout=False,


Test patch:
diff --git a/tests/functional/test_install_vcs_svn.py b/tests/functional/test_install_vcs_svn.py
index 6aea2737d..8db3e8f50 100644
--- a/tests/functional/test_install_vcs_svn.py
+++ b/tests/functional/test_install_vcs_svn.py
@@ -1,30 +1,66 @@
-import pytest
+from unittest import TestCase
+
 from mock import patch
 
+from pip._internal.vcs import RevOptions
 from pip._internal.vcs.subversion import Subversion
 
 
-@patch('pip._internal.vcs.call_subprocess')
-@pytest.mark.network
-def test_obtain_should_recognize_auth_info_url(call_subprocess_mock, script):
-    url = 'svn+http://username:password@svn.example.com/'
-    svn = Subversion()
-    svn.obtain(script.scratch_path / 'test', url=url)
-    assert call_subprocess_mock.call_args[0][0] == [
-        svn.name, 'checkout', '-q', '--username', 'username', '--password',
-        'password', 'http://svn.example.com/',
-        script.scratch_path / 'test',
-    ]
-
-
-@patch('pip._internal.vcs.call_subprocess')
-@pytest.mark.network
-def test_export_should_recognize_auth_info_url(call_subprocess_mock, script):
-    url = 'svn+http://username:password@svn.example.com/'
-    svn = Subversion()
-    svn.export(script.scratch_path / 'test', url=url)
-    assert call_subprocess_mock.call_args[0][0] == [
-        svn.name, 'export', '--username', 'username', '--password',
-        'password', 'http://svn.example.com/',
-        script.scratch_path / 'test',
-    ]
+class TestSubversionArgs(TestCase):
+    def setUp(self):
+        patcher = patch('pip._internal.vcs.call_subprocess')
+        self.addCleanup(patcher.stop)
+        self.call_subprocess_mock = patcher.start()
+
+        # Test Data.
+        self.url = 'svn+http://username:password@svn.example.com/'
+        # use_interactive is set to False to test that remote call options are
+        # properly added.
+        self.svn = Subversion(use_interactive=False)
+        self.rev_options = RevOptions(Subversion)
+        self.dest = '/tmp/test'
+
+    def assert_call_args(self, args):
+        assert self.call_subprocess_mock.call_args[0][0] == args
+
+    def test_obtain(self):
+        self.svn.obtain(self.dest, self.url)
+        self.assert_call_args(
+            ['svn', 'checkout', '-q', '--non-interactive', '--username',
+             'username', '--password', 'password',
+             'http://svn.example.com/', '/tmp/test'])
+
+    def test_export(self):
+        self.svn.export(self.dest, self.url)
+        self.assert_call_args(
+            ['svn', 'export', '--non-interactive', '--username', 'username',
+             '--password', 'password', 'http://svn.example.com/',
+             '/tmp/test'])
+
+    def test_fetch_new(self):
+        self.svn.fetch_new(self.dest, self.url, self.rev_options)
+        self.assert_call_args(
+            ['svn', 'checkout', '-q', '--non-interactive',
+             'svn+http://username:password@svn.example.com/',
+             '/tmp/test'])
+
+    def test_fetch_new_revision(self):
+        rev_options = RevOptions(Subversion, '123')
+        self.svn.fetch_new(self.dest, self.url, rev_options)
+        self.assert_call_args(
+            ['svn', 'checkout', '-q', '--non-interactive',
+             '-r', '123',
+             'svn+http://username:password@svn.example.com/',
+             '/tmp/test'])
+
+    def test_switch(self):
+        self.svn.switch(self.dest, self.url, self.rev_options)
+        self.assert_call_args(
+            ['svn', 'switch', '--non-interactive',
+             'svn+http://username:password@svn.example.com/',
+             '/tmp/test'])
+
+    def test_update(self):
+        self.svn.update(self.dest, self.url, self.rev_options)
+        self.assert_call_args(
+            ['svn', 'update', '--non-interactive', '/tmp/test'])