Skip to content

Commit

Permalink
feat(pypi): support easier use of API tokens
Browse files Browse the repository at this point in the history
Allow setting the environment variable PYPI_TOKEN to automatically fill the username as __token__.

Fixes #213
  • Loading branch information
danth authored and relekang committed Apr 26, 2020
1 parent 149e426 commit bac135c
Show file tree
Hide file tree
Showing 3 changed files with 46 additions and 12 deletions.
2 changes: 0 additions & 2 deletions semantic_release/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,6 @@ def publish(**kwargs):
logger.info("Uploading to PyPI")
upload_to_pypi(
path=dist_path,
username=os.environ.get("PYPI_USERNAME"),
password=os.environ.get("PYPI_PASSWORD"),
# If we are retrying, we don't want errors for files that are already on PyPI.
skip_existing=retry,
)
Expand Down
23 changes: 17 additions & 6 deletions semantic_release/pypi.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Helper for using Twine to upload to PyPI.
"""
import logging
import os

from invoke import run

Expand All @@ -14,22 +15,32 @@
@LoggedFunction(logger)
def upload_to_pypi(
path: str = "dist",
username: str = None,
password: str = None,
skip_existing: bool = False,
):
"""Upload wheels to PyPI with Twine.
Wheels must already be created and stored at the given path.
Credentials are taken from either the environment variable
``PYPI_TOKEN``, or from ``PYPI_USERNAME`` and ``PYPI_PASSWORD``.
:param path: Path to dist folder containing the files to upload.
:param username: PyPI account username.
:param password: PyPI account password.
:param skip_existing: Continue uploading files if one already exists.
(Only valid when uploading to PyPI. Other implementations may not support this.)
"""
if username is None or password is None or username == "" or password == "":
raise ImproperConfigurationError("Missing credentials for uploading")
# Attempt to get an API token from environment
token = os.environ.get("PYPI_TOKEN")
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")
elif not token.startswith("pypi-"):
raise ImproperConfigurationError("PyPI token should begin with \"pypi-\"")
else:
username = '__token__'
password = token

run(
"twine upload -u '{}' -p '{}' {} \"{}/*\"".format(
Expand Down
33 changes: 29 additions & 4 deletions tests/test_pypi.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,46 @@

class PypiTests(TestCase):
@mock.patch("semantic_release.pypi.run")
def test_upload_without_arguments(self, mock_run):
upload_to_pypi(username="username", password="password")
@mock.patch.dict("os.environ", {"PYPI_USERNAME": "username", "PYPI_PASSWORD": "password"})
def test_upload_with_password(self, mock_run):
upload_to_pypi()
self.assertEqual(
mock_run.call_args_list,
[mock.call("twine upload -u 'username' -p 'password' \"dist/*\"")],
)

@mock.patch("semantic_release.pypi.run")
@mock.patch.dict("os.environ", {"PYPI_TOKEN": "pypi-x"})
def test_upload_with_token(self, mock_run):
upload_to_pypi()
self.assertEqual(
mock_run.call_args_list,
[mock.call("twine upload -u '__token__' -p 'pypi-x' \"dist/*\"")],
)

@mock.patch("semantic_release.pypi.run")
@mock.patch.dict("os.environ", {"PYPI_TOKEN": "pypi-x", "PYPI_USERNAME": "username", "PYPI_PASSWORD": "password"})
def test_upload_prefers_token_over_password(self, mock_run):
upload_to_pypi()
self.assertEqual(
mock_run.call_args_list,
[mock.call("twine upload -u '__token__' -p 'pypi-x' \"dist/*\"")],
)

@mock.patch("semantic_release.pypi.run")
@mock.patch.dict("os.environ", {"PYPI_TOKEN": "pypi-x"})
def test_upload_with_custom_path(self, mock_run):
upload_to_pypi(path="custom-dist", username="username", password="password")
upload_to_pypi(path="custom-dist")
self.assertEqual(
mock_run.call_args_list,
[mock.call("twine upload -u 'username' -p 'password' \"custom-dist/*\"")],
[mock.call("twine upload -u '__token__' -p 'pypi-x' \"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()

def test_raises_error_when_missing_credentials(self):
with self.assertRaises(ImproperConfigurationError):
upload_to_pypi()

0 comments on commit bac135c

Please sign in to comment.