Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a64fb20
Support overrides and historical values in customer, contact and attr…
tudorpavel Sep 28, 2026
9235255
Default overrides, historical_values and message to None so absent re…
tudorpavel Oct 5, 2026
d3e2e91
Use case-sensitive request mocks and real attribute casing in with-hi…
tudorpavel Oct 5, 2026
4c2354b
Default custom to None so a DELETE of the last attribute does not rai…
tudorpavel Oct 5, 2026
50db223
Note in README that overrides query params only apply to single-recor…
tudorpavel Oct 5, 2026
31952ea
Mock 201 for the create customer endpoint to match the API
tudorpavel Oct 6, 2026
84d38fc
Mock overrides on write responses and move the absent-key None checks…
tudorpavel Oct 14, 2026
3c62539
Exercise the by-email endpoint in test_add_to_email and assert entry …
tudorpavel Oct 14, 2026
b3daeb8
Pin request bodies and echoed overrides on the remaining write surfaces
tudorpavel Oct 14, 2026
845d2b4
Reword test comments to avoid pin terminology
tudorpavel Oct 14, 2026
09c715d
Drop the case-sensitive mocker from the contact test since its field …
tudorpavel Oct 14, 2026
c259637
Explain override flag semantics in the README examples
tudorpavel Oct 14, 2026
0cd94cb
Note the overrides shape difference between customer and custom attri…
tudorpavel Oct 14, 2026
33a102a
Note that customer overrides live at the top level, not under attributes
tudorpavel Oct 14, 2026
925b8a3
Document override flag semantics and attributes_with_history format i…
tudorpavel Oct 14, 2026
b05d8c2
Note that all write calls accept overrides in the README
tudorpavel Oct 14, 2026
0e4a844
Note that historical value timestamps stay as strings in the README
tudorpavel Oct 14, 2026
289508a
Scope the omitted-keys schema comment to retrieve responses
tudorpavel Oct 14, 2026
3997fda
Use API instead of an email in historical value fixtures
tudorpavel Oct 15, 2026
69098ba
Correct the false flag semantics in the README
tudorpavel Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ 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')
# 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.

# 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')
chartmogul.Customer.merge(config, data={
'from': {'customer_uuid': 'cus_5915ee5a-babd-406b-b8ce-d207133fb4cb'},
Expand All @@ -150,6 +157,20 @@ 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": {

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.

"company": True,
"address": True,
"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 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')
chartmogul.Customer.subscriptions(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb')
chartmogul.Customer.connectSubscriptions(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={
Expand Down Expand Up @@ -196,11 +217,18 @@ 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')
# 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",
"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={})
Expand Down Expand Up @@ -290,10 +318,14 @@ 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')
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

# 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/)
Expand Down Expand Up @@ -325,6 +357,15 @@ 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}}

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.

Worth calling out that the overrides shape differs by endpoint:

  • Customer create/modify (and Customer.retrieve responses) nest custom attributes under attributes: {"attributes": {"custom": {...}}}
  • CustomAttributes writes and Attributes.retrieve don't use that wrapper: {"custom": {...}}

The examples are correct, but it's easy to copy the wrong one between sections. A one-line note would help.

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 note 0cd94cb 👍

})
# 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={

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 👍

'custom': {
'age': 20,
Expand All @@ -334,6 +375,12 @@ 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}}
})
# 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/)
Expand Down
5 changes: 5 additions & 0 deletions chartmogul/api/attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ 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()
# 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. 👍

historical_values = fields.Dict(load_default=None)

@post_load
def make(self, data, **kwargs):
Expand Down
6 changes: 6 additions & 0 deletions chartmogul/api/contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class Contact(Resource):

_path = "/contacts{/uuid}"
_root_key = "entries"
_bool_query_params = ["with_overrides"]
Comment thread
tudorpavel marked this conversation as resolved.
_many = namedtuple("Contacts", [_root_key, "has_more", "cursor"])

class _Schema(Schema):
Expand All @@ -33,6 +34,11 @@ 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; 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)

@post_load
def make(self, data, **kwargs):
Expand Down
7 changes: 6 additions & 1 deletion chartmogul/api/custom_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ 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)

@post_load
def make(self, data, **kwargs):
Expand Down
6 changes: 6 additions & 0 deletions chartmogul/api/customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class Customer(Resource):

_path = "/customers{/uuid}"
_root_key = "entries"
_bool_query_params = ["with_overrides"]
Comment thread
tudorpavel marked this conversation as resolved.
_many = namedtuple("Customers", [_root_key, "has_more", "cursor"], defaults=[None, None])

class _Schema(Schema):
Expand Down Expand Up @@ -71,6 +72,11 @@ 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; 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)

@post_load
def make(self, data, **kwargs):
Expand Down
72 changes: 72 additions & 0 deletions test/api/test_attributes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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": "API",
"initial": False,
}
]
}
},
}


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)

# 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(
"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"])
76 changes: 76 additions & 0 deletions test/api/test_contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -474,3 +476,77 @@ 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": "API",
"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)

@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)
Loading