From 6e89ef7af7b98db8cd11e07e99416ba342f4f4dc Mon Sep 17 00:00:00 2001 From: Albert Date: Wed, 2 Sep 2026 11:56:25 +0000 Subject: [PATCH 1/2] feat(api): add server batch pass-through --- CHANGELOG.md | 6 ++++++ README.md | 14 ++++++++++++++ specs | 2 +- src/altertable/client.py | 16 ++++++++++++++-- tests/test_altertable.py | 15 +++++++++++++++ 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f20dc5e..3caf850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +* **api:** add native server batch pass-through and align with client specs v0.13.0 + ## [0.2.0](https://github.com/altertable-ai/altertable-python/compare/altertable-v0.1.0...altertable-v0.2.0) (2026-03-09) diff --git a/README.md b/README.md index e5e3424..c7186fc 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,20 @@ client.track( ) ``` +### Batch tracking + +Server applications can submit a caller-managed batch directly. The SDK does +not impose a client-side batch-size limit. + +```python +client.track_batch([ + {"event": "button_clicked", "distinct_id": "user_123", "environment": "production"}, + {"event": "checkout_started", "distinct_id": "user_123", "environment": "production"}, +]) +``` + +`identify_batch` and `alias_batch` provide the same pass-through behavior. + ### Identifying Users ```python diff --git a/specs b/specs index c55685e..2491b40 160000 --- a/specs +++ b/specs @@ -1 +1 @@ -Subproject commit c55685efe831466bf8ceb6953ecb13cbb19780ab +Subproject commit 2491b404696590588944d576398e44844799432b diff --git a/src/altertable/client.py b/src/altertable/client.py index e564746..31ba5f3 100644 --- a/src/altertable/client.py +++ b/src/altertable/client.py @@ -1,6 +1,6 @@ import datetime import requests -from typing import Dict, Any, Optional, Union +from typing import Dict, Any, List, Optional, Union class AltertableError(Exception): pass @@ -41,7 +41,7 @@ def _get_timestamp(self, timestamp: Optional[Union[str, int]] = None) -> str: return datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") return timestamp - def _post(self, endpoint: str, payload: Dict[str, Any]): + def _post(self, endpoint: str, payload: Union[Dict[str, Any], List[Dict[str, Any]]]): try: response = self.session.post(f"{self.server_url}{endpoint}", json=payload, timeout=self.timeout) if not response.ok: @@ -70,6 +70,10 @@ def track(self, event: str, distinct_id: str, options: Optional[Dict[str, Any]] return self._post("/track", payload) + def track_batch(self, payloads: List[Dict[str, Any]]): + """Send caller-supplied track payloads as one request without chunking.""" + return self._post("/track", payloads) + def identify(self, distinct_id: str, options: Optional[Dict[str, Any]] = None): options = options or {} payload = { @@ -86,6 +90,10 @@ def identify(self, distinct_id: str, options: Optional[Dict[str, Any]] = None): return self._post("/identify", payload) + def identify_batch(self, payloads: List[Dict[str, Any]]): + """Send caller-supplied identify payloads as one request without chunking.""" + return self._post("/identify", payloads) + def alias(self, distinct_id: str, new_user_id: str, options: Optional[Dict[str, Any]] = None): options = options or {} payload = { @@ -96,3 +104,7 @@ def alias(self, distinct_id: str, new_user_id: str, options: Optional[Dict[str, } return self._post("/alias", payload) + + def alias_batch(self, payloads: List[Dict[str, Any]]): + """Send caller-supplied alias payloads as one request without chunking.""" + return self._post("/alias", payloads) diff --git a/tests/test_altertable.py b/tests/test_altertable.py index 53b8b6c..1c86000 100644 --- a/tests/test_altertable.py +++ b/tests/test_altertable.py @@ -1,5 +1,6 @@ import os import pytest +from unittest.mock import Mock from altertable.client import Altertable, ApiError MOCK_PORT = os.environ.get("ALTERTABLE_MOCK_PORT", "15001") @@ -31,6 +32,20 @@ def test_alias(client): ) assert response.get("ok") is True +def test_track_batch_passes_all_payloads_through_without_chunking(): + client = Altertable("test_pk_abc123", server_url=MOCK_BASE_URL) + response = Mock(ok=True) + response.json.return_value = {"ok": True} + client.session.post = Mock(return_value=response) + payloads = [ + {"event": f"event-{index}", "distinct_id": f"user-{index}", "environment": "test"} + for index in range(101) + ] + + assert client.track_batch(payloads) == {"ok": True} + client.session.post.assert_called_once() + assert client.session.post.call_args.kwargs["json"] == payloads + def test_authentication_error(): client = Altertable("wrong_api_key", server_url=MOCK_BASE_URL) with pytest.raises(ApiError): From a6ed8bd4aa9f0136c9088959680f9fa2a56f8bf5 Mon Sep 17 00:00:00 2001 From: Albert Date: Wed, 2 Sep 2026 12:11:58 +0000 Subject: [PATCH 2/2] fix(api): overload server event methods for batches --- CHANGELOG.md | 2 +- README.md | 4 ++-- src/altertable/client.py | 27 ++++++++++++++------------- tests/test_altertable.py | 16 ++++++++++++++-- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3caf850..f9dfd94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -* **api:** add native server batch pass-through and align with client specs v0.13.0 +* **api:** add native server batch overloads and align with client specs v0.13.0 ## [0.2.0](https://github.com/altertable-ai/altertable-python/compare/altertable-v0.1.0...altertable-v0.2.0) (2026-03-09) diff --git a/README.md b/README.md index c7186fc..4c6fc8c 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,13 @@ Server applications can submit a caller-managed batch directly. The SDK does not impose a client-side batch-size limit. ```python -client.track_batch([ +client.track([ {"event": "button_clicked", "distinct_id": "user_123", "environment": "production"}, {"event": "checkout_started", "distinct_id": "user_123", "environment": "production"}, ]) ``` -`identify_batch` and `alias_batch` provide the same pass-through behavior. +Pass arrays to `identify` and `alias` for the same pass-through behavior. ### Identifying Users diff --git a/src/altertable/client.py b/src/altertable/client.py index 31ba5f3..d78b279 100644 --- a/src/altertable/client.py +++ b/src/altertable/client.py @@ -54,7 +54,12 @@ def _post(self, endpoint: str, payload: Union[Dict[str, Any], List[Dict[str, Any except requests.exceptions.RequestException as e: raise NetworkError("Network request failed", e) - def track(self, event: str, distinct_id: str, options: Optional[Dict[str, Any]] = None): + def track(self, event: Union[str, List[Dict[str, Any]]], distinct_id: Optional[str] = None, options: Optional[Dict[str, Any]] = None): + if isinstance(event, list): + return self._post("/track", event) + if distinct_id is None: + raise TypeError("distinct_id is required when tracking one event") + options = options or {} payload = { "timestamp": self._get_timestamp(options.get("timestamp")), @@ -70,11 +75,10 @@ def track(self, event: str, distinct_id: str, options: Optional[Dict[str, Any]] return self._post("/track", payload) - def track_batch(self, payloads: List[Dict[str, Any]]): - """Send caller-supplied track payloads as one request without chunking.""" - return self._post("/track", payloads) + def identify(self, distinct_id: Union[str, List[Dict[str, Any]]], options: Optional[Dict[str, Any]] = None): + if isinstance(distinct_id, list): + return self._post("/identify", distinct_id) - def identify(self, distinct_id: str, options: Optional[Dict[str, Any]] = None): options = options or {} payload = { "timestamp": self._get_timestamp(options.get("timestamp")), @@ -90,11 +94,12 @@ def identify(self, distinct_id: str, options: Optional[Dict[str, Any]] = None): return self._post("/identify", payload) - def identify_batch(self, payloads: List[Dict[str, Any]]): - """Send caller-supplied identify payloads as one request without chunking.""" - return self._post("/identify", payloads) + def alias(self, distinct_id: Union[str, List[Dict[str, Any]]], new_user_id: Optional[str] = None, options: Optional[Dict[str, Any]] = None): + if isinstance(distinct_id, list): + return self._post("/alias", distinct_id) + if new_user_id is None: + raise TypeError("new_user_id is required when aliasing one user") - def alias(self, distinct_id: str, new_user_id: str, options: Optional[Dict[str, Any]] = None): options = options or {} payload = { "timestamp": self._get_timestamp(options.get("timestamp")), @@ -104,7 +109,3 @@ def alias(self, distinct_id: str, new_user_id: str, options: Optional[Dict[str, } return self._post("/alias", payload) - - def alias_batch(self, payloads: List[Dict[str, Any]]): - """Send caller-supplied alias payloads as one request without chunking.""" - return self._post("/alias", payloads) diff --git a/tests/test_altertable.py b/tests/test_altertable.py index 1c86000..a681598 100644 --- a/tests/test_altertable.py +++ b/tests/test_altertable.py @@ -32,7 +32,7 @@ def test_alias(client): ) assert response.get("ok") is True -def test_track_batch_passes_all_payloads_through_without_chunking(): +def test_track_overload_passes_all_payloads_through_without_chunking(): client = Altertable("test_pk_abc123", server_url=MOCK_BASE_URL) response = Mock(ok=True) response.json.return_value = {"ok": True} @@ -42,10 +42,22 @@ def test_track_batch_passes_all_payloads_through_without_chunking(): for index in range(101) ] - assert client.track_batch(payloads) == {"ok": True} + assert client.track(payloads) == {"ok": True} client.session.post.assert_called_once() assert client.session.post.call_args.kwargs["json"] == payloads +@pytest.mark.parametrize("method_name, endpoint", [("identify", "/identify"), ("alias", "/alias")]) +def test_other_batch_overloads_pass_payloads_through(method_name, endpoint): + client = Altertable("test_pk_abc123", server_url=MOCK_BASE_URL) + response = Mock(ok=True) + response.json.return_value = {"ok": True} + client.session.post = Mock(return_value=response) + payloads = [{"distinct_id": "user-1", "environment": "test"}] + + assert getattr(client, method_name)(payloads) == {"ok": True} + assert client.session.post.call_args.args[0] == f"{MOCK_BASE_URL}{endpoint}" + assert client.session.post.call_args.kwargs["json"] == payloads + def test_authentication_error(): client = Altertable("wrong_api_key", server_url=MOCK_BASE_URL) with pytest.raises(ApiError):