Skip to content

Commit

Permalink
Password: Merge host_field into host and username_field into `u…
Browse files Browse the repository at this point in the history
…sername`
  • Loading branch information
jwodder committed Mar 6, 2021
1 parent f18f566 commit b00b4d3
Show file tree
Hide file tree
Showing 2 changed files with 51 additions and 83 deletions.
87 changes: 34 additions & 53 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, cast
from typing import Any, ClassVar, Dict, Optional, TYPE_CHECKING, Tuple, Union
import pydantic
from . import core
from .util import AnyPath, resolve_path
Expand Down Expand Up @@ -47,56 +47,35 @@ def __get_validators__(cls) -> "CallableGenerator":
yield from super().__get_validators__()


class PasswordMeta(type):
def __new__(
metacls,
name: str,
bases: Tuple[type, ...],
namespace: Dict[str, Any],
) -> "PasswordMeta":
if (
namespace.get("host") is not None
and namespace.get("host_field") is not None
):
raise RuntimeError("host and host_field are mutually exclusive")
if (
namespace.get("username") is not None
and namespace.get("username_field") is not None
):
raise RuntimeError("username and username_field are mutually exclusive")
return cast(PasswordMeta, super().__new__(metacls, name, bases, namespace))


# We have to implement Password configuration via explicit subclassing as using
# a function instead à la pydantic's conlist() leads to mypy errors; cf.
# <https://github.com/samuelcolvin/pydantic/issues/975>
class Password(pydantic.SecretStr, metaclass=PasswordMeta):
class Password(pydantic.SecretStr):
"""
A subclass of `pydantic.SecretStr` that accepts ``outgoing`` password
specifiers as input and automatically resolves them using
`resolve_password()`. Host, username, and ``configpath`` values are passed
to `resolve_password()` as follows:
- If `Password` is subclassed and given a ``host_field`` class variable
naming a field, and if the subclass is then used in a model where a field
with that name is declared before the `Password` subclass field, then
when the model is instantiated, the value of the named field will be
passed as the ``host`` argument to `resolve_password()`.
- As an alternative to defining ``host_field``, a ``host`` class callable
(a classmethod or staticmethod) can be defined on a subclass of
`Password`, and when that subclass is used in a model being instantiated,
the callable will be passed a `dict` of all validated fields declared
before the password field; the return value from the callable will then
be passed as the ``host`` argument to `resolve_password()`.
- If `Password` is subclassed and given a ``host`` class variable naming a
field, and if the subclass is then used in a model where a field with
that name is declared before the `Password` subclass field, then when the
model is instantiated, the value of the named field will be passed as the
``host`` argument to `resolve_password()`.
- Alternatively, `Password` can be subclassed with ``host`` set to a class
callable (a classmethod or staticmethod), and when that subclass is used
in a model being instantiated, the callable will be passed a `dict` of
all validated fields declared before the password field; the return value
from the callable will then be passed as the ``host`` argument to
`resolve_password()`.
- If `Password` is used in a model without being subclassed, or if neither
``host_field`` nor ``host`` is defined in a subclass, then `None` will be
passed as the ``host`` argument to `resolve_password()`.
- If `Password` is used in a model without being subclassed, or if ``host``
is not defined in a subclass, then `None` will be passed as the ``host``
argument to `resolve_password()`.
- The ``username`` argument to `resolve_password()` can likewise be defined
by subclassing `Password` and defining ``username_field`` or ``username``
appropriately.
by subclassing `Password` and defining ``username`` appropriately.
- If there is a field named ``configpath`` declared before the `Password`
field, then the value of ``configpath`` is passed to
Expand All @@ -110,10 +89,10 @@ class Password(pydantic.SecretStr, metaclass=PasswordMeta):
.. code:: python
class MyPassword(outgoing.Password):
host_field = "service"
host = "service"
@staticmethod
def username(values):
def username(values: Dict[str, Any]) -> str:
return "__token__"
Expand Down Expand Up @@ -149,9 +128,7 @@ class MySender(pydantic.BaseModel):
"""

host: ClassVar[Any] = None
host_field: ClassVar[Optional[str]] = None
username: ClassVar[Any] = None
username_field: ClassVar[Optional[str]] = None

@classmethod
def __get_validators__(cls) -> "CallableGenerator":
Expand All @@ -160,18 +137,22 @@ def __get_validators__(cls) -> "CallableGenerator":

@classmethod
def _resolve(cls, v: Any, values: Dict[str, Any]) -> str:
if cls.host_field is not None:
host = values.get(cls.host_field)
if isinstance(cls.host, str):
host = values.get(cls.host)
elif callable(cls.host):
host = cls.host(values)
elif cls.host is None:
host = None
else:
host = cls.host
if cls.username_field is not None:
username = values.get(cls.username_field)
raise RuntimeError("Password.host must be a str, callable, or None")
if isinstance(cls.username, str):
username = values.get(cls.username)
elif callable(cls.username):
username = cls.username(values)
elif cls.username is None:
username = None
else:
username = cls.username
raise RuntimeError("Password.username must be a str, callable, or None")
return core.resolve_password(
v,
host=host,
Expand All @@ -186,12 +167,12 @@ def path_resolve(v: AnyPath, values: Dict[str, Any]) -> pathlib.Path:

class StandardPassword(Password):
"""
A subclass of `Password` in which ``host_field`` is set to ``"host"`` and
``username_field`` is set to ``"username"``.
A subclass of `Password` in which ``host`` is set to ``"host"`` and
``username`` is set to ``"username"``
"""

host_field = "host"
username_field = "username"
host = "host"
username = "username"


class NetrcConfig(pydantic.BaseModel):
Expand Down
47 changes: 17 additions & 30 deletions test/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
from pydantic import BaseModel, SecretStr, ValidationError
import pytest
from pytest_mock import MockerFixture
from outgoing.config import DirectoryPath, FilePath, NetrcConfig, Password, Path
from outgoing.config import (
DirectoryPath,
FilePath,
NetrcConfig,
Password,
Path,
StandardPassword,
)


class Paths(BaseModel):
Expand Down Expand Up @@ -125,19 +132,14 @@ def test_path_resolve_absolute_configpath(
assert obj.dirpath == tmp_path / "foo"


class Password01(Password):
host_field = "host"
username_field = "username"


class Config01(BaseModel):
configpath: pathlib.Path
host: str
username: str
password: Password01
password: StandardPassword


def test_password_fetch_fields(mocker: MockerFixture) -> None:
def test_standard_password(mocker: MockerFixture) -> None:
m = mocker.patch("outgoing.core.resolve_password", return_value="12345")
cfg = Config01(
configpath="foo/bar",
Expand All @@ -158,8 +160,13 @@ def test_password_fetch_fields(mocker: MockerFixture) -> None:


class Password02(Password):
host = "api.example.com"
username = "mylogin"
@classmethod
def host(cls, values: Dict[str, Any]) -> str:
return "api.example.com"

@classmethod
def username(cls, values: Dict[str, Any]) -> str:
return "mylogin"


class Config02(BaseModel):
Expand Down Expand Up @@ -226,26 +233,6 @@ def test_password_callable_fields(mocker: MockerFixture) -> None:
)


def test_password_host_and_host_field() -> None:
with pytest.raises(RuntimeError) as excinfo:
type(
"PasswordTest",
(Password,),
{"host": "api.example.com", "host_field": "host"},
)
assert str(excinfo.value) == "host and host_field are mutually exclusive"


def test_password_username_and_username_field() -> None:
with pytest.raises(RuntimeError) as excinfo:
type(
"PasswordTest",
(Password,),
{"username": "me", "username_field": "username"},
)
assert str(excinfo.value) == "username and username_field are mutually exclusive"


def test_netrc_config(mocker: MockerFixture) -> None:
m = mocker.patch(
"outgoing.core.lookup_netrc",
Expand Down

0 comments on commit b00b4d3

Please sign in to comment.