Repository: relekang/python-semantic-release
Pull request #325 (2021-02-17 18:37:44)

feat: allow the use of .pypirc for twine uploads
Addresses #310

* Defaults to using `$HOME/.pypirc` file for credentials if `PYPI_TOKEN` or (`PYPI_USERNAME` and `PYPI_PASSWORD`) are not specified.
* Allows use of `--repository <repository>` for twine upload. This also comes from `$HOME/.pypirc`.

lewgordon: I also saw #303, but it seemed much more ambitious than what I wanted to do. Also, I wasn't sure of the status of it. 

Patch:
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 70bc160..ec0b506 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -356,6 +356,12 @@ A comma `,` separated list of glob patterns to use when uploading to pypi.
 
 Default: `*`
 
+.. _config-repository:
+
+``repository``
+------------------
+The repository (package index) to upload to. Should be a section in the ``.pypirc`` file.
+
 .. _config-upload_to_release:
 
 ``upload_to_release``
diff --git a/docs/envvars.rst b/docs/envvars.rst
index 14432c4..09d830e 100644
--- a/docs/envvars.rst
+++ b/docs/envvars.rst
@@ -91,6 +91,9 @@ obtain a token is given `here <https://pypi.org/help/#apitoken>`_.
 
 See :ref:`automatic-pypi` for more about PyPI uploads.
 
+.. note::
+  If :ref:`env-pypi_password`, :ref:`env-pypi_username`, and :ref:`env-pypi_token` are not specified credentials from ``$HOME/.pypirc`` will be used.
+
 .. _env-pypi_password:
 
 ``PYPI_PASSWORD``
diff --git a/semantic_release/pypi.py b/semantic_release/pypi.py
index 525d713..6c04ee6 100644
--- a/semantic_release/pypi.py
+++ b/semantic_release/pypi.py
@@ -8,6 +8,8 @@ from invoke import run
 
 from semantic_release import ImproperConfigurationError
 
+from semantic_release.settings import config
+
 from .helpers import LoggedFunction
 
 logger = logging.getLogger(__name__)
@@ -34,24 +36,32 @@ def upload_to_pypi(
 
     # Attempt to get an API token from environment
     token = os.environ.get("PYPI_TOKEN")
+    username = None
+    password = None
     if not token:
         # Look for a username and password instead
         username = os.environ.get("PYPI_USERNAME")
         password = os.environ.get("PYPI_PASSWORD")
-        if not (username or password):
-            raise ImproperConfigurationError(
-                "Missing credentials for uploading to PyPI"
-            )
+        home_dir = os.environ.get('HOME', '')
+        if not (username or password) and (not home_dir or not os.path.isfile(os.path.join(home_dir, '.pypirc'))):
+                raise ImproperConfigurationError(
+                    "Missing credentials for uploading to PyPI"
+                )
     elif not token.startswith("pypi-"):
         raise ImproperConfigurationError('PyPI token should begin with "pypi-"')
     else:
         username = "__token__"
         password = token
 
+    repository = config.get('repository', None)
+    repository_arg = f" -r '{repository}'" if repository else ""
+
+    username_password = f"-u '{username}' -p '{password}'" if username and password else ""
+
     dist = " ".join(
         ['"{}/{}"'.format(path, glob_pattern.strip()) for glob_pattern in glob_patterns]
     )
 
     skip_existing_param = " --skip-existing" if skip_existing else ""
 
-    run(f"twine upload -u '{username}' -p '{password}'{skip_existing_param} {dist}")
+    run(f"twine upload {username_password}{repository_arg}{skip_existing_param} {dist}")


Test patch:
diff --git a/tests/test_pypi.py b/tests/test_pypi.py
index fbe394b..c0e0fcc 100644
--- a/tests/test_pypi.py
+++ b/tests/test_pypi.py
@@ -1,15 +1,19 @@
+
+import os
+import tempfile
+
 from unittest import TestCase
 
 from semantic_release import ImproperConfigurationError
 from semantic_release.pypi import upload_to_pypi
 
-from . import mock
+from . import mock, wrapped_config_get
 
 
 class PypiTests(TestCase):
     @mock.patch("semantic_release.pypi.run")
     @mock.patch.dict(
-        "os.environ", {"PYPI_USERNAME": "username", "PYPI_PASSWORD": "password"}
+        "os.environ", {"PYPI_USERNAME": "username", "PYPI_PASSWORD": "password", "HOME": "/tmp/1234"}
     )
     def test_upload_with_password(self, mock_run):
         upload_to_pypi()
@@ -18,6 +22,18 @@ class PypiTests(TestCase):
             [mock.call("twine upload -u 'username' -p 'password' \"dist/*\"")],
         )
 
+    @mock.patch("semantic_release.pypi.run")
+    @mock.patch.dict(
+        "os.environ", {"PYPI_USERNAME": "username", "PYPI_PASSWORD": "password", "HOME": "/tmp/1234"}
+    )
+    @mock.patch("semantic_release.pypi.config.get", wrapped_config_get(repository='corp-repo'))
+    def test_upload_with_repository(self, mock_run):
+        upload_to_pypi()
+        self.assertEqual(
+            mock_run.call_args_list,
+            [mock.call("twine upload -u 'username' -p 'password' -r 'corp-repo' \"dist/*\"")],
+        )
+
     @mock.patch("semantic_release.pypi.run")
     @mock.patch.dict("os.environ", {"PYPI_TOKEN": "pypi-x"})
     def test_upload_with_token(self, mock_run):
@@ -33,7 +49,7 @@ class PypiTests(TestCase):
         {
             "PYPI_TOKEN": "pypi-x",
             "PYPI_USERNAME": "username",
-            "PYPI_PASSWORD": "password",
+            "PYPI_PASSWORD": "password"
         },
     )
     def test_upload_prefers_token_over_password(self, mock_run):
@@ -65,11 +81,25 @@ class PypiTests(TestCase):
             ],
         )
 
+    @mock.patch("semantic_release.pypi.run")
+    @mock.patch.dict("os.environ", {})
+    def test_upload_with_pypirc_file_exists(self, mock_run):
+        tmpdir = tempfile.mkdtemp()
+        os.environ['HOME'] = tmpdir
+        with open(os.path.join(tmpdir, '.pypirc'), 'w') as pypirc_fp:
+            pypirc_fp.write('hello')
+        upload_to_pypi(path="custom-dist")
+        self.assertEqual(
+            mock_run.call_args_list,
+            [mock.call("twine upload  \"custom-dist/*\"")],
+        )
+
     @mock.patch.dict("os.environ", {"PYPI_TOKEN": "invalid"})
     def test_raises_error_when_token_invalid(self):
         with self.assertRaises(ImproperConfigurationError):
             upload_to_pypi()
 
+    @mock.patch.dict("os.environ", {"HOME": "/tmp/1234"})
     def test_raises_error_when_missing_credentials(self):
         with self.assertRaises(ImproperConfigurationError):
             upload_to_pypi()