From db9801b0ddc6cc7a623c683a56e0f32b0e594af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Eustace?= Date: Wed, 31 Jul 2019 23:28:46 +0200 Subject: [PATCH] Add support for PyPI API tokens --- docs/docs/repositories.md | 15 ++++++++++-- poetry/console/commands/config.py | 13 +++++++++- poetry/console/commands/publish.py | 2 ++ poetry/masonry/publishing/publisher.py | 28 ++++++++++++++++------ tests/console/commands/test_config.py | 13 ++++++++++ tests/masonry/publishing/test_publisher.py | 23 +++++++++++++++--- 6 files changed, 81 insertions(+), 13 deletions(-) diff --git a/docs/docs/repositories.md b/docs/docs/repositories.md index dd976026112..73fb42f321b 100644 --- a/docs/docs/repositories.md +++ b/docs/docs/repositories.md @@ -39,8 +39,18 @@ If you do not specify the password you will be prompted to write it. !!!note - To publish to PyPI, you can set your credentials for the repository - named `pypi`: + To publish to PyPI, you can set your credentials for the repository named `pypi`. + + Note that it is recommended to use [API tokens](https://pypi.org/help/#apitoken) + when uploading packages to PyPI. + Once you have created a new token, you can tell Poetry to use it: + + ```bash + poetry config pypi-token.pypi my-token + ``` + + If you still want to use you username and password, you can do so with the following + call to `config`. ```bash poetry config http-basic.pypi username password @@ -56,6 +66,7 @@ Keyring support is enabled using the [keyring library](https://pypi.org/project/ Alternatively, you can use environment variables to provide the credentials: ```bash +export POETRY_PYPI_TOKEN_PYPI=my-token export POETRY_HTTP_BASIC_PYPI_USERNAME=username export POETRY_HTTP_BASIC_PYPI_PASSWORD=password ``` diff --git a/poetry/console/commands/config.py b/poetry/console/commands/config.py index 05ba28989b4..b7e4ed2790f 100644 --- a/poetry/console/commands/config.py +++ b/poetry/console/commands/config.py @@ -176,7 +176,7 @@ def handle(self): ) # handle auth - m = re.match(r"^(http-basic)\.(.+)", self.argument("key")) + m = re.match(r"^(http-basic|pypi-token)\.(.+)", self.argument("key")) if m: if self.option("unset"): keyring_repository_password_del(config, m.group(2)) @@ -209,6 +209,17 @@ def handle(self): auth_config_source.add_property( "{}.{}".format(m.group(1), m.group(2)), property_value ) + elif m.group(1) == "pypi-token": + if len(values) != 1: + raise ValueError( + "Expected only one argument (token), got {}".format(len(values)) + ) + + token = values[0] + + auth_config_source.add_property( + "{}.{}".format(m.group(1), m.group(2)), token + ) return 0 diff --git a/poetry/console/commands/publish.py b/poetry/console/commands/publish.py index 024ec577407..b888ea045b0 100644 --- a/poetry/console/commands/publish.py +++ b/poetry/console/commands/publish.py @@ -26,6 +26,8 @@ class PublishCommand(Command): the config command. """ + loggers = ["poetry.masonry.publishing.publisher"] + def handle(self): from poetry.masonry.publishing.publisher import Publisher diff --git a/poetry/masonry/publishing/publisher.py b/poetry/masonry/publishing/publisher.py index 5c8f10d9e4a..cf286ddb2d5 100644 --- a/poetry/masonry/publishing/publisher.py +++ b/poetry/masonry/publishing/publisher.py @@ -1,11 +1,13 @@ -from poetry.locations import CONFIG_DIR -from poetry.utils._compat import Path +import logging + from poetry.utils.helpers import get_http_basic_auth -from poetry.utils.toml_file import TomlFile from .uploader import Uploader +logger = logging.getLogger(__name__) + + class Publisher: """ Registers and publishes packages to remote repositories. @@ -55,10 +57,22 @@ def publish(self, repository_name, username, password): url = repository["url"] if not (username and password): - auth = get_http_basic_auth(self._poetry.config, repository_name) - if auth: - username = auth[0] - password = auth[1] + # Check if we have a token first + token = self._poetry.config.get("pypi-token.{}".format(repository_name)) + if token: + logger.debug("Found an API token for {}.".format(repository_name)) + username = "@token" + password = token + else: + auth = get_http_basic_auth(self._poetry.config, repository_name) + if auth: + logger.debug( + "Found authentication information for {}.".format( + repository_name + ) + ) + username = auth[0] + password = auth[1] # Requesting missing credentials if not username: diff --git a/tests/console/commands/test_config.py b/tests/console/commands/test_config.py index b0572f3b050..94e4000c06a 100644 --- a/tests/console/commands/test_config.py +++ b/tests/console/commands/test_config.py @@ -92,3 +92,16 @@ def test_list_displays_set_get_local_setting( assert expected == tester.io.fetch_output() assert "poetry.toml" == init.call_args_list[2][0][1].path.name + assert expected == tester.io.fetch_output() + + +def test_set_pypi_token(app, config_source, config_document, mocker): + init = mocker.spy(ConfigSource, "__init__") + command = app.find("config") + tester = CommandTester(command) + + tester.execute("pypi-token.pypi mytoken") + + tester.execute("--list") + + assert "mytoken" == config_document["pypi-token"]["pypi"] diff --git a/tests/masonry/publishing/test_publisher.py b/tests/masonry/publishing/test_publisher.py index 493df961fbb..27b47ef72be 100644 --- a/tests/masonry/publishing/test_publisher.py +++ b/tests/masonry/publishing/test_publisher.py @@ -5,10 +5,11 @@ from poetry.poetry import Poetry -def test_publish_publishes_to_pypi_by_default(fixture_dir, mocker): +def test_publish_publishes_to_pypi_by_default(fixture_dir, mocker, config): uploader_auth = mocker.patch("poetry.masonry.publishing.uploader.Uploader.auth") uploader_upload = mocker.patch("poetry.masonry.publishing.uploader.Uploader.upload") poetry = Poetry.create(fixture_dir("sample_project")) + poetry._config = config poetry.config.merge( {"http-basic": {"pypi": {"username": "foo", "password": "bar"}}} ) @@ -20,10 +21,11 @@ def test_publish_publishes_to_pypi_by_default(fixture_dir, mocker): assert [("https://upload.pypi.org/legacy/",)] == uploader_upload.call_args -def test_publish_can_publish_to_given_repository(fixture_dir, mocker): +def test_publish_can_publish_to_given_repository(fixture_dir, mocker, config): uploader_auth = mocker.patch("poetry.masonry.publishing.uploader.Uploader.auth") uploader_upload = mocker.patch("poetry.masonry.publishing.uploader.Uploader.upload") poetry = Poetry.create(fixture_dir("sample_project")) + poetry._config = config poetry.config.merge( { "repositories": {"my-repo": {"url": "http://foo.bar"}}, @@ -38,8 +40,9 @@ def test_publish_can_publish_to_given_repository(fixture_dir, mocker): assert [("http://foo.bar",)] == uploader_upload.call_args -def test_publish_raises_error_for_undefined_repository(fixture_dir, mocker): +def test_publish_raises_error_for_undefined_repository(fixture_dir, mocker, config): poetry = Poetry.create(fixture_dir("sample_project")) + poetry._config = config poetry.config.merge( {"http-basic": {"my-repo": {"username": "foo", "password": "bar"}}} ) @@ -47,3 +50,17 @@ def test_publish_raises_error_for_undefined_repository(fixture_dir, mocker): with pytest.raises(RuntimeError): publisher.publish("my-repo", None, None) + + +def test_publish_uses_token_if_it_exists(fixture_dir, mocker, config): + uploader_auth = mocker.patch("poetry.masonry.publishing.uploader.Uploader.auth") + uploader_upload = mocker.patch("poetry.masonry.publishing.uploader.Uploader.upload") + poetry = Poetry.create(fixture_dir("sample_project")) + poetry._config = config + poetry.config.merge({"pypi-token": {"pypi": "my-token"}}) + publisher = Publisher(poetry, NullIO()) + + publisher.publish(None, None, None) + + assert [("@token", "my-token")] == uploader_auth.call_args + assert [("https://upload.pypi.org/legacy/",)] == uploader_upload.call_args