From 604d0fe7cd847cc150e2364f1a21b1f92f2fa0b9 Mon Sep 17 00:00:00 2001 From: Ryan Duguid Date: Mon, 31 Aug 2026 12:39:58 +0000 Subject: [PATCH 1/3] 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/3] 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) From 198730c3502dda165bc473f6df2090898992712d Mon Sep 17 00:00:00 2001 From: Ryan Duguid Date: Wed, 23 Sep 2026 09:48:57 +1000 Subject: [PATCH 3/3] Parse the tax number type prefix once and note when to drop RECEIPT Split the namespaced value with partition, and record that the RECEIPT workaround can go once the OpenAPI spec adds the value and the models are regenerated. --- xero_python/api_client/deserializer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/xero_python/api_client/deserializer.py b/xero_python/api_client/deserializer.py index 8080df34..3d00ce06 100644 --- a/xero_python/api_client/deserializer.py +++ b/xero_python/api_client/deserializer.py @@ -286,10 +286,10 @@ def deserialize_model(model, data, model_finder): model_name = "{}.{}".format(model.__module__, model.__name__) if model_name == "xero_python.accounting.models.contact.Contact": - value = kwargs.get("tax_number_type") + prefix, _, suffix = (kwargs.get("tax_number_type") or "").partition("/") # 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 prefix == "TAXNUMBERTYPE" and suffix: + kwargs["tax_number_type"] = suffix if ( model_name @@ -297,6 +297,7 @@ def deserialize_model(model, data, model_finder): and kwargs.get("source_transaction_type_code") == "RECEIPT" ): # Known API value missing from the generated enum (xero-python#206). + # Remove once XeroAPI/Xero-OpenAPI#846 lands and the models are regenerated. # Construct normally so every other field is still validated. kwargs.pop("source_transaction_type_code") instance = model(**kwargs)