Skip to content
Open
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,21 @@ client.start_verification(
)
```

SMS autofill is set with `autofill`: `Autofill.app_hash("<11-char hash>")` frames the
message for the Android SMS Retriever, `Autofill.none()` sends no marker, and leaving
it unset uses the application default. The verification echoes it back as
`verification.sms.autofill`, only when a hash was stored.

```python
from didww_verification import Autofill

SmsOptions(autofill=Autofill.app_hash("abcdefghijk"))
```

`SmsOptions(app_hash=...)` and `SmsInfo.app_hash` still work but are deprecated in favour
of `autofill`. The alias is sent as `autofill`, and passing it together with a different
`autofill` raises `ValueError`.

Languages are BCP 47 tags, tried in order, falling back to `en-US`. **Send the region
subtag.** A bare primary subtag like `pl` passes validation and then silently falls
back, because the catalogue is matched on the exact canonical tag.
Expand Down
1 change: 1 addition & 0 deletions contract/wire_contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"delivery_method_inclusion",
"delivery_method_invalid",
"languages_invalid",
"autofill_invalid",
"app_hash_invalid",
"code_blank",
"destination_not_supported_for_channel",
Expand Down
3 changes: 2 additions & 1 deletion src/didww_verification/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
DidwwVerificationError,
ErrorItem,
)
from .models import CalloutInfo, CalloutOptions, SmsInfo, SmsOptions, Verification
from .models import Autofill, CalloutInfo, CalloutOptions, SmsInfo, SmsOptions, Verification
from .vocabulary import (
API_ERROR_CODES,
DELIVERY_METHODS,
Expand Down Expand Up @@ -65,6 +65,7 @@
"ApplicationAuth",
"AsyncVerificationClient",
"Auth",
"Autofill",
"BasicAuth",
"CalloutInfo",
"CalloutOptions",
Expand Down
8 changes: 5 additions & 3 deletions src/didww_verification/_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
import base64
import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, fields
from dataclasses import asdict, dataclass, fields
from typing import Any, Literal, cast
from urllib.parse import quote

import httpx2

from .auth import Auth, BasicAuth, PublicAuth
from .models import CalloutOptions, SmsOptions
from .models import Autofill, CalloutOptions, SmsOptions
from .phone_number import digits_of
from .signing import sign, string_to_sign

Expand Down Expand Up @@ -64,10 +64,12 @@ def _channel_block(options: SmsOptions | CalloutOptions) -> dict[str, Any]:
block: dict[str, Any] = {}
for field in fields(options):
value: object = getattr(options, field.name)
if value is None:
if value is None or field.name == "app_hash": # deprecated alias, sent as autofill
continue
if isinstance(value, Sequence) and not isinstance(value, str):
value = list(cast("Sequence[object]", value))
if isinstance(value, Autofill):
value = {"type": value.type} if value.value is None else asdict(value)
block[field.name] = value
return block

Expand Down
18 changes: 16 additions & 2 deletions src/didww_verification/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
DidwwValidationError,
ErrorItem,
)
from .models import CalloutInfo, SmsInfo, Verification
from .models import Autofill, CalloutInfo, SmsInfo, Verification

__all__ = [
"HttpOutcome",
Expand Down Expand Up @@ -156,12 +156,26 @@ def _sms(block: object) -> SmsInfo | None:
timeout = fields.get("interception_timeout")
if timeout is not None and not isinstance(timeout, int):
raise DidwwDecodingError(f"sms.interception_timeout is not an integer: {timeout!r}")
autofill = _autofill(fields.get("autofill"))
return SmsInfo(
template=_optional_str(fields.get("template"), "sms.template"),
language=_optional_str(fields.get("language"), "sms.language"),
interception_timeout=timeout,
app_hash=_optional_str(fields.get("app_hash"), "sms.app_hash"),
app_hash=autofill.value if autofill is not None and autofill.type == "app_hash" else None,
code_length=_required_int(fields.get("code_length"), "sms.code_length"),
autofill=autofill,
)


def _autofill(block: object) -> Autofill | None:
if block is None:
return None
if not isinstance(block, dict):
raise DidwwDecodingError(f"sms.autofill is not an object: {block!r}")
fields = cast("dict[str, object]", block)
return Autofill(
type=_required_str(fields.get("type"), "sms.autofill.type"),
value=_optional_str(fields.get("value"), "sms.autofill.value"),
)


Expand Down
52 changes: 49 additions & 3 deletions src/didww_verification/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,33 @@
from datetime import datetime
from decimal import Decimal
from typing import Any
from warnings import warn

from .vocabulary import DeliveryMethod, VerificationErrorCode, VerificationStatus

__all__ = ["CalloutInfo", "CalloutOptions", "SmsInfo", "SmsOptions", "Verification"]
__all__ = ["Autofill", "CalloutInfo", "CalloutOptions", "SmsInfo", "SmsOptions", "Verification"]


@dataclass(frozen=True, slots=True)
class Autofill:
"""How the SMS is framed for the device's autofill.

``type`` is an open string so a type the service adds later needs no new release
to be read back. Build one with :meth:`app_hash` or :meth:`none`.
"""

type: str
value: str | None = None

@classmethod
def app_hash(cls, value: str) -> Autofill:
"""Android SMS Retriever framing for the 11-character app hash ``value``."""
return cls("app_hash", value)

@classmethod
def none(cls) -> Autofill:
"""No marker, regardless of the application default."""
return cls("none")


@dataclass(frozen=True, slots=True)
Expand All @@ -21,10 +44,30 @@ class SmsOptions:
the canonical tag, so a bare ``pl`` passes validation and then silently falls back
-- send ``pl-PL``. Rejecting a region-less tag here would refuse what the service
accepts.

``app_hash`` is deprecated: pass ``autofill=Autofill.app_hash(...)`` instead. It is
folded into ``autofill`` on construction, and a conflicting ``autofill`` raises
``ValueError``.
"""

languages: Sequence[str] | None = None
app_hash: str | None = None
autofill: Autofill | None = None

def __post_init__(self) -> None:
if self.app_hash is None:
return
derived = Autofill.app_hash(self.app_hash)
if self.autofill is None:
warn(
"SmsOptions(app_hash=...) is deprecated; use autofill=Autofill.app_hash(...)",
DeprecationWarning,
stacklevel=3,
)
# Frozen: fold the alias in place. app_hash stays readable but never travels.
object.__setattr__(self, "autofill", derived)
elif self.autofill != derived:
raise ValueError("pass either autofill or the deprecated app_hash, not both")


@dataclass(frozen=True, slots=True)
Expand All @@ -48,10 +91,13 @@ class SmsInfo:
"""Seconds the SMS Retriever stays armed for. Equal to the application's
configured code lifetime (60-600, default 300), not a fixed window."""
app_hash: str | None
"""Echoed back only when a hash was stored. Equality with what you sent is the
only confirmation it was accepted."""
"""Deprecated, use ``autofill``. Holds ``autofill.value`` when ``autofill.type`` is
``app_hash``, else ``None``."""
code_length: int
"""The generated code's length, 4-8."""
autofill: Autofill | None = None
"""Echoed back only when a hash was stored. Equality with what you sent is the
only confirmation it was accepted."""


@dataclass(frozen=True, slots=True)
Expand Down
2 changes: 2 additions & 0 deletions src/didww_verification/vocabulary.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"delivery_method_inclusion",
"delivery_method_invalid",
"languages_invalid",
"autofill_invalid",
"app_hash_invalid",
"code_blank",
"destination_not_supported_for_channel",
Expand Down Expand Up @@ -101,6 +102,7 @@
"delivery_method_inclusion",
"delivery_method_invalid",
"languages_invalid",
"autofill_invalid",
"app_hash_invalid",
"code_blank",
"destination_not_supported_for_channel",
Expand Down
5 changes: 4 additions & 1 deletion tests/test_client_equivalence.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from didww_verification import (
ApplicationAuth,
AsyncVerificationClient,
Autofill,
CalloutOptions,
SmsOptions,
VerificationClient,
Expand All @@ -31,7 +32,9 @@
{
"destination": "+371 123-456-78",
"delivery_method": "sms",
"sms": SmsOptions(languages=["lv-LV", "en-US"], app_hash="abcdefghijk"),
"sms": SmsOptions(
languages=["lv-LV", "en-US"], autofill=Autofill.app_hash("abcdefghijk")
),
},
),
(
Expand Down
42 changes: 37 additions & 5 deletions tests/test_extension_seams.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
from __future__ import annotations

import json
from dataclasses import dataclass, fields
from dataclasses import dataclass, fields, replace
from typing import Any, cast

import httpx2
import pytest

from didww_verification import (
ApplicationAuth,
Autofill,
DidwwDecodingError,
DidwwNotFoundError,
DidwwServerError,
Expand Down Expand Up @@ -278,14 +279,19 @@
"""
for cls, channel in ((SmsOptions, "sms"), (CalloutOptions, "callout")):
values: dict[str, Any] = {
f.name: ["en-US"] if "Sequence" in str(f.type) else "v" for f in fields(cls)
f.name: ["en-US"] if "Sequence" in str(f.type) else "v"
for f in fields(cls)
if f.name != "app_hash" # the one deprecated alias; it travels as autofill
}
if cls is SmsOptions:
values["autofill"] = Autofill.app_hash("abcdefghijk")
populated = cls(**values)
missing = {f.name for f in fields(cls)} - set(self._block(channel, populated))
expected = {f.name for f in fields(cls)} - {"app_hash"}
missing = expected - set(self._block(channel, populated))
assert not missing, f"{cls.__name__} fields dropped before the wire: {sorted(missing)}"

def test_unset_fields_are_omitted_not_nulled(self) -> None:
assert self._block("sms", SmsOptions(app_hash="abc")) == {"app_hash": "abc"}
assert self._block("sms", SmsOptions(languages=["en-US"])) == {"languages": ["en-US"]}

def test_options_with_nothing_set_send_an_empty_block(self) -> None:
assert self._block("sms", SmsOptions()) == {}
Expand All @@ -302,7 +308,33 @@

def test_a_string_is_never_exploded_into_characters(self) -> None:
"""str is a Sequence. Coercing it would send ["a","b","c"]."""
assert self._block("sms", SmsOptions(app_hash="abc"))["app_hash"] == "abc"
assert self._block("sms", SmsOptions(languages="abc"))["languages"] == "abc"

def test_autofill_is_a_nested_object(self) -> None:
block = self._block("sms", SmsOptions(autofill=Autofill.app_hash("abcdefghijk")))
assert block == {"autofill": {"type": "app_hash", "value": "abcdefghijk"}}

def test_autofill_none_carries_no_value_key(self) -> None:
block = self._block("sms", SmsOptions(autofill=Autofill.none()))
assert block == {"autofill": {"type": "none"}}

def test_the_deprecated_app_hash_maps_onto_autofill(self) -> None:
with pytest.warns(DeprecationWarning, match="autofill") as record:
options = SmsOptions(app_hash="abcdefghijk")
assert record[0].filename == __file__
assert self._block("sms", options) == {
"autofill": {"type": "app_hash", "value": "abcdefghijk"}
}

def test_app_hash_reads_back_as_passed_and_survives_replace(self) -> None:
with pytest.warns(DeprecationWarning):
options = SmsOptions(app_hash="abcdefghijk")
assert options.app_hash == "abcdefghijk"
assert replace(options, languages=["en-US"]).autofill == Autofill.app_hash("abcdefghijk")

def test_app_hash_and_autofill_together_are_refused(self) -> None:
with pytest.raises(ValueError, match="autofill"):

Check warning on line 336 in tests/test_extension_seams.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=didww_didww-verification-python-sdk&issues=AaDy907DlCsCN8b4FPPI&open=AaDy907DlCsCN8b4FPPI&pullRequest=4
SmsOptions(app_hash="abcdefghijk", autofill=Autofill.none())

def test_both_blocks_may_travel_together(self) -> None:
spec = build_start(
Expand Down
26 changes: 26 additions & 0 deletions tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import pytest

from didww_verification import (
Autofill,
DidwwApiError,
DidwwBalanceInsufficientError,
DidwwDecodingError,
Expand Down Expand Up @@ -36,6 +37,31 @@
assert v.callout is None
assert not v.is_finished

def test_sms_autofill_is_decoded_and_app_hash_derived(self) -> None:
sms = {
**verification_payload()["sms"],
"autofill": {"type": "app_hash", "value": "abcdefghijk"},
}
v = decode_verification(ok(sms=sms))
assert v.sms is not None
assert v.sms.autofill == Autofill.app_hash("abcdefghijk")
assert v.sms.app_hash == "abcdefghijk"

def test_sms_autofill_is_absent_when_no_hash_was_stored(self) -> None:
v = decode_verification(ok())
assert v.sms is not None
assert v.sms.autofill is None
assert v.sms.app_hash is None

@pytest.mark.parametrize(
"autofill",
["app_hash", {"value": "x"}, {"type": 1}, {"type": "app_hash", "value": 5}],
)
def test_sms_autofill_rejects_a_malformed_shape(self, autofill: object) -> None:
sms = {**verification_payload()["sms"], "autofill": autofill}
with pytest.raises(DidwwDecodingError):

Check warning on line 62 in tests/test_responses.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=didww_didww-verification-python-sdk&issues=AaDy905zlCsCN8b4FPPH&open=AaDy905zlCsCN8b4FPPH&pullRequest=4
decode_verification(ok(sms=sms))

def test_fee_is_a_decimal_not_a_float(self) -> None:
"""The wire sends a decimal string; money must not round."""
assert decode_verification(ok(fee="0.1")).fee == Decimal("0.1")
Expand Down
6 changes: 3 additions & 3 deletions tests/test_vocabulary.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ def test_literal_members_equal_tuple_members(self, alias: object, tup: tuple[str


class TestShape:
def test_the_registry_has_28_slugs(self) -> None:
"""19 envelope plus 9 outcome, as the service defines them."""
assert len(v.API_ERROR_CODES) == 28
def test_the_registry_has_29_slugs(self) -> None:
"""20 envelope plus 9 outcome, as the service defines them."""
assert len(v.API_ERROR_CODES) == 29
assert len(v.VERIFICATION_ERROR_CODES) == 9

def test_every_outcome_code_is_in_the_combined_set(self) -> None:
Expand Down
Loading