From a64fb20a6627c902a0bcf0d5b1ad1f09c23e2f43 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Mon, 28 Sep 2026 18:27:35 +0300 Subject: [PATCH 01/20] Support overrides and historical values in customer, contact and attributes resources --- README.md | 28 +++++++++++++++ chartmogul/api/attributes.py | 3 ++ chartmogul/api/contact.py | 3 ++ chartmogul/api/custom_attrs.py | 2 ++ chartmogul/api/customer.py | 3 ++ test/api/test_attributes.py | 51 ++++++++++++++++++++++++++ test/api/test_contact.py | 56 +++++++++++++++++++++++++++++ test/api/test_custom_attrs.py | 45 +++++++++++++++++++++++ test/api/test_customer.py | 65 ++++++++++++++++++++++++++++++++++ 9 files changed, 256 insertions(+) create mode 100644 test/api/test_attributes.py diff --git a/README.md b/README.md index 097ddcc..aef9ba5 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ chartmogul.DataSource.destroy(config, uuid='ds_5915ee5a-babd-406b-b8ce-d207133fb chartmogul.Customer.create(config, data={}) chartmogul.Customer.all(config, cursor='cursor==', per_page=20) chartmogul.Customer.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb') +chartmogul.Customer.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', + with_overrides=True, attributes_with_history='company,custom.channel') chartmogul.Customer.search(config, email='email@email.com') chartmogul.Customer.merge(config, data={ 'from': {'customer_uuid': 'cus_5915ee5a-babd-406b-b8ce-d207133fb4cb'}, @@ -150,6 +152,14 @@ chartmogul.Customer.modify(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4c "country": "US", "state": "CA", }) +chartmogul.Customer.modify(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={ + "company": "Pinata Technologies", + "overrides": { + "company": True, + "address": True, + "attributes": {"custom": {"channel": True}} + } +}) chartmogul.Customer.destroy(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb') chartmogul.Customer.subscriptions(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb') chartmogul.Customer.connectSubscriptions(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={ @@ -196,11 +206,17 @@ chartmogul.Customer.createTask(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133 chartmogul.Contact.create(config, data={}) chartmogul.Contact.all(config, cursor='aabbcc', per_page=20, customer_uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data_source_uuid='ds_fef05d54-47b4-431b-aed2-eb6b9e545430', email='test@example.com', customer_external_id='cus_0001', external_id='con_0001') chartmogul.Contact.retrieve(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb') +chartmogul.Contact.retrieve(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', + with_overrides=True, attributes_with_history='title,email') chartmogul.Contact.merge(config, into_uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', from_uuid='con_2123290f-09c8-4628-a205-db5596bd58f7') chartmogul.Contact.modify(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={ "email": "test@example.com", "last_seen": "2025-04-01T12:00:00Z" }) +chartmogul.Contact.modify(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={ + "title": "CTO", + "overrides": {"title": True} +}) chartmogul.Contact.destroy(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb') chartmogul.Contact.tasks(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', cursor='aabbcc', per_page=20) chartmogul.Contact.createTask(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={}) @@ -294,6 +310,8 @@ Note that the returned attributes of type date are not parsed and stay in string ```python chartmogul.Attributes.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb') +chartmogul.Attributes.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', + with_overrides=True, attributes_with_history='custom.channel') ``` #### [Tags](https://dev.chartmogul.com/reference/customers/tags/) @@ -325,6 +343,12 @@ chartmogul.CustomAttributes.add(config, data={ {'type': 'Integer', 'key': 'age', 'value': 8} ] }) +chartmogul.CustomAttributes.add(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={ + 'custom': [ + {'type': 'String', 'key': 'channel', 'value': 'Facebook'} + ], + 'overrides': {'custom': {'channel': True}} +}) chartmogul.CustomAttributes.update(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={ 'custom': { 'age': 20, @@ -334,6 +358,10 @@ chartmogul.CustomAttributes.update(config, uuid='cus_5915ee5a-babd-406b-b8ce-d20 chartmogul.CustomAttributes.remove(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={ 'custom': ['CAC'] }) +chartmogul.CustomAttributes.remove(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={ + 'custom': ['CAC'], + 'overrides': {'custom': {'CAC': False}} +}) ``` #### [Plans](https://dev.chartmogul.com/reference/plans/) diff --git a/chartmogul/api/attributes.py b/chartmogul/api/attributes.py index 3824d8c..c8dd421 100644 --- a/chartmogul/api/attributes.py +++ b/chartmogul/api/attributes.py @@ -8,12 +8,15 @@ class Attributes(Resource): """ _path = "/customers{/uuid}/attributes" + _bool_query_params = ["with_overrides"] class _Schema(Schema): tags = fields.List(fields.String()) stripe = fields.Dict() clearbit = fields.Dict() custom = fields.Dict() + overrides = fields.Dict() + historical_values = fields.Dict() @post_load def make(self, data, **kwargs): diff --git a/chartmogul/api/contact.py b/chartmogul/api/contact.py index 7853a20..8d17682 100644 --- a/chartmogul/api/contact.py +++ b/chartmogul/api/contact.py @@ -12,6 +12,7 @@ class Contact(Resource): _path = "/contacts{/uuid}" _root_key = "entries" + _bool_query_params = ["with_overrides"] _many = namedtuple("Contacts", [_root_key, "has_more", "cursor"]) class _Schema(Schema): @@ -33,6 +34,8 @@ class _Schema(Schema): external_id = fields.String(allow_none=True, load_default=None) last_seen = fields.DateTime(allow_none=True) custom = fields.Dict(allow_none=True) + overrides = fields.Dict() + historical_values = fields.Dict() @post_load def make(self, data, **kwargs): diff --git a/chartmogul/api/custom_attrs.py b/chartmogul/api/custom_attrs.py index d5b4cc2..4148060 100644 --- a/chartmogul/api/custom_attrs.py +++ b/chartmogul/api/custom_attrs.py @@ -13,6 +13,8 @@ class CustomAttributes(Resource): class _Schema(Schema): custom = fields.Dict() + overrides = fields.Dict() + message = fields.String() @post_load def make(self, data, **kwargs): diff --git a/chartmogul/api/customer.py b/chartmogul/api/customer.py index f4bff27..2ea0548 100644 --- a/chartmogul/api/customer.py +++ b/chartmogul/api/customer.py @@ -36,6 +36,7 @@ class Customer(Resource): _path = "/customers{/uuid}" _root_key = "entries" + _bool_query_params = ["with_overrides"] _many = namedtuple("Customers", [_root_key, "has_more", "cursor"], defaults=[None, None]) class _Schema(Schema): @@ -71,6 +72,8 @@ class _Schema(Schema): currency_sign = fields.String(data_key="currency-sign") address = fields.Nested(Address._Schema, allow_none=True, unknown=EXCLUDE) website_url = fields.String(allow_none=True) + overrides = fields.Dict() + historical_values = fields.Dict() @post_load def make(self, data, **kwargs): diff --git a/test/api/test_attributes.py b/test/api/test_attributes.py new file mode 100644 index 0000000..610e63a --- /dev/null +++ b/test/api/test_attributes.py @@ -0,0 +1,51 @@ +import unittest +from chartmogul import Attributes, Config +import requests_mock + +attributesResponse = { + "tags": ["engage", "unit loss"], + "stripe": {"uid": 7, "coupon": True}, + "custom": {"CAC": 213, "salesRep": "Gabi"}, + "overrides": {"custom": {"salesRep": True}}, + "historical_values": { + "custom": { + "salesRep": [ + { + "value": "Gabi", + "update_performed_at": "2026-01-01T16:58:58Z", + "update_performed_by": "adam@example.com", + "initial": False, + } + ] + } + }, +} + + +class AttributesTestCase(unittest.TestCase): + @requests_mock.mock() + def test_retrieve_with_overrides_and_history(self, mock_requests): + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/customers/CUSTOMER_UUID/attributes", + status_code=200, + json=attributesResponse, + ) + + config = Config("token") + result = Attributes.retrieve( + config, + uuid="CUSTOMER_UUID", + with_overrides=True, + attributes_with_history="custom.salesrep", + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual( + mock_requests.last_request.qs, + {"with_overrides": ["true"], "attributes_with_history": ["custom.salesrep"]}, + ) + self.assertTrue(isinstance(result, Attributes)) + self.assertEqual(result.custom, attributesResponse["custom"]) + self.assertEqual(result.overrides, attributesResponse["overrides"]) + self.assertEqual(result.historical_values, attributesResponse["historical_values"]) diff --git a/test/api/test_contact.py b/test/api/test_contact.py index 74fd2f6..0686180 100644 --- a/test/api/test_contact.py +++ b/test/api/test_contact.py @@ -474,3 +474,59 @@ def test_destroy(self, mock_requests): self.assertEqual(mock_requests.call_count, 1, "expected call") self.assertEqual(mock_requests.last_request.qs, {}) self.assertTrue(expected, {}) + + @requests_mock.mock() + def test_retrieve_with_overrides_and_history(self, mock_requests): + overrides = {"title": True} + historical_values = { + "title": [ + { + "value": "CEO", + "update_performed_at": "2026-01-01T16:58:58Z", + "update_performed_by": "adam@example.com", + "initial": False, + } + ] + } + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/contacts/con_00000000-0000-0000-0000-000000000000", + status_code=200, + json={**contact, "overrides": overrides, "historical_values": historical_values}, + ) + + config = Config("token") + result = Contact.retrieve( + config, + uuid="con_00000000-0000-0000-0000-000000000000", + with_overrides=True, + attributes_with_history="title", + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual( + mock_requests.last_request.qs, + {"with_overrides": ["true"], "attributes_with_history": ["title"]}, + ) + self.assertEqual(result.overrides, overrides) + self.assertEqual(result.historical_values, historical_values) + + @requests_mock.mock() + def test_modify_with_overrides(self, mock_requests): + overrides = {"title": True} + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/contacts/con_00000000-0000-0000-0000-000000000000", + status_code=200, + json={**contact, "overrides": overrides}, + ) + + jsonRequest = {"title": "CTO", "overrides": overrides} + config = Config("token") + result = Contact.modify( + config, uuid="con_00000000-0000-0000-0000-000000000000", data=jsonRequest + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual(mock_requests.last_request.json(), jsonRequest) + self.assertEqual(result.overrides, overrides) diff --git a/test/api/test_custom_attrs.py b/test/api/test_custom_attrs.py index 7b0fa7f..abe5133 100644 --- a/test/api/test_custom_attrs.py +++ b/test/api/test_custom_attrs.py @@ -122,3 +122,48 @@ def test_add_to_email(self, mock_requests): self.assertEqual(mock_requests.last_request.json(), jsonRequest2) # No comparison, because unicode strings, dates, serialization order etc. # vary on different Python versions. + + @requests_mock.mock() + def test_add_with_overrides(self, mock_requests): + overrides = {"custom": {"channel": True}} + mock_requests.register_uri( + "POST", + "https://api.chartmogul.com/v1/customers/CUSTOMER_UUID/attributes/custom", + status_code=200, + json={"custom": {"channel": "Facebook"}, "overrides": overrides}, + ) + + jsonRequest = { + "custom": [{"type": "String", "key": "channel", "value": "Facebook"}], + "overrides": overrides, + } + config = Config("token") + result = CustomAttributes.add(config, uuid="CUSTOMER_UUID", data=jsonRequest).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual(mock_requests.last_request.json(), jsonRequest) + self.assertEqual(result.custom, {"channel": "Facebook"}) + self.assertEqual(result.overrides, overrides) + + @requests_mock.mock() + def test_remove_with_overrides_and_message(self, mock_requests): + mock_requests.register_uri( + "DELETE", + "https://api.chartmogul.com/v1/customers/CUSTOMER_UUID/attributes/custom", + status_code=202, + json={ + "custom": {"channel": "Facebook"}, + "overrides": {}, + "message": "Custom attributes deleted from customer", + }, + ) + + jsonRequest = {"custom": ["age"], "overrides": {"custom": {"age": False}}} + config = Config("token") + result = CustomAttributes.remove(config, uuid="CUSTOMER_UUID", data=jsonRequest).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual(mock_requests.last_request.json(), jsonRequest) + self.assertEqual(result.custom, {"channel": "Facebook"}) + self.assertEqual(result.overrides, {}) + self.assertEqual(result.message, "Custom attributes deleted from customer") diff --git a/test/api/test_customer.py b/test/api/test_customer.py index 7cf6f2e..d8bd171 100644 --- a/test/api/test_customer.py +++ b/test/api/test_customer.py @@ -928,3 +928,68 @@ def test_createTask(self, mock_requests): self.assertEqual(mock_requests.last_request.qs, {}) self.assertEqual(mock_requests.last_request.json(), createTask) self.assertTrue(isinstance(expected, Task)) + + @requests_mock.mock() + def test_retrieve_with_overrides_and_history(self, mock_requests): + overrides = {"company": True, "attributes": {"custom": {"salesRep": True}}} + historical_values = { + "company": [ + { + "value": "Example Company", + "update_performed_at": "2026-01-01T16:58:58Z", + "update_performed_by": "adam@example.com", + "initial": False, + } + ], + "attributes": { + "custom": { + "salesRep": [ + { + "value": "Gabi", + "update_performed_at": None, + "update_performed_by": None, + "initial": True, + } + ] + } + }, + } + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/customers/cus_00000000-0000-0000-0000-000000000000", + status_code=200, + json={**entry, "overrides": overrides, "historical_values": historical_values}, + ) + + config = Config("token") + result = Customer.retrieve( + config, + uuid="cus_00000000-0000-0000-0000-000000000000", + with_overrides=True, + attributes_with_history="company,custom.salesrep", + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual( + mock_requests.last_request.qs, + {"with_overrides": ["true"], "attributes_with_history": ["company,custom.salesrep"]}, + ) + self.assertEqual(result.overrides, overrides) + self.assertEqual(result.historical_values, historical_values) + + @requests_mock.mock() + def test_create_with_overrides(self, mock_requests): + overrides = {"company": True, "attributes": {"custom": {"channel": True}}} + mock_requests.register_uri( + "POST", + "https://api.chartmogul.com/v1/customers", + status_code=200, + json={**entry, "overrides": overrides}, + ) + + config = Config("token") + result = Customer.create(config, data={**createCustomer, "overrides": overrides}).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual(mock_requests.last_request.json()["overrides"], overrides) + self.assertEqual(result.overrides, overrides) From 9235255d77cb5c433e916882ca43c12e9d2220b2 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Mon, 5 Oct 2026 16:17:38 +0300 Subject: [PATCH 02/20] Default overrides, historical_values and message to None so absent response keys do not raise AttributeError --- chartmogul/api/attributes.py | 6 ++++-- chartmogul/api/contact.py | 6 ++++-- chartmogul/api/custom_attrs.py | 6 ++++-- chartmogul/api/customer.py | 6 ++++-- test/api/test_attributes.py | 20 ++++++++++++++++++++ test/api/test_contact.py | 2 ++ test/api/test_custom_attrs.py | 2 ++ test/api/test_customer.py | 4 +++- 8 files changed, 43 insertions(+), 9 deletions(-) diff --git a/chartmogul/api/attributes.py b/chartmogul/api/attributes.py index c8dd421..a561be7 100644 --- a/chartmogul/api/attributes.py +++ b/chartmogul/api/attributes.py @@ -15,8 +15,10 @@ class _Schema(Schema): stripe = fields.Dict() clearbit = fields.Dict() custom = fields.Dict() - overrides = fields.Dict() - historical_values = fields.Dict() + # load_default=None ensures these attributes are always present; the API + # omits the keys unless with_overrides / attributes_with_history is passed + overrides = fields.Dict(load_default=None) + historical_values = fields.Dict(load_default=None) @post_load def make(self, data, **kwargs): diff --git a/chartmogul/api/contact.py b/chartmogul/api/contact.py index 8d17682..c38be20 100644 --- a/chartmogul/api/contact.py +++ b/chartmogul/api/contact.py @@ -34,8 +34,10 @@ class _Schema(Schema): external_id = fields.String(allow_none=True, load_default=None) last_seen = fields.DateTime(allow_none=True) custom = fields.Dict(allow_none=True) - overrides = fields.Dict() - historical_values = fields.Dict() + # load_default=None ensures these attributes are always present; the API + # omits the keys unless with_overrides / attributes_with_history is passed + overrides = fields.Dict(load_default=None) + historical_values = fields.Dict(load_default=None) @post_load def make(self, data, **kwargs): diff --git a/chartmogul/api/custom_attrs.py b/chartmogul/api/custom_attrs.py index 4148060..08811df 100644 --- a/chartmogul/api/custom_attrs.py +++ b/chartmogul/api/custom_attrs.py @@ -13,8 +13,10 @@ class CustomAttributes(Resource): class _Schema(Schema): custom = fields.Dict() - overrides = fields.Dict() - message = fields.String() + # load_default=None ensures these attributes are always present; the API + # returns message only on DELETE responses + overrides = fields.Dict(load_default=None) + message = fields.String(load_default=None) @post_load def make(self, data, **kwargs): diff --git a/chartmogul/api/customer.py b/chartmogul/api/customer.py index 2ea0548..901c3ee 100644 --- a/chartmogul/api/customer.py +++ b/chartmogul/api/customer.py @@ -72,8 +72,10 @@ class _Schema(Schema): currency_sign = fields.String(data_key="currency-sign") address = fields.Nested(Address._Schema, allow_none=True, unknown=EXCLUDE) website_url = fields.String(allow_none=True) - overrides = fields.Dict() - historical_values = fields.Dict() + # load_default=None ensures these attributes are always present; the API + # omits the keys unless with_overrides / attributes_with_history is passed + overrides = fields.Dict(load_default=None) + historical_values = fields.Dict(load_default=None) @post_load def make(self, data, **kwargs): diff --git a/test/api/test_attributes.py b/test/api/test_attributes.py index 610e63a..f910457 100644 --- a/test/api/test_attributes.py +++ b/test/api/test_attributes.py @@ -23,6 +23,26 @@ class AttributesTestCase(unittest.TestCase): + @requests_mock.mock() + def test_retrieve(self, mock_requests): + plainResponse = {key: attributesResponse[key] for key in ("tags", "stripe", "custom")} + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/customers/CUSTOMER_UUID/attributes", + status_code=200, + json=plainResponse, + ) + + config = Config("token") + result = Attributes.retrieve(config, uuid="CUSTOMER_UUID").get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual(mock_requests.last_request.qs, {}) + self.assertTrue(isinstance(result, Attributes)) + self.assertEqual(result.custom, attributesResponse["custom"]) + self.assertIsNone(result.overrides) + self.assertIsNone(result.historical_values) + @requests_mock.mock() def test_retrieve_with_overrides_and_history(self, mock_requests): mock_requests.register_uri( diff --git a/test/api/test_contact.py b/test/api/test_contact.py index 0686180..89e759c 100644 --- a/test/api/test_contact.py +++ b/test/api/test_contact.py @@ -314,6 +314,8 @@ def test_retrieve(self, mock_requests): self.assertTrue(isinstance(expected, Contact)) self.assertEqual(expected.email, "test@example.com") self.assertTrue(isinstance(expected.last_seen, datetime)) + self.assertIsNone(expected.overrides) + self.assertIsNone(expected.historical_values) @requests_mock.mock() def test_tasks(self, mock_requests): diff --git a/test/api/test_custom_attrs.py b/test/api/test_custom_attrs.py index abe5133..1e28610 100644 --- a/test/api/test_custom_attrs.py +++ b/test/api/test_custom_attrs.py @@ -104,6 +104,8 @@ def test_add(self, mock_requests): self.assertEqual(result.custom["convertedAt"], expected.custom["convertedAt"]) self.assertEqual(result.custom["pro"], expected.custom["pro"]) self.assertEqual(result.custom["salesRep"], expected.custom["salesRep"]) + self.assertIsNone(result.overrides) + self.assertIsNone(result.message) @requests_mock.mock() def test_add_to_email(self, mock_requests): diff --git a/test/api/test_customer.py b/test/api/test_customer.py index d8bd171..3726b52 100644 --- a/test/api/test_customer.py +++ b/test/api/test_customer.py @@ -526,10 +526,12 @@ def test_create(self, mock_requests): ) config = Config("token") - Customer.create(config, data=createCustomer).get() + result = Customer.create(config, data=createCustomer).get() self.assertEqual(mock_requests.call_count, 1, "expected call") self.assertEqual(mock_requests.last_request.qs, {}) self.assertEqual(mock_requests.last_request.json(), sentCreateExpected) + self.assertIsNone(result.overrides) + self.assertIsNone(result.historical_values) @requests_mock.mock() def test_search(self, mock_requests): From d3e2e911b1a6fd5e412122e02a96a1361b02ec65 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Mon, 5 Oct 2026 16:42:00 +0300 Subject: [PATCH 03/20] Use case-sensitive request mocks and real attribute casing in with-history tests --- test/api/test_attributes.py | 7 ++++--- test/api/test_contact.py | 3 ++- test/api/test_customer.py | 7 ++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/test/api/test_attributes.py b/test/api/test_attributes.py index f910457..5a7de8b 100644 --- a/test/api/test_attributes.py +++ b/test/api/test_attributes.py @@ -43,7 +43,8 @@ def test_retrieve(self, mock_requests): self.assertIsNone(result.overrides) self.assertIsNone(result.historical_values) - @requests_mock.mock() + # case_sensitive pins that the SDK preserves attribute name casing in the query + @requests_mock.Mocker(case_sensitive=True) def test_retrieve_with_overrides_and_history(self, mock_requests): mock_requests.register_uri( "GET", @@ -57,13 +58,13 @@ def test_retrieve_with_overrides_and_history(self, mock_requests): config, uuid="CUSTOMER_UUID", with_overrides=True, - attributes_with_history="custom.salesrep", + attributes_with_history="custom.salesRep", ).get() self.assertEqual(mock_requests.call_count, 1, "expected call") self.assertEqual( mock_requests.last_request.qs, - {"with_overrides": ["true"], "attributes_with_history": ["custom.salesrep"]}, + {"with_overrides": ["true"], "attributes_with_history": ["custom.salesRep"]}, ) self.assertTrue(isinstance(result, Attributes)) self.assertEqual(result.custom, attributesResponse["custom"]) diff --git a/test/api/test_contact.py b/test/api/test_contact.py index 89e759c..52f64dd 100644 --- a/test/api/test_contact.py +++ b/test/api/test_contact.py @@ -477,7 +477,8 @@ def test_destroy(self, mock_requests): self.assertEqual(mock_requests.last_request.qs, {}) self.assertTrue(expected, {}) - @requests_mock.mock() + # case_sensitive pins that the SDK does not alter query param casing + @requests_mock.Mocker(case_sensitive=True) def test_retrieve_with_overrides_and_history(self, mock_requests): overrides = {"title": True} historical_values = { diff --git a/test/api/test_customer.py b/test/api/test_customer.py index 3726b52..08132e0 100644 --- a/test/api/test_customer.py +++ b/test/api/test_customer.py @@ -931,7 +931,8 @@ def test_createTask(self, mock_requests): self.assertEqual(mock_requests.last_request.json(), createTask) self.assertTrue(isinstance(expected, Task)) - @requests_mock.mock() + # case_sensitive pins that the SDK preserves attribute name casing in the query + @requests_mock.Mocker(case_sensitive=True) def test_retrieve_with_overrides_and_history(self, mock_requests): overrides = {"company": True, "attributes": {"custom": {"salesRep": True}}} historical_values = { @@ -968,13 +969,13 @@ def test_retrieve_with_overrides_and_history(self, mock_requests): config, uuid="cus_00000000-0000-0000-0000-000000000000", with_overrides=True, - attributes_with_history="company,custom.salesrep", + attributes_with_history="company,custom.salesRep", ).get() self.assertEqual(mock_requests.call_count, 1, "expected call") self.assertEqual( mock_requests.last_request.qs, - {"with_overrides": ["true"], "attributes_with_history": ["company,custom.salesrep"]}, + {"with_overrides": ["true"], "attributes_with_history": ["company,custom.salesRep"]}, ) self.assertEqual(result.overrides, overrides) self.assertEqual(result.historical_values, historical_values) From 4c2354b3883d28608f693271188e35a56ba5eda7 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Mon, 5 Oct 2026 16:49:22 +0300 Subject: [PATCH 04/20] Default custom to None so a DELETE of the last attribute does not raise AttributeError --- chartmogul/api/custom_attrs.py | 3 ++- test/api/test_custom_attrs.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/chartmogul/api/custom_attrs.py b/chartmogul/api/custom_attrs.py index 08811df..a6bf684 100644 --- a/chartmogul/api/custom_attrs.py +++ b/chartmogul/api/custom_attrs.py @@ -12,9 +12,10 @@ class CustomAttributes(Resource): _path = "/customers{/uuid}/attributes/custom" class _Schema(Schema): - custom = fields.Dict() # load_default=None ensures these attributes are always present; the API + # omits custom when a DELETE removes the last custom attribute, and # returns message only on DELETE responses + custom = fields.Dict(load_default=None) overrides = fields.Dict(load_default=None) message = fields.String(load_default=None) diff --git a/test/api/test_custom_attrs.py b/test/api/test_custom_attrs.py index 1e28610..cc8d15a 100644 --- a/test/api/test_custom_attrs.py +++ b/test/api/test_custom_attrs.py @@ -169,3 +169,25 @@ def test_remove_with_overrides_and_message(self, mock_requests): self.assertEqual(result.custom, {"channel": "Facebook"}) self.assertEqual(result.overrides, {}) self.assertEqual(result.message, "Custom attributes deleted from customer") + + @requests_mock.mock() + def test_remove_last_attribute_omits_custom(self, mock_requests): + mock_requests.register_uri( + "DELETE", + "https://api.chartmogul.com/v1/customers/CUSTOMER_UUID/attributes/custom", + status_code=202, + json={ + "overrides": {}, + "message": "Custom attributes deleted from customer", + }, + ) + + config = Config("token") + result = CustomAttributes.remove( + config, uuid="CUSTOMER_UUID", data={"custom": ["age"]} + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertIsNone(result.custom) + self.assertEqual(result.overrides, {}) + self.assertEqual(result.message, "Custom attributes deleted from customer") From 50db22356c0e8c549551a9d0ef8ad5cd60f230bf Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Mon, 5 Oct 2026 17:21:38 +0300 Subject: [PATCH 05/20] Note in README that overrides query params only apply to single-record retrieval --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index aef9ba5..6c6aec5 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ chartmogul.Customer.all(config, cursor='cursor==', per_page=20) chartmogul.Customer.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb') chartmogul.Customer.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', with_overrides=True, attributes_with_history='company,custom.channel') +# with_overrides and attributes_with_history only work on retrieve; list and search endpoints ignore them chartmogul.Customer.search(config, email='email@email.com') chartmogul.Customer.merge(config, data={ 'from': {'customer_uuid': 'cus_5915ee5a-babd-406b-b8ce-d207133fb4cb'}, @@ -208,6 +209,7 @@ chartmogul.Contact.all(config, cursor='aabbcc', per_page=20, customer_uuid='cus_ chartmogul.Contact.retrieve(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb') chartmogul.Contact.retrieve(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', with_overrides=True, attributes_with_history='title,email') +# with_overrides and attributes_with_history only work on retrieve; list endpoints ignore them chartmogul.Contact.merge(config, into_uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', from_uuid='con_2123290f-09c8-4628-a205-db5596bd58f7') chartmogul.Contact.modify(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={ "email": "test@example.com", From 31952ead4e18b16c7d56125e35a4464b6bfae5b6 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Tue, 6 Oct 2026 10:25:25 +0300 Subject: [PATCH 06/20] Mock 201 for the create customer endpoint to match the API --- test/api/test_customer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/test_customer.py b/test/api/test_customer.py index 08132e0..dd31a51 100644 --- a/test/api/test_customer.py +++ b/test/api/test_customer.py @@ -986,7 +986,7 @@ def test_create_with_overrides(self, mock_requests): mock_requests.register_uri( "POST", "https://api.chartmogul.com/v1/customers", - status_code=200, + status_code=201, json={**entry, "overrides": overrides}, ) From 84d38fc6e6fd2e6e5ba596d5010b357bfa36623a Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Wed, 14 Oct 2026 20:30:29 +0300 Subject: [PATCH 07/20] Mock overrides on write responses and move the absent-key None checks to a plain retrieve test --- test/api/test_custom_attrs.py | 4 ++-- test/api/test_customer.py | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/test/api/test_custom_attrs.py b/test/api/test_custom_attrs.py index cc8d15a..f678998 100644 --- a/test/api/test_custom_attrs.py +++ b/test/api/test_custom_attrs.py @@ -87,7 +87,7 @@ def test_add(self, mock_requests): "POST", "https://api.chartmogul.com/v1/customers/CUSTOMER_UUID/attributes/custom", status_code=200, - json=simpleJSONResult, + json={**simpleJSONResult, "overrides": {}}, ) expected = CustomAttributes(**simpleJSONResult) @@ -104,7 +104,7 @@ def test_add(self, mock_requests): self.assertEqual(result.custom["convertedAt"], expected.custom["convertedAt"]) self.assertEqual(result.custom["pro"], expected.custom["pro"]) self.assertEqual(result.custom["salesRep"], expected.custom["salesRep"]) - self.assertIsNone(result.overrides) + self.assertEqual(result.overrides, {}) self.assertIsNone(result.message) @requests_mock.mock() diff --git a/test/api/test_customer.py b/test/api/test_customer.py index dd31a51..0d1ca90 100644 --- a/test/api/test_customer.py +++ b/test/api/test_customer.py @@ -521,8 +521,8 @@ def test_create(self, mock_requests): mock_requests.register_uri( "POST", "https://api.chartmogul.com/v1/customers", - status_code=200, - json=entry, + status_code=201, + json={**entry, "overrides": {}}, ) config = Config("token") @@ -530,6 +530,24 @@ def test_create(self, mock_requests): self.assertEqual(mock_requests.call_count, 1, "expected call") self.assertEqual(mock_requests.last_request.qs, {}) self.assertEqual(mock_requests.last_request.json(), sentCreateExpected) + self.assertEqual(result.overrides, {}) + self.assertIsNone(result.historical_values) + + @requests_mock.mock() + def test_retrieve(self, mock_requests): + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/customers/cus_5915ee5a-babd-406b-b8ce-d207133fb4cb", + status_code=200, + json=entry, + ) + + config = Config("token") + result = Customer.retrieve(config, uuid="cus_5915ee5a-babd-406b-b8ce-d207133fb4cb").get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual(mock_requests.last_request.qs, {}) + self.assertTrue(isinstance(result, Customer)) self.assertIsNone(result.overrides) self.assertIsNone(result.historical_values) From 3c62539ad12fea2de3d0f3ec437188d3ea771714 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Wed, 14 Oct 2026 20:37:52 +0300 Subject: [PATCH 08/20] Exercise the by-email endpoint in test_add_to_email and assert entry overrides --- test/api/test_custom_attrs.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/api/test_custom_attrs.py b/test/api/test_custom_attrs.py index f678998..aaeda76 100644 --- a/test/api/test_custom_attrs.py +++ b/test/api/test_custom_attrs.py @@ -56,6 +56,7 @@ "clearbit": {"name": "Acme"}, "custom": {"channel": "Facebook"}, }, + "overrides": {"attributes": {"custom": {"channel": True}}}, }, { "id": 13456, @@ -71,6 +72,7 @@ "clearbit": {"name": "Umbrella Corp."}, "custom": {"channel": "Facebook"}, }, + "overrides": {}, }, ] } @@ -111,19 +113,19 @@ def test_add(self, mock_requests): def test_add_to_email(self, mock_requests): mock_requests.register_uri( "POST", - "https://api.chartmogul.com/v1/customers/CUSTOMER_UUID/attributes/custom", + "https://api.chartmogul.com/v1/customers/attributes/custom", status_code=200, json=jsonResponse2, ) config = Config("token") - result = CustomAttributes.add(config, uuid="CUSTOMER_UUID", data=jsonRequest2).get() + result = CustomAttributes.add_by_email(config, data=jsonRequest2).get() self.assertEqual(mock_requests.call_count, 1, "expected call") self.assertEqual(mock_requests.last_request.qs, {}) self.assertEqual(mock_requests.last_request.json(), jsonRequest2) - # No comparison, because unicode strings, dates, serialization order etc. - # vary on different Python versions. + self.assertEqual(result.entries[0].overrides, {"attributes": {"custom": {"channel": True}}}) + self.assertEqual(result.entries[1].overrides, {}) @requests_mock.mock() def test_add_with_overrides(self, mock_requests): From b3daeb8353262ea522e6d8c3652a97c9bf573273 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Wed, 14 Oct 2026 20:41:07 +0300 Subject: [PATCH 09/20] Pin request bodies and echoed overrides on the remaining write surfaces --- test/api/test_contact.py | 18 ++++++++++++++++ test/api/test_custom_attrs.py | 19 +++++++++++++++++ test/api/test_customer.py | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/test/api/test_contact.py b/test/api/test_contact.py index 52f64dd..0beea34 100644 --- a/test/api/test_contact.py +++ b/test/api/test_contact.py @@ -533,3 +533,21 @@ def test_modify_with_overrides(self, mock_requests): self.assertEqual(mock_requests.call_count, 1, "expected call") self.assertEqual(mock_requests.last_request.json(), jsonRequest) self.assertEqual(result.overrides, overrides) + + @requests_mock.mock() + def test_create_with_overrides(self, mock_requests): + overrides = {"title": True} + mock_requests.register_uri( + "POST", + "https://api.chartmogul.com/v1/contacts", + status_code=201, + json={**contact, "overrides": overrides}, + ) + + jsonRequest = {**createContact, "overrides": overrides} + config = Config("token") + result = Contact.create(config, data=jsonRequest).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual(mock_requests.last_request.json(), jsonRequest) + self.assertEqual(result.overrides, overrides) diff --git a/test/api/test_custom_attrs.py b/test/api/test_custom_attrs.py index aaeda76..25db21a 100644 --- a/test/api/test_custom_attrs.py +++ b/test/api/test_custom_attrs.py @@ -149,6 +149,25 @@ def test_add_with_overrides(self, mock_requests): self.assertEqual(result.custom, {"channel": "Facebook"}) self.assertEqual(result.overrides, overrides) + @requests_mock.mock() + def test_update_with_overrides(self, mock_requests): + overrides = {"custom": {"channel": True}} + mock_requests.register_uri( + "PUT", + "https://api.chartmogul.com/v1/customers/CUSTOMER_UUID/attributes/custom", + status_code=200, + json={"custom": {"channel": "Twitter"}, "overrides": overrides}, + ) + + jsonRequest = {"custom": {"channel": "Twitter"}, "overrides": overrides} + config = Config("token") + result = CustomAttributes.update(config, uuid="CUSTOMER_UUID", data=jsonRequest).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual(mock_requests.last_request.json(), jsonRequest) + self.assertEqual(result.custom, {"channel": "Twitter"}) + self.assertEqual(result.overrides, overrides) + @requests_mock.mock() def test_remove_with_overrides_and_message(self, mock_requests): mock_requests.register_uri( diff --git a/test/api/test_customer.py b/test/api/test_customer.py index 0d1ca90..f1525ab 100644 --- a/test/api/test_customer.py +++ b/test/api/test_customer.py @@ -1014,3 +1014,43 @@ def test_create_with_overrides(self, mock_requests): self.assertEqual(mock_requests.call_count, 1, "expected call") self.assertEqual(mock_requests.last_request.json()["overrides"], overrides) self.assertEqual(result.overrides, overrides) + + @requests_mock.mock() + def test_modify_with_overrides(self, mock_requests): + overrides = {"company": True, "attributes": {"custom": {"channel": True}}} + mock_requests.register_uri( + "PATCH", + "https://api.chartmogul.com/v1/customers/cus_5915ee5a-babd-406b-b8ce-d207133fb4cb", + status_code=200, + json={**entry, "overrides": overrides}, + ) + + jsonRequest = {"company": "Pinata Technologies", "overrides": overrides} + config = Config("token") + result = Customer.modify( + config, uuid="cus_5915ee5a-babd-406b-b8ce-d207133fb4cb", data=jsonRequest + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual(mock_requests.last_request.json(), jsonRequest) + self.assertEqual(result.overrides, overrides) + + @requests_mock.mock() + def test_createContact_with_overrides(self, mock_requests): + overrides = {"title": True} + mock_requests.register_uri( + "POST", + "https://api.chartmogul.com/v1/customers/cus_00000000-0000-0000-0000-000000000000/contacts", + status_code=201, + json={**contact, "overrides": overrides}, + ) + + jsonRequest = {**createContact, "overrides": overrides} + config = Config("token") + result = Customer.createContact( + config, uuid="cus_00000000-0000-0000-0000-000000000000", data=jsonRequest + ).get() + + self.assertEqual(mock_requests.call_count, 1, "expected call") + self.assertEqual(mock_requests.last_request.json(), jsonRequest) + self.assertEqual(result.overrides, overrides) From 845d2b48a535b747495f1f3164e0c7af69ab7462 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Wed, 14 Oct 2026 20:42:57 +0300 Subject: [PATCH 10/20] Reword test comments to avoid pin terminology --- test/api/test_attributes.py | 2 +- test/api/test_contact.py | 2 +- test/api/test_customer.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/api/test_attributes.py b/test/api/test_attributes.py index 5a7de8b..c469dfc 100644 --- a/test/api/test_attributes.py +++ b/test/api/test_attributes.py @@ -43,7 +43,7 @@ def test_retrieve(self, mock_requests): self.assertIsNone(result.overrides) self.assertIsNone(result.historical_values) - # case_sensitive pins that the SDK preserves attribute name casing in the query + # case_sensitive asserts that the SDK preserves attribute name casing in the query @requests_mock.Mocker(case_sensitive=True) def test_retrieve_with_overrides_and_history(self, mock_requests): mock_requests.register_uri( diff --git a/test/api/test_contact.py b/test/api/test_contact.py index 0beea34..cdf82c2 100644 --- a/test/api/test_contact.py +++ b/test/api/test_contact.py @@ -477,7 +477,7 @@ def test_destroy(self, mock_requests): self.assertEqual(mock_requests.last_request.qs, {}) self.assertTrue(expected, {}) - # case_sensitive pins that the SDK does not alter query param casing + # case_sensitive asserts that the SDK does not alter query param casing @requests_mock.Mocker(case_sensitive=True) def test_retrieve_with_overrides_and_history(self, mock_requests): overrides = {"title": True} diff --git a/test/api/test_customer.py b/test/api/test_customer.py index f1525ab..d2c84e9 100644 --- a/test/api/test_customer.py +++ b/test/api/test_customer.py @@ -949,7 +949,7 @@ def test_createTask(self, mock_requests): self.assertEqual(mock_requests.last_request.json(), createTask) self.assertTrue(isinstance(expected, Task)) - # case_sensitive pins that the SDK preserves attribute name casing in the query + # case_sensitive asserts that the SDK preserves attribute name casing in the query @requests_mock.Mocker(case_sensitive=True) def test_retrieve_with_overrides_and_history(self, mock_requests): overrides = {"company": True, "attributes": {"custom": {"salesRep": True}}} From 09c715d8ccbf386d6c75c4cedb9241a03c2da755 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Wed, 14 Oct 2026 20:45:31 +0300 Subject: [PATCH 11/20] Drop the case-sensitive mocker from the contact test since its field names are lowercase --- test/api/test_contact.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/api/test_contact.py b/test/api/test_contact.py index cdf82c2..1bb2d00 100644 --- a/test/api/test_contact.py +++ b/test/api/test_contact.py @@ -477,8 +477,7 @@ def test_destroy(self, mock_requests): self.assertEqual(mock_requests.last_request.qs, {}) self.assertTrue(expected, {}) - # case_sensitive asserts that the SDK does not alter query param casing - @requests_mock.Mocker(case_sensitive=True) + @requests_mock.mock() def test_retrieve_with_overrides_and_history(self, mock_requests): overrides = {"title": True} historical_values = { From c259637ee47707aeed578c04061744cb0153b100 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Wed, 14 Oct 2026 20:48:44 +0300 Subject: [PATCH 12/20] Explain override flag semantics in the README examples --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 6c6aec5..882c5a4 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,10 @@ chartmogul.Customer.modify(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4c "attributes": {"custom": {"channel": True}} } }) +# An overrides flag set to True protects the value written by the request from +# later updates; False removes an existing override, keeping the current value. +# API writes to an overridden field are reverted unless the request carries a +# flag for that field. chartmogul.Customer.destroy(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb') chartmogul.Customer.subscriptions(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb') chartmogul.Customer.connectSubscriptions(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={ @@ -364,6 +368,8 @@ chartmogul.CustomAttributes.remove(config, uuid='cus_5915ee5a-babd-406b-b8ce-d20 'custom': ['CAC'], 'overrides': {'custom': {'CAC': False}} }) +# remove keeps overridden attributes unless the request carries a False flag +# for them; the response message names the ones kept. ``` #### [Plans](https://dev.chartmogul.com/reference/plans/) From 0cd94cbf6213467f88aabade5cb5bc0e3111f963 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Wed, 14 Oct 2026 20:51:06 +0300 Subject: [PATCH 13/20] Note the overrides shape difference between customer and custom attribute endpoints --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 882c5a4..f55a21d 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,9 @@ chartmogul.CustomAttributes.add(config, uuid='cus_5915ee5a-babd-406b-b8ce-d20713 ], 'overrides': {'custom': {'channel': True}} }) +# Note the overrides shape here is {'custom': {...}} — on Customer create/modify +# and in Customer.retrieve responses the same flags nest under 'attributes': +# {'attributes': {'custom': {...}}} chartmogul.CustomAttributes.update(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={ 'custom': { 'age': 20, From 33a102acf08ac5c6a598930885f22739e246ffe1 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Wed, 14 Oct 2026 20:54:19 +0300 Subject: [PATCH 14/20] Note that customer overrides live at the top level, not under attributes --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f55a21d..5bf3400 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ chartmogul.Customer.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb chartmogul.Customer.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', with_overrides=True, attributes_with_history='company,custom.channel') # with_overrides and attributes_with_history only work on retrieve; list and search endpoints ignore them +# Overrides and history live at the top level of the customer (customer.overrides, +# customer.historical_values); customer.attributes.overrides is never populated chartmogul.Customer.search(config, email='email@email.com') chartmogul.Customer.merge(config, data={ 'from': {'customer_uuid': 'cus_5915ee5a-babd-406b-b8ce-d207133fb4cb'}, From 925b8a3944bfd0d12127cc5525826f837db38932 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Wed, 14 Oct 2026 20:56:32 +0300 Subject: [PATCH 15/20] Document override flag semantics and attributes_with_history format in the README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 5bf3400..2eb8d6c 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ chartmogul.Customer.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb chartmogul.Customer.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', with_overrides=True, attributes_with_history='company,custom.channel') # with_overrides and attributes_with_history only work on retrieve; list and search endpoints ignore them +# attributes_with_history is a comma-separated string of case-sensitive names; +# custom attributes take the custom. prefix; unknown names return a 422 # Overrides and history live at the top level of the customer (customer.overrides, # customer.historical_values); customer.attributes.overrides is never populated chartmogul.Customer.search(config, email='email@email.com') @@ -320,6 +322,8 @@ Note that the returned attributes of type date are not parsed and stay in string chartmogul.Attributes.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb') chartmogul.Attributes.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', with_overrides=True, attributes_with_history='custom.channel') +# attributes_with_history accepts custom attributes only on this endpoint; +# standard field names like 'company' return a 422 ``` #### [Tags](https://dev.chartmogul.com/reference/customers/tags/) From b05d8c28e4d175724f056a843dbf5155b17c925d Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Wed, 14 Oct 2026 21:00:31 +0300 Subject: [PATCH 16/20] Note that all write calls accept overrides in the README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2eb8d6c..772a7c9 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,9 @@ chartmogul.Customer.modify(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4c # An overrides flag set to True protects the value written by the request from # later updates; False removes an existing override, keeping the current value. # API writes to an overridden field are reverted unless the request carries a -# flag for that field. +# flag for that field. All write calls accept an overrides entry in data: +# Customer and Contact create/modify, Customer.createContact, and +# CustomAttributes add/update/remove (including add by email). chartmogul.Customer.destroy(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb') chartmogul.Customer.subscriptions(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb') chartmogul.Customer.connectSubscriptions(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={ From 0e4a844c00da7ba293583e6490414740c153c951 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Wed, 14 Oct 2026 21:02:48 +0300 Subject: [PATCH 17/20] Note that historical value timestamps stay as strings in the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 772a7c9..f252246 100644 --- a/README.md +++ b/README.md @@ -318,7 +318,7 @@ Date filters take ISO 8601 strings. `Task.patch` returns `None` when the API rep #### [Customer Attributes](https://dev.chartmogul.com/reference/customers/attributes/) -Note that the returned attributes of type date are not parsed and stay in string. +Note that the returned attributes of type date are not parsed and stay in string. The same applies inside `historical_values` entries: `update_performed_at` stays an ISO 8601 string. ```python chartmogul.Attributes.retrieve(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb') From 289508a877a2c95613ed62b855b3a645d0dab3ca Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Wed, 14 Oct 2026 21:04:57 +0300 Subject: [PATCH 18/20] Scope the omitted-keys schema comment to retrieve responses --- chartmogul/api/contact.py | 5 +++-- chartmogul/api/customer.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/chartmogul/api/contact.py b/chartmogul/api/contact.py index c38be20..d293f16 100644 --- a/chartmogul/api/contact.py +++ b/chartmogul/api/contact.py @@ -34,8 +34,9 @@ class _Schema(Schema): external_id = fields.String(allow_none=True, load_default=None) last_seen = fields.DateTime(allow_none=True) custom = fields.Dict(allow_none=True) - # load_default=None ensures these attributes are always present; the API - # omits the keys unless with_overrides / attributes_with_history is passed + # load_default=None ensures these attributes are always present; on + # retrieve the API omits the keys unless with_overrides / + # attributes_with_history is passed overrides = fields.Dict(load_default=None) historical_values = fields.Dict(load_default=None) diff --git a/chartmogul/api/customer.py b/chartmogul/api/customer.py index 901c3ee..7facb95 100644 --- a/chartmogul/api/customer.py +++ b/chartmogul/api/customer.py @@ -72,8 +72,9 @@ class _Schema(Schema): currency_sign = fields.String(data_key="currency-sign") address = fields.Nested(Address._Schema, allow_none=True, unknown=EXCLUDE) website_url = fields.String(allow_none=True) - # load_default=None ensures these attributes are always present; the API - # omits the keys unless with_overrides / attributes_with_history is passed + # load_default=None ensures these attributes are always present; on + # retrieve the API omits the keys unless with_overrides / + # attributes_with_history is passed overrides = fields.Dict(load_default=None) historical_values = fields.Dict(load_default=None) From 3997fda06abce8b236fb02fdddc487eadea36f80 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Thu, 15 Oct 2026 09:13:11 +0300 Subject: [PATCH 19/20] Use API instead of an email in historical value fixtures --- test/api/test_attributes.py | 2 +- test/api/test_contact.py | 2 +- test/api/test_customer.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/api/test_attributes.py b/test/api/test_attributes.py index c469dfc..610bc48 100644 --- a/test/api/test_attributes.py +++ b/test/api/test_attributes.py @@ -13,7 +13,7 @@ { "value": "Gabi", "update_performed_at": "2026-01-01T16:58:58Z", - "update_performed_by": "adam@example.com", + "update_performed_by": "API", "initial": False, } ] diff --git a/test/api/test_contact.py b/test/api/test_contact.py index 1bb2d00..929662f 100644 --- a/test/api/test_contact.py +++ b/test/api/test_contact.py @@ -485,7 +485,7 @@ def test_retrieve_with_overrides_and_history(self, mock_requests): { "value": "CEO", "update_performed_at": "2026-01-01T16:58:58Z", - "update_performed_by": "adam@example.com", + "update_performed_by": "API", "initial": False, } ] diff --git a/test/api/test_customer.py b/test/api/test_customer.py index d2c84e9..6536920 100644 --- a/test/api/test_customer.py +++ b/test/api/test_customer.py @@ -958,7 +958,7 @@ def test_retrieve_with_overrides_and_history(self, mock_requests): { "value": "Example Company", "update_performed_at": "2026-01-01T16:58:58Z", - "update_performed_by": "adam@example.com", + "update_performed_by": "API", "initial": False, } ], From 69098ba08fff7d6bf1d56229ad48dd1a30992956 Mon Sep 17 00:00:00 2001 From: Tudor Pavel Date: Thu, 15 Oct 2026 09:25:47 +0300 Subject: [PATCH 20/20] Correct the false flag semantics in the README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f252246..ad54c95 100644 --- a/README.md +++ b/README.md @@ -166,9 +166,9 @@ chartmogul.Customer.modify(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4c } }) # An overrides flag set to True protects the value written by the request from -# later updates; False removes an existing override, keeping the current value. -# API writes to an overridden field are reverted unless the request carries a -# flag for that field. All write calls accept an overrides entry in data: +# later updates; False removes an existing override and restores the field's +# most recent value from edit history. API writes to an overridden field are +# reverted unless the request carries a flag for that field. All write calls accept an overrides entry in data: # Customer and Contact create/modify, Customer.createContact, and # CustomAttributes add/update/remove (including add by email). chartmogul.Customer.destroy(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb')