Skip to content

Commit

Permalink
feat: allow the use of .pypirc for twine uploads (#325)
Browse files Browse the repository at this point in the history
  • Loading branch information
lewgordon committed Feb 18, 2021
1 parent 77ad988 commit 6bc56b8
Show file tree
Hide file tree
Showing 4 changed files with 57 additions and 8 deletions.
6 changes: 6 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
3 changes: 3 additions & 0 deletions docs/envvars.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
20 changes: 15 additions & 5 deletions semantic_release/pypi.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from semantic_release import ImproperConfigurationError

from semantic_release.settings import config

from .helpers import LoggedFunction

logger = logging.getLogger(__name__)
Expand All @@ -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}")
36 changes: 33 additions & 3 deletions tests/test_pypi.py
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -18,6 +22,18 @@ def test_upload_with_password(self, mock_run):
[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):
Expand All @@ -33,7 +49,7 @@ def test_upload_with_token(self, mock_run):
{
"PYPI_TOKEN": "pypi-x",
"PYPI_USERNAME": "username",
"PYPI_PASSWORD": "password",
"PYPI_PASSWORD": "password"
},
)
def test_upload_prefers_token_over_password(self, mock_run):
Expand Down Expand Up @@ -65,11 +81,25 @@ def test_upload_with_custom_globs(self, mock_run):
],
)

@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()

0 comments on commit 6bc56b8

Please sign in to comment.