Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/repositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,17 @@ You can prevent this by adding double dashes to prevent any following argument f
poetry config -- http-basic.pypi myUsername -myPasswordStartingWithDash
```

{{% note %}}
In some cases like that of [Gemfury](https://gemfury.com/help/errors/repo-url-password/) repositories, it might be
required to set an empty password. This is supported by Poetry.

```bash
poetry config http-basic.foo <TOKEN> ""
```

**Note:** Usernames cannot be empty. Attempting to use an empty username can result in an unpredictable failure.
{{% /note %}}

## Certificates

### Custom certificate authority and mutual TLS authentication
Expand Down
4 changes: 1 addition & 3 deletions src/poetry/utils/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,7 @@ def get_http_credentials(
self, password_manager: PasswordManager
) -> HTTPAuthCredential:
# try with the repository name via the password manager
credential = HTTPAuthCredential(
**(password_manager.get_http_auth(self.name) or {})
)
credential = password_manager.get_http_auth(self.name)

if credential.password is not None:
return credential
Expand Down
30 changes: 11 additions & 19 deletions src/poetry/utils/password_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,22 +192,17 @@ def delete_pypi_token(self, repo_name: str) -> None:

self.keyring.delete_password(repo_name, "__token__")

def get_http_auth(self, repo_name: str) -> dict[str, str | None] | None:
def get_http_auth(self, repo_name: str) -> HTTPAuthCredential:
username = self._config.get(f"http-basic.{repo_name}.username")
password = self._config.get(f"http-basic.{repo_name}.password")
if not username and not password:
return None

if not password:
if self.use_keyring:
password = self.keyring.get_password(repo_name, username)
else:
return None
if not username:
return HTTPAuthCredential()

if password is None and self.use_keyring:
password = self.keyring.get_password(repo_name, username)

return {
"username": username,
"password": password,
}
return HTTPAuthCredential(username=username, password=password)

def set_http_password(self, repo_name: str, username: str, password: str) -> None:
auth = {"username": username}
Expand All @@ -222,15 +217,12 @@ def set_http_password(self, repo_name: str, username: str, password: str) -> Non

def delete_http_password(self, repo_name: str) -> None:
auth = self.get_http_auth(repo_name)
if not auth:
return

username = auth.get("username")
if username is None:
if auth.username is None:
return

with suppress(PoetryKeyringError):
self.keyring.delete_password(repo_name, username)
self.keyring.delete_password(repo_name, auth.username)

self._config.auth_config_source.remove_property(f"http-basic.{repo_name}")

Expand All @@ -239,5 +231,5 @@ def get_credential(
) -> HTTPAuthCredential:
if self.use_keyring:
return self.keyring.get_credential(*names, username=username)
else:
return HTTPAuthCredential(username=username, password=None)

return HTTPAuthCredential(username=username, password=None)
5 changes: 2 additions & 3 deletions tests/utils/test_authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ def test_authenticator_uses_empty_strings_as_default_password(
assert request.headers["Authorization"] == f"Basic {basic_auth}"


def test_authenticator_uses_empty_strings_as_default_username(
def test_authenticator_ignores_empty_strings_as_default_username(
config: Config,
mock_remote: None,
repo: dict[str, dict[str, str]],
Expand All @@ -170,8 +170,7 @@ def test_authenticator_uses_empty_strings_as_default_username(
authenticator.request("get", "https://foo.bar/files/foo-0.1.0.tar.gz")

request = http.last_request()
basic_auth = base64.b64encode(b":bar").decode()
assert request.headers["Authorization"] == f"Basic {basic_auth}"
assert request.headers["Authorization"] is None


def test_authenticator_falls_back_to_keyring_url(
Expand Down
62 changes: 48 additions & 14 deletions tests/utils/test_password_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import pytest

from poetry.utils.password_manager import HTTPAuthCredential
from poetry.utils.password_manager import PasswordManager
from poetry.utils.password_manager import PoetryKeyring
from poetry.utils.password_manager import PoetryKeyringError
Expand Down Expand Up @@ -36,19 +37,36 @@ def test_set_http_password(
assert "password" not in auth


@pytest.mark.parametrize(
("username", "password", "is_valid"),
[
("bar", "baz", True),
("", "baz", False),
("bar", "", True),
("", "", False),
],
)
def test_get_http_auth(
config: Config, with_simple_keyring: None, dummy_keyring: DummyBackend
username: str,
password: str,
is_valid: bool,
config: Config,
with_simple_keyring: None,
dummy_keyring: DummyBackend,
) -> None:
dummy_keyring.set_password("poetry-repository-foo", "bar", "baz")
config.auth_config_source.add_property("http-basic.foo", {"username": "bar"})
dummy_keyring.set_password("poetry-repository-foo", username, password)
config.auth_config_source.add_property("http-basic.foo", {"username": username})
manager = PasswordManager(config)

assert PoetryKeyring.is_available()
auth = manager.get_http_auth("foo")
assert auth is not None

assert auth["username"] == "bar"
assert auth["password"] == "baz"
if is_valid:
assert auth is not None
assert auth.username == username
assert auth.password == password
else:
assert auth.username is auth.password is None


def test_delete_http_password(
Expand Down Expand Up @@ -113,20 +131,36 @@ def test_set_http_password_with_unavailable_backend(
assert auth["password"] == "baz"


@pytest.mark.parametrize(
("username", "password", "is_valid"),
[
("bar", "baz", True),
("", "baz", False),
("bar", "", True),
("", "", False),
],
)
def test_get_http_auth_with_unavailable_backend(
config: Config, with_fail_keyring: None
username: str,
password: str,
is_valid: bool,
config: Config,
with_fail_keyring: None,
) -> None:
config.auth_config_source.add_property(
"http-basic.foo", {"username": "bar", "password": "baz"}
"http-basic.foo", {"username": username, "password": password}
)
manager = PasswordManager(config)

assert not PoetryKeyring.is_available()
auth = manager.get_http_auth("foo")
assert auth is not None

assert auth["username"] == "bar"
assert auth["password"] == "baz"
if is_valid:
assert auth is not None
assert auth.username == username
assert auth.password == password
else:
assert auth.username is auth.password is None


def test_delete_http_password_with_unavailable_backend(
Expand Down Expand Up @@ -271,7 +305,7 @@ def test_get_http_auth_from_environment_variables(
manager = PasswordManager(config)

auth = manager.get_http_auth("foo")
assert auth == {"username": "bar", "password": "baz"}
assert auth == HTTPAuthCredential(username="bar", password="baz")


def test_get_http_auth_does_not_call_keyring_when_credentials_in_environment_variables(
Expand All @@ -284,7 +318,7 @@ def test_get_http_auth_does_not_call_keyring_when_credentials_in_environment_var
manager.keyring = MagicMock()

auth = manager.get_http_auth("foo")
assert auth == {"username": "bar", "password": "baz"}
assert auth == HTTPAuthCredential(username="bar", password="baz")
manager.keyring.get_password.assert_not_called()


Expand All @@ -302,7 +336,7 @@ def test_get_http_auth_does_not_call_keyring_when_password_in_environment_variab
manager.keyring = MagicMock()

auth = manager.get_http_auth("foo")
assert auth == {"username": "bar", "password": "baz"}
assert auth == HTTPAuthCredential(username="bar", password="baz")
manager.keyring.get_password.assert_not_called()


Expand Down