Skip to content

Support overrides and historical values on customer, contact and attributes endpoints - #145

Merged
tudorpavel merged 20 commits into
mainfrom
tudorpavel/overrides-and-historical-values
Oct 15, 2026
Merged

tudorpavel merged 20 commits into
mainfrom
tudorpavel/overrides-and-historical-values

Conversation

@tudorpavel

@tudorpavel tudorpavel commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Adds support for the overrides and historical values API to the Python client, targeting the current API where write responses always include overrides.

  • Customer, Contact and Attributes register with_overrides in _bool_query_params so with_overrides=True serializes as true (same pattern as with_processing_status on DataSource); attributes_with_history passes through as a plain string query param.
  • Marshmallow schemas use unknown=EXCLUDE, so overrides and historical_values were dropped from responses; fields.Dict(load_default=None) entries on the Customer, Contact and Attributes schemas now keep them and make the attributes always present (None when the API omits the key), with attribute names round-tripping verbatim (Dict fields don't convert keys). The CustomAttributes schema keeps overrides, custom (absent when a DELETE removes the last attribute) and the DELETE message the same way; the add-by-email response loads through Customer._Schema and picks up overrides from there.
  • Request data dicts are dumped as-is, so overrides already reaches the API on every write; tests pin the outgoing bodies (last_request.json()) and query strings.
  • Tests: 210 passing (+14), flake8 clean; every write surface pins its outgoing body and the echoed overrides (distinguishing {} from None), mock statuses match the API (201 on creates, 202 on deletes), the by-email test exercises the real /customers/attributes/custom endpoint and asserts entry overrides, and the with-history tests use case-sensitive request mocks where attribute names are mixed-case (the API matches names case-sensitively).
  • README examples added for retrieves with the new params and writes with overrides, plus notes on flag semantics, which write calls accept overrides, the payload shape difference between the customer and custom attribute endpoints, the attributes_with_history format, that overrides live at the top level of the customer, and that historical value timestamps stay as strings.

@keith-chartmogul keith-chartmogul left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a Python developer, so Claude assisted with the heavy lifting on this review. There were a few points raised during the review, which all seem legitimate to my untrained eye, so I posted inline comments with those findings. Please feel free to ignore if they do not apply, but I want to make sure you see them and consider them before choosing to ignore or act on any of them. 👍

Comment thread test/api/test_attributes.py Outdated
Comment thread chartmogul/api/customer.py Outdated
Comment thread test/api/test_custom_attrs.py
Comment thread chartmogul/api/contact.py
Comment thread chartmogul/api/customer.py
@tudorpavel
tudorpavel force-pushed the tudorpavel/overrides-and-historical-values branch from 7cafdef to 31952ea Compare October 13, 2026 10:07

@keith-chartmogul keith-chartmogul left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some more findings from another review round. All testing and documentation related, but I think these are all probably worth doing ahead of the release. 💪

Comment thread test/api/test_customer.py
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also applies to line 107 in test/api/test_custom_attrs.py

This now asserts overrides is None on a create response, but POST /customers always returns overrides (it renders with with_overrides: true). The same goes for test_custom_attrs.py:107: POST .../attributes/custom always includes overrides in its response. So both assertions pin a response shape the API can't produce, and they contradict the PR description ("write responses always include overrides").

Could we add overrides to the mocked responses for these two tests, and move the "missing key → None" check to a plain Customer.retrieve test? test_customer.py doesn't have one yet; only test_retrieve_with_overrides_and_history exists. Small related thing: test_create still mocks a 200, while the new test_create_with_overrides correctly uses 201.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 84d38fc 👍

Comment thread test/api/test_custom_attrs.py Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions that the add-by-email response picks up overrides through Customer._Schema, and the API does return overrides for each customer in that response. But this test's fixture (jsonResponse2) has no overrides and the test makes no assertions on the result, so nothing actually covers it.

Also, this calls CustomAttributes.add with uuid="CUSTOMER_UUID", so it hits the single-customer endpoint rather than the by-email one (/customers/attributes/custom). That isn't new to this PR, but since we're relying on this path now, could we call it without uuid (or via add_by_email), add overrides to the fixture entries, and assert result.entries[0].overrides?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed 3c62539 👍 mocks being mocks..

Comment thread test/api/test_customer.py
self.assertEqual(result.historical_values, historical_values)

@requests_mock.mock()
def test_create_with_overrides(self, mock_requests):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. Could we add a matching test for Customer.modify with overrides? It's the main example in the README, and right now only create is tested.

The API also accepts overrides on two other writes that have no tests:

  • Contact.create / Customer.createContact
  • CustomAttributes.update (PUT)

They all serialize the body the same way, so the risk is low. A short body-pinning test for each would still back up the README, for example:

self.assertEqual(mock_requests.last_request.json(), jsonRequest)
self.assertEqual(result.overrides, overrides)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done b3daeb8 👍

Comment thread test/api/test_contact.py Outdated
self.assertEqual(mock_requests.last_request.qs, {})
self.assertTrue(expected, {})

# case_sensitive pins that the SDK does not alter query param casing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: title is all lowercase, so case_sensitive=True and the comment above it don't prove anything here; the test would pass either way. I'd drop the comment and use the default @requests_mock.mock(), unless we use a mixed-case value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right 09c715d — with all-lowercase contact field names it discriminates nothing, and a mixed-case value would pin a shape the contact endpoint rejects (field names match case-sensitively, so Title 422s). Reverted to the default mocker; the customer and attributes tests keep case_sensitive=True where salesRep makes it meaningful.

Comment thread README.md
})
chartmogul.Customer.modify(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={
"company": "Pinata Technologies",
"overrides": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The examples show the payload shape, but not what the flags actually do. Could we add a sentence or two? Something like:

  • True pins the field's value after the write.
  • False unpins it and restores the most recent value from edit history.
  • While a field is pinned, API writes to it are reverted unless the request includes an override for that field.
  • CustomAttributes.remove skips pinned attributes; the response's message says which ones were kept.

Without this, someone updating a pinned field will see their write silently undone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added c259637 some short explanations 👍

@tudorpavel tudorpavel Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tweaked 69098ba

Correction to my earlier reply: you were right about the restore. UnpinService#unpin_field! ends with restore_most_recent_value! — blocked writes still land in edit history, so removing the override catches the field up to them. Our live check couldn't distinguish this because the pinned value was also the latest history entry. The README note is fixed accordingly.

custom = 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Customer._Schema.attributes reuses this schema for its nested attributes field, so a Customer now always has customer.attributes.overrides and customer.attributes.historical_values, both None. On a customer, custom-attribute pins and history actually live in customer.overrides["attributes"] and customer.historical_values["attributes"], so these two will never be filled in.

I don't think it's worth a separate nested schema, but a README note would stop people reaching for customer.attributes.overrides.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hope 33a102a is good enough. 👍

Comment thread README.md
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we document what format attributes_with_history takes?

  • It's a comma-separated string, and names are case-sensitive.
  • Custom attributes need the custom. prefix.
  • At most 100 names; unknown names return a 422.
  • Attributes.retrieve only accepts custom attributes, so attributes_with_history='company' there returns a 422.

I'd keep it a string rather than also accepting a list: the Ruby, Node, PHP and Go SDKs all take a comma-separated string.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hope 925b8a3 makes sense, I kept the 100 cap hidden from dev docs so I'll do the same here.

Comment thread README.md
],
'overrides': {'custom': {'channel': True}}
})
chartmogul.CustomAttributes.update(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API also accepts overrides on these writes, but the README has no example for them:

  • Customer.create
  • Contact.create / Customer.createContact
  • CustomAttributes.update
  • CustomAttributes.add by email

Either an example for each, or a single line saying "all create/modify/update/add/remove calls accept an overrides dict", would cover it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with the one-liner b05d8c2 👍

Comment thread README.md
```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')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Historical value entries come back as plain dicts, so update_performed_at stays an ISO 8601 string and isn't converted to a datetime, unlike other datetime fields in the SDK. Since this section already notes that date attributes stay as strings, could we extend that note to cover historical_values?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 0e4a844

Comment thread chartmogul/api/customer.py Outdated
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also applies to line 38 in chartmogul/api/contact.py

Small accuracy fix: write responses (create/modify) always include overrides, so "omits the keys unless with_overrides / attributes_with_history is passed" only holds for retrieve. Maybe "on retrieve, the API omits these keys unless…"? The same wording is in contact.py:38. It's fine as is in attributes.py, since that endpoint is GET-only.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified 289508a

@tudorpavel
tudorpavel merged commit 2f97f44 into main Oct 15, 2026
9 checks passed
@tudorpavel
tudorpavel deleted the tudorpavel/overrides-and-historical-values branch October 15, 2026 09:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants