Skip to content

Commit

Permalink
Merge pull request #1040 from woodruffw-forks/ww/pypi-mandatory-api-t…
Browse files Browse the repository at this point in the history
…okens

twine: use API tokens by default on PyPI
  • Loading branch information
sigmavirus24 authored Jan 5, 2024
2 parents b54af26 + b3b363a commit 89ec78c
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 17 deletions.
2 changes: 1 addition & 1 deletion tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ def get_password(system, username):
)


def test_logs_cli_values(caplog):
def test_logs_cli_values(caplog, config):
caplog.set_level(logging.INFO, "twine")

res = auth.Resolver(config, auth.CredentialInput("username", "password"))
Expand Down
43 changes: 40 additions & 3 deletions tests/test_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ def test_non_existent_package(register_settings):
register.register(register_settings, package)


def test_values_from_env(monkeypatch):
@pytest.mark.parametrize("repo", ["pypi", "testpypi"])
def test_values_from_env_pypi(monkeypatch, repo):
"""Use env vars for settings when run from command line."""

def none_register(*args, **settings_kwargs):
Expand All @@ -88,13 +89,49 @@ def none_register(*args, **settings_kwargs):
replaced_register = pretend.call_recorder(none_register)
monkeypatch.setattr(register, "register", replaced_register)
testenv = {
"TWINE_USERNAME": "pypiuser",
"TWINE_REPOSITORY": repo,
# Ignored because the TWINE_REPOSITORY is PyPI/TestPyPI
"TWINE_USERNAME": "this-is-ignored",
"TWINE_PASSWORD": "pypipassword",
"TWINE_CERT": "/foo/bar.crt",
}
with helpers.set_env(**testenv):
cli.dispatch(["register", helpers.WHEEL_FIXTURE])
register_settings = replaced_register.calls[0].args[0]
assert "pypipassword" == register_settings.password
assert "pypiuser" == register_settings.username
assert "__token__" == register_settings.username
assert "/foo/bar.crt" == register_settings.cacert


def test_values_from_env_not_pypi(monkeypatch, write_config_file):
"""Use env vars for settings when run from command line."""
write_config_file(
"""
[distutils]
index-servers =
notpypi
[notpypi]
repository: https://upload.example.org/legacy/
username:someusername
password:password
"""
)

def none_register(*args, **settings_kwargs):
pass

replaced_register = pretend.call_recorder(none_register)
monkeypatch.setattr(register, "register", replaced_register)
testenv = {
"TWINE_REPOSITORY": "notpypi",
"TWINE_USERNAME": "someusername",
"TWINE_PASSWORD": "pypipassword",
"TWINE_CERT": "/foo/bar.crt",
}
with helpers.set_env(**testenv):
cli.dispatch(["register", helpers.WHEEL_FIXTURE])
register_settings = replaced_register.calls[0].args[0]
assert "pypipassword" == register_settings.password
assert "someusername" == register_settings.username
assert "/foo/bar.crt" == register_settings.cacert
38 changes: 34 additions & 4 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,16 @@ def test_settings_takes_no_positional_arguments():
settings.Settings("a", "b", "c")


def test_settings_transforms_repository_config(write_config_file):
"""Set repository config and defaults when .pypirc is provided."""
def test_settings_transforms_repository_config_pypi(write_config_file):
"""Set repository config and defaults when .pypirc is provided.
Ignores the username setting due to PyPI being the index.
"""
config_file = write_config_file(
"""
[pypi]
repository: https://upload.pypi.org/legacy/
username:username
username:this-is-ignored
password:password
"""
)
Expand All @@ -43,7 +46,34 @@ def test_settings_transforms_repository_config(write_config_file):
assert s.sign is False
assert s.sign_with == "gpg"
assert s.identity is None
assert s.username == "username"
assert s.username == "__token__"
assert s.password == "password"
assert s.cacert is None
assert s.client_cert is None
assert s.disable_progress_bar is False


def test_settings_transforms_repository_config_non_pypi(write_config_file):
"""Set repository config and defaults when .pypirc is provided."""
config_file = write_config_file(
"""
[distutils]
index-servers =
notpypi
[notpypi]
repository: https://upload.example.org/legacy/
username:someusername
password:password
"""
)

s = settings.Settings(config_file=config_file, repository_name="notpypi")
assert s.repository_config["repository"] == "https://upload.example.org/legacy/"
assert s.sign is False
assert s.sign_with == "gpg"
assert s.identity is None
assert s.username == "someusername"
assert s.password == "password"
assert s.cacert is None
assert s.client_cert is None
Expand Down
42 changes: 39 additions & 3 deletions tests/test_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,22 +544,58 @@ def test_skip_upload_respects_skip_existing():
)


def test_values_from_env(monkeypatch):
@pytest.mark.parametrize("repo", ["pypi", "testpypi"])
def test_values_from_env_pypi(monkeypatch, repo):
def none_upload(*args, **settings_kwargs):
pass

replaced_upload = pretend.call_recorder(none_upload)
monkeypatch.setattr(upload, "upload", replaced_upload)
testenv = {
"TWINE_USERNAME": "pypiuser",
"TWINE_REPOSITORY": repo,
# Ignored because TWINE_REPOSITORY is PyPI/TestPyPI
"TWINE_USERNAME": "this-is-ignored",
"TWINE_PASSWORD": "pypipassword",
"TWINE_CERT": "/foo/bar.crt",
}
with helpers.set_env(**testenv):
cli.dispatch(["upload", "path/to/file"])
upload_settings = replaced_upload.calls[0].args[0]
assert "pypipassword" == upload_settings.password
assert "pypiuser" == upload_settings.username
assert "__token__" == upload_settings.username
assert "/foo/bar.crt" == upload_settings.cacert


def test_values_from_env_non_pypi(monkeypatch, write_config_file):
write_config_file(
"""
[distutils]
index-servers =
notpypi
[notpypi]
repository: https://upload.example.org/legacy/
username:someusername
password:password
"""
)

def none_upload(*args, **settings_kwargs):
pass

replaced_upload = pretend.call_recorder(none_upload)
monkeypatch.setattr(upload, "upload", replaced_upload)
testenv = {
"TWINE_REPOSITORY": "notpypi",
"TWINE_USERNAME": "someusername",
"TWINE_PASSWORD": "pypipassword",
"TWINE_CERT": "/foo/bar.crt",
}
with helpers.set_env(**testenv):
cli.dispatch(["upload", "path/to/file"])
upload_settings = replaced_upload.calls[0].args[0]
assert "pypipassword" == upload_settings.password
assert "someusername" == upload_settings.username
assert "/foo/bar.crt" == upload_settings.cacert


Expand Down
18 changes: 17 additions & 1 deletion twine/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ def choose(cls, interactive: bool) -> Type["Resolver"]:
@property
@functools.lru_cache()
def username(self) -> Optional[str]:
if cast(str, self.config["repository"]).startswith(
(utils.DEFAULT_REPOSITORY, utils.TEST_REPOSITORY)
):
# As of 2024-01-01, PyPI requires API tokens for uploads, meaning
# that the username is invariant.
return "__token__"

return utils.get_userpass_value(
self.input.username,
self.config,
Expand Down Expand Up @@ -90,7 +97,16 @@ def password_from_keyring_or_prompt(self) -> str:
logger.info("password set from keyring")
return password

return self.prompt("password", getpass.getpass)
# As of 2024-01-01, PyPI requires API tokens for uploads;
# specialize the prompt to clarify that an API token must be provided.
if cast(str, self.config["repository"]).startswith(
(utils.DEFAULT_REPOSITORY, utils.TEST_REPOSITORY)
):
prompt = "API token"
else:
prompt = "password"

return self.prompt(prompt, getpass.getpass)

def prompt(self, what: str, how: Callable[..., str]) -> str:
return how(f"Enter your {what}: ")
Expand Down
3 changes: 0 additions & 3 deletions twine/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,6 @@ def _handle_repository_options(
repository_name,
repository_url,
)
self.repository_config["repository"] = utils.normalize_repository_url(
cast(str, self.repository_config["repository"]),
)

def _handle_certificates(
self, cacert: Optional[str], client_cert: Optional[str]
Expand Down
7 changes: 5 additions & 2 deletions twine/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import os
import os.path
import unicodedata
from typing import Any, Callable, DefaultDict, Dict, Optional, Sequence, Union
from typing import Any, Callable, DefaultDict, Dict, Optional, Sequence, Union, cast
from urllib.parse import urlparse
from urllib.parse import urlunparse

Expand Down Expand Up @@ -133,7 +133,7 @@ def get_repository_from_config(
}

try:
return get_config(config_file)[repository]
config = get_config(config_file)[repository]
except OSError as exc:
raise exceptions.InvalidConfiguration(str(exc))
except KeyError:
Expand All @@ -142,6 +142,9 @@ def get_repository_from_config(
f"More info: https://packaging.python.org/specifications/pypirc/ "
)

config["repository"] = normalize_repository_url(cast(str, config["repository"]))
return config


_HOSTNAMES = {
"pypi.python.org",
Expand Down

0 comments on commit 89ec78c

Please sign in to comment.