Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

twine: use API tokens by default on PyPI #1040

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion tests/test_auth.py
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
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
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
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
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
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
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