Skip to content

Commit

Permalink
NetrcConfig now fully resolves & stores the username & password on co…
Browse files Browse the repository at this point in the history
…nstruction
  • Loading branch information
jwodder committed Mar 6, 2021
1 parent e347318 commit af35c46
Show file tree
Hide file tree
Showing 4 changed files with 78 additions and 82 deletions.
91 changes: 33 additions & 58 deletions src/outgoing/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pathlib
from typing import Any, ClassVar, Dict, Optional, TYPE_CHECKING, Tuple, Union
from typing import Any, ClassVar, Dict, Optional, TYPE_CHECKING, Union
import pydantic
from . import core
from .util import AnyPath, resolve_path
Expand Down Expand Up @@ -180,15 +180,22 @@ class NetrcConfig(pydantic.BaseModel):
"""
A pydantic model usable as a base class for any senders that wish to
support both ``password`` fields and netrc files. The model accepts the
fields ``configpath``, ``netrc`` (a boolean or file path; defaults to
fields ``configpath``, ``netrc`` (a boolean or a file path; defaults to
`False`), ``host`` (required), ``username`` (optional), and ``password``
(optional).
The model's validators will raise an error if ``password`` is set while
``netrc`` is true, or if ``password`` is set but ``username`` is not set.
The username & password are retrieved from an instance of this class by
calling the `get_username_password()` method.
(optional). When the model is instantiated, if ``password`` is `None` but
``netrc`` is true or a filepath, the entry for ``host`` is looked up in
:file:`~/.netrc` or the given file, and the ``username`` and ``password``
fields are set to the values found.
The model will raise a validation error if any of the following are true:
- ``password`` is set but ``netrc`` is true
- ``password`` is set but ``username`` is not set
- ``username`` is set but ``password`` is not set and ``netrc`` is false
- ``netrc`` is true or a filepath, ``username`` is non-`None`, and the
username in the netrc file differs from ``username``
- ``netrc`` is true or a filepath and no entry can be found in the netrc
file
"""

configpath: Optional[Path]
Expand All @@ -198,55 +205,23 @@ class NetrcConfig(pydantic.BaseModel):
password: Optional[StandardPassword]

@pydantic.root_validator(skip_on_failure=True)
def _forbid_netrc_if_password(
cls, # noqa: B902
values: Dict[str, Any],
) -> Dict[str, Any]:
if values["password"] is not None and values["netrc"]:
raise ValueError("netrc cannot be set when a password is present")
return values

@pydantic.root_validator(skip_on_failure=True)
def _require_username_if_password(
cls, # noqa: B902
values: Dict[str, Any],
) -> Dict[str, Any]:
if values["password"] is not None and values["username"] is None:
raise ValueError("Password cannot be given without username")
return values

def get_username_password(self) -> Optional[Tuple[str, str]]:
"""
Retrieve the username & password according to the instance's field
values.
- If ``netrc`` is false and both ``username`` and ``password`` are
non-`None`, a ``(username, password)`` pair is returned.
- If ``netrc`` is false and ``password`` is `None`, return `None`.
- If ``netrc`` is true or a filepath, look up the entry for ``host`` in
:file:`~/.netrc` or the given file and return a ``(username,
password)`` pair.
- If ``username`` is also non-`None`, raise an error if the username
in the netrc file differs.
:raises NetrcLookupError:
if no entry for ``host`` or the default entry is present in the
netrc file; or if ``username`` differs from the username in the
netrc file
"""

if self.password is not None:
assert self.username is not None, "Password is set but username is not"
return (self.username, self.password.get_secret_value())
elif self.netrc:
def _validate(cls, values: Dict[str, Any]) -> Dict[str, Any]: # noqa: B902
if values["password"] is not None:
if values["netrc"]:
raise ValueError("netrc cannot be set when a password is present")
elif values["username"] is None:
raise ValueError("Password cannot be given without username")
elif values["netrc"]:
path: Optional[Path]
if isinstance(self.netrc, bool):
if isinstance(values["netrc"], bool):
path = None
else:
path = self.netrc
return core.lookup_netrc(self.host, username=self.username, path=path)
else:
return None
path = values["netrc"]
username, password = core.lookup_netrc(
values["host"], username=values["username"], path=path
)
values["username"] = username
values["password"] = StandardPassword(password)
elif values["username"] is not None:
raise ValueError("Username cannot be given without netrc or password")
return values
6 changes: 3 additions & 3 deletions src/outgoing/senders/smtp.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ def open(self) -> None:
self._client = smtplib.SMTP(self.host, self.port)
if self.ssl == STARTTLS:
self._client.starttls()
auth = self.get_username_password()
if auth is not None:
self._client.login(auth[0], auth[1])
if self.username is not None:
assert self.password is not None
self._client.login(self.username, self.password.get_secret_value())

def close(self) -> None:
if self._client is None:
Expand Down
56 changes: 40 additions & 16 deletions test/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,20 @@ def test_password_callable_fields(mocker: MockerFixture) -> None:
def test_netrc_config(mocker: MockerFixture) -> None:
m = mocker.patch(
"outgoing.core.lookup_netrc",
return_value=(sentinel.USERNAME, sentinel.PASSWORD),
return_value=(sentinel.USERNAME, "hunter2"),
)
cfg = NetrcConfig(
netrc=True,
host="api.example.com",
username="myname",
)
assert cfg.get_username_password() == (sentinel.USERNAME, sentinel.PASSWORD)
assert cfg.dict() == {
"configpath": None,
"netrc": True,
"host": "api.example.com",
"username": sentinel.USERNAME,
"password": SecretStr("hunter2"),
}
m.assert_called_once_with("api.example.com", username="myname", path=None)


Expand All @@ -256,7 +262,7 @@ def test_netrc_config_path(
) -> None:
m = mocker.patch(
"outgoing.core.lookup_netrc",
return_value=(sentinel.USERNAME, sentinel.PASSWORD),
return_value=(sentinel.USERNAME, "hunter2"),
)
(tmp_path / "foo.txt").touch()
monkeypatch.chdir(tmp_path)
Expand All @@ -265,7 +271,13 @@ def test_netrc_config_path(
host="api.example.com",
username=username,
)
assert cfg.get_username_password() == (sentinel.USERNAME, sentinel.PASSWORD)
assert cfg.dict() == {
"configpath": None,
"netrc": tmp_path / "foo.txt",
"host": "api.example.com",
"username": sentinel.USERNAME,
"password": SecretStr("hunter2"),
}
m.assert_called_once_with(
"api.example.com", username=username, path=tmp_path / "foo.txt"
)
Expand All @@ -277,15 +289,21 @@ def test_netrc_config_path_expanduser(
) -> None:
m = mocker.patch(
"outgoing.core.lookup_netrc",
return_value=(sentinel.USERNAME, sentinel.PASSWORD),
return_value=(sentinel.USERNAME, "hunter2"),
)
(tmp_home / "foo.txt").touch()
cfg = NetrcConfig(
netrc="~/foo.txt",
host="api.example.com",
username=username,
)
assert cfg.get_username_password() == (sentinel.USERNAME, sentinel.PASSWORD)
assert cfg.dict() == {
"configpath": None,
"netrc": tmp_home / "foo.txt",
"host": "api.example.com",
"username": sentinel.USERNAME,
"password": SecretStr("hunter2"),
}
m.assert_called_once_with(
"api.example.com", username=username, path=tmp_home / "foo.txt"
)
Expand All @@ -297,7 +315,7 @@ def test_netrc_config_path_configpath(
) -> None:
m = mocker.patch(
"outgoing.core.lookup_netrc",
return_value=(sentinel.USERNAME, sentinel.PASSWORD),
return_value=(sentinel.USERNAME, "hunter2"),
)
(tmp_path / "foo.txt").touch()
cfg = NetrcConfig(
Expand All @@ -306,7 +324,13 @@ def test_netrc_config_path_configpath(
host="api.example.com",
username=username,
)
assert cfg.get_username_password() == (sentinel.USERNAME, sentinel.PASSWORD)
assert cfg.dict() == {
"configpath": tmp_path / "quux.txt",
"netrc": tmp_path / "foo.txt",
"host": "api.example.com",
"username": sentinel.USERNAME,
"password": SecretStr("hunter2"),
}
m.assert_called_once_with(
"api.example.com", username=username, path=tmp_path / "foo.txt"
)
Expand All @@ -318,7 +342,7 @@ def test_netrc_config_no_such_path(
) -> None:
m = mocker.patch(
"outgoing.core.lookup_netrc",
return_value=(sentinel.USERNAME, sentinel.PASSWORD),
return_value=(sentinel.USERNAME, "hunter2"),
)
with pytest.raises(ValidationError):
NetrcConfig(
Expand All @@ -332,7 +356,7 @@ def test_netrc_config_no_such_path(
def test_netrc_config_password_no_username(mocker: MockerFixture) -> None:
m = mocker.patch(
"outgoing.core.lookup_netrc",
return_value=(sentinel.USERNAME, sentinel.PASSWORD),
return_value=(sentinel.USERNAME, "hunter2"),
)
with pytest.raises(ValidationError) as excinfo:
NetrcConfig(
Expand All @@ -354,7 +378,7 @@ def test_netrc_config_password_netrc(
) -> None:
m = mocker.patch(
"outgoing.core.lookup_netrc",
return_value=(sentinel.USERNAME, sentinel.PASSWORD),
return_value=(sentinel.USERNAME, "hunter2"),
)
(tmp_path / "foo.txt").touch()
monkeypatch.chdir(tmp_path)
Expand All @@ -373,7 +397,7 @@ def test_netrc_config_no_netrc(mocker: MockerFixture) -> None:
m1 = mocker.patch("outgoing.core.resolve_password", return_value="12345")
m2 = mocker.patch(
"outgoing.core.lookup_netrc",
return_value=(sentinel.USERNAME, sentinel.PASSWORD),
return_value=(sentinel.USERNAME, "hunter2"),
)
cfg = NetrcConfig(
configpath="foo/bar",
Expand All @@ -382,8 +406,8 @@ def test_netrc_config_no_netrc(mocker: MockerFixture) -> None:
username="myname",
password=sentinel.PASSWORD,
)
assert cfg.username == "myname"
assert cfg.password == SecretStr("12345")
assert cfg.get_username_password() == ("myname", "12345")
m1.assert_called_once_with(
sentinel.PASSWORD,
host="api.example.com",
Expand All @@ -405,8 +429,8 @@ def test_netrc_config_no_netrc_key(mocker: MockerFixture) -> None:
username="myname",
password=sentinel.PASSWORD,
)
assert cfg.username == "myname"
assert cfg.password == SecretStr("12345")
assert cfg.get_username_password() == ("myname", "12345")
m1.assert_called_once_with(
sentinel.PASSWORD,
host="api.example.com",
Expand All @@ -428,8 +452,8 @@ def test_netrc_config_nothing(mocker: MockerFixture) -> None:
username=None,
password=None,
)
assert cfg.username is None
assert cfg.password is None
assert cfg.get_username_password() is None
m.assert_not_called()


Expand All @@ -442,6 +466,6 @@ def test_netrc_config_nothing_no_keys(mocker: MockerFixture) -> None:
configpath="foo/bar",
host="api.example.com",
)
assert cfg.username is None
assert cfg.password is None
assert cfg.get_username_password() is None
m.assert_not_called()
7 changes: 2 additions & 5 deletions test/test_senders/test_smtp.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ def test_smtp_construct_no_ssl(monkeypatch: pytest.MonkeyPatch, tmp_path: Path)
"netrc": False,
}
assert sender._client is None
assert sender.get_username_password() == ("me", "hunter2")


def test_smtp_construct_ssl(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
Expand All @@ -101,7 +100,6 @@ def test_smtp_construct_ssl(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) ->
"netrc": False,
}
assert sender._client is None
assert sender.get_username_password() == ("me", "12345")


def test_smtp_construct_starttls(
Expand All @@ -123,14 +121,13 @@ def test_smtp_construct_starttls(
assert sender.dict() == {
"configpath": tmp_path / "foo.txt",
"host": "mx.example.com",
"username": None,
"password": None,
"username": "me",
"password": SecretStr("secret"),
"port": 587,
"ssl": "starttls",
"netrc": tmp_path / "net.rc",
}
assert sender._client is None
assert sender.get_username_password() == ("me", "secret")


@pytest.mark.parametrize("ssl", [False, True, "starttls"])
Expand Down

0 comments on commit af35c46

Please sign in to comment.