From 908ffd2cbd97452ab563f0adf1f47e77d702b6af Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Thu, 9 Apr 2026 11:34:40 +0200 Subject: [PATCH 1/3] Add DataSource json_imports and import_status methods (PIP-310) - DataSource.import_json: POST /data_sources/{uuid}/json_imports - DataSource.import_status: GET /data_sources/{uuid}/json_imports/{import_id} Co-Authored-By: Claude Opus 4.6 (1M context) --- chartmogul/api/data_source.py | 6 ++++ test/api/test_data_source_imports.py | 52 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 test/api/test_data_source_imports.py diff --git a/chartmogul/api/data_source.py b/chartmogul/api/data_source.py index e689dec..00e2058 100644 --- a/chartmogul/api/data_source.py +++ b/chartmogul/api/data_source.py @@ -53,3 +53,9 @@ def make(self, data, **kwargs): return DataSource(**data) _schema = _Schema(unknown=EXCLUDE) + + +DataSource.import_json = DataSource._method( + "create", "post", "/data_sources{/uuid}/json_imports") +DataSource.import_status = DataSource._method( + "retrieve", "get", "/data_sources{/uuid}/json_imports{/import_id}") diff --git a/test/api/test_data_source_imports.py b/test/api/test_data_source_imports.py new file mode 100644 index 0000000..576ad60 --- /dev/null +++ b/test/api/test_data_source_imports.py @@ -0,0 +1,52 @@ +import unittest + +import requests_mock + +from chartmogul import DataSource, Config + + +class DataSourceImportsTestCase(unittest.TestCase): + + @requests_mock.mock() + def test_import_json(self, mock_requests): + mock_requests.register_uri( + "POST", + "https://api.chartmogul.com/v1/data_sources/ds_123/json_imports", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json={"id": "imp_456"}, + ) + + config = Config("token") + result = DataSource.import_json( + config, uuid="ds_123", data={"customers": []} + ).get() + + self.assertEqual(mock_requests.call_count, 1) + self.assertEqual( + mock_requests.last_request.json(), {"customers": []} + ) + + @requests_mock.mock() + def test_import_status(self, mock_requests): + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/data_sources/ds_123/json_imports/imp_456", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json={ + "uuid": "ds_123", + "name": "test", + "created_at": "2025-01-01T00:00:00.000Z", + "status": "idle", + "system": "Import API", + }, + ) + + config = Config("token") + result = DataSource.import_status( + config, uuid="ds_123", import_id="imp_456" + ).get() + + self.assertEqual(mock_requests.call_count, 1) + self.assertTrue(isinstance(result, DataSource)) From 7f3d54e2deddbf1713da18acec97623808a8d772 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Tue, 14 Apr 2026 09:27:17 +0200 Subject: [PATCH 2/3] Refactor bulk import into dedicated JsonImport resource (PIP-310) Replace monkey-patched DataSource.import_json/import_status with a dedicated JsonImport class that has its own schema, so response fields (id, external_id, status, status_details) are properly deserialized instead of being silently dropped by DataSource's EXCLUDE schema. Co-Authored-By: Claude Opus 4.6 (1M context) --- chartmogul/__init__.py | 1 + chartmogul/api/data_source.py | 6 - chartmogul/api/json_import.py | 31 +++ test/api/test_data_source_imports.py | 52 ----- test/api/test_json_import.py | 271 +++++++++++++++++++++++++++ 5 files changed, 303 insertions(+), 58 deletions(-) create mode 100644 chartmogul/api/json_import.py delete mode 100644 test/api/test_data_source_imports.py create mode 100644 test/api/test_json_import.py diff --git a/chartmogul/__init__.py b/chartmogul/__init__.py index 9b94c1b..28a2d6b 100644 --- a/chartmogul/__init__.py +++ b/chartmogul/__init__.py @@ -29,6 +29,7 @@ from .api.account import Account from .api.activity import Activity from .api.activities_export import ActivitiesExport +from .api.json_import import JsonImport from .version import __version__ diff --git a/chartmogul/api/data_source.py b/chartmogul/api/data_source.py index 00e2058..e689dec 100644 --- a/chartmogul/api/data_source.py +++ b/chartmogul/api/data_source.py @@ -53,9 +53,3 @@ def make(self, data, **kwargs): return DataSource(**data) _schema = _Schema(unknown=EXCLUDE) - - -DataSource.import_json = DataSource._method( - "create", "post", "/data_sources{/uuid}/json_imports") -DataSource.import_status = DataSource._method( - "retrieve", "get", "/data_sources{/uuid}/json_imports{/import_id}") diff --git a/chartmogul/api/json_import.py b/chartmogul/api/json_import.py new file mode 100644 index 0000000..ec9f16b --- /dev/null +++ b/chartmogul/api/json_import.py @@ -0,0 +1,31 @@ +from marshmallow import Schema, fields, post_load, EXCLUDE +from ..resource import Resource + + +class JsonImport(Resource): + """ + https://dev.chartmogul.com/reference/bulk-import/ + """ + + _path = "/data_sources{/uuid}/json_imports{/import_id}" + + class _Schema(Schema): + id = fields.String() + data_source_uuid = fields.String(allow_none=True) + status = fields.String(allow_none=True) + external_id = fields.String(allow_none=True) + status_details = fields.Raw(allow_none=True) + created_at = fields.DateTime(allow_none=True) + updated_at = fields.DateTime(allow_none=True) + + @post_load + def make(self, data, **kwargs): + return JsonImport(**data) + + _schema = _Schema(unknown=EXCLUDE) + + +JsonImport.create = JsonImport._method( + "create", "post", "/data_sources{/uuid}/json_imports") +JsonImport.retrieve = JsonImport._method( + "retrieve", "get", "/data_sources{/uuid}/json_imports{/import_id}") diff --git a/test/api/test_data_source_imports.py b/test/api/test_data_source_imports.py deleted file mode 100644 index 576ad60..0000000 --- a/test/api/test_data_source_imports.py +++ /dev/null @@ -1,52 +0,0 @@ -import unittest - -import requests_mock - -from chartmogul import DataSource, Config - - -class DataSourceImportsTestCase(unittest.TestCase): - - @requests_mock.mock() - def test_import_json(self, mock_requests): - mock_requests.register_uri( - "POST", - "https://api.chartmogul.com/v1/data_sources/ds_123/json_imports", - request_headers={"Authorization": "Basic dG9rZW46"}, - status_code=200, - json={"id": "imp_456"}, - ) - - config = Config("token") - result = DataSource.import_json( - config, uuid="ds_123", data={"customers": []} - ).get() - - self.assertEqual(mock_requests.call_count, 1) - self.assertEqual( - mock_requests.last_request.json(), {"customers": []} - ) - - @requests_mock.mock() - def test_import_status(self, mock_requests): - mock_requests.register_uri( - "GET", - "https://api.chartmogul.com/v1/data_sources/ds_123/json_imports/imp_456", - request_headers={"Authorization": "Basic dG9rZW46"}, - status_code=200, - json={ - "uuid": "ds_123", - "name": "test", - "created_at": "2025-01-01T00:00:00.000Z", - "status": "idle", - "system": "Import API", - }, - ) - - config = Config("token") - result = DataSource.import_status( - config, uuid="ds_123", import_id="imp_456" - ).get() - - self.assertEqual(mock_requests.call_count, 1) - self.assertTrue(isinstance(result, DataSource)) diff --git a/test/api/test_json_import.py b/test/api/test_json_import.py new file mode 100644 index 0000000..87384e2 --- /dev/null +++ b/test/api/test_json_import.py @@ -0,0 +1,271 @@ +import unittest + +import requests_mock + +from chartmogul import JsonImport, Config + + +importRequestData = { + "external_id": "import_batch_2026_04", + "customers": [ + { + "external_id": "cus_acme_001", + "name": "Acme Corp", + "email": "billing@acme.com", + "company": "Acme Corporation", + "country": "US", + "state": "US-CA", + "city": "San Francisco", + "zip": "94105", + "lead_created_at": "2025-10-15T00:00:00Z", + "free_trial_started_at": "2025-11-01T00:00:00Z", + }, + { + "external_id": "cus_globex_002", + "name": "Globex Inc", + "email": "accounts@globex.io", + "company": "Globex Inc", + "country": "DE", + "city": "Berlin", + }, + ], + "plans": [ + { + "name": "Professional Monthly", + "external_id": "plan_pro_monthly", + "interval_count": 1, + "interval_unit": "month", + }, + { + "name": "Enterprise Annual", + "external_id": "plan_ent_annual", + "interval_count": 1, + "interval_unit": "year", + }, + ], + "invoices": [ + { + "external_id": "inv_2025_11_001", + "customer_external_id": "cus_acme_001", + "date": "2025-11-01T00:00:00Z", + "due_date": "2025-12-01T00:00:00Z", + "currency": "USD", + "collection_method": "automatic", + }, + { + "external_id": "inv_2025_12_001", + "customer_external_id": "cus_acme_001", + "date": "2025-12-01T00:00:00Z", + "due_date": "2026-01-01T00:00:00Z", + "currency": "USD", + }, + { + "external_id": "inv_2025_11_002", + "customer_external_id": "cus_globex_002", + "date": "2025-11-15T00:00:00Z", + "due_date": "2025-12-15T00:00:00Z", + "currency": "EUR", + "collection_method": "manual", + }, + ], + "line_items": [ + { + "invoice_external_id": "inv_2025_11_001", + "type": "subscription", + "amount_in_cents": 9900, + "quantity": 5, + "plan_external_id": "plan_pro_monthly", + "subscription_external_id": "sub_acme_pro", + "service_period_start": "2025-11-01T00:00:00Z", + "service_period_end": "2025-12-01T00:00:00Z", + }, + { + "invoice_external_id": "inv_2025_12_001", + "type": "subscription", + "amount_in_cents": 9900, + "quantity": 5, + "plan_external_id": "plan_pro_monthly", + "subscription_external_id": "sub_acme_pro", + "service_period_start": "2025-12-01T00:00:00Z", + "service_period_end": "2026-01-01T00:00:00Z", + "discount_amount_in_cents": 500, + "discount_code": "WINTER25", + "tax_amount_in_cents": 1700, + }, + { + "invoice_external_id": "inv_2025_11_002", + "type": "subscription", + "amount_in_cents": 249900, + "quantity": 1, + "plan_external_id": "plan_ent_annual", + "subscription_external_id": "sub_globex_ent", + "service_period_start": "2025-11-15T00:00:00Z", + "service_period_end": "2026-11-15T00:00:00Z", + "tax_amount_in_cents": 47481, + "transaction_fees_in_cents": 7497, + "transaction_fees_currency": "EUR", + }, + { + "invoice_external_id": "inv_2025_11_001", + "type": "one_time", + "amount_in_cents": 5000, + "description": "Onboarding setup fee", + }, + ], + "transactions": [ + { + "invoice_external_id": "inv_2025_11_001", + "external_id": "txn_001", + "type": "payment", + "result": "successful", + "date": "2025-11-01T12:30:00Z", + }, + { + "invoice_external_id": "inv_2025_12_001", + "external_id": "txn_002", + "type": "payment", + "result": "successful", + "date": "2025-12-01T08:15:00Z", + }, + { + "invoice_external_id": "inv_2025_11_002", + "external_id": "txn_003", + "type": "payment", + "result": "successful", + "date": "2025-11-16T10:00:00Z", + "amount_in_cents": 249900, + }, + ], + "subscription_events": [ + { + "external_id": "evt_acme_start", + "customer_external_id": "cus_acme_001", + "subscription_external_id": "sub_acme_pro", + "plan_external_id": "plan_pro_monthly", + "event_type": "subscription_start", + "event_date": "2025-11-01T00:00:00Z", + "effective_date": "2025-11-01T00:00:00Z", + "currency": "USD", + "amount_in_cents": 9900, + "quantity": 5, + }, + { + "external_id": "evt_globex_start", + "customer_external_id": "cus_globex_002", + "subscription_external_id": "sub_globex_ent", + "subscription_set_external_id": "set_globex_main", + "plan_external_id": "plan_ent_annual", + "event_type": "subscription_start", + "event_date": "2025-11-15T00:00:00Z", + "effective_date": "2025-11-15T00:00:00Z", + "currency": "EUR", + "amount_in_cents": 249900, + "quantity": 1, + "tax_amount_in_cents": 47481, + }, + ], +} + +importResponseData = { + "id": "4815d987-abcd-11ee-a987-978df45c5114", + "data_source_uuid": "ds_45d064ca-fcf8-11f0-903f-33618f80d753", + "status": "queued", + "external_id": "import_batch_2026_04", + "status_details": {}, + "created_at": "2026-04-14T10:30:00Z", + "updated_at": "2026-04-14T10:30:00Z", +} + +importStatusResponseData = { + "id": "4815d987-abcd-11ee-a987-978df45c5114", + "data_source_uuid": "ds_45d064ca-fcf8-11f0-903f-33618f80d753", + "status": "completed", + "external_id": "import_batch_2026_04", + "status_details": { + "plans": {"status": "imported"}, + "cus_acme_001": { + "status": "imported", + "invoices": {"status": "imported"}, + "customers": {"status": "imported"}, + "line_items": {"status": "imported"}, + "transactions": {"status": "imported"}, + "subscription_events": {"status": "imported"}, + }, + "cus_globex_002": { + "status": "imported", + "invoices": {"status": "imported"}, + "customers": {"status": "imported"}, + "line_items": {"status": "imported"}, + "transactions": {"status": "imported"}, + "subscription_events": {"status": "imported"}, + }, + }, + "created_at": "2026-04-14T10:30:00Z", + "updated_at": "2026-04-14T10:31:15Z", +} + + +class JsonImportTestCase(unittest.TestCase): + + @requests_mock.mock() + def test_create(self, mock_requests): + mock_requests.register_uri( + "POST", + "https://api.chartmogul.com/v1/data_sources/ds_45d064ca-fcf8-11f0-903f-33618f80d753/json_imports", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=importResponseData, + ) + + config = Config("token") + result = JsonImport.create( + config, + uuid="ds_45d064ca-fcf8-11f0-903f-33618f80d753", + data=importRequestData, + ).get() + + self.assertEqual(mock_requests.call_count, 1) + sent = mock_requests.last_request.json() + self.assertEqual(sent["external_id"], "import_batch_2026_04") + self.assertEqual(len(sent["customers"]), 2) + self.assertEqual(sent["customers"][0]["external_id"], "cus_acme_001") + self.assertEqual(len(sent["plans"]), 2) + self.assertEqual(sent["plans"][0]["interval_unit"], "month") + self.assertEqual(len(sent["invoices"]), 3) + self.assertEqual(len(sent["line_items"]), 4) + self.assertEqual(len(sent["transactions"]), 3) + self.assertEqual(len(sent["subscription_events"]), 2) + + self.assertTrue(isinstance(result, JsonImport)) + self.assertEqual(result.id, "4815d987-abcd-11ee-a987-978df45c5114") + self.assertEqual(result.data_source_uuid, "ds_45d064ca-fcf8-11f0-903f-33618f80d753") + self.assertEqual(result.status, "queued") + self.assertEqual(result.external_id, "import_batch_2026_04") + self.assertEqual(result.status_details, {}) + + @requests_mock.mock() + def test_retrieve(self, mock_requests): + mock_requests.register_uri( + "GET", + "https://api.chartmogul.com/v1/data_sources/ds_45d064ca-fcf8-11f0-903f-33618f80d753/json_imports/4815d987-abcd-11ee-a987-978df45c5114", + request_headers={"Authorization": "Basic dG9rZW46"}, + status_code=200, + json=importStatusResponseData, + ) + + config = Config("token") + result = JsonImport.retrieve( + config, + uuid="ds_45d064ca-fcf8-11f0-903f-33618f80d753", + import_id="4815d987-abcd-11ee-a987-978df45c5114", + ).get() + + self.assertEqual(mock_requests.call_count, 1) + self.assertTrue(isinstance(result, JsonImport)) + self.assertEqual(result.id, "4815d987-abcd-11ee-a987-978df45c5114") + self.assertEqual(result.status, "completed") + self.assertEqual(result.external_id, "import_batch_2026_04") + self.assertEqual(result.status_details["plans"]["status"], "imported") + self.assertEqual( + result.status_details["cus_acme_001"]["invoices"]["status"], "imported" + ) From 87b5c34943e15f3da0e2dcc8f203b2f8fcf5d652 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Wed, 15 Apr 2026 12:09:39 +0200 Subject: [PATCH 3/3] Rename params: uuid -> data_source_uuid, import_id -> id Address review feedback from loomchild: use data_source_uuid consistently with the rest of the SDK, and id for the import identifier. Add custom _validate_arguments for both params. Co-Authored-By: Claude Opus 4.6 (1M context) --- chartmogul/api/json_import.py | 16 +++++++++++++--- test/api/test_json_import.py | 6 +++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/chartmogul/api/json_import.py b/chartmogul/api/json_import.py index ec9f16b..e4f63b2 100644 --- a/chartmogul/api/json_import.py +++ b/chartmogul/api/json_import.py @@ -1,5 +1,6 @@ from marshmallow import Schema, fields, post_load, EXCLUDE from ..resource import Resource +from chartmogul import ArgumentMissingError class JsonImport(Resource): @@ -7,7 +8,7 @@ class JsonImport(Resource): https://dev.chartmogul.com/reference/bulk-import/ """ - _path = "/data_sources{/uuid}/json_imports{/import_id}" + _path = "/data_sources{/data_source_uuid}/json_imports{/id}" class _Schema(Schema): id = fields.String() @@ -24,8 +25,17 @@ def make(self, data, **kwargs): _schema = _Schema(unknown=EXCLUDE) + @classmethod + def _validate_arguments(cls, method, kwargs): + if method in ["create", "retrieve"] and "data_source_uuid" not in kwargs: + raise ArgumentMissingError("Please pass 'data_source_uuid' parameter") + if method in ["create"] and "data" not in kwargs: + raise ArgumentMissingError("Please pass 'data' parameter") + if method in ["retrieve"] and "id" not in kwargs: + raise ArgumentMissingError("Please pass 'id' parameter") + JsonImport.create = JsonImport._method( - "create", "post", "/data_sources{/uuid}/json_imports") + "create", "post", "/data_sources{/data_source_uuid}/json_imports") JsonImport.retrieve = JsonImport._method( - "retrieve", "get", "/data_sources{/uuid}/json_imports{/import_id}") + "retrieve", "get", "/data_sources{/data_source_uuid}/json_imports{/id}") diff --git a/test/api/test_json_import.py b/test/api/test_json_import.py index 87384e2..6401566 100644 --- a/test/api/test_json_import.py +++ b/test/api/test_json_import.py @@ -220,7 +220,7 @@ def test_create(self, mock_requests): config = Config("token") result = JsonImport.create( config, - uuid="ds_45d064ca-fcf8-11f0-903f-33618f80d753", + data_source_uuid="ds_45d064ca-fcf8-11f0-903f-33618f80d753", data=importRequestData, ).get() @@ -256,8 +256,8 @@ def test_retrieve(self, mock_requests): config = Config("token") result = JsonImport.retrieve( config, - uuid="ds_45d064ca-fcf8-11f0-903f-33618f80d753", - import_id="4815d987-abcd-11ee-a987-978df45c5114", + data_source_uuid="ds_45d064ca-fcf8-11f0-903f-33618f80d753", + id="4815d987-abcd-11ee-a987-978df45c5114", ).get() self.assertEqual(mock_requests.call_count, 1)