From 604d0fe7cd847cc150e2364f1a21b1f92f2fa0b9 Mon Sep 17 00:00:00 2001 From: Ryan Duguid Date: Mon, 31 Aug 2026 12:39:58 +0000 Subject: [PATCH 1/2] fix: keep contacts and linked transactions when the API sends extra enums Generated setters reject TaxNumberType TAXNUMBERTYPE/SSN (#203, #205) and LinkedTransaction SourceTransactionTypeCode RECEIPT (#206). Deserialization now retries namespaced values as their suffix, then preserves the raw API value on the private field so one unknown enum cannot drop a valid payload. Valid members (EIN, ACCPAY, SPEND) still go through the generated setters. --- tests/test_api_client/test_deserializer.py | 30 +++++++++++++++++++ xero_python/api_client/deserializer.py | 34 +++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/tests/test_api_client/test_deserializer.py b/tests/test_api_client/test_deserializer.py index 9c851a8a..3bba4fcc 100644 --- a/tests/test_api_client/test_deserializer.py +++ b/tests/test_api_client/test_deserializer.py @@ -381,3 +381,33 @@ def test_deserialize_model_enum(data, expected): # then correct Shape enum expected assert isinstance(result, Shape) assert result == expected + + +def test_deserialize_contact_strips_tax_number_type_namespace(): + from xero_python.accounting.models.contact import Contact + + contact = deserialize_model( + Contact, {"TaxNumberType": "TAXNUMBERTYPE/SSN"}, model_finder=None + ) + + assert contact.tax_number_type == "SSN" + + +def test_deserialize_contact_keeps_valid_tax_number_type(): + from xero_python.accounting.models.contact import Contact + + contact = deserialize_model(Contact, {"TaxNumberType": "EIN"}, model_finder=None) + + assert contact.tax_number_type == "EIN" + + +def test_deserialize_linked_transaction_accepts_receipt_source(): + from xero_python.accounting.models.linked_transaction import LinkedTransaction + + txn = deserialize_model( + LinkedTransaction, + {"SourceTransactionTypeCode": "RECEIPT"}, + model_finder=None, + ) + + assert txn.source_transaction_type_code == "RECEIPT" diff --git a/xero_python/api_client/deserializer.py b/xero_python/api_client/deserializer.py index b64e9820..e40acb7f 100644 --- a/xero_python/api_client/deserializer.py +++ b/xero_python/api_client/deserializer.py @@ -284,5 +284,37 @@ def deserialize_model(model, data, model_finder): value = data[attr_key] kwargs[attr] = deserialize(attr_type, value, model_finder) - instance = model(**kwargs) + try: + return model(**kwargs) + except ValueError: + # Generated setters reject enum members the live API still returns + # (xero-python#203, #205, #206). Keep valid data; do not fail the + # whole payload because one closed enum is behind the spec. + return _deserialize_model_preserving_api_enums(model, kwargs) + + +def _enum_namespace_suffix(value): + if isinstance(value, str) and "/" in value: + return value.rsplit("/", 1)[-1] + return value + + +def _deserialize_model_preserving_api_enums(model, kwargs): + instance = model() + for attr, value in kwargs.items(): + try: + setattr(instance, attr, value) + continue + except ValueError as err: + stripped = _enum_namespace_suffix(value) + if stripped != value: + try: + setattr(instance, attr, stripped) + continue + except ValueError: + pass + private = "_{}".format(attr) + if not hasattr(instance, private): + raise err + setattr(instance, private, value) return instance From 85ea99b36a702715e9649e9b450092caf04ba871 Mon Sep 17 00:00:00 2001 From: Ryan Duguid Date: Sat, 5 Sep 2026 15:27:39 +1000 Subject: [PATCH 2/2] fix: scope API enum compatibility to reported values --- tests/test_api_client/test_deserializer.py | 90 +++++++++++++++++++++- xero_python/api_client/deserializer.py | 54 +++++-------- 2 files changed, 107 insertions(+), 37 deletions(-) diff --git a/tests/test_api_client/test_deserializer.py b/tests/test_api_client/test_deserializer.py index 3bba4fcc..44756610 100644 --- a/tests/test_api_client/test_deserializer.py +++ b/tests/test_api_client/test_deserializer.py @@ -383,14 +383,17 @@ def test_deserialize_model_enum(data, expected): assert result == expected -def test_deserialize_contact_strips_tax_number_type_namespace(): +@pytest.mark.parametrize("tax_number_type", ["SSN", "EIN", "ITIN", "ATIN", "None"]) +def test_deserialize_contact_strips_tax_number_type_namespace(tax_number_type): from xero_python.accounting.models.contact import Contact contact = deserialize_model( - Contact, {"TaxNumberType": "TAXNUMBERTYPE/SSN"}, model_finder=None + Contact, + {"TaxNumberType": "TAXNUMBERTYPE/" + tax_number_type}, + model_finder=None, ) - assert contact.tax_number_type == "SSN" + assert contact.tax_number_type == tax_number_type def test_deserialize_contact_keeps_valid_tax_number_type(): @@ -411,3 +414,84 @@ def test_deserialize_linked_transaction_accepts_receipt_source(): ) assert txn.source_transaction_type_code == "RECEIPT" + + +@pytest.mark.parametrize( + "data", + [ + {"TaxNumberType": "UNKNOWN"}, + {"TaxNumberType": "OTHER/SSN"}, + {"TaxNumberType": "TAXNUMBERTYPE/UNKNOWN"}, + {"TaxNumberType": "TAXNUMBERTYPE/"}, + {"TaxNumberType": "TAXNUMBERTYPE/OTHER/SSN"}, + {"TaxNumberType": "TAXNUMBERTYPE/SSN", "Name": "x" * 256}, + {"TaxNumberType": "TAXNUMBERTYPE/SSN", "ContactStatus": "INVALID"}, + ], +) +def test_deserialize_contact_preserves_validation(data): + from xero_python.accounting.models.contact import Contact + + with pytest.raises(ValueError): + deserialize_model(Contact, data, model_finder=None) + + +@pytest.mark.parametrize( + "data", + [ + {"SourceTransactionTypeCode": "UNKNOWN"}, + {"SourceTransactionTypeCode": "OTHER/SPEND"}, + {"SourceTransactionTypeCode": "RECEIPT", "Status": "INVALID"}, + ], +) +def test_deserialize_linked_transaction_preserves_validation(data): + from xero_python.accounting.models.linked_transaction import LinkedTransaction + + with pytest.raises(ValueError): + deserialize_model(LinkedTransaction, data, model_finder=None) + + +def test_deserialize_unrelated_model_preserves_validation(): + from xero_python.accounting.models.account import Account + + with pytest.raises(ValueError): + deserialize_model(Account, {"Status": "INVALID"}, model_finder=None) + + +@pytest.mark.parametrize( + "response_type,payload,collection,attribute,expected", + [ + ( + "Contacts", + '{"Contacts":[{"Name":"Example","TaxNumberType":"TAXNUMBERTYPE/SSN"}]}', + "contacts", + "tax_number_type", + "SSN", + ), + ( + "LinkedTransactions", + '{"LinkedTransactions":[{"Status":"APPROVED","SourceTransactionTypeCode":"RECEIPT"}]}', + "linked_transactions", + "source_transaction_type_code", + "RECEIPT", + ), + ], +) +def test_deserialize_api_response_with_known_enum_variants( + api_client, response_type, payload, collection, attribute, expected +): + from types import SimpleNamespace + + from xero_python.accounting import models + from xero_python.api_client import ModelFinder + + result = api_client.deserialize( + SimpleNamespace(text=payload), response_type, ModelFinder(models) + ) + + item = getattr(result, collection)[0] + assert getattr(item, attribute) == expected + assert ( + item.name == "Example" + if collection == "contacts" + else item.status == "APPROVED" + ) diff --git a/xero_python/api_client/deserializer.py b/xero_python/api_client/deserializer.py index e40acb7f..8080df34 100644 --- a/xero_python/api_client/deserializer.py +++ b/xero_python/api_client/deserializer.py @@ -284,37 +284,23 @@ def deserialize_model(model, data, model_finder): value = data[attr_key] kwargs[attr] = deserialize(attr_type, value, model_finder) - try: - return model(**kwargs) - except ValueError: - # Generated setters reject enum members the live API still returns - # (xero-python#203, #205, #206). Keep valid data; do not fail the - # whole payload because one closed enum is behind the spec. - return _deserialize_model_preserving_api_enums(model, kwargs) - - -def _enum_namespace_suffix(value): - if isinstance(value, str) and "/" in value: - return value.rsplit("/", 1)[-1] - return value - - -def _deserialize_model_preserving_api_enums(model, kwargs): - instance = model() - for attr, value in kwargs.items(): - try: - setattr(instance, attr, value) - continue - except ValueError as err: - stripped = _enum_namespace_suffix(value) - if stripped != value: - try: - setattr(instance, attr, stripped) - continue - except ValueError: - pass - private = "_{}".format(attr) - if not hasattr(instance, private): - raise err - setattr(instance, private, value) - return instance + model_name = "{}.{}".format(model.__module__, model.__name__) + if model_name == "xero_python.accounting.models.contact.Contact": + value = kwargs.get("tax_number_type") + # The API prefixes tax number types; keep the generated setter's validation. + if value and value.startswith("TAXNUMBERTYPE/") and value.split("/", 1)[1]: + kwargs["tax_number_type"] = value.split("/", 1)[1] + + if ( + model_name + == "xero_python.accounting.models.linked_transaction.LinkedTransaction" + and kwargs.get("source_transaction_type_code") == "RECEIPT" + ): + # Known API value missing from the generated enum (xero-python#206). + # Construct normally so every other field is still validated. + kwargs.pop("source_transaction_type_code") + instance = model(**kwargs) + instance._source_transaction_type_code = "RECEIPT" + return instance + + return model(**kwargs)