From da195afc690f4f631c4d8da290371edd05a88d53 Mon Sep 17 00:00:00 2001 From: Kirill Paromonov Date: Wed, 30 Sep 2026 18:36:30 +0300 Subject: [PATCH 1/2] Send the SMS Retriever hash as sms.autofill For 1.2.0 (unreleased). - Autofill.app_hash(value) / Autofill.none() and SmsOptions.autofill; the deprecated SmsOptions.app_hash maps onto it with a DeprecationWarning and never reaches the wire. - SmsInfo.autofill decodes the echoed marker; SmsInfo.app_hash is derived from it. - autofill_invalid is a known error code. --- README.md | 14 ++++++++ contract/wire_contract.json | 1 + src/didww_verification/__init__.py | 3 +- src/didww_verification/_requests.py | 8 +++-- src/didww_verification/_responses.py | 18 ++++++++-- src/didww_verification/models.py | 52 ++++++++++++++++++++++++++-- src/didww_verification/vocabulary.py | 2 ++ tests/test_client_equivalence.py | 5 ++- tests/test_extension_seams.py | 42 +++++++++++++++++++--- tests/test_responses.py | 26 ++++++++++++++ tests/test_vocabulary.py | 6 ++-- 11 files changed, 159 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 1e9b76c..33c5e76 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,20 @@ 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; the +service no longer reads or returns the `app_hash` field. + 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. diff --git a/contract/wire_contract.json b/contract/wire_contract.json index 4813174..6b8c3bd 100644 --- a/contract/wire_contract.json +++ b/contract/wire_contract.json @@ -24,6 +24,7 @@ "delivery_method_inclusion", "delivery_method_invalid", "languages_invalid", + "autofill_invalid", "app_hash_invalid", "code_blank", "destination_not_supported_for_channel", diff --git a/src/didww_verification/__init__.py b/src/didww_verification/__init__.py index 0ca5ebe..ba71e85 100644 --- a/src/didww_verification/__init__.py +++ b/src/didww_verification/__init__.py @@ -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, @@ -65,6 +65,7 @@ "ApplicationAuth", "AsyncVerificationClient", "Auth", + "Autofill", "BasicAuth", "CalloutInfo", "CalloutOptions", diff --git a/src/didww_verification/_requests.py b/src/didww_verification/_requests.py index 57b70cd..15047de 100644 --- a/src/didww_verification/_requests.py +++ b/src/didww_verification/_requests.py @@ -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 @@ -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 diff --git a/src/didww_verification/_responses.py b/src/didww_verification/_responses.py index 80a0f58..85d2452 100644 --- a/src/didww_verification/_responses.py +++ b/src/didww_verification/_responses.py @@ -23,7 +23,7 @@ DidwwValidationError, ErrorItem, ) -from .models import CalloutInfo, SmsInfo, Verification +from .models import Autofill, CalloutInfo, SmsInfo, Verification __all__ = [ "HttpOutcome", @@ -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"), ) diff --git a/src/didww_verification/models.py b/src/didww_verification/models.py index 25f7354..bff4d56 100644 --- a/src/didww_verification/models.py +++ b/src/didww_verification/models.py @@ -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) @@ -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) @@ -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: ``autofill.value`` when ``autofill.type`` is ``app_hash``, else + ``None``. The service no longer returns this field itself.""" 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) diff --git a/src/didww_verification/vocabulary.py b/src/didww_verification/vocabulary.py index 5b7d1c7..cfaf6bf 100644 --- a/src/didww_verification/vocabulary.py +++ b/src/didww_verification/vocabulary.py @@ -65,6 +65,7 @@ "delivery_method_inclusion", "delivery_method_invalid", "languages_invalid", + "autofill_invalid", "app_hash_invalid", "code_blank", "destination_not_supported_for_channel", @@ -101,6 +102,7 @@ "delivery_method_inclusion", "delivery_method_invalid", "languages_invalid", + "autofill_invalid", "app_hash_invalid", "code_blank", "destination_not_supported_for_channel", diff --git a/tests/test_client_equivalence.py b/tests/test_client_equivalence.py index a240c18..51e54ff 100644 --- a/tests/test_client_equivalence.py +++ b/tests/test_client_equivalence.py @@ -18,6 +18,7 @@ from didww_verification import ( ApplicationAuth, AsyncVerificationClient, + Autofill, CalloutOptions, SmsOptions, VerificationClient, @@ -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") + ), }, ), ( diff --git a/tests/test_extension_seams.py b/tests/test_extension_seams.py index 57ac5fb..eb79dcd 100644 --- a/tests/test_extension_seams.py +++ b/tests/test_extension_seams.py @@ -7,7 +7,7 @@ from __future__ import annotations import json -from dataclasses import dataclass, fields +from dataclasses import dataclass, fields, replace from typing import Any, cast import httpx2 @@ -15,6 +15,7 @@ from didww_verification import ( ApplicationAuth, + Autofill, DidwwDecodingError, DidwwNotFoundError, DidwwServerError, @@ -278,14 +279,19 @@ def test_every_declared_field_reaches_the_wire(self) -> None: """ 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()) == {} @@ -302,7 +308,33 @@ def test_a_sequence_that_is_not_a_list_is_coerced(self) -> None: 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"): + SmsOptions(app_hash="abcdefghijk", autofill=Autofill.none()) def test_both_blocks_may_travel_together(self) -> None: spec = build_start( diff --git a/tests/test_responses.py b/tests/test_responses.py index a29bfb2..789ebc5 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -8,6 +8,7 @@ import pytest from didww_verification import ( + Autofill, DidwwApiError, DidwwBalanceInsufficientError, DidwwDecodingError, @@ -36,6 +37,31 @@ def test_decodes_a_pending_sms_verification(self) -> None: 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): + 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") diff --git a/tests/test_vocabulary.py b/tests/test_vocabulary.py index 80e6add..51ac4e8 100644 --- a/tests/test_vocabulary.py +++ b/tests/test_vocabulary.py @@ -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: From 7446ef7dcf5b3f7287db4a78e3a0b009bc681a1d Mon Sep 17 00:00:00 2001 From: Kirill Paromonov Date: Fri, 2 Oct 2026 15:01:04 +0300 Subject: [PATCH 2/2] Describe app_hash as deprecated, not removed README and the SmsInfo.app_hash docstring said the API no longer reads or returns app_hash. It still does, as a deprecated alias of autofill. --- README.md | 5 +++-- src/didww_verification/models.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 33c5e76..7aefae4 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,9 @@ from didww_verification import Autofill SmsOptions(autofill=Autofill.app_hash("abcdefghijk")) ``` -`SmsOptions(app_hash=...)` and `SmsInfo.app_hash` still work but are deprecated; the -service no longer reads or returns the `app_hash` field. +`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 diff --git a/src/didww_verification/models.py b/src/didww_verification/models.py index bff4d56..df4142b 100644 --- a/src/didww_verification/models.py +++ b/src/didww_verification/models.py @@ -91,8 +91,8 @@ 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 - """Deprecated: ``autofill.value`` when ``autofill.type`` is ``app_hash``, else - ``None``. The service no longer returns this field itself.""" + """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